From 9d2d781c4bc1331a884700452736cbda5e3bd510 Mon Sep 17 00:00:00 2001 From: BotTony329 <75003862+BotTony329@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:10:24 +1000 Subject: [PATCH 1/6] [X02] Add Ghost Arrow projectile --- actors/enemies/ghost_arrow.gd | 97 +++++++++++++++++++++++++++++++++ actors/enemies/ghost_arrow.tscn | 20 +++++++ 2 files changed, 117 insertions(+) create mode 100644 actors/enemies/ghost_arrow.gd create mode 100644 actors/enemies/ghost_arrow.tscn diff --git a/actors/enemies/ghost_arrow.gd b/actors/enemies/ghost_arrow.gd new file mode 100644 index 0000000..3289177 --- /dev/null +++ b/actors/enemies/ghost_arrow.gd @@ -0,0 +1,97 @@ +class_name GhostArrow +extends Area2D +## One-hit, non-homing Ghost Archer projectile. It builds a DamageContext and +## delegates damage application to the target's existing Damageable contract. + +signal impacted(target: Node) + +var _source_id: StringName = &"unknown" +var _owner_actor: Node +var _direction: Vector2 = Vector2.RIGHT +var _speed: float +var _damage: float +var _skill_multiplier: float +var _remaining_lifetime: float +var _has_impacted: bool = false +var _configured: bool = false + + +func _ready() -> void: + area_entered.connect(_on_area_entered) + body_entered.connect(_on_body_entered) + + +func configure( + source_id: StringName, + owner_actor: Node, + direction: Vector2, + damage: float, + skill_multiplier: float, + speed: float, + lifetime: float +) -> void: + _source_id = source_id + _owner_actor = owner_actor + _direction = direction.normalized() + _damage = damage + _skill_multiplier = skill_multiplier + _speed = speed + _remaining_lifetime = lifetime + rotation = _direction.angle() + _configured = true + + +func _physics_process(delta: float) -> void: + if not _configured or _has_impacted: + return + global_position += _direction * _speed * delta + _remaining_lifetime -= delta + if _remaining_lifetime <= 0.0: + _expire() + + +func has_impacted() -> bool: + return _has_impacted + + +func _on_area_entered(area: Area2D) -> void: + if _has_impacted or not (area is Hurtbox): + return + var target := (area as Hurtbox).actor() + if target == null or target == _owner_actor or not target.has_method("receive_damage"): + return + + var context := DamageContext.new() + context.source_id = _source_id + context.target_id = target.call("actor_id") + context.base_damage = _damage + context.skill_multiplier = _skill_multiplier + context.crit_allowed = false + context.target_armour = float(target.call("armour")) + context.target_current_hp = float(target.call("current_hp")) + context.tags = [&"ranged", &"projectile"] as Array[StringName] + target.call("receive_damage", context) + _impact(target) + + +func _on_body_entered(body: Node2D) -> void: + if body != _owner_actor: + _impact(body) + + +func _impact(target: Node) -> void: + if _has_impacted: + return + _has_impacted = true + set_deferred(&"monitoring", false) + set_deferred(&"monitorable", false) + impacted.emit(target) + queue_free() + + +func _expire() -> void: + if _has_impacted: + return + set_deferred(&"monitoring", false) + set_deferred(&"monitorable", false) + queue_free() diff --git a/actors/enemies/ghost_arrow.tscn b/actors/enemies/ghost_arrow.tscn new file mode 100644 index 0000000..833ff09 --- /dev/null +++ b/actors/enemies/ghost_arrow.tscn @@ -0,0 +1,20 @@ +[gd_scene load_steps=3 format=3] + +[ext_resource type="Script" path="res://actors/enemies/ghost_arrow.gd" id="1_arrow"] + +[sub_resource type="CapsuleShape2D" id="CapsuleShape2D_arrow"] +radius = 2.0 +height = 12.0 + +[node name="GhostArrow" type="Area2D"] +collision_layer = 64 +collision_mask = 9 +script = ExtResource("1_arrow") + +[node name="FallbackVisual" type="Polygon2D" parent="."] +polygon = PackedVector2Array(-7, -1, 4, -1, 4, -3, 8, 0, 4, 3, 4, 1, -7, 1) +color = Color(0.31, 0.88, 0.65, 0.9) + +[node name="Shape" type="CollisionShape2D" parent="."] +rotation = 1.5708 +shape = SubResource("CapsuleShape2D_arrow") From 6536fb45bff55ab2b703f7070b82ac28809dda35 Mon Sep 17 00:00:00 2001 From: BotTony329 <75003862+BotTony329@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:10:24 +1000 Subject: [PATCH 2/6] [X02] Add Ghost Archer scene and AI behaviour --- actors/enemies/ghost_archer.gd | 115 +++++++++++++++ actors/enemies/ghost_archer.tscn | 18 +++ actors/enemies/ghost_archer_config.gd | 16 +++ actors/enemies/ghost_archer_frames.tres | 177 ++++++++++++++++++++++++ data/actors/ghost_archer.tres | 25 ++++ data/actors/ghost_archer_tactics.tres | 15 ++ tools/generate_sprite_frames.py | 12 ++ 7 files changed, 378 insertions(+) create mode 100644 actors/enemies/ghost_archer.gd create mode 100644 actors/enemies/ghost_archer.tscn create mode 100644 actors/enemies/ghost_archer_config.gd create mode 100644 actors/enemies/ghost_archer_frames.tres create mode 100644 data/actors/ghost_archer.tres create mode 100644 data/actors/ghost_archer_tactics.tres diff --git a/actors/enemies/ghost_archer.gd b/actors/enemies/ghost_archer.gd new file mode 100644 index 0000000..62d335a --- /dev/null +++ b/actors/enemies/ghost_archer.gd @@ -0,0 +1,115 @@ +class_name GhostArcher +extends EnemyBase +## Concrete ranged enemy. EnemyBase's frozen states map to: +## IDLE=patrol, CHASE=spacing/reposition, WINDUP=aim, ATTACK=shoot, +## RECOVER=post-shot recovery/cooldown. + +signal arrow_fired(arrow: GhostArrow) + +@export var archer_config: GhostArcherConfig + +var _patrol_origin_x: float +var _patrol_direction: int = 1 + + +func _ready() -> void: + super._ready() + # This archetype deals damage only through GhostArrow. + attack_hitbox.collision_mask = 0 + attack_hitbox.set_active(false) + + +func initialize(enemy_config: EnemyConfig, target: Node2D) -> void: + _patrol_origin_x = global_position.x + super.initialize(enemy_config, target) + + +func _enter_state(next: int) -> void: + super._enter_state(next) + if next == State.ATTACK: + attack_hitbox.set_active(false) + _shoot() + + +func _update_state(delta: float) -> void: + match _state: + State.IDLE: + _patrol() + if _target_in_range(config.detection_range): + change_state(State.CHASE) + State.CHASE: + _update_spacing(delta) + State.WINDUP: + _decelerate(delta) + _face_target() + if _state_elapsed >= config.attack_windup: + change_state(State.ATTACK) + State.ATTACK: + _decelerate(delta) + if _state_elapsed >= config.attack_active: + change_state(State.RECOVER) + State.RECOVER: + _decelerate(delta) + if _state_elapsed >= config.attack_recovery: + _cooldown = config.attack_cooldown + change_state(State.CHASE if _target_in_range(config.detection_range) else State.IDLE) + State.HURT: + _decelerate(delta) + if _state_elapsed >= config.hurt_duration: + change_state(State.CHASE if _target_in_range(config.detection_range) else State.IDLE) + State.DEAD: + pass + + +func _patrol() -> void: + if archer_config == null: + velocity.x = 0.0 + return + if is_on_wall() or absf(global_position.x - _patrol_origin_x) >= archer_config.patrol_radius: + _patrol_direction *= -1 + _facing = _patrol_direction + sprite.flip_h = _facing < 0 + velocity.x = _facing * config.move_speed * archer_config.patrol_speed_multiplier + + +func _update_spacing(delta: float) -> void: + if archer_config == null or not _target_in_range(config.detection_range): + change_state(State.IDLE) + return + _face_target() + var distance := _distance_to_target() + if distance < archer_config.retreat_distance: + velocity.x = -_facing * config.move_speed + return + if distance > archer_config.preferred_distance: + velocity.x = _facing * config.move_speed + return + _decelerate(delta) + if distance <= config.attack_range and _cooldown <= 0.0: + change_state(State.WINDUP) + + +func _shoot() -> void: + if _is_dead or archer_config == null or archer_config.projectile_scene == null: + return + if _target == null or not is_instance_valid(_target): + return + var arrow := archer_config.projectile_scene.instantiate() as GhostArrow + if arrow == null: + return + var spawn_offset := archer_config.projectile_spawn_offset + spawn_offset.x *= _facing + var spawn_position := global_position + spawn_offset + var direction := (_target.global_position - spawn_position).normalized() + arrow.configure( + actor_id(), + self, + direction, + config.attack_damage, + config.attack_skill_multiplier, + archer_config.projectile_speed, + archer_config.projectile_lifetime + ) + get_parent().add_child(arrow) + arrow.global_position = spawn_position + arrow_fired.emit(arrow) diff --git a/actors/enemies/ghost_archer.tscn b/actors/enemies/ghost_archer.tscn new file mode 100644 index 0000000..3783d97 --- /dev/null +++ b/actors/enemies/ghost_archer.tscn @@ -0,0 +1,18 @@ +[gd_scene load_steps=5 format=3] + +[ext_resource type="PackedScene" path="res://actors/enemies/enemy_base.tscn" id="1_base"] +[ext_resource type="Script" path="res://actors/enemies/ghost_archer.gd" id="2_script"] +[ext_resource type="SpriteFrames" path="res://actors/enemies/ghost_archer_frames.tres" id="3_frames"] +[ext_resource type="Resource" path="res://data/actors/ghost_archer_tactics.tres" id="4_tactics"] + +[node name="GhostArcher" instance=ExtResource("1_base")] +script = ExtResource("2_script") +archer_config = ExtResource("4_tactics") + +[node name="Sprite" parent="." index="0"] +sprite_frames = ExtResource("3_frames") +animation = &"idle" +autoplay = "idle" + +[node name="Shape" parent="AttackHitbox" index="0"] +disabled = true diff --git a/actors/enemies/ghost_archer_config.gd b/actors/enemies/ghost_archer_config.gd new file mode 100644 index 0000000..10633fb --- /dev/null +++ b/actors/enemies/ghost_archer_config.gd @@ -0,0 +1,16 @@ +class_name GhostArcherConfig +extends Resource +## Data-only spacing and projectile values for the concrete Ghost Archer. +## Base vitals and shared attack timings remain in EnemyConfig. + +@export_group("Spacing") +@export var patrol_radius: float +@export var patrol_speed_multiplier: float +@export var preferred_distance: float +@export var retreat_distance: float + +@export_group("Projectile") +@export var projectile_scene: PackedScene +@export var projectile_speed: float +@export var projectile_lifetime: float +@export var projectile_spawn_offset: Vector2 diff --git a/actors/enemies/ghost_archer_frames.tres b/actors/enemies/ghost_archer_frames.tres new file mode 100644 index 0000000..0a8a900 --- /dev/null +++ b/actors/enemies/ghost_archer_frames.tres @@ -0,0 +1,177 @@ +; Generated by tools/generate_sprite_frames.py from docs/ART_SPEC.md. +; Do not hand-edit: re-run the generator instead. +[gd_resource type="SpriteFrames" load_steps=26 format=3] + +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_archer_idle.png" id="1_idle"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_archer_run.png" id="2_run"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_archer_attack.png" id="3_attack"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_archer_hurt.png" id="4_hurt"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_archer_death.png" id="5_death"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] +atlas = ExtResource("1_idle") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] +atlas = ExtResource("1_idle") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] +atlas = ExtResource("1_idle") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] +atlas = ExtResource("1_idle") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] +atlas = ExtResource("2_run") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] +atlas = ExtResource("2_run") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] +atlas = ExtResource("2_run") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] +atlas = ExtResource("2_run") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] +atlas = ExtResource("2_run") +region = Rect2(192, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] +atlas = ExtResource("2_run") +region = Rect2(240, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] +atlas = ExtResource("3_attack") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] +atlas = ExtResource("3_attack") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] +atlas = ExtResource("3_attack") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] +atlas = ExtResource("3_attack") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] +atlas = ExtResource("4_hurt") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] +atlas = ExtResource("4_hurt") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] +atlas = ExtResource("5_death") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] +atlas = ExtResource("5_death") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] +atlas = ExtResource("5_death") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] +atlas = ExtResource("5_death") +region = Rect2(144, 0, 48, 48) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_3") +}], +"loop": true, +"name": &"idle", +"speed": 8.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_5") +}], +"loop": true, +"name": &"run", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_3") +}], +"loop": false, +"name": &"attack", +"speed": 12.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_1") +}], +"loop": false, +"name": &"hurt", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_3") +}], +"loop": false, +"name": &"death", +"speed": 8.0 +}] diff --git a/data/actors/ghost_archer.tres b/data/actors/ghost_archer.tres new file mode 100644 index 0000000..f2246e8 --- /dev/null +++ b/data/actors/ghost_archer.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="EnemyConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://actors/enemies/enemy_config.gd" id="1_config"] + +[resource] +script = ExtResource("1_config") +actor_id = &"ghost_archer" +display_name = "鬼弓手 — Ghost Archer" +max_hp = 26.0 +armour = 2.0 +move_speed = 58.0 +gravity = 780.0 +max_fall_speed = 420.0 +detection_range = 260.0 +attack_range = 220.0 +preferred_gap = 150.0 +attack_damage = 8.0 +attack_skill_multiplier = 1.0 +attack_windup = 0.55 +attack_active = 0.12 +attack_recovery = 0.38 +attack_cooldown = 0.85 +hurt_duration = 0.18 +death_duration = 0.5 +telegraph_tint = Color(1.15, 1.75, 1.45, 1) diff --git a/data/actors/ghost_archer_tactics.tres b/data/actors/ghost_archer_tactics.tres new file mode 100644 index 0000000..5db18f8 --- /dev/null +++ b/data/actors/ghost_archer_tactics.tres @@ -0,0 +1,15 @@ +[gd_resource type="Resource" script_class="GhostArcherConfig" load_steps=3 format=3] + +[ext_resource type="Script" path="res://actors/enemies/ghost_archer_config.gd" id="1_config"] +[ext_resource type="PackedScene" path="res://actors/enemies/ghost_arrow.tscn" id="2_projectile"] + +[resource] +script = ExtResource("1_config") +patrol_radius = 56.0 +patrol_speed_multiplier = 0.45 +preferred_distance = 150.0 +retreat_distance = 92.0 +projectile_scene = ExtResource("2_projectile") +projectile_speed = 190.0 +projectile_lifetime = 2.4 +projectile_spawn_offset = Vector2(18, -22) diff --git a/tools/generate_sprite_frames.py b/tools/generate_sprite_frames.py index 19b14f2..dc3fb82 100644 --- a/tools/generate_sprite_frames.py +++ b/tools/generate_sprite_frames.py @@ -50,6 +50,18 @@ ("death", "assets/enemy/ghost_melee_death.png", 4, 8.0, False), ], ), + "ghost_archer": ( + "actors/enemies/ghost_archer_frames.tres", + 48, + 48, + [ + ("idle", "assets/enemy/ghost_archer_idle.png", 4, 8.0, True), + ("run", "assets/enemy/ghost_archer_run.png", 6, 10.0, True), + ("attack", "assets/enemy/ghost_archer_attack.png", 4, 12.0, False), + ("hurt", "assets/enemy/ghost_archer_hurt.png", 2, 10.0, False), + ("death", "assets/enemy/ghost_archer_death.png", 4, 8.0, False), + ], + ), "boss": ( "actors/boss/boss_frames.tres", 96, From 6a58b0e493bb76bc71f85c2f50af93968a38f004 Mon Sep 17 00:00:00 2001 From: BotTony329 <75003862+BotTony329@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:10:24 +1000 Subject: [PATCH 3/6] [X02] Integrate Ghost Archer debug spawn --- scenes/main.tscn | 4 ++-- ui/debug_panel.gd | 2 +- ui/debug_panel.tscn | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scenes/main.tscn b/scenes/main.tscn index 29930a8..6da8a80 100644 --- a/scenes/main.tscn +++ b/scenes/main.tscn @@ -4,9 +4,9 @@ [ext_resource type="PackedScene" path="res://scenes/arena.tscn" id="2_arena"] [ext_resource type="Script" path="res://systems/run_coordinator.gd" id="3_coordinator"] [ext_resource type="PackedScene" path="res://actors/player/player.tscn" id="4_player"] -[ext_resource type="PackedScene" path="res://actors/enemies/ghost_melee.tscn" id="5_enemy"] +[ext_resource type="PackedScene" path="res://actors/enemies/ghost_archer.tscn" id="5_enemy"] [ext_resource type="PackedScene" path="res://actors/boss/boss.tscn" id="6_boss"] -[ext_resource type="Resource" path="res://data/actors/ghost_melee.tres" id="7_enemy_config"] +[ext_resource type="Resource" path="res://data/actors/ghost_archer.tres" id="7_enemy_config"] [ext_resource type="Resource" path="res://data/actors/boss_gate_guardian.tres" id="8_boss_config"] [ext_resource type="PackedScene" path="res://ui/hud.tscn" id="9_hud"] [ext_resource type="PackedScene" path="res://ui/sacrifice_panel.tscn" id="10_sacrifice"] diff --git a/ui/debug_panel.gd b/ui/debug_panel.gd index 82d15d9..1490e9f 100644 --- a/ui/debug_panel.gd +++ b/ui/debug_panel.gd @@ -44,7 +44,7 @@ func _unhandled_input(event: InputEvent) -> void: _note("damaged %.0f" % DAMAGE_AMOUNT) elif event.is_action_pressed(&"debug_spawn_enemy"): _coordinator.debug_spawn_reference_enemy() - _note("spawned reference enemy") + _note("spawned Ghost Archer") elif event.is_action_pressed(&"debug_start_boss"): _coordinator.begin_boss() _note("boss encounter started") diff --git a/ui/debug_panel.tscn b/ui/debug_panel.tscn index c8d7628..5ddc34c 100644 --- a/ui/debug_panel.tscn +++ b/ui/debug_panel.tscn @@ -44,4 +44,4 @@ offset_right = 636.0 offset_bottom = 356.0 theme_override_colors/font_color = Color(0.463, 0.451, 0.416, 1) theme_override_font_sizes/font_size = 9 -text = "A/D move Space jump J attack | F1 debug F2 heal F3 hurt F4 enemy F5 boss F6 preview F7 apply F8 boxes R restart" +text = "A/D move Space jump J attack | F1 debug F2 heal F3 hurt F4 archer F5 boss F6 preview F7 apply F8 boxes R restart" From efc6de4ee37994d269b4bb4c3c8a3db25a320878 Mon Sep 17 00:00:00 2001 From: BotTony329 <75003862+BotTony329@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:10:24 +1000 Subject: [PATCH 4/6] [X02] Add Ghost Archer tests --- tests/cases/test_ghost_archer.gd | 172 +++++++++++++++++++++++++++++++ tests/cases/test_run_loop.gd | 6 ++ 2 files changed, 178 insertions(+) create mode 100644 tests/cases/test_ghost_archer.gd diff --git a/tests/cases/test_ghost_archer.gd b/tests/cases/test_ghost_archer.gd new file mode 100644 index 0000000..73b5cb3 --- /dev/null +++ b/tests/cases/test_ghost_archer.gd @@ -0,0 +1,172 @@ +extends TestCase +## X02: Ghost Archer spacing, shot timing, projectile contract and death safety. + +const ARCHER_SCENE := "res://actors/enemies/ghost_archer.tscn" +const ARCHER_CONFIG := "res://data/actors/ghost_archer.tres" +const ARROW_SCENE := "res://actors/enemies/ghost_arrow.tscn" + + +class MockDamageable: + extends Node2D + var hp: float = 100.0 + var defence: float = 0.0 + var hits: int = 0 + + func actor_id() -> StringName: + return &"mock_target" + + func armour() -> float: + return defence + + func current_hp() -> float: + return hp + + func receive_damage(context: DamageContext) -> DamageResult: + var result := GameData.combat.resolve(context) + hp = maxf(0.0, hp - result.final_damage) + hits += 1 + return result + + +var _spawned: Array[Node] = [] + + +func after_each() -> void: + for node in _spawned: + if is_instance_valid(node): + node.queue_free() + _spawned.clear() + + +func _track(node: Node) -> Node: + tree.root.add_child(node) + _spawned.append(node) + return node + + +func _target(position: Vector2, with_hurtbox: bool = false) -> MockDamageable: + var target := MockDamageable.new() + _track(target) + target.global_position = position + if with_hurtbox: + var hurtbox := Hurtbox.new() + hurtbox.collision_layer = 8 + hurtbox.collision_mask = 0 + target.add_child(hurtbox) + var shape := CollisionShape2D.new() + var circle := CircleShape2D.new() + circle.radius = 8.0 + shape.shape = circle + hurtbox.add_child(shape) + return target + + +func _spawn_archer(target: Node2D = null) -> GhostArcher: + var archer := (load(ARCHER_SCENE) as PackedScene).instantiate() as GhostArcher + _track(archer) + var enemy_config := (load(ARCHER_CONFIG) as EnemyConfig).duplicate(true) as EnemyConfig + # Actor tests have no arena floor; removing gravity isolates horizontal AI. + enemy_config.gravity = 0.0 + archer.initialize(enemy_config, target) + return archer + + +func _spawn_arrow( + owner_actor: Node, + direction: Vector2, + lifetime: float, + position: Vector2 = Vector2.ZERO +) -> GhostArrow: + var arrow := (load(ARROW_SCENE) as PackedScene).instantiate() as GhostArrow + arrow.configure(&"ghost_archer", owner_actor, direction, 12.0, 1.0, 240.0, lifetime) + _track(arrow) + arrow.global_position = position + return arrow + + +func _blow(target: EnemyBase, amount: float) -> DamageContext: + var context := DamageContext.new() + context.source_id = &"test" + context.target_id = target.actor_id() + context.base_damage = amount + context.crit_allowed = false + context.target_armour = target.armour() + context.target_current_hp = target.current_hp() + return context + + +func test_archer_patrols_then_detects_the_player() -> void: + var target := _target(Vector2(400.0, 0.0)) + var archer := _spawn_archer(target) + var start_x := archer.global_position.x + await step_physics(4) + assert_equal(archer.state(), EnemyBase.State.IDLE, "outside detection range stays in patrol") + assert_greater(archer.global_position.x, start_x, "idle patrol moves within its route") + + target.global_position = Vector2(180.0, 0.0) + await step_physics(2) + assert_equal(archer.state(), EnemyBase.State.CHASE, "the player is detected") + + +func test_archer_retreats_when_the_player_is_close() -> void: + var target := _target(Vector2(40.0, 0.0)) + var archer := _spawn_archer(target) + await step_physics(3) + assert_less(archer.velocity.x, 0.0, "an archer left of a close player retreats left") + assert_equal(archer.state(), EnemyBase.State.CHASE, "retreat is spacing, not a new framework state") + + +func test_archer_aims_shoots_and_respects_cooldown() -> void: + var target := _target(Vector2(150.0, 0.0)) + var archer := _spawn_archer(target) + var shots := [0] + archer.arrow_fired.connect(func(_arrow: GhostArrow) -> void: shots[0] += 1) + + await step_physics(3) + assert_equal(archer.state(), EnemyBase.State.WINDUP, "preferred range begins the readable aim") + await step_physics(36) + assert_equal(shots[0], 1, "one arrow is released after the wind-up") + + await step_physics(55) + assert_equal(shots[0], 1, "recovery and cooldown prevent an immediate second shot") + await step_physics(60) + assert_greater(shots[0], 1, "the archer may shoot again after cooldown") + + +func test_arrow_hits_once_through_combat_resolver_and_is_destroyed() -> void: + var target := _target(Vector2(32.0, 0.0), true) + target.defence = 20.0 + var arrow := _spawn_arrow(null, Vector2.RIGHT, 1.0) + await step_physics(16) + + assert_equal(target.hits, 1, "overlap resolves exactly one hit") + assert_almost(target.hp, 90.0, "12 damage is mitigated by armour through CombatResolver") + assert_false(is_instance_valid(arrow), "the projectile is destroyed after impact") + + +func test_arrow_cannot_hit_its_owner() -> void: + var owner_actor := _target(Vector2.ZERO, true) + var arrow := _spawn_arrow(owner_actor, Vector2.RIGHT, 1.0, Vector2.ZERO) + await step_physics(3) + assert_equal(owner_actor.hits, 0, "owner overlap is ignored") + assert_false(arrow.has_impacted(), "owner contact does not consume the projectile") + + +func test_arrow_expires_without_hitting_anything() -> void: + var arrow := _spawn_arrow(null, Vector2.RIGHT, 0.03) + await step_physics(5) + assert_false(is_instance_valid(arrow), "timeout destroys a missed arrow") + + +func test_archer_takes_damage_dies_and_never_shoots_after_death() -> void: + var target := _target(Vector2(150.0, 0.0)) + var archer := _spawn_archer(target) + var shots := [0] + archer.arrow_fired.connect(func(_arrow: GhostArrow) -> void: shots[0] += 1) + var result := archer.receive_damage(_blow(archer, 999.0)) + assert_true(result.is_lethal, "lethality is reported by CombatResolver") + assert_true(archer.is_dead(), "the inherited idempotent death path runs") + + await step_physics(20) + assert_equal(archer.state(), EnemyBase.State.DEAD, "behaviour remains parked in DEAD") + assert_equal(shots[0], 0, "a dead archer cannot release a projectile") diff --git a/tests/cases/test_run_loop.gd b/tests/cases/test_run_loop.gd index e28fc4b..e73b04b 100644 --- a/tests/cases/test_run_loop.gd +++ b/tests/cases/test_run_loop.gd @@ -12,6 +12,8 @@ const CRITICAL_SCENES := [ "res://actors/player/player.tscn", "res://actors/enemies/enemy_base.tscn", "res://actors/enemies/ghost_melee.tscn", + "res://actors/enemies/ghost_archer.tscn", + "res://actors/enemies/ghost_arrow.tscn", "res://actors/boss/boss.tscn", "res://ui/hud.tscn", "res://ui/sacrifice_panel.tscn", @@ -160,6 +162,10 @@ func test_debug_commands_route_through_the_coordinator() -> void: assert_equal( coordinator.live_enemies().size(), enemies_before + 1, "debug spawn added an enemy" ) + assert_true( + coordinator.live_enemies()[-1] is GhostArcher, + "the injected debug enemy is the Ghost Archer" + ) var digest := state.snapshot_hash() var preview := coordinator.debug_preview_sacrifice() From b5607b2bd0caa243bf8bfb80e03a13ea7a3eb71d Mon Sep 17 00:00:00 2001 From: BotTony329 <75003862+BotTony329@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:10:25 +1000 Subject: [PATCH 5/6] [X02] Document Ghost Archer handoff --- README.md | 2 +- docs/AI_HANDOFF.md | 41 ++++++++++++++++++++++++++++++++++------- docs/TASKS/README.md | 2 +- 3 files changed, 36 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 62b8df0..20cfe2d 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The main scene is `res://scenes/main.tscn`. There are no external dependencies | --- | --- | | `F1` | Toggle the RunState readout (seed, phase, player state, every stat, snapshot hash) | | `F2` / `F3` | Heal / damage the player | -| `F4` | Spawn a reference enemy next to the player | +| `F4` | Spawn a Ghost Archer next to the player | | `F5` | Start the boss encounter | | `F6` / `F7` | Preview / apply the sacrifice | | `F8` | Toggle hit, hurt and body boxes | diff --git a/docs/AI_HANDOFF.md b/docs/AI_HANDOFF.md index ef2bf06..5082b21 100644 --- a/docs/AI_HANDOFF.md +++ b/docs/AI_HANDOFF.md @@ -1,8 +1,8 @@ # AI Handoff -- **Current branch:** `claude/core-framework` (PR open into `develop`, unmerged) -- **Current phase:** M1 — playable vertical slice foundation, delivered -- **Next owner:** Game Director (review), then Codex for X01–X08 +- **Current branch:** `codex/x02-ghost-archer` +- **Current phase:** X02 Ghost Archer implemented on the M1 foundation +- **Next owner:** Claude and Game Director for X02 review - **Last updated:** 2026-08-02 (Australia/Melbourne) --- @@ -31,6 +31,8 @@ display during development. Engine: Godot 4.3 stable. | Player | Move, jump, light attack, hit reaction, death, restart, camera follow, 5-state machine | | `EnemyBase` | Detect, approach, telegraphed wind-up, attack, recovery, hurt, idempotent death | | Reference enemy | 鬼卒 Ghost Soldier at X01 timings | +| Ghost Archer (X02) | Patrol, detect, maintain range, retreat, aim, shoot, cooldown, hurt and death | +| Ghost Arrow (X02) | Fixed trajectory, one hit, owner exclusion, impact/timeout cleanup, CombatResolver path | | `BossActor` | Extends `EnemyBase`; idle, chase, one telegraphed melee attack, health bar, victory | | Arena | One fixed 1024×360 space, collision, spawns, parallax, camera bounds | | UI | HUD, sacrifice preview/confirm, death and victory screens, restart | @@ -77,7 +79,7 @@ All interfaces these depend on are now frozen. See `docs/TASKS/README.md`. | ID | Task | Depends on | Notes | | --- | --- | --- | --- | | X01 | Melee ghost, full behaviour | `EnemyBase` | The reference enemy is a starting point, not the finished X01 | -| X02 | Ranged ghost | `EnemyBase` | Art present: `ghost_archer_*`. Needs a projectile; do not add a second damage path | +| X02 | Ghost Archer | `EnemyBase` | **Done on `codex/x02-ghost-archer`; pending review** | | X03 | Charger ghost | `EnemyBase` | Art present: `corpse_beast_*` at 64×48 — needs its own collider sizes | | X04 | Boss attack pack | `BossActor` | Blocked on art or a ruling — see gaps below | | X05 | Sacrifice selection UI | `SacrificeService` | Three A/B/C slots; preview through the service, confirm through the coordinator | @@ -109,11 +111,14 @@ director, and the Boss `PhaseController`. 7. **No audio.** Out of scope for M1. 8. **Placeholder art everywhere.** Magenta border = placeholder, per `docs/ART_SPEC.md` section 5. +9. **The Ghost Arrow has no supplied sprite.** X02 uses a small procedural + polygon fallback in `ghost_arrow.tscn`; replace only the `FallbackVisual` + when approved projectile art arrives. Collision and behaviour are final. ## Test status -- **Last successful run:** 2026-08-02 — 52 tests, 205 assertions, 0 failures, - Godot 4.3 stable headless. +- **Last successful run:** 2026-08-02 — 59 tests, 229 assertions, 0 failures, + Godot 4.7.1 stable headless locally. Existing CI remains pinned to Godot 4.3. - **Command:** `godot --headless --import` then `godot --headless --path . res://tests/test_runner.tscn` - **CI:** `repository-validation` (unchanged) and `godot-tests` (new). Both @@ -125,7 +130,8 @@ director, and the Boss `PhaseController`. | --- | --- | | `assets/player/` | All 6 sheets used; dimensions match `docs/ART_SPEC.md` exactly | | `assets/enemy/ghost_melee_*` | All 5 sheets used | -| `assets/enemy/ghost_archer_*`, `corpse_beast_*` | Verified, unused — X02 and X03 | +| `assets/enemy/ghost_archer_*` | All 5 sheets used by X02 | +| `assets/enemy/corpse_beast_*` | Verified, unused — X03 | | `assets/boss/` | idle, run, attack, hurt, death used; only one attack sheet exists | | `assets/tiles/` | floor, stone_brick, brazier, tombstone used; `tile_wall`, `tile_wood_bridge`, `tile_ground_spike` unused (no platforms or hazards in M1) | | `assets/background/` | bg_deep, bg_tree, bg_broken_flag, bg_chains all used in three parallax layers | @@ -137,3 +143,24 @@ director, and the Boss `PhaseController`. Nothing was stretched, scaled by a non-integer factor, or resized. Regenerate `SpriteFrames` with `python3 tools/generate_sprite_frames.py` after replacing a sheet; it aborts if the new dimensions disagree with the spec. + +## X02 handoff + +### Files added + +- `actors/enemies/ghost_archer.gd`, `.tscn`, `_config.gd`, and `_frames.tres` +- `actors/enemies/ghost_arrow.gd` and `.tscn` +- `data/actors/ghost_archer.tres` and `ghost_archer_tactics.tres` +- `tests/cases/test_ghost_archer.gd` + +### Integration and validation + +- `scenes/main.tscn` injects the Ghost Archer through the existing + `RunCoordinator.enemy_scene` / `enemy_config` extension seam. The wave and F4 + debug command therefore use the same coordinator-owned spawn path. +- Added tests for patrol/detection, retreat spacing, aim/shoot/cooldown, one-hit + projectile resolution, armour integration, owner exclusion, timeout, damage, + death, and no post-death behaviour. +- Commands run: SpriteFrames generator, headless import, full test scene, and a + 180-frame headless M1 main-scene smoke run. +- No frozen interface or protected framework file changed. diff --git a/docs/TASKS/README.md b/docs/TASKS/README.md index 48640d5..83fbb43 100644 --- a/docs/TASKS/README.md +++ b/docs/TASKS/README.md @@ -28,7 +28,7 @@ files in section 3. | ID | Task | Owner | Status | Branch | Dependency | PR | | --- | --- | --- | --- | --- | --- | --- | | X01 | Melee ghost — full behaviour | Codex | Ready | `codex/x01-melee-ghost` | `EnemyBase` frozen | — | -| X02 | Ranged ghost — projectile, spacing, reposition | Codex | Ready | `codex/x02-ranged-ghost` | `EnemyBase` frozen | — | +| X02 | Ghost Archer — projectile, spacing, reposition | Codex | Done | `codex/x02-ghost-archer` | `EnemyBase` frozen | Pending review | | X03 | Charger ghost — telegraphed line charge, wall stagger | Codex | Ready | `codex/x03-charger-ghost` | `EnemyBase` frozen | — | | X04 | Boss attack pack — three moves | Codex | Blocked on art | `codex/x04-boss-attacks` | ADR-010 | — | | X05 | Sacrifice selection UI — A/B/C slots | Codex | Ready | `codex/x05-sacrifice-ui` | `SacrificeService` frozen | — | From c80596bc0030079aae016d2a69450549bd9c6d56 Mon Sep 17 00:00:00 2001 From: BotTony329 <75003862+BotTony329@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:11:46 +1000 Subject: [PATCH 6/6] [X02] Record Ghost Archer pull request --- docs/AI_HANDOFF.md | 2 +- docs/TASKS/README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/AI_HANDOFF.md b/docs/AI_HANDOFF.md index 5082b21..7ad5698 100644 --- a/docs/AI_HANDOFF.md +++ b/docs/AI_HANDOFF.md @@ -1,7 +1,7 @@ # AI Handoff - **Current branch:** `codex/x02-ghost-archer` -- **Current phase:** X02 Ghost Archer implemented on the M1 foundation +- **Current phase:** X02 Ghost Archer implemented on the M1 foundation; PR #4 open - **Next owner:** Claude and Game Director for X02 review - **Last updated:** 2026-08-02 (Australia/Melbourne) diff --git a/docs/TASKS/README.md b/docs/TASKS/README.md index 83fbb43..61982cc 100644 --- a/docs/TASKS/README.md +++ b/docs/TASKS/README.md @@ -28,7 +28,7 @@ files in section 3. | ID | Task | Owner | Status | Branch | Dependency | PR | | --- | --- | --- | --- | --- | --- | --- | | X01 | Melee ghost — full behaviour | Codex | Ready | `codex/x01-melee-ghost` | `EnemyBase` frozen | — | -| X02 | Ghost Archer — projectile, spacing, reposition | Codex | Done | `codex/x02-ghost-archer` | `EnemyBase` frozen | Pending review | +| X02 | Ghost Archer — projectile, spacing, reposition | Codex | Done | `codex/x02-ghost-archer` | `EnemyBase` frozen | #4 | | X03 | Charger ghost — telegraphed line charge, wall stagger | Codex | Ready | `codex/x03-charger-ghost` | `EnemyBase` frozen | — | | X04 | Boss attack pack — three moves | Codex | Blocked on art | `codex/x04-boss-attacks` | ADR-010 | — | | X05 | Sacrifice selection UI — A/B/C slots | Codex | Ready | `codex/x05-sacrifice-ui` | `SacrificeService` frozen | — |