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