diff --git a/actors/boss/boss.tscn b/actors/boss/boss.tscn index c143387..1bb7258 100644 --- a/actors/boss/boss.tscn +++ b/actors/boss/boss.tscn @@ -1,7 +1,7 @@ [gd_scene load_steps=10 format=3 uid="uid://bnnbossgate001"] [ext_resource type="Script" path="res://actors/boss/boss_actor.gd" id="1_boss"] -[ext_resource type="SpriteFrames" path="res://assets_v2/godot/gate_warden_frames.tres" id="2_frames"] +[ext_resource type="SpriteFrames" path="res://assets_v3/godot/gate_warden_frames.tres" id="2_frames"] [ext_resource type="Script" path="res://core/hurtbox.gd" id="3_hurtbox"] [ext_resource type="Script" path="res://core/hitbox.gd" id="4_hitbox"] [ext_resource type="Script" path="res://visuals/death_effect.gd" id="5_death_effect"] diff --git a/actors/boss/boss_frames.tres b/actors/boss/boss_frames.tres deleted file mode 100644 index ab5b1d6..0000000 --- a/actors/boss/boss_frames.tres +++ /dev/null @@ -1,212 +0,0 @@ -; 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=31 format=3] - -[ext_resource type="Texture2D" path="res://assets_v3/production/gate_warden/gate_warden_idle.png" id="1_idle"] -[ext_resource type="Texture2D" path="res://assets_v3/production/gate_warden/gate_warden_walk.png" id="2_run"] -[ext_resource type="Texture2D" path="res://assets_v3/production/gate_warden/gate_warden_attack.png" id="3_attack"] -[ext_resource type="Texture2D" path="res://assets_v3/production/gate_warden/gate_warden_hurt.png" id="4_hurt"] -[ext_resource type="Texture2D" path="res://assets_v3/production/gate_warden/gate_warden_death.png" id="5_death"] - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] -atlas = ExtResource("1_idle") -region = Rect2(0, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] -atlas = ExtResource("1_idle") -region = Rect2(320, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] -atlas = ExtResource("1_idle") -region = Rect2(640, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] -atlas = ExtResource("1_idle") -region = Rect2(960, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] -atlas = ExtResource("2_run") -region = Rect2(0, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] -atlas = ExtResource("2_run") -region = Rect2(320, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] -atlas = ExtResource("2_run") -region = Rect2(640, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] -atlas = ExtResource("2_run") -region = Rect2(960, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] -atlas = ExtResource("2_run") -region = Rect2(1280, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] -atlas = ExtResource("2_run") -region = Rect2(1600, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] -atlas = ExtResource("3_attack") -region = Rect2(0, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] -atlas = ExtResource("3_attack") -region = Rect2(320, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] -atlas = ExtResource("3_attack") -region = Rect2(640, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] -atlas = ExtResource("3_attack") -region = Rect2(960, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_4"] -atlas = ExtResource("3_attack") -region = Rect2(1280, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] -atlas = ExtResource("4_hurt") -region = Rect2(0, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] -atlas = ExtResource("4_hurt") -region = Rect2(320, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] -atlas = ExtResource("5_death") -region = Rect2(0, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] -atlas = ExtResource("5_death") -region = Rect2(320, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] -atlas = ExtResource("5_death") -region = Rect2(640, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] -atlas = ExtResource("5_death") -region = Rect2(960, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_4"] -atlas = ExtResource("5_death") -region = Rect2(1280, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_5"] -atlas = ExtResource("5_death") -region = Rect2(1600, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_6"] -atlas = ExtResource("5_death") -region = Rect2(1920, 0, 320, 256) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_7"] -atlas = ExtResource("5_death") -region = Rect2(2240, 0, 320, 256) - -[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": 5.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": 7.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") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_attack_4") -}], -"loop": false, -"name": &"attack", -"speed": 9.0 -}, { -"frames": [{ -"duration": 1.0, -"texture": SubResource("AtlasTexture_hurt_0") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_hurt_1") -}], -"loop": false, -"name": &"hurt", -"speed": 7.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") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_death_4") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_death_5") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_death_6") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_death_7") -}], -"loop": false, -"name": &"death", -"speed": 6.0 -}] diff --git a/actors/enemies/ghost_archer.tscn b/actors/enemies/ghost_archer.tscn index c9fa3e3..5eff2eb 100644 --- a/actors/enemies/ghost_archer.tscn +++ b/actors/enemies/ghost_archer.tscn @@ -2,7 +2,7 @@ [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://assets_v2/godot/ghost_archer_frames.tres" id="3_frames"] +[ext_resource type="SpriteFrames" path="res://assets_v3/godot/ghost_archer_frames.tres" id="3_frames"] [ext_resource type="Resource" path="res://data/actors/ghost_archer_tactics.tres" id="4_tactics"] [ext_resource type="Script" path="res://visuals/death_effect.gd" id="5_death_effect"] [ext_resource type="Texture2D" path="res://assets_v3/production/effects/death.png" id="6_death_texture"] diff --git a/actors/enemies/ghost_archer_frames.tres b/actors/enemies/ghost_archer_frames.tres deleted file mode 100644 index 1100a07..0000000 --- a/actors/enemies/ghost_archer_frames.tres +++ /dev/null @@ -1,177 +0,0 @@ -; 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_v3/production/ghost_archer/ghost_archer_idle.png" id="1_idle"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_archer/ghost_archer_walk.png" id="2_run"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_archer/ghost_archer_attack.png" id="3_attack"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_archer/ghost_archer_hurt.png" id="4_hurt"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_archer/ghost_archer_death.png" id="5_death"] - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] -atlas = ExtResource("1_idle") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] -atlas = ExtResource("1_idle") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] -atlas = ExtResource("1_idle") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] -atlas = ExtResource("1_idle") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] -atlas = ExtResource("2_run") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] -atlas = ExtResource("2_run") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] -atlas = ExtResource("2_run") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] -atlas = ExtResource("2_run") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] -atlas = ExtResource("2_run") -region = Rect2(768, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] -atlas = ExtResource("2_run") -region = Rect2(960, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] -atlas = ExtResource("3_attack") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] -atlas = ExtResource("3_attack") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] -atlas = ExtResource("3_attack") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] -atlas = ExtResource("3_attack") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] -atlas = ExtResource("4_hurt") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] -atlas = ExtResource("4_hurt") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] -atlas = ExtResource("5_death") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] -atlas = ExtResource("5_death") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] -atlas = ExtResource("5_death") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] -atlas = ExtResource("5_death") -region = Rect2(576, 0, 192, 160) - -[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": 6.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": 9.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": 10.0 -}, { -"frames": [{ -"duration": 1.0, -"texture": SubResource("AtlasTexture_hurt_0") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_hurt_1") -}], -"loop": false, -"name": &"hurt", -"speed": 8.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": 7.0 -}] diff --git a/actors/enemies/ghost_melee.tscn b/actors/enemies/ghost_melee.tscn index 7b06052..95e45e2 100644 --- a/actors/enemies/ghost_melee.tscn +++ b/actors/enemies/ghost_melee.tscn @@ -1,7 +1,7 @@ [gd_scene load_steps=8 format=3 uid="uid://bnnghostmelee01"] [ext_resource type="PackedScene" path="res://actors/enemies/enemy_base.tscn" id="1_base"] -[ext_resource type="SpriteFrames" path="res://assets_v2/godot/melee_ghost_frames.tres" id="2_frames"] +[ext_resource type="SpriteFrames" path="res://assets_v3/godot/melee_ghost_frames.tres" id="2_frames"] [ext_resource type="Script" path="res://actors/enemies/melee_ghost.gd" id="3_script"] [ext_resource type="Script" path="res://visuals/death_effect.gd" id="4_death_effect"] [ext_resource type="Texture2D" path="res://assets_v3/production/effects/death.png" id="5_death_texture"] diff --git a/actors/enemies/ghost_melee_frames.tres b/actors/enemies/ghost_melee_frames.tres deleted file mode 100644 index 0170a88..0000000 --- a/actors/enemies/ghost_melee_frames.tres +++ /dev/null @@ -1,177 +0,0 @@ -; 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_v3/production/ghost_melee/ghost_melee_idle.png" id="1_idle"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_melee/ghost_melee_walk.png" id="2_run"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_melee/ghost_melee_attack.png" id="3_attack"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_melee/ghost_melee_hurt.png" id="4_hurt"] -[ext_resource type="Texture2D" path="res://assets_v3/production/ghost_melee/ghost_melee_death.png" id="5_death"] - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] -atlas = ExtResource("1_idle") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] -atlas = ExtResource("1_idle") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] -atlas = ExtResource("1_idle") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] -atlas = ExtResource("1_idle") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] -atlas = ExtResource("2_run") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] -atlas = ExtResource("2_run") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] -atlas = ExtResource("2_run") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] -atlas = ExtResource("2_run") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] -atlas = ExtResource("2_run") -region = Rect2(768, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] -atlas = ExtResource("2_run") -region = Rect2(960, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] -atlas = ExtResource("3_attack") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] -atlas = ExtResource("3_attack") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] -atlas = ExtResource("3_attack") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] -atlas = ExtResource("3_attack") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] -atlas = ExtResource("4_hurt") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] -atlas = ExtResource("4_hurt") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] -atlas = ExtResource("5_death") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] -atlas = ExtResource("5_death") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] -atlas = ExtResource("5_death") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] -atlas = ExtResource("5_death") -region = Rect2(576, 0, 192, 160) - -[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": 6.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": 9.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": 10.0 -}, { -"frames": [{ -"duration": 1.0, -"texture": SubResource("AtlasTexture_hurt_0") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_hurt_1") -}], -"loop": false, -"name": &"hurt", -"speed": 8.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": 7.0 -}] diff --git a/actors/player/player.tscn b/actors/player/player.tscn index ca04743..18d9132 100644 --- a/actors/player/player.tscn +++ b/actors/player/player.tscn @@ -1,7 +1,7 @@ [gd_scene load_steps=8 format=3 uid="uid://bnnplayer0001"] [ext_resource type="Script" path="res://actors/player/player.gd" id="1_player"] -[ext_resource type="SpriteFrames" path="res://assets_v2/godot/player_frames.tres" id="2_frames"] +[ext_resource type="SpriteFrames" path="res://assets_v3/godot/player_frames.tres" id="2_frames"] [ext_resource type="Script" path="res://core/hurtbox.gd" id="3_hurtbox"] [ext_resource type="Script" path="res://core/hitbox.gd" id="4_hitbox"] [ext_resource type="Script" path="res://visuals/player_slash_visual.gd" id="5_slash"] diff --git a/actors/player/player_frames.tres b/actors/player/player_frames.tres deleted file mode 100644 index d7abeb8..0000000 --- a/actors/player/player_frames.tres +++ /dev/null @@ -1,211 +0,0 @@ -; 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=31 format=3] - -[ext_resource type="Texture2D" path="res://assets_v3/production/player/player_idle.png" id="1_idle"] -[ext_resource type="Texture2D" path="res://assets_v3/production/player/player_walk.png" id="2_run"] -[ext_resource type="Texture2D" path="res://assets_v3/production/player/player_idle.png" id="3_jump"] -[ext_resource type="Texture2D" path="res://assets_v3/production/player/player_attack.png" id="4_attack"] -[ext_resource type="Texture2D" path="res://assets_v3/production/player/player_hurt.png" id="5_hurt"] -[ext_resource type="Texture2D" path="res://assets_v3/production/player/player_death.png" id="6_death"] - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] -atlas = ExtResource("1_idle") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] -atlas = ExtResource("1_idle") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] -atlas = ExtResource("1_idle") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] -atlas = ExtResource("1_idle") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] -atlas = ExtResource("2_run") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] -atlas = ExtResource("2_run") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] -atlas = ExtResource("2_run") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] -atlas = ExtResource("2_run") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] -atlas = ExtResource("2_run") -region = Rect2(768, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] -atlas = ExtResource("2_run") -region = Rect2(960, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_jump_0"] -atlas = ExtResource("3_jump") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_jump_1"] -atlas = ExtResource("3_jump") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] -atlas = ExtResource("4_attack") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] -atlas = ExtResource("4_attack") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] -atlas = ExtResource("4_attack") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] -atlas = ExtResource("4_attack") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] -atlas = ExtResource("5_hurt") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] -atlas = ExtResource("5_hurt") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] -atlas = ExtResource("6_death") -region = Rect2(0, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] -atlas = ExtResource("6_death") -region = Rect2(192, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] -atlas = ExtResource("6_death") -region = Rect2(384, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] -atlas = ExtResource("6_death") -region = Rect2(576, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_4"] -atlas = ExtResource("6_death") -region = Rect2(768, 0, 192, 160) - -[sub_resource type="AtlasTexture" id="AtlasTexture_death_5"] -atlas = ExtResource("6_death") -region = Rect2(960, 0, 192, 160) - -[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": 6.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_jump_0") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_jump_1") -}], -"loop": false, -"name": &"jump", -"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": 8.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") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_death_4") -}, { -"duration": 1.0, -"texture": SubResource("AtlasTexture_death_5") -}], -"loop": false, -"name": &"death", -"speed": 8.0 -}] diff --git a/assets_v2/godot/gate_warden_frames.tres b/assets_v3/godot/gate_warden_frames.tres similarity index 100% rename from assets_v2/godot/gate_warden_frames.tres rename to assets_v3/godot/gate_warden_frames.tres diff --git a/assets_v2/godot/ghost_archer_frames.tres b/assets_v3/godot/ghost_archer_frames.tres similarity index 100% rename from assets_v2/godot/ghost_archer_frames.tres rename to assets_v3/godot/ghost_archer_frames.tres diff --git a/assets_v2/godot/melee_ghost_frames.tres b/assets_v3/godot/melee_ghost_frames.tres similarity index 100% rename from assets_v2/godot/melee_ghost_frames.tres rename to assets_v3/godot/melee_ghost_frames.tres diff --git a/assets_v2/godot/player_frames.tres b/assets_v3/godot/player_frames.tres similarity index 100% rename from assets_v2/godot/player_frames.tres rename to assets_v3/godot/player_frames.tres diff --git a/core/meta_config.gd b/core/meta_config.gd new file mode 100644 index 0000000..46a168e --- /dev/null +++ b/core/meta_config.gd @@ -0,0 +1,27 @@ +class_name MetaConfig +extends Resource +## Tunables for the meta layer only. Kept apart from BalanceConfig on purpose: +## in-run balance is owned by the combat design and must not be disturbed by +## somebody tuning the shop. +## +## Shipped instance: res://data/meta_config.tres. + +@export_group("Soul Ash reward") +## Paid for finishing a run at all, so a bad run still moves the meta forward. +@export var soul_ash_base: int = 5 +@export var soul_ash_per_kill: int = 2 +@export var soul_ash_victory_bonus: int = 25 + +@export_group("Tempered Blade") +@export var tempered_blade_cost: int = 40 +## Flat starting attack added to every future run once bought. +@export var tempered_blade_attack_bonus: float = 2.0 + + +## Prices a finished run. Pure: same result in, same number out — which is why +## the reward rule can be tested without a save file or a scene. +func soul_ash_for(result: RunResult) -> int: + var earned := soul_ash_base + soul_ash_per_kill * maxi(0, result.kills) + if result.is_victory(): + earned += soul_ash_victory_bonus + return maxi(0, earned) diff --git a/core/meta_save.gd b/core/meta_save.gd new file mode 100644 index 0000000..7f2fa5f --- /dev/null +++ b/core/meta_save.gd @@ -0,0 +1,73 @@ +class_name MetaSave +extends RefCounted +## Reads and writes the meta profile as JSON. +## +## The path is injected so tests write to a scratch file instead of the player's +## real save. That is the whole reason this is an object rather than a set of +## statics: the filesystem is exactly the dependency you want to be able to +## point somewhere else. +## +## Failure policy: a missing, unreadable or malformed save yields a fresh +## profile. Losing progress is bad; refusing to launch is worse. + +const DEFAULT_PATH := "user://meta_save.json" +## Bump when the stored shape changes in a way `MetaState.from_dictionary` +## cannot absorb, and add the migration there. +const VERSION := 1 + +var _path: String + +func _init(path: String = DEFAULT_PATH) -> void: + _path = path + +func path() -> String: + return _path + +func exists() -> bool: + return FileAccess.file_exists(_path) + + +func load_state() -> MetaState: + if not FileAccess.file_exists(_path): + return MetaState.new() + var file := FileAccess.open(_path, FileAccess.READ) + if file == null: + push_warning("MetaSave: cannot read %s (%d) — starting fresh" % [_path, FileAccess.get_open_error()]) + return MetaState.new() + var parsed: Variant = JSON.parse_string(file.get_as_text()) + file.close() + if typeof(parsed) != TYPE_DICTIONARY: + push_warning("MetaSave: %s is not valid JSON — starting fresh" % _path) + return MetaState.new() + var payload: Dictionary = parsed + var stored_version := int(payload.get("version", 0)) + if stored_version > VERSION: + push_warning( + "MetaSave: %s was written by version %d, this build reads %d — starting fresh" + % [_path, stored_version, VERSION] + ) + return MetaState.new() + return MetaState.from_dictionary(payload.get("meta", {})) + + +func save_state(meta: MetaState) -> bool: + var file := FileAccess.open(_path, FileAccess.WRITE) + if file == null: + push_error("MetaSave: cannot write %s (%d)" % [_path, FileAccess.get_open_error()]) + return false + file.store_string(JSON.stringify({ + "version": VERSION, + "meta": meta.to_dictionary(), + }, "\t")) + file.close() + return true + + +## Deletes the file and returns a fresh profile. The caller decides what to do +## with it — this does not reach into anyone's state. +func reset() -> MetaState: + if FileAccess.file_exists(_path): + var error := DirAccess.remove_absolute(ProjectSettings.globalize_path(_path)) + if error != OK: + push_warning("MetaSave: could not delete %s (%d)" % [_path, error]) + return MetaState.new() diff --git a/core/meta_state.gd b/core/meta_state.gd new file mode 100644 index 0000000..31427bd --- /dev/null +++ b/core/meta_state.gd @@ -0,0 +1,76 @@ +class_name MetaState +extends RefCounted +## Everything that survives a run. Five values and nothing else — the Ghost +## Market is a loop closer in M2, not an economy. +## +## Mirrors RunState's shape deliberately: private fields, reads through getters, +## writes through named commands. UI reads and asks; it does not assign. +## +## Serialisation lives here rather than in MetaSave because the field list and +## its JSON shape are one piece of knowledge. MetaSave owns the file. + +var _soul_ash: int = 0 +var _runs: int = 0 +var _deaths: int = 0 +var _victories: int = 0 +var _tempered_blade_owned: bool = false + + +# --- reads ------------------------------------------------------------------ +func soul_ash() -> int: return _soul_ash +func runs() -> int: return _runs +func deaths() -> int: return _deaths +func victories() -> int: return _victories +func tempered_blade_owned() -> bool: return _tempered_blade_owned + +## Flat attack this profile grants to a new run. The only channel through which +## meta progression touches a run's numbers. +func attack_bonus(config: MetaConfig) -> float: + return config.tempered_blade_attack_bonus if _tempered_blade_owned else 0.0 + +func can_buy_tempered_blade(config: MetaConfig) -> bool: + return not _tempered_blade_owned and _soul_ash >= config.tempered_blade_cost + + +# --- commands --------------------------------------------------------------- +## Applies a finished run: prices it, banks the ash, and moves the counters. +## Mutates `result.soul_ash_earned` so the result screen and the market both +## show the figure that was actually banked. +func record_run(result: RunResult, config: MetaConfig) -> void: + result.soul_ash_earned = config.soul_ash_for(result) + _soul_ash += result.soul_ash_earned + _runs += 1 + if result.is_victory(): + _victories += 1 + else: + _deaths += 1 + +## Returns false and changes nothing when it is unaffordable or already owned. +func buy_tempered_blade(config: MetaConfig) -> bool: + if not can_buy_tempered_blade(config): + return false + _soul_ash -= config.tempered_blade_cost + _tempered_blade_owned = true + return true + + +# --- serialisation ---------------------------------------------------------- +func to_dictionary() -> Dictionary: + return { + "soul_ash": _soul_ash, + "runs": _runs, + "deaths": _deaths, + "victories": _victories, + "tempered_blade_owned": _tempered_blade_owned, + } + +## Missing or malformed keys fall back to a fresh profile's value rather than +## throwing: a save written by an older build should cost the player nothing. +static func from_dictionary(data: Dictionary) -> MetaState: + var meta := MetaState.new() + meta._soul_ash = maxi(0, int(data.get("soul_ash", 0))) + meta._runs = maxi(0, int(data.get("runs", 0))) + meta._deaths = maxi(0, int(data.get("deaths", 0))) + meta._victories = maxi(0, int(data.get("victories", 0))) + meta._tempered_blade_owned = bool(data.get("tempered_blade_owned", false)) + return meta diff --git a/core/run_state.gd b/core/run_state.gd index 45ab450..88e7234 100644 --- a/core/run_state.gd +++ b/core/run_state.gd @@ -180,6 +180,13 @@ func set_stamina_recovery(value: float) -> void: _stamina_recovery = maxf(0.0, value) recalculate_derived() +## Flat base attack, used by meta progression at run creation. Offensive, not +## structural, so it does not move integrity — a permanent upgrade must not make +## the body read as more broken or more whole. +func add_flat_attack(value: float) -> void: + _attack = maxf(0.0, _attack + value) + recalculate_derived() + func add_additive(value: float) -> void: _additive_sum += value recalculate_derived() diff --git a/data/meta_config.tres b/data/meta_config.tres new file mode 100644 index 0000000..8baff82 --- /dev/null +++ b/data/meta_config.tres @@ -0,0 +1,11 @@ +[gd_resource type="Resource" script_class="MetaConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://core/meta_config.gd" id="1_meta_config"] + +[resource] +script = ExtResource("1_meta_config") +soul_ash_base = 5 +soul_ash_per_kill = 2 +soul_ash_victory_bonus = 25 +tempered_blade_cost = 40 +tempered_blade_attack_bonus = 2.0 diff --git a/docs/AI_HANDOFF.md b/docs/AI_HANDOFF.md index 50155b4..418f95e 100644 --- a/docs/AI_HANDOFF.md +++ b/docs/AI_HANDOFF.md @@ -1,12 +1,16 @@ # AI Handoff -- **Current branch:** `codex/production-asset-sprint-01` -- **Current phase:** Art V3 production assets integrated on the latest `develop` -- **Next owner:** Game Director for Draft PR #8 review +- **Current branch:** `codex/m2-runtime-art-source-fix` +- **Current phase:** M2 Ghost Market loop integrated with the Art V3 runtime +- **Next owner:** Game Director for integration review - **Last updated:** 2026-08-02 (Australia/Melbourne) ## Playable status +The game now opens in the **Ghost Market**: start a run, fight, die or win, read +the run result, return to the market with soul ash banked, buy the one upgrade, +run again. See `docs/GHOST_MARKET_LOOP.md`. + The complete M1 loop remains playable: arena wave → sacrifice → Gate Warden → victory/defeat → restart. Normal gameplay now renders the approved Art V3 player, melee ghost, Ghost Archer, Ghost Arrow, Gate Warden, environment, @@ -31,6 +35,28 @@ kept only as develop's emergency fallback; normal rendering uses the V3 arrow. See `docs/PRODUCTION_ASSET_REPLACEMENT.md` and the V3 production `frame_map.json` files for the replacement inventory and metadata. +## M2 Ghost Market loop + +| Piece | Where | +| --- | --- | +| Main scene | `scenes/app.tscn` — owns `MetaState`, swaps market and run | +| Hub | `scenes/ghost_market.tscn` — soul ash, latest run, one upgrade, start, reset | +| Persisted state | `core/meta_state.gd` — soul ash, runs, deaths, victories, Tempered Blade | +| Run summary | `systems/run_result.gd` — outcome, duration, kills, sacrifices, integrity, imbalance, soul ash | +| Save | `core/meta_save.gd` — `user://meta_save.json`, versioned, path injectable | +| Meta tunables | `data/meta_config.tres` — reward rates and upgrade cost, apart from `BalanceConfig` | + +Death and victory share one run-end pipeline; duplicate end requests are ignored +at both the coordinator and the app layer. Combat, enemy AI, the sacrifice +system, balance values, art and animations were not modified. The two changes to +existing gameplay code are recorded as ADR-013 (`run_finished` now carries a +`RunResult`; `RunState.add_flat_attack` added) and ADR-014 (one live child scene +rather than hiding the idle one — a hidden `Node2D` does not hide its +`CanvasLayer` children, and the market was drawing over the run). + +Market visuals reuse existing floor, brick, brazier and ghost-fire art and are +decoration only: no script reads them, so real market art changes no code. + ## M1.5 integration | Area | Integrated mapping | @@ -41,7 +67,7 @@ files for the replacement inventory and metadata. | Ghost Arrow | supplied 32×32 projectile texture; hidden emergency fallback | | Gate Warden | idle, walk, attack 1, hurt, death | | Effects | blade slash, confirmed hit, post-animation death dissolve | -| HUD | HP, stamina, attack, integrity/Boss, imbalance, sacrifice icons | +| HUD | V3 HP, Boss and sacrifice icons; text/bar stats retain existing behaviour | Animation frames are authoritative only for presentation and for gating the existing attack flow. Player and melee active frames are 4–5; Gate Warden active @@ -55,12 +81,12 @@ frame rates, pivots, foot positions, timing windows, fallbacks, and unused art. ## Architecture compliance -No frozen framework interface changed. In particular, this work leaves -`RunState`, `CombatResolver`, `DamageContext`, `DamageResult`, -`IntegrityService`, `SacrificeService`, `EventBus`, `RNGService`, -`RunCoordinator`, `EnemyBase`, the player controller/state-machine foundation, -and the Hitbox/Hurtbox pipeline unchanged. The V2 adapters live in concrete -actor/state scripts and scenes. +The integration makes no new frozen-interface change. It retains Claude's M2 +additions to `RunState` and `RunCoordinator`, documented in ADR-013, while +leaving `CombatResolver`, `DamageContext`, `DamageResult`, `IntegrityService`, +`SacrificeService`, `EventBus`, `RNGService`, `EnemyBase`, the player +controller/state-machine foundation, and the Hitbox/Hurtbox pipeline unchanged. +Production animation adapters remain in concrete actor/state scripts and scenes. ## Notable files @@ -69,8 +95,6 @@ actor/state scripts and scenes. - `actors/enemies/melee_ghost.gd` - `visuals/player_slash_visual.gd` - `visuals/death_effect.gd` -- `visuals/one_shot_sprite.gd` -- `visuals/combat_effects.gd` - `tests/cases/test_art_v2.gd` - `docs/ART_V2_INTEGRATION_REPORT.md` @@ -84,21 +108,25 @@ actor/state scripts and scenes. ## Tests -- **Local:** 71 tests, 686 assertions, 0 failures -- **Engine:** Godot 4.7.1 stable headless locally; CI remains pinned to 4.3 +- **Local:** 85 tests, 796 assertions, 0 failures +- **Engine:** Godot 4.3 stable clean import and test run - **Commands:** - `godot --headless --import` - `godot --headless --path . res://tests/test_runner.tscn` -Coverage added for V2 resource/scene loading, all required player mappings, +Coverage includes production resource/scene loading, all required player mappings, player/melee/Boss active frames, Archer release timing, hurt/death overrides, Boss victory deferral, no post-death release, and debug-overlay defaults. +The M2 integration additionally boots the real `App`, starts a run, and checks +the live player, wave enemies, Boss, HUD, and combat feedback resolve to V3. + +See `docs/RUNTIME_ART_SOURCE_AUDIT.md` for the runtime dependency trace and the +exact branch-divergence cause of the Art V2 regression. -Visual evidence is stored in `assets_v2/review/integration_*.png`: idle, run, -jump, player active-frame hitbox, Archer aim/arrow, and Boss attack/death. Godot -rendered the live main scene at 640×360. The environment's interactive macOS -control service and MP4 conversion were unavailable, so no preview video was -added. +Runtime evidence from the real App path is stored in +`docs/screenshots/runtime_market_v3.png`, `runtime_combat_v3.png`, and +`runtime_boss_v3.png`. Godot 4.3 rendered the Ghost Market, V3 player and melee +enemy with the current HUD, and the V3 Gate Warden at 640×360. ## Known limitations and next work @@ -109,6 +137,14 @@ added. 4. Combat has no audio; audio integration remains outside M1.5 scope. 5. X04 now has a second supplied attack strip, but still depends on the Boss `PhaseController` / `AttackScheduler` foundation and an approved move design. +6. Meta progression is one currency and one upgrade. No upgrade tree, no meta + unlocks touching the sacrifice pool or enemy roster, no multiple profiles. +7. **Engine version drift.** `project.godot` declares `4.3`, `godot-tests` pins + `4.3-stable`, but the committed `assets_v2/**.import` files carry Godot 4.4+ + keys (`compress/uastc_level`, `process/channel_remap/*`) and the M1.5 handoff + reports local work on 4.7.1. Opening the project on 4.3 rewrites those files. + They were reverted rather than committed on this branch. The team should pick + one engine version and align `project.godot`, CI and local tooling. ## Frozen interfaces diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2d44a0f..e115ebf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -48,7 +48,16 @@ Follows Prototype Development Pack A3, plus `scenes/` (see ADR-009). ## 3. Scene tree -`scenes/main.tscn` matches Prototype Development Pack A4: +`scenes/app.tscn` is the main scene and the composition root outside a run. It +owns the meta profile and keeps exactly one child scene in the tree — the market +or a run, never both. See `docs/GHOST_MARKET_LOOP.md`. + +``` +App (Node, scenes/app.gd) ← owns MetaState + MetaSave +└── GhostMarket (scenes/ghost_market.tscn) ← or Main, never both +``` + +`scenes/main.tscn` is one run, and matches Prototype Development Pack A4: ``` Main (Node2D, scenes/main.gd) ← composition root @@ -64,10 +73,11 @@ Main (Node2D, scenes/main.gd) ← composition root └── DebugShapes (DebugShapeOverlay) ``` -`Main._ready()` binds every observer and only then calls -`RunCoordinator.start_run()`. Starting the run from the coordinator's own -`_ready` would let observers bind after the opening `state_ready` had already -fired. +`Main._ready()` binds every observer before the run starts. Starting the run +from the coordinator's own `_ready` would let observers bind after the opening +`state_ready` had already fired. `App` sets `autostart = false` and calls +`start_run` itself once it has bound too; with `autostart` left on, `main.tscn` +plays standalone in the editor with no meta layer. Actor scenes: @@ -90,6 +100,8 @@ No core calculation lives in `Main`, in `Arena` or in any UI node. | Sacrifice library | `GameData` (autoload) | Process | | RNG streams | `RNGService` (autoload) | Re-seeded per run | | Active `RunState` | `RunCoordinator` | One run | +| `MetaState` (soul ash, counters, upgrade) | `App` | Process, persisted to `user://meta_save.json` | +| Latest `RunResult` | `App` | Until the next run ends | | Enemy and Boss HP | The actor instance | One actor | | Everything the UI shows | Nobody — it is read each frame | — | @@ -265,3 +277,5 @@ Listed so nobody mistakes absence for oversight. Each is scoped to a later task. - No three-slot sacrifice generator: M1 offers one card at a fixed point. - No Boss phase controller or attack scheduler. - No telemetry persistence: events are emitted but nothing writes them to disk. +- Meta progression is one upgrade and one currency (M2). No upgrade tree, no + meta unlocks touching the sacrifice pool or the enemy roster. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 5e01fa9..0739b99 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -235,3 +235,44 @@ Append decisions in this format; do not rewrite accepted history. must use the existing configured timer as a documented fallback. Only actions already supported by gameplay are mapped. - **Interfaces affected:** none. No frozen or protected framework file changed. + +## ADR-013: `run_finished` carries a `RunResult` instead of a bare outcome + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** M2 needs duration, kills, sacrifices, integrity and imbalance at + the moment a run ends, and needs death and victory to travel one path. The + frozen signature was `run_finished(outcome: StringName)`, which forced every + listener to reach back into `RunCoordinator` and reassemble the figures — a + second place where "what the run amounted to" would be defined. +- **Decision:** `RunCoordinator._finish_run` builds a `RunResult` and emits it. + `RunCoordinator` gains a kill count and a start timestamp; `RunState` gains + `add_flat_attack` for meta progression. Nothing else in the run changes: + `CombatResolver`, enemy AI, the sacrifice system and `BalanceConfig` are + untouched. +- **Consequences:** One listener needed updating (`ResultScreen`). Any future + telemetry sink gets the whole picture from the payload. `RunResult` is a plain + value, so it outlives the run scene and a test can build one by hand. +- **Interfaces affected:** `RunCoordinator.run_finished`, + `ResultScreen.show_outcome`, `RunState.add_flat_attack` (additive). + +## ADR-014: One live child scene, rather than hiding the idle one + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** The first Ghost Market implementation kept both scenes in the + tree and toggled `visible` on the idle one. It drew the market's title, panel + and braziers straight over the running game. `GhostMarket` and `Main` are + `Node2D`s whose UI lives on `CanvasLayer` children, and a `CanvasLayer` does + not inherit visibility from a parent `Node2D`. +- **Decision:** `App` instantiates and frees. Exactly one of the market and the + run exists at any moment. +- **Consequences:** No visibility subtleties and no stale nodes between runs; a + second run rebuilds the run scene from scratch, which is a stronger reset than + `_clear_actors`. The in-scene restart path (debug `R`) still relies on + `_clear_actors` and has its own test. Rebuilding a scene per transition is + negligible at this size. +- **Interfaces affected:** `App.market()` / `App.run()` return null when the + other is active. diff --git a/docs/GHOST_MARKET_LOOP.md b/docs/GHOST_MARKET_LOOP.md new file mode 100644 index 0000000..3e19bc4 --- /dev/null +++ b/docs/GHOST_MARKET_LOOP.md @@ -0,0 +1,106 @@ +# Ghost Market Loop + +> **STATUS: ACTIVE — M2** · Owner: Claude + +The loop between runs. Minimal on purpose: a hub, one upgrade, one save file. + +``` +Ghost Market → Start Run → Combat → Death / Victory → Run Result → Ghost Market +``` + +## Scenes + +| Scene | Role | +| --- | --- | +| `scenes/app.tscn` | Main scene. Owns `MetaState` and `MetaSave`; swaps between the two below. | +| `scenes/ghost_market.tscn` | Hub UI: soul ash, latest run, one upgrade, start, reset. | +| `scenes/main.tscn` | One run. Unchanged in behaviour; it does not know the market exists. | + +Exactly one of the last two is in the tree at a time. Hiding the idle one is not +an option: both present their UI on `CanvasLayer`s, and a `CanvasLayer` does not +inherit visibility from a parent `Node2D`, so a "hidden" market keeps drawing. + +`main.tscn` still runs standalone (F6 in the editor): `autostart` defaults true +and dismissing the result screen restarts in place. `App` sets `autostart` false +and drives the run itself. + +## Run end pipeline + +Death and victory already converge on `RunCoordinator._finish_run`, which is +guarded by the phase machine. Everything after that is one path: + +``` +RunCoordinator._finish_run(outcome) + → builds RunResult (outcome, duration, kills, sacrifices, integrity, imbalance) + → run_finished(result) +App._on_run_finished(result) + → MetaState.record_run (prices the run, banks the ash, moves the counters) + → MetaSave.save_state +ResultScreen.dismissed + → App.open_market +``` + +Banking happens on run end, not on dismissal: a player who quits at the result +screen keeps what they earned. + +**Duplicate end requests are ignored** at two layers, each for its own reason — +`RunCoordinator` will not leave `PHASE_RESULT` twice, and `App._run_recorded` +guarantees one reward per run even if the signal is re-emitted. + +## Data + +`MetaState` (`core/meta_state.gd`) persists five values and nothing else: +soul ash, runs, deaths, victories, Tempered Blade owned. Private fields, reads +through getters, writes through named commands — the same rule `RunState` +follows, so the market observes and asks rather than assigning. + +`RunResult` (`systems/run_result.gd`) is a plain value: outcome, duration, kills, +sacrifices, integrity, imbalance, soul ash earned. The run fills all but the +last; pricing is a meta concern. + +`MetaConfig` (`data/meta_config.tres`) holds the meta tunables, kept apart from +`BalanceConfig` so tuning the shop cannot disturb combat balance: + +| Field | Default | +| --- | --- | +| `soul_ash_base` | 5 | +| `soul_ash_per_kill` | 2 | +| `soul_ash_victory_bonus` | 25 | +| `tempered_blade_cost` | 40 | +| `tempered_blade_attack_bonus` | 2.0 | + +## Save + +`user://meta_save.json`, shape `{ "version": 1, "meta": { … } }`. +`MetaSave` takes its path as a constructor argument so tests write to a scratch +file. A missing, unreadable, malformed or newer-versioned save yields a fresh +profile with a warning — refusing to launch would be worse than losing a save. +Bump `MetaSave.VERSION` when the stored shape changes in a way +`MetaState.from_dictionary` cannot absorb, and add the migration there. + +## The one upgrade + +**Tempered Blade** — costs soul ash once, grants flat starting attack to every +later run, permanently. It reaches a run through exactly one channel: +`App` sets `Main.meta_attack_bonus`, `RunCoordinator` applies it via +`RunState.add_flat_attack` when the run's state is created. Offensive, not +structural, so a permanent upgrade cannot make the body read as more or less +whole and the integrity formula is unaffected. + +## What a new run resets + +Reset by creating a fresh `RunState` and clearing the actor root: current and +max HP, stamina, integrity, imbalance, sacrifice history, enemies, corpses and +projectiles. Kept: soul ash and the upgrade, which live in `MetaState`. + +## Visuals + +The market reuses existing floor, brick, brazier and ghost-fire art. It is +decoration: no script reads it, so replacing it with real market art changes no +code. There is no merchant, no NPC and no walkable hub — one panel. + +## Not in M2 + +No second upgrade, no upgrade tree, no currency sinks beyond the one purchase, +no run modifiers chosen in the hub, no meta unlocks affecting the sacrifice pool +or enemy roster, no cloud save, no multiple profiles. diff --git a/docs/PRODUCTION_ASSET_REPLACEMENT.md b/docs/PRODUCTION_ASSET_REPLACEMENT.md index da67c01..ff483f1 100644 --- a/docs/PRODUCTION_ASSET_REPLACEMENT.md +++ b/docs/PRODUCTION_ASSET_REPLACEMENT.md @@ -10,16 +10,16 @@ are excluded. | Current asset | Scene / consumer | Replacement asset | Priority | | --- | --- | --- | --- | -| `assets/player/player_idle.png` | `actors/player/player_frames.tres` | `assets_v3/production/player/player_idle.png` | P0 | -| `assets/player/player_run.png` | `actors/player/player_frames.tres` | `assets_v3/production/player/player_walk.png` | P0 | -| `assets/player/player_attack.png` | `actors/player/player_frames.tres` | `assets_v3/production/player/player_attack.png` | P0 | -| `assets/player/player_hurt.png` | `actors/player/player_frames.tres` | `assets_v3/production/player/player_hurt.png` | P0 | -| `assets/player/player_death.png` | `actors/player/player_frames.tres` | `assets_v3/production/player/player_death.png` | P0 | +| `assets/player/player_idle.png` | `assets_v3/godot/player_frames.tres` | `assets_v3/production/player/player_idle.png` | P0 | +| `assets/player/player_run.png` | `assets_v3/godot/player_frames.tres` | `assets_v3/production/player/player_walk.png` | P0 | +| `assets/player/player_attack.png` | `assets_v3/godot/player_frames.tres` | `assets_v3/production/player/player_attack.png` | P0 | +| `assets/player/player_hurt.png` | `assets_v3/godot/player_frames.tres` | `assets_v3/production/player/player_hurt.png` | P0 | +| `assets/player/player_death.png` | `assets_v3/godot/player_frames.tres` | `assets_v3/production/player/player_death.png` | P0 | | `assets/player/player_jump.png` | player jump state | production idle frame reused; no new jump art in Sprint 01 | P0 | -| `assets/enemy/ghost_melee_*` | `actors/enemies/ghost_melee_frames.tres` | `assets_v3/production/ghost_melee/ghost_melee_*.png` | P0 | -| `assets/enemy/ghost_archer_*` | `actors/enemies/ghost_archer_frames.tres` | `assets_v3/production/ghost_archer/ghost_archer_*.png` | P0 | +| `assets/enemy/ghost_melee_*` | `assets_v3/godot/melee_ghost_frames.tres` | `assets_v3/production/ghost_melee/ghost_melee_*.png` | P0 | +| `assets/enemy/ghost_archer_*` | `assets_v3/godot/ghost_archer_frames.tres` | `assets_v3/production/ghost_archer/ghost_archer_*.png` | P0 | | `Polygon2D` arrow fallback | `actors/enemies/ghost_arrow.tscn` | `assets_v3/production/projectiles/ghost_arrow.png` | P0 | -| `assets/boss/boss_*` | `actors/boss/boss_frames.tres` | `assets_v3/production/gate_warden/gate_warden_*.png` | P0 | +| `assets/boss/boss_*` | `assets_v3/godot/gate_warden_frames.tres` | `assets_v3/production/gate_warden/gate_warden_*.png` | P0 | | `assets/tiles/tile_floor.png` | `scenes/arena.tscn` floor region | `assets_v3/production/environment/stone_floor.png` | P0 | | `assets/tiles/tile_stone_brick.png` | arena wall/boundary sprites | `assets_v3/production/environment/stone_wall.png` | P0 | | arena tomb/flag focal prop | `scenes/arena.tscn` | `assets_v3/production/environment/fortress_gate.png` | P1 | diff --git a/docs/RUNTIME_ART_SOURCE_AUDIT.md b/docs/RUNTIME_ART_SOURCE_AUDIT.md new file mode 100644 index 0000000..91179c9 --- /dev/null +++ b/docs/RUNTIME_ART_SOURCE_AUDIT.md @@ -0,0 +1,115 @@ +# Runtime Art Source Audit + +## Audit context + +The M2 Ghost Market line and Art V3 production line both forked from commit +`5bbeb87`. The audit compares the Claude M2 tip (`c418516`) with latest +`develop` (`1ecdd05`) and traces the integrated working tree from the real +application entry point. Line numbers below refer to those committed revisions +unless explicitly marked as the integrated working tree. + +## Live runtime chain + +`project.godot:13` selects `scenes/app.tscn`. `scenes/app.tscn:5` exports +`scenes/main.tscn` as the run scene, and `scenes/app.gd:70-83` instantiates that +exact `PackedScene`. The combat composition selects its concrete actors at +`scenes/main.tscn:6-10`. + +| Actor / visual | Spawned scene | Visual node | Runtime texture / animation source | Current | Expected | Status | +| --- | --- | --- | --- | --- | --- | --- | +| Player | `actors/player/player.tscn` | `Sprite` (`AnimatedSprite2D`) | scene line 4 → `assets_v3/godot/player_frames.tres:5-13` → `assets_v3/production/player/**` | V3 | V3 | CORRECT | +| Melee Ghost | `actors/enemies/ghost_melee.tscn` | inherited `Sprite` | scene line 4 → `assets_v3/godot/melee_ghost_frames.tres:5-9` → `assets_v3/production/ghost_melee/**` | V3 | V3 | CORRECT | +| Ghost Archer | `actors/enemies/ghost_archer.tscn` | inherited `Sprite` | scene line 5 → `assets_v3/godot/ghost_archer_frames.tres:5-10` → `assets_v3/production/ghost_archer/**` | V3 | V3 | CORRECT | +| Ghost Arrow | `actors/enemies/ghost_arrow.tscn` | `Visual` (`Sprite2D`) | scene line 4 → `assets_v3/production/projectiles/ghost_arrow.png` | V3 | V3 | CORRECT | +| Gate Warden | `actors/boss/boss.tscn` | `Sprite` (`AnimatedSprite2D`) | scene line 4 → `assets_v3/godot/gate_warden_frames.tres:5-10` → `assets_v3/production/gate_warden/**` | V3 | V3 | CORRECT | +| Combat effects | `scenes/main.tscn` | `CombatFeedback` | `visuals/combat_feedback.gd:10-22` → V3 slash, hit, blood, death | V3 | V3 | CORRECT | +| Primary HUD icons | `ui/hud.tscn` | `Player/Icon`, `Boss/Icon` | lines 6-7 → V3 HP and Boss icons | V3 | V3 | CORRECT | +| Secondary HUD icons | `ui/hud.tscn` | obsolete stamina, attack, integrity, imbalance icon nodes | removed; their text and bars remain | absent | V3-only runtime | CORRECT | + +`tools/generate_sprite_frames.py` writes the four live resources under +`assets_v3/godot/`. This generator plus those generated resources is the single +source of truth for production actor animations. Legacy generated resources +under `assets_v2/godot/` and unreferenced actor-local `*_frames.tres` files are +not part of the live graph. + +## Exact source of the observed Art V2 visuals + +The regression is branch selection, not a Godot cache defect. At the M2 tip +`c418516`, the same compatibility resources were still their pre-production +versions: + +- `assets_v2/godot/player_frames.tres:5-13` loaded + `assets_v2/characters/player/**`. +- `assets_v2/godot/melee_ghost_frames.tres:5-9` loaded + `assets_v2/characters/melee_ghost/**`. +- `assets_v2/godot/ghost_archer_frames.tres:5-10` loaded + `assets_v2/characters/ghost_archer/**`. +- `assets_v2/godot/gate_warden_frames.tres:5-10` loaded + `assets_v2/characters/gate_warden/**`. +- `actors/enemies/ghost_arrow.tscn:4` loaded the V2 arrow. +- `actors/player/player.tscn:8,10`, the concrete enemy/Boss scenes, and + `scenes/main.tscn:17` loaded V2 slash, death, and hit effects. +- `ui/hud.tscn:6-10` loaded V2 HUD icons. + +M2's `App` correctly instantiates `scenes/main.tscn`; it does not override an +actor or texture. Running the M2 branch therefore selected its older committed +run resource graph exactly as authored. Art V3 existed only on the divergent +production branch. A later production-branch commit (`ba3ae3f`) changed only +`project.godot` to point at `scenes/app.tscn` but did not contain that scene, so +it is not a valid integration source. + +## Likely-cause verdicts + +1. **Scene reference mismatch — rejected.** `App` exports and instantiates the + current `scenes/main.tscn`; `Main` exports the current concrete actor scenes. +2. **Actor scene points to Art V2 — confirmed on M2.** The scene points to a + compatibility `.tres` whose M2 revision loaded V2 atlases. The integrated + revision loads V3 atlases. +3. **Duplicate visual nodes — rejected for live actors.** Each actor has one + character `AnimatedSprite2D`. Actor-local frame resources are duplicate + files, not simultaneously rendered nodes. +4. **Fallback always active — rejected.** Ghost Arrow's polygon starts hidden; + `ghost_arrow.gd:23-25` enables it only if `Visual` or its texture is null. +5. **Old merge resolution — confirmed as a branch-history risk.** M2 never + contained the production scene/resource commits; choosing its versions + recreates the regression. +6. **Runtime-generated SpriteFrames — rejected for actors.** No actor script + replaces `sprite_frames`; only transient combat effects build frames at + runtime, from V3 preloads. +7. **PackedScene override — rejected.** `app.tscn` exports current `main.tscn`; + no test or runtime code replaces its player, enemy, or Boss scene fields. +8. **Import/cache mismatch — rejected.** The M2 committed source paths are V2; + cache clearing cannot turn them into V3. A clean import remains required for + validation. +9. **Wrong branch / missing merge — confirmed root cause.** Latest `develop` + contained Art V3 but not M2; M2 contained the app loop but predated Art V3. +10. **Wrong launch scene — partially confirmed elsewhere.** `ba3ae3f` points to + a missing `app.tscn`. The integrated project selects the real M2 App and its + current Main scene. + +## Required integration resolution + +The Claude-authored M2 line is merged onto latest `develop`, leaving develop's +Art V3 actor scenes, generated animation mappings, effects, and primary HUD +icons authoritative. Unused duplicate animation mappings and visible V2 HUD +icons are removed so normal gameplay has one production visual path. The Ghost +Arrow fallback remains asset-failure-only; collision and debug geometry are +unchanged. + +Regression coverage must boot the actual `App`, start a run, and inspect the +live player, wave enemy, Ghost Archer, arrow, Gate Warden, HUD, effects, death +return, and second run—not merely load PNG files from disk. + +## Validation result + +- Godot 4.3 stable imported from a clean generated cache. +- Full suite: 85 tests, 796 assertions, 0 failures. +- The App opens the Ghost Market with no combat scene present. +- App-driven first and second runs resolve live player and melee atlases to + `assets_v3/production`. +- Direct production-scene coverage verifies Ghost Archer, Ghost Arrow, Gate + Warden, fallback state, HUD icons, and all four combat-feedback textures. +- Death and victory both return to the Ghost Market through the M2 pipeline. +- Evidence: `docs/screenshots/runtime_market_v3.png`, + `docs/screenshots/runtime_combat_v3.png`, and + `docs/screenshots/runtime_boss_v3.png`. diff --git a/docs/screenshots/runtime_boss_v3.png b/docs/screenshots/runtime_boss_v3.png new file mode 100644 index 0000000..8ad5971 Binary files /dev/null and b/docs/screenshots/runtime_boss_v3.png differ diff --git a/docs/screenshots/runtime_combat_v3.png b/docs/screenshots/runtime_combat_v3.png new file mode 100644 index 0000000..711d65f Binary files /dev/null and b/docs/screenshots/runtime_combat_v3.png differ diff --git a/docs/screenshots/runtime_market_v3.png b/docs/screenshots/runtime_market_v3.png new file mode 100644 index 0000000..2fa61e1 Binary files /dev/null and b/docs/screenshots/runtime_market_v3.png differ diff --git a/project.godot b/project.godot index 2dc9f50..3b51894 100644 --- a/project.godot +++ b/project.godot @@ -10,7 +10,7 @@ config_version=5 config/name="Nine Nether" config/description="Godot 4 greybox prototype: sacrifice structural survival for offensive power." -run/main_scene="res://scenes/main.tscn" +run/main_scene="res://scenes/app.tscn" config/features=PackedStringArray("4.3", "GL Compatibility") [autoload] diff --git a/scenes/app.gd b/scenes/app.gd new file mode 100644 index 0000000..4bebbb5 --- /dev/null +++ b/scenes/app.gd @@ -0,0 +1,157 @@ +class_name App +extends Node +## Top-level loop: Ghost Market → run → run end → Ghost Market. +## +## The composition root for everything outside a run. It owns the meta profile +## and the save file, decides which scene is on screen, and is the only place +## the two halves of the game meet. `Main` knows nothing about the market; +## `GhostMarket` knows nothing about combat. +## +## Run-end pipeline (docs/GHOST_MARKET_LOOP.md), identical for death and +## victory because both arrive on the same signal: +## +## RunCoordinator.run_finished(result) +## → MetaState.record_run (prices the run, banks the ash, moves counters) +## → MetaSave.save_state +## → result screen dismissed +## → Ghost Market +## +## Duplicate end requests are ignored: `_run_recorded` is raised on the first +## and lowered only when the next run starts. +## +## Exactly one child scene exists at a time. Hiding the idle one is not an +## option: both scenes present their UI on CanvasLayers, and a CanvasLayer does +## not inherit visibility from a parent Node2D, so a "hidden" market would keep +## drawing over the run. + +const MARKET_SCENE := "res://scenes/ghost_market.tscn" +const RUN_SCENE := "res://scenes/main.tscn" +const CONFIG_PATH := "res://data/meta_config.tres" + +@export var market_scene: PackedScene +@export var run_scene: PackedScene +@export var meta_config: MetaConfig + +var _save: MetaSave +var _meta: MetaState +var _market: GhostMarket +var _run: Main +var _last_result: RunResult +var _run_recorded: bool = false + +func _ready() -> void: + if market_scene == null: + market_scene = load(MARKET_SCENE) + if run_scene == null: + run_scene = load(RUN_SCENE) + if meta_config == null: + meta_config = load(CONFIG_PATH) + if _save == null: + _save = MetaSave.new() + _meta = _save.load_state() + open_market() + + +## Redirects the profile to another file. Tests call this before the node +## enters the tree so they never touch the player's real save. +func use_save(save: MetaSave) -> void: + _save = save + +func meta() -> MetaState: + return _meta + +func market() -> GhostMarket: + return _market + +func run() -> Main: + return _run + +func last_result() -> RunResult: + return _last_result + + +# --- navigation ------------------------------------------------------------- + +func open_market() -> void: + _close_run() + if _market == null: + _market = market_scene.instantiate() + _market.start_run_requested.connect(start_run) + _market.buy_tempered_blade_requested.connect(buy_tempered_blade) + _market.reset_save_requested.connect(reset_save) + add_child(_market) + _market.show_profile(_meta, meta_config, _last_result) + + +func start_run() -> void: + if _run != null: + return + _run_recorded = false + _close_market() + _run = run_scene.instantiate() + # Assigned before the scene enters the tree, so the coordinator already has + # the bonus when it builds the run's RunState. autostart is off because the + # market owns the run's lifecycle: Main's standalone restart-in-place would + # otherwise fire alongside the return to the market. + _run.autostart = false + _run.meta_attack_bonus = _meta.attack_bonus(meta_config) + add_child(_run) + _run.coordinator().run_finished.connect(_on_run_finished) + _run.result_screen().dismissed.connect(_on_result_dismissed) + _run.coordinator().start_run(RNGService.run_seed()) + + +# --- market commands -------------------------------------------------------- + +func buy_tempered_blade() -> void: + if not _meta.buy_tempered_blade(meta_config): + return + _save.save_state(_meta) + _refresh_market() + + +func reset_save() -> void: + _meta = _save.reset() + _last_result = null + _refresh_market() + + +func _refresh_market() -> void: + if _market != null: + _market.show_profile(_meta, meta_config, _last_result) + + +# --- run end ---------------------------------------------------------------- + +## Banks the run immediately rather than on dismissal: a player who quits at the +## result screen should still keep what they earned. +func _on_run_finished(result: RunResult) -> void: + if _run_recorded: + return + _run_recorded = true + _meta.record_run(result, meta_config) + _save.save_state(_meta) + _last_result = result + + +func _on_result_dismissed(_result: RunResult) -> void: + open_market() + + +func _close_run() -> void: + if _run == null: + return + # Unpause first: a run abandoned during the sacrifice panel leaves the tree + # paused, and the market would come up frozen. + get_tree().paused = false + remove_child(_run) + _run.queue_free() + _run = null + + +func _close_market() -> void: + if _market == null: + return + remove_child(_market) + _market.queue_free() + _market = null diff --git a/scenes/app.tscn b/scenes/app.tscn new file mode 100644 index 0000000..3c433a7 --- /dev/null +++ b/scenes/app.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=5 format=3 uid="uid://bnnapproot0001"] + +[ext_resource type="Script" path="res://scenes/app.gd" id="1_app"] +[ext_resource type="PackedScene" path="res://scenes/ghost_market.tscn" id="2_market"] +[ext_resource type="PackedScene" path="res://scenes/main.tscn" id="3_run"] +[ext_resource type="Resource" path="res://data/meta_config.tres" id="4_meta_config"] + +[node name="App" type="Node"] +script = ExtResource("1_app") +market_scene = ExtResource("2_market") +run_scene = ExtResource("3_run") +meta_config = ExtResource("4_meta_config") diff --git a/scenes/ghost_market.gd b/scenes/ghost_market.gd new file mode 100644 index 0000000..e79a1b9 --- /dev/null +++ b/scenes/ghost_market.gd @@ -0,0 +1,60 @@ +class_name GhostMarket +extends Node2D +## The hub between runs. Shows what you have, what the last run earned, the one +## upgrade, and the door out. +## +## It reads MetaState and asks — `buy_tempered_blade` and `reset_save` are +## requests to the owner, not writes. Same rule the HUD follows for RunState, +## for the same reason: one place mutates, everything else observes. +## +## The scenery is decoration only. Nothing here reads it, so replacing the +## placeholder brazier with real market art touches this file not at all. + +signal start_run_requested +signal buy_tempered_blade_requested +signal reset_save_requested + +@onready var _soul_ash: Label = $Ui/Root/Panel/SoulAsh +@onready var _profile: Label = $Ui/Root/Panel/Profile +@onready var _last_run: Label = $Ui/Root/Panel/LastRun +@onready var _upgrade: Label = $Ui/Root/Panel/Upgrade +@onready var _buy_button: Button = $Ui/Root/Panel/Buy +@onready var _start_button: Button = $Ui/Root/Panel/Start +@onready var _reset_button: Button = $Ui/Root/Panel/Reset + +var _meta: MetaState +var _config: MetaConfig + +func _ready() -> void: + _buy_button.pressed.connect(func() -> void: buy_tempered_blade_requested.emit()) + _start_button.pressed.connect(func() -> void: start_run_requested.emit()) + _reset_button.pressed.connect(func() -> void: reset_save_requested.emit()) + +## Renders a profile. Called again after every purchase, reset and returning +## run, so there is one refresh path and no incremental UI state to drift. +func show_profile(meta: MetaState, config: MetaConfig, last_run: RunResult) -> void: + _meta = meta + _config = config + _soul_ash.text = "Soul Ash %d" % meta.soul_ash() + _profile.text = "Runs %d Victories %d Deaths %d" % [ + meta.runs(), meta.victories(), meta.deaths() + ] + _last_run.text = ( + "Last run\n %s" % last_run.summary() if last_run != null + else "Last run\n none yet" + ) + _upgrade.text = ( + "Tempered Blade — +%.1f starting attack, permanent\n %s" + % [ + config.tempered_blade_attack_bonus, + "OWNED" if meta.tempered_blade_owned() else "Cost %d Soul Ash" % config.tempered_blade_cost, + ] + ) + _buy_button.disabled = not meta.can_buy_tempered_blade(config) + _buy_button.text = "Owned" if meta.tempered_blade_owned() else "Buy Tempered Blade" + _start_button.grab_focus() + +func _unhandled_input(event: InputEvent) -> void: + if event.is_action_pressed(&"confirm"): + start_run_requested.emit() + get_viewport().set_input_as_handled() diff --git a/scenes/ghost_market.tscn b/scenes/ghost_market.tscn new file mode 100644 index 0000000..e7c973a --- /dev/null +++ b/scenes/ghost_market.tscn @@ -0,0 +1,167 @@ +[gd_scene load_steps=8 format=3 uid="uid://bnnghostmarket"] + +[ext_resource type="Script" path="res://scenes/ghost_market.gd" id="1_market"] +[ext_resource type="Texture2D" path="res://assets/background/bg_deep.png" id="2_bg_deep"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_floor.png" id="3_floor"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_stone_brick.png" id="4_brick"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_brazier.png" id="5_brazier"] +[ext_resource type="Texture2D" path="res://assets/effects/effect_ghost_fire.png" id="6_ghost_fire"] +[ext_resource type="Texture2D" path="res://assets/icons/ui/icon_ghostfire.png" id="7_icon"] + +[node name="GhostMarket" type="Node2D"] +script = ExtResource("1_market") + +[node name="Void" type="CanvasLayer" parent="."] +layer = -3 + +[node name="Fill" type="ColorRect" parent="Void"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 1) + +[node name="Backdrop" type="CanvasLayer" parent="."] +layer = -2 + +[node name="Deep" type="Sprite2D" parent="Backdrop"] +texture_repeat = 2 +position = Vector2(0, 124) +centered = false +texture = ExtResource("2_bg_deep") +region_enabled = true +region_rect = Rect2(0, 0, 640, 180) +modulate = Color(0.72, 0.7, 0.8, 1) + +[node name="Scenery" type="CanvasLayer" parent="."] +layer = -1 + +[node name="Wall" type="Sprite2D" parent="Scenery"] +texture_repeat = 2 +position = Vector2(0, 112) +centered = false +texture = ExtResource("4_brick") +region_enabled = true +region_rect = Rect2(0, 0, 640, 192) +modulate = Color(0.55, 0.53, 0.6, 1) + +[node name="Floor" type="Sprite2D" parent="Scenery"] +texture_repeat = 2 +position = Vector2(0, 304) +centered = false +texture = ExtResource("3_floor") +region_enabled = true +region_rect = Rect2(0, 0, 640, 56) + +[node name="BrazierLeft" type="Sprite2D" parent="Scenery"] +position = Vector2(56, 272) +centered = false +texture = ExtResource("5_brazier") + +[node name="FireLeft" type="Sprite2D" parent="Scenery"] +position = Vector2(48, 232) +centered = false +texture = ExtResource("6_ghost_fire") +modulate = Color(1, 1, 1, 0.75) + +[node name="BrazierRight" type="Sprite2D" parent="Scenery"] +position = Vector2(552, 272) +centered = false +texture = ExtResource("5_brazier") + +[node name="FireRight" type="Sprite2D" parent="Scenery"] +position = Vector2(544, 232) +centered = false +texture = ExtResource("6_ghost_fire") +modulate = Color(1, 1, 1, 0.75) + +[node name="Ui" type="CanvasLayer" parent="."] +layer = 2 + +[node name="Root" type="Control" parent="Ui"] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Title" type="Label" parent="Ui/Root"] +offset_left = 120.0 +offset_top = 8.0 +offset_right = 520.0 +offset_bottom = 32.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 16 +text = "鬼市 — GHOST MARKET" +horizontal_alignment = 1 + +[node name="Panel" type="Panel" parent="Ui/Root"] +offset_left = 96.0 +offset_top = 40.0 +offset_right = 544.0 +offset_bottom = 318.0 + +[node name="Icon" type="TextureRect" parent="Ui/Root/Panel"] +offset_left = 10.0 +offset_top = 10.0 +offset_right = 42.0 +offset_bottom = 42.0 +texture = ExtResource("7_icon") + +[node name="SoulAsh" type="Label" parent="Ui/Root/Panel"] +offset_left = 48.0 +offset_top = 12.0 +offset_right = 300.0 +offset_bottom = 34.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 14 +text = "Soul Ash 0" + +[node name="Profile" type="Label" parent="Ui/Root/Panel"] +offset_left = 48.0 +offset_top = 34.0 +offset_right = 438.0 +offset_bottom = 50.0 +theme_override_colors/font_color = Color(0.463, 0.451, 0.416, 1) +theme_override_font_sizes/font_size = 9 +text = "Runs 0 Victories 0 Deaths 0" + +[node name="LastRun" type="Label" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 58.0 +offset_right = 436.0 +offset_bottom = 104.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 +text = "Last run + none yet" + +[node name="Upgrade" type="Label" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 112.0 +offset_right = 436.0 +offset_bottom = 152.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 +text = "Tempered Blade" + +[node name="Buy" type="Button" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 158.0 +offset_right = 200.0 +offset_bottom = 182.0 +theme_override_font_sizes/font_size = 10 +text = "Buy Tempered Blade" + +[node name="Start" type="Button" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 196.0 +offset_right = 436.0 +offset_bottom = 228.0 +theme_override_font_sizes/font_size = 13 +text = "Start Run (Enter)" + +[node name="Reset" type="Button" parent="Ui/Root/Panel"] +offset_left = 12.0 +offset_top = 238.0 +offset_right = 140.0 +offset_bottom = 262.0 +theme_override_font_sizes/font_size = 9 +text = "Reset Save" diff --git a/scenes/main.gd b/scenes/main.gd index da51e05..a4f07f6 100644 --- a/scenes/main.gd +++ b/scenes/main.gd @@ -1,10 +1,24 @@ class_name Main extends Node2D -## Composition root. Builds the object graph and starts the run. +## Composition root for one run. Builds the object graph and starts the run. ## ## Wiring lives here rather than inside each node so that every dependency is ## visible in one place, and so no observer can miss the opening `state_ready` ## by binding after the coordinator has already started. +## +## Two ways in, both real: +## * `app.tscn` sets `meta_attack_bonus`, leaves `autostart` on, and listens +## for `run_finished` / `ResultScreen.dismissed` to close the Ghost Market +## loop. +## * Pressing F6 on this scene in the editor plays a run with no meta layer, +## which is how a combat change gets tested without going through the shop. +## Set `autostart = false` to hold the run until the owner calls `start_run`. + +@export var autostart: bool = true + +## Flat starting attack from meta progression. Assign before the scene enters +## the tree; the coordinator reads it when the run is created. +var meta_attack_bonus: float = 0.0 @onready var _coordinator: RunCoordinator = $RunCoordinator @onready var _hud: Hud = $UIRoot/Hud @@ -18,7 +32,17 @@ func _ready() -> void: _sacrifice_panel.bind(_coordinator) _result_screen.bind(_coordinator) _debug_panel.bind(_coordinator, _debug_shapes) - _coordinator.start_run(RNGService.run_seed()) + _coordinator.meta_attack_bonus = meta_attack_bonus + if autostart: + # Standalone: there is no Ghost Market to return to, so dismissing the + # result screen restarts in place. + _result_screen.dismissed.connect( + func(_result: RunResult) -> void: _coordinator.restart_run() + ) + _coordinator.start_run(RNGService.run_seed()) func coordinator() -> RunCoordinator: return _coordinator + +func result_screen() -> ResultScreen: + return _result_screen diff --git a/systems/run_coordinator.gd b/systems/run_coordinator.gd index 1cc9ac1..ffb8501 100644 --- a/systems/run_coordinator.gd +++ b/systems/run_coordinator.gd @@ -12,7 +12,10 @@ signal phase_changed(phase: StringName) signal state_ready(state: RunState) signal sacrifice_offered(definition: SacrificeDefinition) signal boss_spawned(boss: BossActor) -signal run_finished(outcome: StringName) +## Carries the finished run's RunResult. Emitted exactly once per run: both +## death and victory arrive here through `_finish_run`, which is guarded by the +## phase machine. See docs/GHOST_MARKET_LOOP.md and ADR-013. +signal run_finished(result: RunResult) const PHASE_BOOT := &"boot" const PHASE_WAVE := &"wave" @@ -32,6 +35,11 @@ const M1_SACRIFICE_ID := &"severed_lifespan" @export var arena_path: NodePath @export var actor_root_path: NodePath +## Flat starting attack granted by meta progression. Set by the caller before +## `start_run`; zero when the run scene is played standalone. This is the only +## channel through which the Ghost Market touches a run's numbers. +var meta_attack_bonus: float = 0.0 + var _arena: Arena var _actor_root: Node2D var _state: RunState @@ -40,6 +48,9 @@ var _boss: BossActor var _phase: StringName = PHASE_BOOT var _live_enemies: Array[EnemyBase] = [] var _run_counter: int = 0 +var _kills: int = 0 +var _run_started_msec: int = 0 +var _result: RunResult ## Resolves scene references only. The run is started by Main once every @@ -69,6 +80,13 @@ func live_enemies() -> Array[EnemyBase]: func offered_sacrifice() -> SacrificeDefinition: return GameData.definition(M1_SACRIFICE_ID) +## The finished run's result, or null while a run is in progress. +func last_result() -> RunResult: + return _result + +func kills() -> int: + return _kills + ## Fresh run. Reusing the scene rather than reloading it keeps the seed under ## our control, which is what makes a reported bug replayable. @@ -79,7 +97,14 @@ func start_run(run_seed: int, restarted: bool = false) -> void: EventBus.bind_run(_run_counter) _clear_actors() + _kills = 0 + _result = null + _run_started_msec = Time.get_ticks_msec() _state = RunState.create(GameData.balance, run_seed) + # Meta progression is applied once, at creation, so the rest of the run sees + # it as an ordinary starting stat. Nothing downstream special-cases it. + if not is_zero_approx(meta_attack_bonus): + _state.add_flat_attack(meta_attack_bonus) _state.set_run_status(RunState.STATUS_ACTIVE) _spawn_player() state_ready.emit(_state) @@ -154,13 +179,29 @@ func begin_boss() -> void: boss_spawned.emit(_boss) +## The single run-end path. Death and victory both arrive here, and the phase +## guard makes a second request a no-op — the run cannot end twice. func _finish_run(outcome: StringName) -> void: if _phase == PHASE_RESULT: return _set_phase(PHASE_RESULT) _state.set_run_status(outcome) - EventBus.run_completed.emit(EventBus.context({"outcome": outcome})) - run_finished.emit(outcome) + _result = _build_result(outcome) + EventBus.run_completed.emit(EventBus.context(_result.to_dictionary())) + run_finished.emit(_result) + + +func _build_result(outcome: StringName) -> RunResult: + var result := RunResult.new() + result.outcome = outcome + result.duration_seconds = float(Time.get_ticks_msec() - _run_started_msec) / 1000.0 + result.kills = _kills + result.sacrifices = _state.sacrifice_history().size() + result.integrity = _state.integrity() + result.imbalance = _state.imbalance() + # soul_ash_earned stays 0 here: pricing a run is a meta concern, applied by + # MetaState.record_run. + return result # --- spawning --------------------------------------------------------------- @@ -204,11 +245,13 @@ func _clear_actors() -> void: func _on_enemy_died(enemy: EnemyBase) -> void: _live_enemies.erase(enemy) + _kills += 1 if _phase == PHASE_WAVE and _live_enemies.is_empty(): _begin_sacrifice() func _on_boss_defeated(_boss_actor: BossActor) -> void: + _kills += 1 _finish_run(RunState.STATUS_VICTORY) diff --git a/systems/run_result.gd b/systems/run_result.gd new file mode 100644 index 0000000..6f1569e --- /dev/null +++ b/systems/run_result.gd @@ -0,0 +1,55 @@ +class_name RunResult +extends RefCounted +## What one run amounted to. Produced once by RunCoordinator when the run ends, +## consumed by the meta layer and by the result screen. +## +## Deliberately a plain value: no behaviour, no references to live nodes. That +## is what lets it survive the run scene being freed, and lets a test build one +## by hand. +## +## `soul_ash_earned` is the one field the run does not know. The run reports +## what happened; MetaConfig prices it. See docs/GHOST_MARKET_LOOP.md. + +var outcome: StringName = RunState.STATUS_DEFEAT +var duration_seconds: float = 0.0 +var kills: int = 0 +var sacrifices: int = 0 +var integrity: float = 1.0 +var imbalance: float = 0.0 +var soul_ash_earned: int = 0 + +func is_victory() -> bool: + return outcome == RunState.STATUS_VICTORY + +func to_dictionary() -> Dictionary: + return { + "outcome": outcome, + "duration_seconds": duration_seconds, + "kills": kills, + "sacrifices": sacrifices, + "integrity": integrity, + "imbalance": imbalance, + "soul_ash_earned": soul_ash_earned, + } + +## Compact one-line summary for the Ghost Market's "latest run" panel. +func summary() -> String: + return ( + "%s · %s · %s · %s · integrity %.3f · imbalance %.0f · +%d soul ash" + % [ + String(outcome).to_upper(), + format_duration(duration_seconds), + _plural(kills, "kill"), + _plural(sacrifices, "sacrifice"), + integrity, + imbalance, + soul_ash_earned, + ] + ) + +static func _plural(count: int, noun: String) -> String: + return "%d %s%s" % [count, noun, "" if count == 1 else "s"] + +static func format_duration(seconds: float) -> String: + var total := int(maxf(0.0, seconds)) + return "%d:%02d" % [total / 60, total % 60] diff --git a/tests/cases/test_art_v2.gd b/tests/cases/test_art_v2.gd index b5534bd..ce9762d 100644 --- a/tests/cases/test_art_v2.gd +++ b/tests/cases/test_art_v2.gd @@ -44,10 +44,10 @@ func _spawn_enemy(scene_path: String, config_path: String, target: Node2D) -> En func test_all_integrated_resources_load_with_declared_animations() -> void: - var player_frames := load("res://assets_v2/godot/player_frames.tres") as SpriteFrames - var melee_frames := load("res://assets_v2/godot/melee_ghost_frames.tres") as SpriteFrames - var archer_frames := load("res://assets_v2/godot/ghost_archer_frames.tres") as SpriteFrames - var boss_frames := load("res://assets_v2/godot/gate_warden_frames.tres") as SpriteFrames + var player_frames := load("res://assets_v3/godot/player_frames.tres") as SpriteFrames + var melee_frames := load("res://assets_v3/godot/melee_ghost_frames.tres") as SpriteFrames + var archer_frames := load("res://assets_v3/godot/ghost_archer_frames.tres") as SpriteFrames + var boss_frames := load("res://assets_v3/godot/gate_warden_frames.tres") as SpriteFrames assert_not_null(player_frames, "player SpriteFrames load") assert_not_null(melee_frames, "melee ghost SpriteFrames load") assert_not_null(archer_frames, "Ghost Archer SpriteFrames load") diff --git a/tests/cases/test_meta_loop.gd b/tests/cases/test_meta_loop.gd new file mode 100644 index 0000000..6ca0658 --- /dev/null +++ b/tests/cases/test_meta_loop.gd @@ -0,0 +1,348 @@ +extends TestCase +## The Ghost Market loop: market → run → run end → market, and what survives. +## +## Scope is deliberately the M2 brief and nothing else. Combat, sacrifices and +## enemy behaviour have their own cases and are not re-tested here. + +const APP_SCENE := "res://scenes/app.tscn" +const SAVE_PATH := "user://test_meta_save.json" + +var save: MetaSave +var config: MetaConfig +var app: App + +func before_each() -> void: + save = MetaSave.new(SAVE_PATH) + save.reset() + config = (load(App.CONFIG_PATH) as MetaConfig).duplicate(true) + +func after_each() -> void: + tree.paused = false + if app != null and is_instance_valid(app): + app.queue_free() + # One frame so the scene is really gone before the next case boots one. + await tree.process_frame + app = null + save.reset() + +## Boots the app against the scratch save file. +func _launch() -> App: + app = (load(APP_SCENE) as PackedScene).instantiate() + app.use_save(save) + app.meta_config = config + tree.root.add_child(app) + await step_physics(2) + return app + +## Keeps hitting the player until the run actually ends. A single blow is not +## enough: the player is briefly invulnerable after any hit, so a test that has +## already damaged them would otherwise have its killing blow ignored. +func _end_run_by_death() -> void: + var coordinator: RunCoordinator = app.run().coordinator() + await wait_for(func() -> bool: + if coordinator.phase() == RunCoordinator.PHASE_RESULT: + return true + coordinator.debug_damage(100000.0) + return false + ) + +func _dismiss_result() -> bool: + var screen: ResultScreen = app.run().result_screen() + var shown: bool = await wait_for(func() -> bool: return screen.visible) + if shown: + screen.dismiss() + await step_physics(2) + return shown + + +# --- the loop --------------------------------------------------------------- + +func test_ghost_market_loads_first() -> void: + await _launch() + assert_not_null(app.market(), "the market exists") + assert_not_null(app.market(), "the game opens in the Ghost Market") + assert_null(app.run(), "no run is in progress") + assert_null(app.last_result(), "there is no previous run to report") + assert_equal(ProjectSettings.get_setting("application/run/main_scene"), "res://scenes/app.tscn", "F5 enters through App") + + +func test_start_run_launches_a_playable_run() -> void: + await _launch() + app.start_run() + await step_physics(2) + var coordinator: RunCoordinator = app.run().coordinator() + assert_null(app.market(), "the market is torn down, not merely hidden") + assert_equal(coordinator.phase(), RunCoordinator.PHASE_WAVE, "the run boots into a wave") + assert_not_null(coordinator.player(), "a player exists") + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "the wave spawned" + ) + assert_equal(app.run().scene_file_path, "res://scenes/main.tscn", "App instantiated the current production Main scene") + + +func test_app_run_resolves_live_combat_visuals_to_art_v3() -> void: + await _launch() + app.start_run() + await step_physics(2) + var run := app.run() + var coordinator := run.coordinator() + + _assert_sprite_uses_v3(coordinator.player().sprite, "live player") + for enemy: EnemyBase in coordinator.live_enemies(): + _assert_sprite_uses_v3(enemy.sprite, "live wave enemy") + + var hud := run.get_node("UIRoot/Hud") + assert_true( + (hud.get_node("Player/Icon") as TextureRect).texture.resource_path.begins_with("res://assets_v3/production/"), + "live player HUD icon resolves to V3" + ) + assert_true( + (hud.get_node("Boss/Icon") as TextureRect).texture.resource_path.begins_with("res://assets_v3/production/"), + "live Boss HUD icon resolves to V3" + ) + assert_not_null(run.get_node_or_null("CombatFeedback"), "the V3 combat feedback observer is live") + assert_null(run.get_node_or_null("CombatEffects"), "the Art V2 hit observer is absent") + + coordinator.begin_boss() + await step_physics(2) + _assert_sprite_uses_v3(coordinator.boss().sprite, "live Gate Warden") + + +func test_death_returns_to_the_ghost_market() -> void: + await _launch() + app.start_run() + await step_physics(2) + await _end_run_by_death() + + var result := app.last_result() + assert_not_null(result, "the run produced a result") + assert_equal(result.outcome, RunState.STATUS_DEFEAT, "recorded as a defeat") + + assert_true(await _dismiss_result(), "the result screen appeared") + assert_null(app.run(), "the run scene was torn down") + assert_not_null(app.market(), "the market is back") + assert_equal(app.meta().deaths(), 1, "the death was counted") + assert_equal(app.meta().runs(), 1, "the run was counted") + + +func test_victory_returns_to_the_ghost_market() -> void: + await _launch() + app.start_run() + await step_physics(2) + var coordinator: RunCoordinator = app.run().coordinator() + coordinator.begin_boss() + await step_physics(2) + coordinator.boss().die(&"test") + # The boss holds victory until its collapse animation finishes. + assert_true( + await wait_for(func() -> bool: return app.last_result() != null), + "the victory was reported" + ) + + assert_equal(app.last_result().outcome, RunState.STATUS_VICTORY, "recorded as a victory") + assert_true(await _dismiss_result(), "the result screen appeared") + assert_not_null(app.market(), "the market is back") + assert_equal(app.meta().victories(), 1, "the victory was counted") + assert_equal(app.meta().deaths(), 0, "no death was counted") + + +# --- reward ------------------------------------------------------------------ + +func test_a_run_is_rewarded_exactly_once() -> void: + await _launch() + app.start_run() + await step_physics(2) + await _end_run_by_death() + + var banked := app.meta().soul_ash() + var result := app.last_result() + assert_greater(float(banked), 0.0, "the run paid something") + assert_equal(banked, result.soul_ash_earned, "what was banked is what the result reports") + + # Re-emitting the real signal is the route a duplicate would arrive by. + var coordinator: RunCoordinator = app.run().coordinator() + coordinator.run_finished.emit(result) + coordinator.run_finished.emit(result) + assert_equal(app.meta().soul_ash(), banked, "soul ash was not paid twice") + assert_equal(app.meta().runs(), 1, "the run was not counted twice") + + +func test_reward_prices_kills_and_victory_from_config() -> void: + var defeat := RunResult.new() + defeat.outcome = RunState.STATUS_DEFEAT + defeat.kills = 3 + assert_equal( + config.soul_ash_for(defeat), + config.soul_ash_base + 3 * config.soul_ash_per_kill, + "a defeat pays base plus kills" + ) + + var victory := RunResult.new() + victory.outcome = RunState.STATUS_VICTORY + victory.kills = 3 + assert_equal( + config.soul_ash_for(victory), + config.soul_ash_base + 3 * config.soul_ash_per_kill + config.soul_ash_victory_bonus, + "a victory adds the bonus" + ) + + +# --- persistence ------------------------------------------------------------- + +func test_soul_ash_persists_across_a_relaunch() -> void: + await _launch() + app.start_run() + await step_physics(2) + await _end_run_by_death() + var banked := app.meta().soul_ash() + assert_greater(float(banked), 0.0, "the run paid something") + + # A second App reading the same file is the honest test of persistence. + var reloaded := MetaSave.new(SAVE_PATH).load_state() + assert_equal(reloaded.soul_ash(), banked, "soul ash survived the write") + assert_equal(reloaded.runs(), 1, "so did the run counter") + + +func test_upgrade_persists_and_reaches_the_next_run() -> void: + await _launch() + # Fund the purchase through the normal path rather than by poking a field. + var funded := RunResult.new() + funded.outcome = RunState.STATUS_VICTORY + funded.kills = 40 + app.meta().record_run(funded, config) + assert_true(app.meta().can_buy_tempered_blade(config), "the purchase is affordable") + + var before := app.meta().soul_ash() + app.buy_tempered_blade() + assert_true(app.meta().tempered_blade_owned(), "the blade is owned") + assert_equal( + app.meta().soul_ash(), before - config.tempered_blade_cost, "the cost was paid" + ) + assert_false(app.meta().can_buy_tempered_blade(config), "it cannot be bought twice") + + assert_true( + MetaSave.new(SAVE_PATH).load_state().tempered_blade_owned(), + "the purchase was written to disk" + ) + + app.start_run() + await step_physics(2) + assert_almost( + app.run().coordinator().state().attack(), + GameData.balance.base_attack + config.tempered_blade_attack_bonus, + "the upgrade is applied to the new run's starting attack", + 1e-6 + ) + + +func test_reset_save_clears_progress_and_the_file() -> void: + await _launch() + var funded := RunResult.new() + funded.kills = 10 + app.meta().record_run(funded, config) + save.save_state(app.meta()) + assert_true(save.exists(), "there is a save to clear") + + app.reset_save() + assert_equal(app.meta().soul_ash(), 0, "soul ash is cleared") + assert_equal(app.meta().runs(), 0, "counters are cleared") + assert_false(app.meta().tempered_blade_owned(), "upgrades are cleared") + assert_false(save.exists(), "the file is gone") + + +func test_a_malformed_save_yields_a_fresh_profile_instead_of_failing() -> void: + var file := FileAccess.open(SAVE_PATH, FileAccess.WRITE) + file.store_string("{ not json") + file.close() + var meta := MetaSave.new(SAVE_PATH).load_state() + assert_equal(meta.soul_ash(), 0, "a corrupt save starts fresh rather than crashing") + + +# --- second run -------------------------------------------------------------- + +func test_a_second_run_starts_from_a_clean_state() -> void: + await _launch() + app.start_run() + await step_physics(2) + + # Dirty the first run thoroughly: damage, a sacrifice, and a live arrow. + var first: RunCoordinator = app.run().coordinator() + first.debug_damage(20.0) + first.debug_apply_sacrifice() + await step_physics(2) + assert_less(first.state().current_hp(), first.state().max_hp(), "the player is hurt") + assert_false(first.state().sacrifice_history().is_empty(), "a sacrifice was taken") + assert_greater(first.state().imbalance(), 0.0, "imbalance rose") + + await _end_run_by_death() + assert_true(await _dismiss_result(), "the result screen appeared") + + app.start_run() + await step_physics(2) + var second: RunCoordinator = app.run().coordinator() + var state := second.state() + _assert_sprite_uses_v3(second.player().sprite, "second-run player") + for enemy: EnemyBase in second.live_enemies(): + _assert_sprite_uses_v3(enemy.sprite, "second-run wave enemy") + assert_almost(state.current_hp(), state.max_hp(), "HP is full again") + assert_almost(state.current_stamina(), state.max_stamina(), "stamina is full again") + assert_almost(state.max_hp(), GameData.balance.base_max_hp, "max lifespan is restored") + assert_almost(state.integrity(), 1.0, "integrity is whole again") + assert_almost(state.imbalance(), 0.0, "imbalance is cleared") + assert_true(state.sacrifice_history().is_empty(), "sacrifice history is cleared") + assert_equal( + second.live_enemies().size(), GameData.balance.wave_enemy_count, + "a fresh wave spawned" + ) + assert_equal(second.kills(), 0, "the kill count restarted") + + # The market path rebuilds the whole run scene, so this mostly guards against + # something being parented outside it. The in-scene restart path is checked + # by test_restart_clears_leftover_actors below. + for node in app.run().get_node("Actors").get_children(): + assert_true( + node is Player or node is EnemyBase, + "unexpected leftover actor %s" % node.name + ) + + # And the meta side kept what it should. + assert_equal(app.meta().runs(), 1, "the finished run is still counted") + assert_greater(float(app.meta().soul_ash()), 0.0, "soul ash carried over") + + +## Debug restart reuses the run scene instead of rebuilding it, so this is the +## path where _clear_actors has to do the work — corpses mid-death animation and +## projectiles still in flight included. +func test_restart_clears_leftover_actors() -> void: + await _launch() + app.start_run() + await step_physics(2) + var coordinator: RunCoordinator = app.run().coordinator() + var actors: Node2D = app.run().get_node("Actors") + + var debris := Node2D.new() + debris.name = "InFlightProjectile" + actors.add_child(debris) + assert_not_null(actors.get_node_or_null("InFlightProjectile"), "the debris is there") + + coordinator.restart_run() + await step_physics(2) + assert_null( + actors.get_node_or_null("InFlightProjectile"), + "restart removed everything under Actors" + ) + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "and respawned a clean wave" + ) + + +func _assert_sprite_uses_v3(sprite: AnimatedSprite2D, label: String) -> void: + var texture := sprite.sprite_frames.get_frame_texture(sprite.animation, 0) + assert_true(texture is AtlasTexture, "%s uses an atlas frame" % label) + if texture is AtlasTexture: + assert_true( + (texture as AtlasTexture).atlas.resource_path.begins_with("res://assets_v3/production/"), + "%s resolves to Art V3" % label + ) diff --git a/tests/cases/test_production_assets.gd b/tests/cases/test_production_assets.gd index 75d303d..68c3f78 100644 --- a/tests/cases/test_production_assets.gd +++ b/tests/cases/test_production_assets.gd @@ -5,25 +5,25 @@ extends TestCase const ACTORS := { "player": { "scene": "res://actors/player/player.tscn", - "frames": "res://assets_v2/godot/player_frames.tres", + "frames": "res://assets_v3/godot/player_frames.tres", "map": "res://assets_v3/production/player/frame_map.json", "counts": {&"player_idle": 4, &"player_run": 6, &"player_jump": 2, &"player_fall": 2, &"player_light_attack_1": 8, &"player_hurt": 2, &"player_death": 6}, }, "ghost_melee": { "scene": "res://actors/enemies/ghost_melee.tscn", - "frames": "res://assets_v2/godot/melee_ghost_frames.tres", + "frames": "res://assets_v3/godot/melee_ghost_frames.tres", "map": "res://assets_v3/production/ghost_melee/frame_map.json", "counts": {&"melee_ghost_idle": 4, &"melee_ghost_walk": 6, &"melee_ghost_attack": 8, &"melee_ghost_hurt": 2, &"melee_ghost_death": 4}, }, "ghost_archer": { "scene": "res://actors/enemies/ghost_archer.tscn", - "frames": "res://assets_v2/godot/ghost_archer_frames.tres", + "frames": "res://assets_v3/godot/ghost_archer_frames.tres", "map": "res://assets_v3/production/ghost_archer/frame_map.json", "counts": {&"ghost_archer_idle": 4, &"ghost_archer_retreat": 6, &"ghost_archer_aim": 6, &"ghost_archer_shoot": 4, &"ghost_archer_hurt": 2, &"ghost_archer_death": 4}, }, "gate_warden": { "scene": "res://actors/boss/boss.tscn", - "frames": "res://assets_v2/godot/gate_warden_frames.tres", + "frames": "res://assets_v3/godot/gate_warden_frames.tres", "map": "res://assets_v3/production/gate_warden/frame_map.json", "counts": {&"gate_warden_idle": 4, &"gate_warden_walk": 6, &"gate_warden_attack_1": 10, &"gate_warden_hurt": 2, &"gate_warden_death": 8}, }, @@ -114,12 +114,39 @@ func test_live_scenes_reference_v3_production_art() -> void: var main := (load("res://scenes/main.tscn") as PackedScene).instantiate() assert_not_null(main.get_node_or_null("CombatFeedback"), "one V3 combat-feedback observer is present") assert_null(main.get_node_or_null("CombatEffects"), "legacy hit observer is not duplicated") + var feedback_script := main.get_node("CombatFeedback").get_script() as Script + var effects: Dictionary = feedback_script.get_script_constant_map()["EFFECTS"] + for effect_name: StringName in [&"slash", &"hit", &"blood", &"death"]: + var texture: Texture2D = effects[effect_name]["texture"] + assert_true(texture.resource_path.begins_with("res://assets_v3/production/"), "%s feedback maps to V3" % effect_name) var hud := main.get_node("UIRoot/Hud") assert_true((hud.get_node("Player/Icon") as TextureRect).texture.resource_path.begins_with("res://assets_v3/production/"), "player HUD uses V3 HP icon") assert_true((hud.get_node("Boss/Icon") as TextureRect).texture.resource_path.begins_with("res://assets_v3/production/"), "Boss HUD uses V3 icon") + assert_null(hud.get_node_or_null("Player/StaminaIcon"), "obsolete V2 stamina icon is absent") + assert_null(hud.get_node_or_null("Player/AttackIcon"), "obsolete V2 attack icon is absent") + assert_null(hud.get_node_or_null("Player/IntegrityIcon"), "obsolete V2 integrity icon is absent") + assert_null(hud.get_node_or_null("Player/ImbalanceIcon"), "obsolete V2 imbalance icon is absent") main.free() +func test_live_actor_scenes_have_no_legacy_visual_paths() -> void: + var paths := [ + "res://actors/player/player.tscn", + "res://actors/enemies/ghost_melee.tscn", + "res://actors/enemies/ghost_archer.tscn", + "res://actors/enemies/ghost_arrow.tscn", + "res://actors/boss/boss.tscn", + ] + for path: String in paths: + var file := FileAccess.open(path, FileAccess.READ) + assert_not_null(file, "%s opens" % path) + if file == null: + continue + var source := file.get_as_text() + assert_false(source.contains("res://assets_v2/"), "%s has no Art V2 visual reference" % path) + assert_false(source.contains("res://assets/player/") or source.contains("res://assets/enemy/") or source.contains("res://assets/boss/"), "%s has no Art V1 actor reference" % path) + + func _read_json(path: String) -> Dictionary: var file := FileAccess.open(path, FileAccess.READ) if file == null: diff --git a/tests/framework/test_case.gd b/tests/framework/test_case.gd index c770906..914cd2d 100644 --- a/tests/framework/test_case.gd +++ b/tests/framework/test_case.gd @@ -37,6 +37,15 @@ func step_physics(count: int) -> void: for _i in range(count): await tree.physics_frame +## Steps physics until `condition` returns true, up to `max_frames`. Returns +## whether it became true, so a caller can assert rather than hang. +func wait_for(condition: Callable, max_frames: int = 240) -> bool: + for _i in range(max_frames): + if bool(condition.call()): + return true + await tree.physics_frame + return bool(condition.call()) + ## A BalanceConfig detached from the shipped resource, so a test can retune a ## value without leaking the change into the next test. func make_balance() -> BalanceConfig: diff --git a/tools/capture_runtime_v3.gd b/tools/capture_runtime_v3.gd new file mode 100644 index 0000000..d64a8bf --- /dev/null +++ b/tools/capture_runtime_v3.gd @@ -0,0 +1,65 @@ +extends Node +## Captures the real M2 application path for runtime Art V3 evidence. +## +## Usage: +## godot --path . res://tools/capture_runtime_v3.tscn -- market +## godot --path . res://tools/capture_runtime_v3.tscn -- combat +## godot --path . res://tools/capture_runtime_v3.tscn -- boss + +const APP := preload("res://scenes/app.tscn") +const CAPTURE_SAVE := "user://runtime_art_capture_meta.json" + +var _save := MetaSave.new(CAPTURE_SAVE) + + +func _ready() -> void: + _save.reset() + var app := APP.instantiate() as App + app.use_save(_save) + add_child(app) + await _frames(12) + + var kind := "market" + var args := OS.get_cmdline_user_args() + if not args.is_empty(): + kind = args[0] + + if kind != "market": + app.start_run() + await _frames(12) + var coordinator := app.run().coordinator() + var player := coordinator.player() + player.global_position = Vector2(330.0, 304.0) + if kind == "boss": + for enemy: EnemyBase in coordinator.live_enemies(): + enemy.queue_free() + await _frames(2) + coordinator.begin_boss() + await _frames(4) + coordinator.boss().global_position = Vector2(430.0, 304.0) + coordinator.boss().acquire_target(null) + else: + var enemies := coordinator.live_enemies() + for index in enemies.size(): + if index == 0: + enemies[index].global_position = Vector2(410.0, 304.0) + enemies[index].acquire_target(null) + else: + enemies[index].queue_free() + await _frames(20) + + var output := "res://docs/screenshots/runtime_%s_v3.png" % kind + var image := get_viewport().get_texture().get_image() + var error := image.save_png(output) + _save.reset() + if error != OK: + push_error("Could not write %s: %s" % [output, error_string(error)]) + get_tree().quit(1) + return + print("Wrote %s" % output) + get_tree().quit() + + +func _frames(count: int) -> void: + for _frame in count: + await get_tree().process_frame diff --git a/tools/capture_runtime_v3.tscn b/tools/capture_runtime_v3.tscn new file mode 100644 index 0000000..43f8b0c --- /dev/null +++ b/tools/capture_runtime_v3.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/capture_runtime_v3.gd" id="1_capture"] + +[node name="CaptureRuntimeV3" type="Node"] +script = ExtResource("1_capture") diff --git a/tools/generate_sprite_frames.py b/tools/generate_sprite_frames.py index 3de2f84..bfefe01 100644 --- a/tools/generate_sprite_frames.py +++ b/tools/generate_sprite_frames.py @@ -30,7 +30,7 @@ # calls locomotion "walk" in metadata, while the state machines call it "run". ACTORS = { "player": ( - "assets_v2/godot/player_frames.tres", + "assets_v3/godot/player_frames.tres", 192, 160, [ @@ -50,7 +50,7 @@ ], ), "ghost_melee": ( - "assets_v2/godot/melee_ghost_frames.tres", + "assets_v3/godot/melee_ghost_frames.tres", 192, 160, [ @@ -62,7 +62,7 @@ ], ), "ghost_archer": ( - "assets_v2/godot/ghost_archer_frames.tres", + "assets_v3/godot/ghost_archer_frames.tres", 192, 160, [ @@ -75,7 +75,7 @@ ], ), "boss": ( - "assets_v2/godot/gate_warden_frames.tres", + "assets_v3/godot/gate_warden_frames.tres", 320, 256, [ diff --git a/ui/hud.tscn b/ui/hud.tscn index 7e809c1..07e6f9c 100644 --- a/ui/hud.tscn +++ b/ui/hud.tscn @@ -1,14 +1,10 @@ -[gd_scene load_steps=10 format=3 uid="uid://bnnhud0000001"] +[gd_scene load_steps=6 format=3 uid="uid://bnnhud0000001"] [ext_resource type="Script" path="res://ui/hud.gd" id="1_hud"] [ext_resource type="Texture2D" path="res://assets/ui/ui_health_bar.png" id="2_health_bar"] [ext_resource type="Texture2D" path="res://assets/ui/ui_stamina_bar.png" id="3_stamina_bar"] [ext_resource type="Texture2D" path="res://assets_v3/production/icons/icon_hp.png" id="4_icon_hp"] [ext_resource type="Texture2D" path="res://assets_v3/production/icons/icon_boss.png" id="5_icon_boss"] -[ext_resource type="Texture2D" path="res://assets_v2/icons/icon_qi.png" id="6_icon_qi"] -[ext_resource type="Texture2D" path="res://assets_v2/icons/icon_songdao.png" id="7_icon_attack"] -[ext_resource type="Texture2D" path="res://assets_v2/icons/icon_ghostfire.png" id="8_icon_imbalance"] -[ext_resource type="Texture2D" path="res://assets_v2/icons/icon_obsession.png" id="9_icon_integrity"] [node name="Hud" type="CanvasLayer"] script = ExtResource("1_hud") @@ -26,14 +22,6 @@ texture = ExtResource("4_icon_hp") expand_mode = 1 stretch_mode = 5 -[node name="StaminaIcon" type="TextureRect" parent="Player"] -offset_left = 6.0 -offset_top = 24.0 -offset_right = 22.0 -offset_bottom = 40.0 -texture = ExtResource("6_icon_qi") -expand_mode = 1 - [node name="HealthBar" type="TextureRect" parent="Player"] offset_left = 40.0 offset_top = 6.0 @@ -63,7 +51,7 @@ offset_bottom = 15.0 color = Color(0.165, 0.541, 0.29, 1) [node name="Stats" type="Label" parent="Player"] -offset_left = 24.0 +offset_left = 6.0 offset_top = 44.0 offset_right = 236.0 offset_bottom = 96.0 @@ -73,30 +61,6 @@ text = "ATK EHP Integrity" -[node name="AttackIcon" type="TextureRect" parent="Player"] -offset_left = 6.0 -offset_top = 45.0 -offset_right = 20.0 -offset_bottom = 59.0 -texture = ExtResource("7_icon_attack") -expand_mode = 1 - -[node name="IntegrityIcon" type="TextureRect" parent="Player"] -offset_left = 6.0 -offset_top = 61.0 -offset_right = 20.0 -offset_bottom = 75.0 -texture = ExtResource("9_icon_integrity") -expand_mode = 1 - -[node name="ImbalanceIcon" type="TextureRect" parent="Player"] -offset_left = 6.0 -offset_top = 77.0 -offset_right = 20.0 -offset_bottom = 91.0 -texture = ExtResource("8_icon_imbalance") -expand_mode = 1 - [node name="Boss" type="Control" parent="."] offset_left = 220.0 offset_right = 480.0 diff --git a/ui/result_screen.gd b/ui/result_screen.gd index 3d459a9..42cc81d 100644 --- a/ui/result_screen.gd +++ b/ui/result_screen.gd @@ -1,13 +1,20 @@ class_name ResultScreen extends CanvasLayer -## End-of-run screen for both outcomes. Restart is requested from the -## coordinator; the screen does not reset anything itself. +## End-of-run screen for both outcomes. +## +## It reports and then steps aside: `dismissed` is the only thing it does. Who +## listens decides what happens next — the Ghost Market loop returns to the +## market, and `main.tscn` played standalone restarts in place. The screen has +## no opinion, which is what lets both work without a mode flag. + +signal dismissed(result: RunResult) @onready var _title: Label = $Root/Title @onready var _summary: Label = $Root/Summary @onready var _restart_button: Button = $Root/Restart var _coordinator: RunCoordinator +var _result: RunResult func bind(coordinator: RunCoordinator) -> void: _coordinator = coordinator @@ -17,12 +24,15 @@ func bind(coordinator: RunCoordinator) -> void: func _ready() -> void: process_mode = Node.PROCESS_MODE_ALWAYS visible = false - _restart_button.pressed.connect(_on_restart) + _restart_button.pressed.connect(dismiss) + +func result() -> RunResult: + return _result -func show_outcome(outcome: StringName) -> void: +func show_outcome(result: RunResult) -> void: + _result = result var state := _coordinator.state() - var victory := outcome == RunState.STATUS_VICTORY - if not victory: + if not result.is_victory(): var player := _coordinator.player() if ( player != null @@ -30,33 +40,41 @@ func show_outcome(outcome: StringName) -> void: and player.sprite.is_playing() ): await player.sprite.animation_finished - # Not 同归: the same-death mechanic is not implemented in M1, so a win here - # is an ordinary victory and the screen must not claim otherwise. - _title.text = "VICTORY" if victory else "DEATH" - _title.modulate = Color(0.706, 0.392, 0.118) if victory else Color(0.831, 0.353, 0.294) + # Not 同归: the same-death mechanic is not implemented yet, so a win here is + # an ordinary victory and the screen must not claim otherwise. + _title.text = "VICTORY" if result.is_victory() else "DEATH" + _title.modulate = ( + Color(0.706, 0.392, 0.118) if result.is_victory() else Color(0.831, 0.353, 0.294) + ) _summary.text = ( - "Seed %d\nSacrifices %d\nAttack %.1f Estimated DPS %.1f\n" - + "Max lifespan %.0f Effective HP %.0f\nIntegrity %.3f Imbalance %.0f" + "Seed %d\nTime %s Kills %d Sacrifices %d\n" + + "Attack %.1f Estimated DPS %.1f\n" + + "Max lifespan %.0f Effective HP %.0f\n" + + "Integrity %.3f Imbalance %.0f\nSoul Ash earned %d" ) % [ state.run_seed(), - state.sacrifice_history().size(), + RunResult.format_duration(result.duration_seconds), + result.kills, + result.sacrifices, state.attack(), state.dps_estimate(), state.max_hp(), state.effective_hp(), - state.integrity(), - state.imbalance(), + result.integrity, + result.imbalance, + result.soul_ash_earned, ] visible = true _restart_button.grab_focus() func _unhandled_input(event: InputEvent) -> void: if visible and (event.is_action_pressed(&"confirm") or event.is_action_pressed(&"restart_run")): - _on_restart() + dismiss() get_viewport().set_input_as_handled() -func _on_restart() -> void: +## Closes the screen and hands control back. Safe to call when already hidden. +func dismiss() -> void: if not visible: return visible = false - _coordinator.restart_run() + dismissed.emit(_result) diff --git a/visuals/combat_effects.gd b/visuals/combat_effects.gd deleted file mode 100644 index 8acd2fc..0000000 --- a/visuals/combat_effects.gd +++ /dev/null @@ -1,54 +0,0 @@ -class_name CombatEffects -extends Node2D -## EventBus observer for confirmed damage feedback. No calculation or gameplay -## mutation occurs here; the existing CombatResolver result is already final. - -@export var hit_texture: Texture2D -@export var hit_frames: int = 4 -@export var hit_fps: float = 16.0 - - -func _ready() -> void: - z_index = 20 - EventBus.hit_dealt.connect(_on_hit) - EventBus.hit_taken.connect(_on_hit) - - -func _on_hit(payload: Dictionary) -> void: - if float(payload.get("final_damage", 0.0)) <= 0.0 or hit_texture == null: - return - var target := _find_actor(StringName(payload.get("target_id", &"unknown"))) - if target == null: - return - var effect := OneShotSprite.new() - effect.configure(hit_texture, hit_frames, hit_fps) - add_child(effect) - effect.global_position = target.global_position + Vector2(0.0, -32.0) - - -func _find_actor(target_id: StringName) -> Node2D: - var candidates: Array[Node2D] = [] - _collect_actors(get_tree().current_scene, target_id, candidates) - if candidates.is_empty(): - return null - if candidates.size() == 1: - return candidates[0] - var players: Array[Node2D] = [] - _collect_actors(get_tree().current_scene, &"player", players) - var player: Node2D = players[0] if not players.is_empty() else null - if player == null: - return candidates[0] - var closest := candidates[0] - for candidate in candidates: - if candidate.global_position.distance_squared_to(player.global_position) < closest.global_position.distance_squared_to(player.global_position): - closest = candidate - return closest - - -func _collect_actors(node: Node, target_id: StringName, output: Array[Node2D]) -> void: - if node == null: - return - if node is Node2D and node.has_method("actor_id") and node.call("actor_id") == target_id: - output.append(node as Node2D) - for child in node.get_children(): - _collect_actors(child, target_id, output) diff --git a/visuals/one_shot_sprite.gd b/visuals/one_shot_sprite.gd deleted file mode 100644 index e1db9d6..0000000 --- a/visuals/one_shot_sprite.gd +++ /dev/null @@ -1,20 +0,0 @@ -class_name OneShotSprite -extends Sprite2D -## Small self-cleaning frame-strip player for combat feedback only. - -var _fps: float = 1.0 -var _elapsed: float = 0.0 - - -func configure(source: Texture2D, frame_count: int, fps: float) -> void: - texture = source - hframes = frame_count - _fps = fps - frame = 0 - - -func _process(delta: float) -> void: - _elapsed += delta - frame = mini(int(_elapsed * _fps), hframes - 1) - if _elapsed >= float(hframes) / _fps: - queue_free()