Files
rthythm_archor_game/scenes/combat/player_projectile.gd
T
2026-07-06 22:59:47 -07:00

191 lines
6.7 KiB
GDScript

class_name PlayerProjectile
extends Area2D
const EFFECT_TEXTURE := preload("res://assets/art/effects/effect_sheet.png")
# effect_sheet.png is a 6x2 grid of 32x32 cells; row one holds four flight frames
# (cells four and five are empty), row two holds the impact burst.
const SHEET_HFRAMES := 6
const SHEET_VFRAMES := 2
const FLIGHT_FRAME_COUNT := 4
const FLIGHT_FPS := 16.0
const VANISH_FRAME := 4
const HIT_LEAD_DISTANCE := 18.0
const DEFAULT_MAX_RANGE := 460.0
# The player blade wave uses the author's PROJECTILE sheet (5 frames, 33fps loop).
const WAVE_TEXTURE_PATH := "res://assets/art/characters/player/11_blade_wave/projectile.png"
# collision_radius keeps the hit circle inside the visible art: the effect_sheet
# flight frames show a ~32x18px bolt at scale 2 (radius 9 = half its height),
# the wave sheet's smallest frame is ~66x65px at scale 0.55 (radius 32 fits).
const VISUAL_PROFILES := {
&"bullet": {"hframes": SHEET_HFRAMES, "vframes": SHEET_VFRAMES, "first": 0, "last": FLIGHT_FRAME_COUNT - 1, "fps": FLIGHT_FPS, "scale": 2.0, "vanish_frame": VANISH_FRAME, "collision_radius": 9.0},
&"wave": {"path": WAVE_TEXTURE_PATH, "hframes": 5, "vframes": 1, "first": 0, "last": 4, "fps": 33.0, "scale": 0.55, "vanish_frame": -1, "collision_radius": 32.0},
}
@export var damage := 100
@export var hit_type: StringName = &"projectile"
@export var base_knockback := Vector2.ZERO
## Snapshotted when fired: weak-state enemy projectiles deal damage but do not
## interrupt, while Boss/player projectiles keep full authority.
var attacker_interrupts := true
var source_actor: Node = null
var action_context: Resource
var judgement_context: Dictionary = {"label": "perfect"}
var direction := Vector2.RIGHT
var speed := 520.0
var max_range := DEFAULT_MAX_RANGE
var visual_profile: StringName = &"bullet"
var _travelled := 0.0
var _age := 0.0
var _sprite: Sprite2D
var _hit_confirmed := false
func _init() -> void:
_create_sprite()
func _ready() -> void:
if _sprite == null:
_create_sprite()
_apply_visual_profile()
if not area_entered.is_connected(_on_area_entered):
area_entered.connect(_on_area_entered)
func _apply_visual_profile() -> void:
if _sprite == null:
return
var profile := _visual()
var texture_path := str(profile.get("path", ""))
if not texture_path.is_empty() and ResourceLoader.exists(texture_path):
_sprite.texture = load(texture_path)
else:
_sprite.texture = EFFECT_TEXTURE
_sprite.hframes = maxi(1, int(profile.get("hframes", 1)))
_sprite.vframes = maxi(1, int(profile.get("vframes", 1)))
var profile_scale := float(profile.get("scale", 1.0))
_sprite.scale = Vector2(profile_scale, profile_scale)
_sprite.frame = clampi(int(profile.get("first", 0)), 0, _sprite.hframes * _sprite.vframes - 1)
_apply_collision_profile(profile)
func _apply_collision_profile(profile: Dictionary) -> void:
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node == null or not (shape_node.shape is CircleShape2D):
return
# The scene's CircleShape2D is a shared subresource: duplicate before sizing
# per profile so bullet and wave instances never overwrite each other.
var circle := (shape_node.shape as CircleShape2D).duplicate() as CircleShape2D
circle.radius = maxf(1.0, float(profile.get("collision_radius", circle.radius)))
shape_node.shape = circle
func _visual() -> Dictionary:
return VISUAL_PROFILES.get(visual_profile, VISUAL_PROFILES[&"bullet"])
func _create_sprite() -> void:
if _sprite != null and is_instance_valid(_sprite):
return
_sprite = Sprite2D.new()
_sprite.name = "Sprite"
_sprite.texture = EFFECT_TEXTURE
_sprite.hframes = SHEET_HFRAMES
_sprite.vframes = SHEET_VFRAMES
_sprite.centered = true
_sprite.scale = Vector2(2.0, 2.0)
add_child(_sprite)
func _process(delta: float) -> void:
_age += delta
if not _hit_confirmed:
var profile := _visual()
var first := int(profile.get("first", 0))
var span := maxi(1, int(profile.get("last", first)) - first + 1)
_sprite.frame = first + int(_age * float(profile.get("fps", FLIGHT_FPS))) % span
_sprite.rotation = direction.angle()
_sprite.flip_h = false
func _physics_process(delta: float) -> void:
if _hit_confirmed:
return
var movement := direction.normalized() * speed * delta
var receiver := _swept_receiver(movement)
if receiver != null:
_on_area_entered(receiver)
return
position += movement
_travelled += movement.length()
if _travelled >= max_travel_distance():
queue_free()
func _on_area_entered(receiver: Area2D) -> void:
if _hit_confirmed:
return
if receiver.is_in_group("enemy_projectiles") and is_in_group("player_projectiles"):
receiver.queue_free()
return
if not receiver.is_in_group("damage_receivers"):
return
_hit_confirmed = true
_vanish_after_hit()
var combat := _combat_manager_or_null()
if combat != null and combat.has_method("resolve_hit"):
combat.call("resolve_hit", self, receiver)
queue_free()
func _combat_manager_or_null() -> Node:
if not is_inside_tree():
return null
return get_tree().root.get_node_or_null("CombatManager")
func max_travel_distance() -> float:
return max_range if max_range > 0.0 else DEFAULT_MAX_RANGE
func _vanish_after_hit() -> void:
set_deferred("monitoring", false)
set_deferred("monitorable", false)
visible = false
var vanish_frame := int(_visual().get("vanish_frame", -1))
if _sprite != null and vanish_frame >= 0:
_sprite.frame = vanish_frame
func _swept_receiver(movement: Vector2) -> Area2D:
if movement == Vector2.ZERO or not is_inside_tree():
return null
var travel_direction := movement.normalized()
var shape_node := get_node_or_null("CollisionShape2D") as CollisionShape2D
if shape_node != null and shape_node.shape != null:
var shape_query := PhysicsShapeQueryParameters2D.new()
shape_query.shape = shape_node.shape
shape_query.transform = shape_node.global_transform.translated(travel_direction * HIT_LEAD_DISTANCE + movement)
shape_query.collision_mask = collision_mask
shape_query.collide_with_areas = true
shape_query.collide_with_bodies = false
shape_query.exclude = [get_rid()]
for hit: Dictionary in get_world_2d().direct_space_state.intersect_shape(shape_query, 8):
var shape_collider = hit.get("collider", null)
if shape_collider is Area2D and shape_collider.is_in_group("damage_receivers"):
return shape_collider
var query := PhysicsRayQueryParameters2D.create(
global_position + travel_direction * HIT_LEAD_DISTANCE,
global_position + travel_direction * HIT_LEAD_DISTANCE + movement,
collision_mask
)
query.collide_with_areas = true
query.collide_with_bodies = false
var hit := get_world_2d().direct_space_state.intersect_ray(query)
var collider = hit.get("collider", null)
if collider is Area2D and collider.is_in_group("damage_receivers"):
return collider
return null