From 18dd0d2768e688dfb1eaad2031335425dae8ebe7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 04:56:49 +0000 Subject: [PATCH 01/10] [C01][Claude] Inspect repository and initialize Godot project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Godot 4.3 project shell on top of the existing docs and art pipeline. No gameplay yet — this is the runnable container everything else sits inside. - 640x360 viewport upscaled to a 1280x720 window with integer scaling, nearest filtering, 2D pixel snapping and MSAA off, so the pixel art in assets/ renders without blur or shimmer. This deviates from the 1280x720 viewport suggested in godot/import_presets.md, which would draw a 48px sprite at 1:1; see ADR-002. - Texture importer defaults follow docs/art/GODOT_IMPORT_GUIDE.md (lossless, no mipmaps, no 3D detection) and are applied to all 62 PNGs. - Input map for movement, jump, light attack, confirm, restart and ten debug commands. - Named 2D physics layers so hit/hurt box masks read as words in scenes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- assets/background/bg_broken_flag.png.import | 34 ++++ assets/background/bg_chains.png.import | 34 ++++ assets/background/bg_deep.png.import | 34 ++++ assets/background/bg_tree.png.import | 34 ++++ assets/boss/boss_attack.png.import | 34 ++++ assets/boss/boss_death.png.import | 34 ++++ assets/boss/boss_hurt.png.import | 34 ++++ assets/boss/boss_idle.png.import | 34 ++++ assets/boss/boss_run.png.import | 34 ++++ assets/effects/effect_blood_splash.png.import | 34 ++++ assets/effects/effect_death_fade.png.import | 34 ++++ assets/effects/effect_ghost_fire.png.import | 34 ++++ assets/effects/effect_hit_slash.png.import | 34 ++++ assets/enemy/corpse_beast_attack.png.import | 34 ++++ assets/enemy/corpse_beast_death.png.import | 34 ++++ assets/enemy/corpse_beast_hurt.png.import | 34 ++++ assets/enemy/corpse_beast_idle.png.import | 34 ++++ assets/enemy/corpse_beast_run.png.import | 34 ++++ assets/enemy/ghost_archer_attack.png.import | 34 ++++ assets/enemy/ghost_archer_death.png.import | 34 ++++ assets/enemy/ghost_archer_hurt.png.import | 34 ++++ assets/enemy/ghost_archer_idle.png.import | 34 ++++ assets/enemy/ghost_archer_run.png.import | 34 ++++ assets/enemy/ghost_melee_attack.png.import | 34 ++++ assets/enemy/ghost_melee_death.png.import | 34 ++++ assets/enemy/ghost_melee_hurt.png.import | 34 ++++ assets/enemy/ghost_melee_idle.png.import | 34 ++++ assets/enemy/ghost_melee_run.png.import | 34 ++++ assets/icons/ui/icon_armor.png.import | 34 ++++ assets/icons/ui/icon_attack.png.import | 34 ++++ assets/icons/ui/icon_boss.png.import | 34 ++++ assets/icons/ui/icon_coin.png.import | 34 ++++ assets/icons/ui/icon_crit.png.import | 34 ++++ assets/icons/ui/icon_ghostfire.png.import | 34 ++++ assets/icons/ui/icon_hp.png.import | 34 ++++ assets/icons/ui/icon_key.png.import | 34 ++++ assets/icons/ui/icon_sacrifice.png.import | 34 ++++ assets/icons/ui/icon_speed.png.import | 34 ++++ assets/icons/ui/icon_stamina.png.import | 34 ++++ assets/icons/weapons/weapon_flag.png.import | 34 ++++ .../icons/weapons/weapon_ghostfire.png.import | 34 ++++ assets/icons/weapons/weapon_gourd.png.import | 34 ++++ .../icons/weapons/weapon_songdao.png.import | 34 ++++ assets/icons/weapons/weapon_spear.png.import | 34 ++++ .../icons/weapons/weapon_talisman.png.import | 34 ++++ assets/player/player_attack.png.import | 34 ++++ assets/player/player_death.png.import | 34 ++++ assets/player/player_hurt.png.import | 34 ++++ assets/player/player_idle.png.import | 34 ++++ assets/player/player_jump.png.import | 34 ++++ assets/player/player_run.png.import | 34 ++++ assets/tiles/tile_brazier.png.import | 34 ++++ assets/tiles/tile_floor.png.import | 34 ++++ assets/tiles/tile_ground_spike.png.import | 34 ++++ assets/tiles/tile_stone_brick.png.import | 34 ++++ assets/tiles/tile_tombstone.png.import | 34 ++++ assets/tiles/tile_wall.png.import | 34 ++++ assets/tiles/tile_wood_bridge.png.import | 34 ++++ assets/ui/ui_frame.png.import | 34 ++++ assets/ui/ui_health_bar.png.import | 34 ++++ assets/ui/ui_minimap_frame.png.import | 34 ++++ assets/ui/ui_stamina_bar.png.import | 34 ++++ project.godot | 154 ++++++++++++++++++ 63 files changed, 2262 insertions(+) create mode 100644 assets/background/bg_broken_flag.png.import create mode 100644 assets/background/bg_chains.png.import create mode 100644 assets/background/bg_deep.png.import create mode 100644 assets/background/bg_tree.png.import create mode 100644 assets/boss/boss_attack.png.import create mode 100644 assets/boss/boss_death.png.import create mode 100644 assets/boss/boss_hurt.png.import create mode 100644 assets/boss/boss_idle.png.import create mode 100644 assets/boss/boss_run.png.import create mode 100644 assets/effects/effect_blood_splash.png.import create mode 100644 assets/effects/effect_death_fade.png.import create mode 100644 assets/effects/effect_ghost_fire.png.import create mode 100644 assets/effects/effect_hit_slash.png.import create mode 100644 assets/enemy/corpse_beast_attack.png.import create mode 100644 assets/enemy/corpse_beast_death.png.import create mode 100644 assets/enemy/corpse_beast_hurt.png.import create mode 100644 assets/enemy/corpse_beast_idle.png.import create mode 100644 assets/enemy/corpse_beast_run.png.import create mode 100644 assets/enemy/ghost_archer_attack.png.import create mode 100644 assets/enemy/ghost_archer_death.png.import create mode 100644 assets/enemy/ghost_archer_hurt.png.import create mode 100644 assets/enemy/ghost_archer_idle.png.import create mode 100644 assets/enemy/ghost_archer_run.png.import create mode 100644 assets/enemy/ghost_melee_attack.png.import create mode 100644 assets/enemy/ghost_melee_death.png.import create mode 100644 assets/enemy/ghost_melee_hurt.png.import create mode 100644 assets/enemy/ghost_melee_idle.png.import create mode 100644 assets/enemy/ghost_melee_run.png.import create mode 100644 assets/icons/ui/icon_armor.png.import create mode 100644 assets/icons/ui/icon_attack.png.import create mode 100644 assets/icons/ui/icon_boss.png.import create mode 100644 assets/icons/ui/icon_coin.png.import create mode 100644 assets/icons/ui/icon_crit.png.import create mode 100644 assets/icons/ui/icon_ghostfire.png.import create mode 100644 assets/icons/ui/icon_hp.png.import create mode 100644 assets/icons/ui/icon_key.png.import create mode 100644 assets/icons/ui/icon_sacrifice.png.import create mode 100644 assets/icons/ui/icon_speed.png.import create mode 100644 assets/icons/ui/icon_stamina.png.import create mode 100644 assets/icons/weapons/weapon_flag.png.import create mode 100644 assets/icons/weapons/weapon_ghostfire.png.import create mode 100644 assets/icons/weapons/weapon_gourd.png.import create mode 100644 assets/icons/weapons/weapon_songdao.png.import create mode 100644 assets/icons/weapons/weapon_spear.png.import create mode 100644 assets/icons/weapons/weapon_talisman.png.import create mode 100644 assets/player/player_attack.png.import create mode 100644 assets/player/player_death.png.import create mode 100644 assets/player/player_hurt.png.import create mode 100644 assets/player/player_idle.png.import create mode 100644 assets/player/player_jump.png.import create mode 100644 assets/player/player_run.png.import create mode 100644 assets/tiles/tile_brazier.png.import create mode 100644 assets/tiles/tile_floor.png.import create mode 100644 assets/tiles/tile_ground_spike.png.import create mode 100644 assets/tiles/tile_stone_brick.png.import create mode 100644 assets/tiles/tile_tombstone.png.import create mode 100644 assets/tiles/tile_wall.png.import create mode 100644 assets/tiles/tile_wood_bridge.png.import create mode 100644 assets/ui/ui_frame.png.import create mode 100644 assets/ui/ui_health_bar.png.import create mode 100644 assets/ui/ui_minimap_frame.png.import create mode 100644 assets/ui/ui_stamina_bar.png.import create mode 100644 project.godot diff --git a/assets/background/bg_broken_flag.png.import b/assets/background/bg_broken_flag.png.import new file mode 100644 index 0000000..f8bcde7 --- /dev/null +++ b/assets/background/bg_broken_flag.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhv3t65rpaibp" +path="res://.godot/imported/bg_broken_flag.png-f9c11b009239579970eda162d98e3dd8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_broken_flag.png" +dest_files=["res://.godot/imported/bg_broken_flag.png-f9c11b009239579970eda162d98e3dd8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/background/bg_chains.png.import b/assets/background/bg_chains.png.import new file mode 100644 index 0000000..11b7e5a --- /dev/null +++ b/assets/background/bg_chains.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ds7h1dgasorgw" +path="res://.godot/imported/bg_chains.png-065cfe175d20058c3994acac3b2c065a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_chains.png" +dest_files=["res://.godot/imported/bg_chains.png-065cfe175d20058c3994acac3b2c065a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/background/bg_deep.png.import b/assets/background/bg_deep.png.import new file mode 100644 index 0000000..429cdd4 --- /dev/null +++ b/assets/background/bg_deep.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://byfqbk1d8hduy" +path="res://.godot/imported/bg_deep.png-eed9817dfbc5b1972845c14742627109.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_deep.png" +dest_files=["res://.godot/imported/bg_deep.png-eed9817dfbc5b1972845c14742627109.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/background/bg_tree.png.import b/assets/background/bg_tree.png.import new file mode 100644 index 0000000..418ef16 --- /dev/null +++ b/assets/background/bg_tree.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhgi5i47tfowh" +path="res://.godot/imported/bg_tree.png-efa66bcc88c2c944ec44360cfeae57aa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/background/bg_tree.png" +dest_files=["res://.godot/imported/bg_tree.png-efa66bcc88c2c944ec44360cfeae57aa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_attack.png.import b/assets/boss/boss_attack.png.import new file mode 100644 index 0000000..fb9cbe3 --- /dev/null +++ b/assets/boss/boss_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://f55djfeo0mdv" +path="res://.godot/imported/boss_attack.png-cc8d2cb44fc3265bc09020f79570bfc0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_attack.png" +dest_files=["res://.godot/imported/boss_attack.png-cc8d2cb44fc3265bc09020f79570bfc0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_death.png.import b/assets/boss/boss_death.png.import new file mode 100644 index 0000000..6a5acbf --- /dev/null +++ b/assets/boss/boss_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://425fsmbamyoy" +path="res://.godot/imported/boss_death.png-8cef6efa623363ddef6d9fa0e772ab49.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_death.png" +dest_files=["res://.godot/imported/boss_death.png-8cef6efa623363ddef6d9fa0e772ab49.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_hurt.png.import b/assets/boss/boss_hurt.png.import new file mode 100644 index 0000000..eb2f906 --- /dev/null +++ b/assets/boss/boss_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cpia32vxwflov" +path="res://.godot/imported/boss_hurt.png-15d40a6d567405598ecac98ebaa976fd.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_hurt.png" +dest_files=["res://.godot/imported/boss_hurt.png-15d40a6d567405598ecac98ebaa976fd.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_idle.png.import b/assets/boss/boss_idle.png.import new file mode 100644 index 0000000..1de7012 --- /dev/null +++ b/assets/boss/boss_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dpsbdyeqas3vf" +path="res://.godot/imported/boss_idle.png-26326c8f164d6b3fbd8b7aecc5e40b83.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_idle.png" +dest_files=["res://.godot/imported/boss_idle.png-26326c8f164d6b3fbd8b7aecc5e40b83.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/boss/boss_run.png.import b/assets/boss/boss_run.png.import new file mode 100644 index 0000000..d402500 --- /dev/null +++ b/assets/boss/boss_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bbk7gegw3fevd" +path="res://.godot/imported/boss_run.png-510f9badd5915d03995ad692a6c53ff0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/boss/boss_run.png" +dest_files=["res://.godot/imported/boss_run.png-510f9badd5915d03995ad692a6c53ff0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_blood_splash.png.import b/assets/effects/effect_blood_splash.png.import new file mode 100644 index 0000000..3b843ac --- /dev/null +++ b/assets/effects/effect_blood_splash.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://de2k4u7rr8hgu" +path="res://.godot/imported/effect_blood_splash.png-0e24bd9ff1c465c106def06c8dd1d37f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_blood_splash.png" +dest_files=["res://.godot/imported/effect_blood_splash.png-0e24bd9ff1c465c106def06c8dd1d37f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_death_fade.png.import b/assets/effects/effect_death_fade.png.import new file mode 100644 index 0000000..8e313f7 --- /dev/null +++ b/assets/effects/effect_death_fade.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr54t0xaqwljw" +path="res://.godot/imported/effect_death_fade.png-42af3271e4d471eea4309621e6989208.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_death_fade.png" +dest_files=["res://.godot/imported/effect_death_fade.png-42af3271e4d471eea4309621e6989208.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_ghost_fire.png.import b/assets/effects/effect_ghost_fire.png.import new file mode 100644 index 0000000..437b6e8 --- /dev/null +++ b/assets/effects/effect_ghost_fire.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://1wcqmy0j2rh6" +path="res://.godot/imported/effect_ghost_fire.png-5f55d939584e92b1c058f2c33c5d7655.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_ghost_fire.png" +dest_files=["res://.godot/imported/effect_ghost_fire.png-5f55d939584e92b1c058f2c33c5d7655.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/effects/effect_hit_slash.png.import b/assets/effects/effect_hit_slash.png.import new file mode 100644 index 0000000..6ca21ac --- /dev/null +++ b/assets/effects/effect_hit_slash.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://e7eduq8qvwoi" +path="res://.godot/imported/effect_hit_slash.png-2265f09dab59d01552bf5d01d2c00954.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/effects/effect_hit_slash.png" +dest_files=["res://.godot/imported/effect_hit_slash.png-2265f09dab59d01552bf5d01d2c00954.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_attack.png.import b/assets/enemy/corpse_beast_attack.png.import new file mode 100644 index 0000000..1315b33 --- /dev/null +++ b/assets/enemy/corpse_beast_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://f4a61lc3xp60" +path="res://.godot/imported/corpse_beast_attack.png-f581648e30e243193c400587e0a6c6c8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_attack.png" +dest_files=["res://.godot/imported/corpse_beast_attack.png-f581648e30e243193c400587e0a6c6c8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_death.png.import b/assets/enemy/corpse_beast_death.png.import new file mode 100644 index 0000000..574fbc8 --- /dev/null +++ b/assets/enemy/corpse_beast_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cutqpxkaiowv0" +path="res://.godot/imported/corpse_beast_death.png-6a59f72967609c66db3a4d35c88f9780.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_death.png" +dest_files=["res://.godot/imported/corpse_beast_death.png-6a59f72967609c66db3a4d35c88f9780.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_hurt.png.import b/assets/enemy/corpse_beast_hurt.png.import new file mode 100644 index 0000000..4e463e4 --- /dev/null +++ b/assets/enemy/corpse_beast_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sho6gtb1xmqs" +path="res://.godot/imported/corpse_beast_hurt.png-84bc2d4aad09ff5a156bc8cce5a122b0.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_hurt.png" +dest_files=["res://.godot/imported/corpse_beast_hurt.png-84bc2d4aad09ff5a156bc8cce5a122b0.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_idle.png.import b/assets/enemy/corpse_beast_idle.png.import new file mode 100644 index 0000000..90784e0 --- /dev/null +++ b/assets/enemy/corpse_beast_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsktg0aqhj8vj" +path="res://.godot/imported/corpse_beast_idle.png-e39fca8d9e094b7df704d487ad9c245e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_idle.png" +dest_files=["res://.godot/imported/corpse_beast_idle.png-e39fca8d9e094b7df704d487ad9c245e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/corpse_beast_run.png.import b/assets/enemy/corpse_beast_run.png.import new file mode 100644 index 0000000..1b6350c --- /dev/null +++ b/assets/enemy/corpse_beast_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3sqktkw1s4ss" +path="res://.godot/imported/corpse_beast_run.png-ec926f018d8733bb76bb9dcca39a73a6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/corpse_beast_run.png" +dest_files=["res://.godot/imported/corpse_beast_run.png-ec926f018d8733bb76bb9dcca39a73a6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_attack.png.import b/assets/enemy/ghost_archer_attack.png.import new file mode 100644 index 0000000..a254f82 --- /dev/null +++ b/assets/enemy/ghost_archer_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://drf451cmvg2d8" +path="res://.godot/imported/ghost_archer_attack.png-ea38d14beec5034462e16852a120c353.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_attack.png" +dest_files=["res://.godot/imported/ghost_archer_attack.png-ea38d14beec5034462e16852a120c353.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_death.png.import b/assets/enemy/ghost_archer_death.png.import new file mode 100644 index 0000000..bf129c6 --- /dev/null +++ b/assets/enemy/ghost_archer_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://co84p0q5k6hrx" +path="res://.godot/imported/ghost_archer_death.png-5351b3c5fae4f4bd2f424dc5bfc9bf9e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_death.png" +dest_files=["res://.godot/imported/ghost_archer_death.png-5351b3c5fae4f4bd2f424dc5bfc9bf9e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_hurt.png.import b/assets/enemy/ghost_archer_hurt.png.import new file mode 100644 index 0000000..fece4fb --- /dev/null +++ b/assets/enemy/ghost_archer_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ckdalymryaoq" +path="res://.godot/imported/ghost_archer_hurt.png-71a6a81ec02fdaac217a95ac1aff1a48.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_hurt.png" +dest_files=["res://.godot/imported/ghost_archer_hurt.png-71a6a81ec02fdaac217a95ac1aff1a48.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_idle.png.import b/assets/enemy/ghost_archer_idle.png.import new file mode 100644 index 0000000..dc1c0e7 --- /dev/null +++ b/assets/enemy/ghost_archer_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dh4lg7jn6lh3y" +path="res://.godot/imported/ghost_archer_idle.png-56c44223141825735882190f60a5bfea.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_idle.png" +dest_files=["res://.godot/imported/ghost_archer_idle.png-56c44223141825735882190f60a5bfea.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_archer_run.png.import b/assets/enemy/ghost_archer_run.png.import new file mode 100644 index 0000000..b8d1fb8 --- /dev/null +++ b/assets/enemy/ghost_archer_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://w0scfnpkvl3f" +path="res://.godot/imported/ghost_archer_run.png-86a3f3672a38fb9091f13c0c2723782a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_archer_run.png" +dest_files=["res://.godot/imported/ghost_archer_run.png-86a3f3672a38fb9091f13c0c2723782a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_attack.png.import b/assets/enemy/ghost_melee_attack.png.import new file mode 100644 index 0000000..378bccf --- /dev/null +++ b/assets/enemy/ghost_melee_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://n6r0tsqaikgi" +path="res://.godot/imported/ghost_melee_attack.png-05e56701a913044d74b77b7511007a24.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_attack.png" +dest_files=["res://.godot/imported/ghost_melee_attack.png-05e56701a913044d74b77b7511007a24.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_death.png.import b/assets/enemy/ghost_melee_death.png.import new file mode 100644 index 0000000..a1980cf --- /dev/null +++ b/assets/enemy/ghost_melee_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://lxfjwtm3mf5d" +path="res://.godot/imported/ghost_melee_death.png-c16e2d731896969300c8599cd28a3675.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_death.png" +dest_files=["res://.godot/imported/ghost_melee_death.png-c16e2d731896969300c8599cd28a3675.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_hurt.png.import b/assets/enemy/ghost_melee_hurt.png.import new file mode 100644 index 0000000..30e1c4a --- /dev/null +++ b/assets/enemy/ghost_melee_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://divm24f2prjha" +path="res://.godot/imported/ghost_melee_hurt.png-832460fae6ae7d951c70ee2b4f1a1add.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_hurt.png" +dest_files=["res://.godot/imported/ghost_melee_hurt.png-832460fae6ae7d951c70ee2b4f1a1add.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_idle.png.import b/assets/enemy/ghost_melee_idle.png.import new file mode 100644 index 0000000..2cedbdb --- /dev/null +++ b/assets/enemy/ghost_melee_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c1hf08wj8xlr4" +path="res://.godot/imported/ghost_melee_idle.png-c2f0c20b1e3e88b3006eab51a6cdf57b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_idle.png" +dest_files=["res://.godot/imported/ghost_melee_idle.png-c2f0c20b1e3e88b3006eab51a6cdf57b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/enemy/ghost_melee_run.png.import b/assets/enemy/ghost_melee_run.png.import new file mode 100644 index 0000000..3cf428f --- /dev/null +++ b/assets/enemy/ghost_melee_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://br5fi23i7refg" +path="res://.godot/imported/ghost_melee_run.png-9af3bc7d8c3951e5b04fcc829b3adc76.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/enemy/ghost_melee_run.png" +dest_files=["res://.godot/imported/ghost_melee_run.png-9af3bc7d8c3951e5b04fcc829b3adc76.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_armor.png.import b/assets/icons/ui/icon_armor.png.import new file mode 100644 index 0000000..a8df1bb --- /dev/null +++ b/assets/icons/ui/icon_armor.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dnbojlwtngory" +path="res://.godot/imported/icon_armor.png-0c1d59e09dbad92aa701e02c59911c80.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_armor.png" +dest_files=["res://.godot/imported/icon_armor.png-0c1d59e09dbad92aa701e02c59911c80.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_attack.png.import b/assets/icons/ui/icon_attack.png.import new file mode 100644 index 0000000..edb2fbf --- /dev/null +++ b/assets/icons/ui/icon_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c6rfl3o86r7af" +path="res://.godot/imported/icon_attack.png-b8d243255885ae630858a76db5c15d77.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_attack.png" +dest_files=["res://.godot/imported/icon_attack.png-b8d243255885ae630858a76db5c15d77.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_boss.png.import b/assets/icons/ui/icon_boss.png.import new file mode 100644 index 0000000..d4c4887 --- /dev/null +++ b/assets/icons/ui/icon_boss.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3o7vjs1alogy" +path="res://.godot/imported/icon_boss.png-0a405990ef0223b3c6321541b8229b62.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_boss.png" +dest_files=["res://.godot/imported/icon_boss.png-0a405990ef0223b3c6321541b8229b62.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_coin.png.import b/assets/icons/ui/icon_coin.png.import new file mode 100644 index 0000000..12b2ed5 --- /dev/null +++ b/assets/icons/ui/icon_coin.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cx7ghlegv366b" +path="res://.godot/imported/icon_coin.png-72b148a217a8ddbbb8e9c04808dcf46a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_coin.png" +dest_files=["res://.godot/imported/icon_coin.png-72b148a217a8ddbbb8e9c04808dcf46a.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_crit.png.import b/assets/icons/ui/icon_crit.png.import new file mode 100644 index 0000000..d243ae0 --- /dev/null +++ b/assets/icons/ui/icon_crit.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dpr3lsosh38t1" +path="res://.godot/imported/icon_crit.png-7f6d473da08152516495b9e9851d05b6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_crit.png" +dest_files=["res://.godot/imported/icon_crit.png-7f6d473da08152516495b9e9851d05b6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_ghostfire.png.import b/assets/icons/ui/icon_ghostfire.png.import new file mode 100644 index 0000000..8170979 --- /dev/null +++ b/assets/icons/ui/icon_ghostfire.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://n8vybk0ng800" +path="res://.godot/imported/icon_ghostfire.png-667f276d4a0b64a79dfb81720e1edd8e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_ghostfire.png" +dest_files=["res://.godot/imported/icon_ghostfire.png-667f276d4a0b64a79dfb81720e1edd8e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_hp.png.import b/assets/icons/ui/icon_hp.png.import new file mode 100644 index 0000000..d6e316e --- /dev/null +++ b/assets/icons/ui/icon_hp.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dkqomhoim2742" +path="res://.godot/imported/icon_hp.png-5f5456432c62ca6f9e092c0bd19b0de3.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_hp.png" +dest_files=["res://.godot/imported/icon_hp.png-5f5456432c62ca6f9e092c0bd19b0de3.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_key.png.import b/assets/icons/ui/icon_key.png.import new file mode 100644 index 0000000..4f41c91 --- /dev/null +++ b/assets/icons/ui/icon_key.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhbabm5aa4241" +path="res://.godot/imported/icon_key.png-5df25ae3f110277a10f14bc1ca06274b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_key.png" +dest_files=["res://.godot/imported/icon_key.png-5df25ae3f110277a10f14bc1ca06274b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_sacrifice.png.import b/assets/icons/ui/icon_sacrifice.png.import new file mode 100644 index 0000000..647404c --- /dev/null +++ b/assets/icons/ui/icon_sacrifice.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0oxout2n20m6" +path="res://.godot/imported/icon_sacrifice.png-7a9bd2e0bcf7a1754d115f27a58a28b5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_sacrifice.png" +dest_files=["res://.godot/imported/icon_sacrifice.png-7a9bd2e0bcf7a1754d115f27a58a28b5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_speed.png.import b/assets/icons/ui/icon_speed.png.import new file mode 100644 index 0000000..9668150 --- /dev/null +++ b/assets/icons/ui/icon_speed.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://beosunmpmayvw" +path="res://.godot/imported/icon_speed.png-2d7d550435e2cd00693d6e84a89d23c2.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_speed.png" +dest_files=["res://.godot/imported/icon_speed.png-2d7d550435e2cd00693d6e84a89d23c2.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/ui/icon_stamina.png.import b/assets/icons/ui/icon_stamina.png.import new file mode 100644 index 0000000..19e941c --- /dev/null +++ b/assets/icons/ui/icon_stamina.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b2q620ud25l17" +path="res://.godot/imported/icon_stamina.png-94b1c48c8a9934ecd08b0ec76727b3e6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/ui/icon_stamina.png" +dest_files=["res://.godot/imported/icon_stamina.png-94b1c48c8a9934ecd08b0ec76727b3e6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_flag.png.import b/assets/icons/weapons/weapon_flag.png.import new file mode 100644 index 0000000..bba8617 --- /dev/null +++ b/assets/icons/weapons/weapon_flag.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://6ti3in5rr8an" +path="res://.godot/imported/weapon_flag.png-62c7ab1a76fbe03241fda264e2a7e957.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_flag.png" +dest_files=["res://.godot/imported/weapon_flag.png-62c7ab1a76fbe03241fda264e2a7e957.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_ghostfire.png.import b/assets/icons/weapons/weapon_ghostfire.png.import new file mode 100644 index 0000000..697b7bb --- /dev/null +++ b/assets/icons/weapons/weapon_ghostfire.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b0d1s1sefg5os" +path="res://.godot/imported/weapon_ghostfire.png-f2a92591998123e6405eae5a3f6cec8f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_ghostfire.png" +dest_files=["res://.godot/imported/weapon_ghostfire.png-f2a92591998123e6405eae5a3f6cec8f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_gourd.png.import b/assets/icons/weapons/weapon_gourd.png.import new file mode 100644 index 0000000..3cd4079 --- /dev/null +++ b/assets/icons/weapons/weapon_gourd.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://7evsfccqy3c1" +path="res://.godot/imported/weapon_gourd.png-48632adf2b51377d15d7271e5a5028e5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_gourd.png" +dest_files=["res://.godot/imported/weapon_gourd.png-48632adf2b51377d15d7271e5a5028e5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_songdao.png.import b/assets/icons/weapons/weapon_songdao.png.import new file mode 100644 index 0000000..8c59d51 --- /dev/null +++ b/assets/icons/weapons/weapon_songdao.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blkmt1bhbfi23" +path="res://.godot/imported/weapon_songdao.png-2f0e169de0f3d0e4cc6d0d0a41453765.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_songdao.png" +dest_files=["res://.godot/imported/weapon_songdao.png-2f0e169de0f3d0e4cc6d0d0a41453765.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_spear.png.import b/assets/icons/weapons/weapon_spear.png.import new file mode 100644 index 0000000..8c03b07 --- /dev/null +++ b/assets/icons/weapons/weapon_spear.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c4e8j723h0fjr" +path="res://.godot/imported/weapon_spear.png-2a15fab0dfa7525f3a55333c8a499136.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_spear.png" +dest_files=["res://.godot/imported/weapon_spear.png-2a15fab0dfa7525f3a55333c8a499136.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/icons/weapons/weapon_talisman.png.import b/assets/icons/weapons/weapon_talisman.png.import new file mode 100644 index 0000000..2ef8340 --- /dev/null +++ b/assets/icons/weapons/weapon_talisman.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rv5w4hvmw1vi" +path="res://.godot/imported/weapon_talisman.png-1ba53dd1c2bc777789258168ccc4e76e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/icons/weapons/weapon_talisman.png" +dest_files=["res://.godot/imported/weapon_talisman.png-1ba53dd1c2bc777789258168ccc4e76e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_attack.png.import b/assets/player/player_attack.png.import new file mode 100644 index 0000000..2b4ebc7 --- /dev/null +++ b/assets/player/player_attack.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://8r73tvuox67j" +path="res://.godot/imported/player_attack.png-0af7dce842d1c697a05378aecb71f4af.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_attack.png" +dest_files=["res://.godot/imported/player_attack.png-0af7dce842d1c697a05378aecb71f4af.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_death.png.import b/assets/player/player_death.png.import new file mode 100644 index 0000000..0145933 --- /dev/null +++ b/assets/player/player_death.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dc7u5o45na5a" +path="res://.godot/imported/player_death.png-f4f037084ed9d3ca30606cf910681549.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_death.png" +dest_files=["res://.godot/imported/player_death.png-f4f037084ed9d3ca30606cf910681549.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_hurt.png.import b/assets/player/player_hurt.png.import new file mode 100644 index 0000000..d6da12a --- /dev/null +++ b/assets/player/player_hurt.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://xef4cqawi822" +path="res://.godot/imported/player_hurt.png-9a125650bdca44663ea27ebf17dbece5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_hurt.png" +dest_files=["res://.godot/imported/player_hurt.png-9a125650bdca44663ea27ebf17dbece5.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_idle.png.import b/assets/player/player_idle.png.import new file mode 100644 index 0000000..fa17604 --- /dev/null +++ b/assets/player/player_idle.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ceiuoddfsnctk" +path="res://.godot/imported/player_idle.png-e26fadf165b07744f21b91a4fc32f036.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_idle.png" +dest_files=["res://.godot/imported/player_idle.png-e26fadf165b07744f21b91a4fc32f036.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_jump.png.import b/assets/player/player_jump.png.import new file mode 100644 index 0000000..38d1068 --- /dev/null +++ b/assets/player/player_jump.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c5jv0d7l3kge" +path="res://.godot/imported/player_jump.png-a9a55f2d2e8ee7e546d824cbe569976f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_jump.png" +dest_files=["res://.godot/imported/player_jump.png-a9a55f2d2e8ee7e546d824cbe569976f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/player/player_run.png.import b/assets/player/player_run.png.import new file mode 100644 index 0000000..018cb71 --- /dev/null +++ b/assets/player/player_run.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dil1reydutj3l" +path="res://.godot/imported/player_run.png-a8fa5f6477361258876a91c79d7aa2ec.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/player/player_run.png" +dest_files=["res://.godot/imported/player_run.png-a8fa5f6477361258876a91c79d7aa2ec.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_brazier.png.import b/assets/tiles/tile_brazier.png.import new file mode 100644 index 0000000..0250422 --- /dev/null +++ b/assets/tiles/tile_brazier.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cwl02m7md7wnd" +path="res://.godot/imported/tile_brazier.png-e7ad2a81ac1f3a6f39578455758e9c23.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_brazier.png" +dest_files=["res://.godot/imported/tile_brazier.png-e7ad2a81ac1f3a6f39578455758e9c23.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_floor.png.import b/assets/tiles/tile_floor.png.import new file mode 100644 index 0000000..ac127a1 --- /dev/null +++ b/assets/tiles/tile_floor.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://nb7cn4mxoxin" +path="res://.godot/imported/tile_floor.png-71c8fc302ab1f3c453e0cd89aa9d343b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_floor.png" +dest_files=["res://.godot/imported/tile_floor.png-71c8fc302ab1f3c453e0cd89aa9d343b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_ground_spike.png.import b/assets/tiles/tile_ground_spike.png.import new file mode 100644 index 0000000..cb2169d --- /dev/null +++ b/assets/tiles/tile_ground_spike.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dsy3orcp7rvsd" +path="res://.godot/imported/tile_ground_spike.png-4613cc623c68d23885250933b126e71f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_ground_spike.png" +dest_files=["res://.godot/imported/tile_ground_spike.png-4613cc623c68d23885250933b126e71f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_stone_brick.png.import b/assets/tiles/tile_stone_brick.png.import new file mode 100644 index 0000000..9e566ec --- /dev/null +++ b/assets/tiles/tile_stone_brick.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kce6o88v4bwx" +path="res://.godot/imported/tile_stone_brick.png-eb6eb5849c6fbc2f3a87c5d9af14d627.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_stone_brick.png" +dest_files=["res://.godot/imported/tile_stone_brick.png-eb6eb5849c6fbc2f3a87c5d9af14d627.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_tombstone.png.import b/assets/tiles/tile_tombstone.png.import new file mode 100644 index 0000000..2e1dee3 --- /dev/null +++ b/assets/tiles/tile_tombstone.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://x8uhovqw2pe8" +path="res://.godot/imported/tile_tombstone.png-e9c53699973fc0d41aee61dfcba3fd97.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_tombstone.png" +dest_files=["res://.godot/imported/tile_tombstone.png-e9c53699973fc0d41aee61dfcba3fd97.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_wall.png.import b/assets/tiles/tile_wall.png.import new file mode 100644 index 0000000..14210e9 --- /dev/null +++ b/assets/tiles/tile_wall.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ds6xketq47le1" +path="res://.godot/imported/tile_wall.png-043e603e1d656a0403799ed59a15fe68.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_wall.png" +dest_files=["res://.godot/imported/tile_wall.png-043e603e1d656a0403799ed59a15fe68.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/tiles/tile_wood_bridge.png.import b/assets/tiles/tile_wood_bridge.png.import new file mode 100644 index 0000000..13f2b83 --- /dev/null +++ b/assets/tiles/tile_wood_bridge.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dwlvgig7pgsph" +path="res://.godot/imported/tile_wood_bridge.png-b54c690ee1eb951f1dc789e010335657.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/tiles/tile_wood_bridge.png" +dest_files=["res://.godot/imported/tile_wood_bridge.png-b54c690ee1eb951f1dc789e010335657.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_frame.png.import b/assets/ui/ui_frame.png.import new file mode 100644 index 0000000..0046668 --- /dev/null +++ b/assets/ui/ui_frame.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bew7m7456vh2s" +path="res://.godot/imported/ui_frame.png-41070a91e4690c57ec6a98035f6848cf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_frame.png" +dest_files=["res://.godot/imported/ui_frame.png-41070a91e4690c57ec6a98035f6848cf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_health_bar.png.import b/assets/ui/ui_health_bar.png.import new file mode 100644 index 0000000..dd38fa5 --- /dev/null +++ b/assets/ui/ui_health_bar.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cxm0vhxh27b8" +path="res://.godot/imported/ui_health_bar.png-ca4406dbebbb98771c21c8d269164abf.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_health_bar.png" +dest_files=["res://.godot/imported/ui_health_bar.png-ca4406dbebbb98771c21c8d269164abf.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_minimap_frame.png.import b/assets/ui/ui_minimap_frame.png.import new file mode 100644 index 0000000..83ee62c --- /dev/null +++ b/assets/ui/ui_minimap_frame.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://c48u3kcugdrdo" +path="res://.godot/imported/ui_minimap_frame.png-4751a59a033eb58be6f5b35a9d1a3549.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_minimap_frame.png" +dest_files=["res://.godot/imported/ui_minimap_frame.png-4751a59a033eb58be6f5b35a9d1a3549.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/assets/ui/ui_stamina_bar.png.import b/assets/ui/ui_stamina_bar.png.import new file mode 100644 index 0000000..d7d808f --- /dev/null +++ b/assets/ui/ui_stamina_bar.png.import @@ -0,0 +1,34 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cslgpurrd4o5e" +path="res://.godot/imported/ui_stamina_bar.png-df1c016c13ebef01b4b1e6b124e9940b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/ui/ui_stamina_bar.png" +dest_files=["res://.godot/imported/ui_stamina_bar.png-df1c016c13ebef01b4b1e6b124e9940b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=0 diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..2dc9f50 --- /dev/null +++ b/project.godot @@ -0,0 +1,154 @@ +; Nine Nether — Godot 4 project configuration. +; +; Rendering and display values are documented in docs/ARCHITECTURE.md +; ("Pixel art rendering configuration") and ADR-002 in docs/DECISIONS.md. +; Balance values do NOT belong here: see res://data/balance_config.tres. + +config_version=5 + +[application] + +config/name="Nine Nether" +config/description="Godot 4 greybox prototype: sacrifice structural survival for offensive power." +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.3", "GL Compatibility") + +[autoload] + +EventBus="*res://core/event_bus.gd" +RNGService="*res://core/rng_service.gd" +GameData="*res://core/game_data.gd" + +[display] + +window/size/viewport_width=640 +window/size/viewport_height=360 +window/size/window_width_override=1280 +window/size/window_height_override=720 +window/stretch/mode="viewport" +window/stretch/aspect="keep" +window/stretch/scale_mode="integer" + +[importer_defaults] + +texture={ +"compress/channel_pack": 0, +"compress/hdr_compression": 1, +"compress/high_quality": false, +"compress/lossy_quality": 0.7, +"compress/mode": 0, +"compress/normal_map": 0, +"detect_3d/compress_to": 0, +"editor/convert_colors_with_editor_theme": false, +"editor/scale_with_editor_scale": false, +"mipmaps/generate": false, +"mipmaps/limit": -1, +"process/fix_alpha_border": true, +"process/hdr_as_srgb": false, +"process/hdr_clamp_exposure": false, +"process/normal_map_invert_y": false, +"process/premult_alpha": false, +"process/size_limit": 0, +"roughness/mode": 0, +"roughness/src_normal": "" +} + +[input] + +move_left={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":65,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194319,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +move_right={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":68,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194321,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +jump={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +light_attack={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +, Object(InputEventMouseButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"button_mask":1,"position":Vector2(0, 0),"global_position":Vector2(0, 0),"factor":1.0,"button_index":1,"canceled":false,"pressed":false,"double_click":false,"script":null) +] +} +confirm={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194309,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +restart_run={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":82,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_toggle_panel={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194332,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_heal_player={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194333,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_damage_player={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194334,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_spawn_enemy={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194335,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_start_boss={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194336,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_preview_sacrifice={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194337,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_apply_sacrifice={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194338,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} +debug_toggle_hitboxes={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":4194339,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null) +] +} + +[layer_names] + +2d_physics/layer_1="world" +2d_physics/layer_2="player_body" +2d_physics/layer_3="enemy_body" +2d_physics/layer_4="player_hurtbox" +2d_physics/layer_5="enemy_hurtbox" +2d_physics/layer_6="player_hitbox" +2d_physics/layer_7="enemy_hitbox" + +[physics] + +common/physics_ticks_per_second=60 + +[rendering] + +renderer/rendering_method="gl_compatibility" +renderer/rendering_method.mobile="gl_compatibility" +textures/canvas_textures/default_texture_filter=0 +2d/snap/snap_2d_transforms_to_pixel=true +2d/snap/snap_2d_vertices_to_pixel=true +anti_aliasing/quality/msaa_2d=0 From a8b8e05401c44ea368b13327d1af6e3f0fa8af01 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 04:57:00 +0000 Subject: [PATCH 02/10] [C02][Claude] Add RunState and core services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunState is the authoritative source for run-level player state. - Fields are private; reads go through getters and writes through named commands, so the UI physically cannot poke a stat. Structural values (max HP, max stamina, armour, recovery, locks, taxes) are kept apart from momentary ones (current HP, current stamina) — the split that stops 'take damage, get a better sacrifice, heal, repeat' arbitrage. - snapshot/restore/clone/snapshot_hash support preview, rollback, logging and tests. recalculate_derived() is the single place derived values are produced. - EventBus carries a uniform payload envelope and is documented as observation-only: deterministic resolution uses explicit service calls. - RNGService gives each subsystem its own stream derived from the run seed via FNV-1a, so an extra roll in one system cannot shift another system's sequence. - BalanceConfig holds every tunable number; data/balance_config.tres carries the research report's Appendix A baselines. SoftCaps and DerivedStats keep the cap curves and EHP/DPS projections in one place each. - GameData autoloads the config, the stateless services and the sacrifice library. It deliberately does not own the active RunState. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- core/balance_config.gd | 109 ++++++++++++++ core/derived_stats.gd | 30 ++++ core/event_bus.gd | 43 ++++++ core/game_data.gd | 70 +++++++++ core/rng_service.gd | 63 +++++++++ core/run_state.gd | 296 +++++++++++++++++++++++++++++++++++++++ core/soft_caps.gd | 26 ++++ data/balance_config.tres | 70 +++++++++ 8 files changed, 707 insertions(+) create mode 100644 core/balance_config.gd create mode 100644 core/derived_stats.gd create mode 100644 core/event_bus.gd create mode 100644 core/game_data.gd create mode 100644 core/rng_service.gd create mode 100644 core/run_state.gd create mode 100644 core/soft_caps.gd create mode 100644 data/balance_config.tres diff --git a/core/balance_config.gd b/core/balance_config.gd new file mode 100644 index 0000000..24e56e2 --- /dev/null +++ b/core/balance_config.gd @@ -0,0 +1,109 @@ +class_name BalanceConfig +extends Resource +## Every tunable number in the prototype. Scripts read from here; they must not +## hardcode balance values. +## +## Baselines come from the research report "残躯换锋 · 2D 动作肉鸽核心机制深度研究报告" +## (Appendix A parameter table, sections 5.1–5.7) via the Prototype Development +## Pack. The shipped instance is res://data/balance_config.tres. + +@export_group("Player baseline") +## Section 5.1. `hp_0`, `st_0`, `arm_0`, `sr_0` in the integrity formula. +@export var base_max_hp: float = 100.0 +@export var base_max_stamina: float = 100.0 +@export var base_armour: float = 10.0 +@export var base_stamina_recovery: float = 12.0 +@export var base_attack: float = 10.0 +@export var base_crit_rate: float = 0.05 +@export var base_crit_damage: float = 1.5 +@export var base_attack_speed: float = 1.0 + +@export_group("Structural floors") +## Section 5.7. Sacrifices may not push a structural stat below these. +@export var min_max_hp: float = 10.0 +@export var min_max_stamina: float = 15.0 +@export var min_armour: float = 0.0 + +@export_group("Soft and hard caps") +## Section 5.7. Crit rate is a hard cap; the rest compress past the knee. +@export var crit_rate_hard_cap: float = 0.80 +@export var crit_damage_soft_cap: float = 4.5 +@export var crit_damage_soft_slope: float = 0.35 +@export var attack_speed_soft_cap: float = 2.4 +@export var attack_speed_soft_slope: float = 0.35 +@export var more_product_soft_cap: float = 12.0 +@export var more_product_soft_slope: float = 0.30 + +@export_group("Integrity weights") +## Section 5.4: I = clamp(0.34h + 0.24s + 0.16a + 0.14r + 0.12f, 0, 1) +@export var integrity_weight_hp: float = 0.34 +@export var integrity_weight_stamina: float = 0.24 +@export var integrity_weight_armour: float = 0.16 +@export var integrity_weight_recovery: float = 0.14 +@export var integrity_weight_freedom: float = 0.12 +@export var integrity_major_lock_penalty: float = 0.12 +@export var integrity_action_tax_penalty: float = 0.06 + +@export_group("Sacrifice reward curve") +## Section 5.5: D = (1 - I)^exponent, G = g_S * (base + slope * D) * Q +@export var deficiency_exponent: float = 1.35 +@export var reward_base: float = 0.55 +@export var reward_slope: float = 1.45 +## g_S indexed by strength 1..5; index 0 is unused padding. +@export var strength_gain: PackedFloat32Array = PackedFloat32Array( + [0.0, 0.12, 0.20, 0.32, 0.48, 0.70] +) +@export var synergy_bonus: float = 0.12 +@export var dilution_penalty: float = 0.08 +@export var synergy_quality_min: float = 0.85 +@export var synergy_quality_max: float = 1.45 + +@export_group("Sacrifice cost and imbalance") +## Section 5.6: C_res = 100*dh + 80*ds + 60*da + 50*dr; dB = C * (0.75 + 0.05*S) +@export var cost_weight_hp: float = 100.0 +@export var cost_weight_stamina: float = 80.0 +@export var cost_weight_armour: float = 60.0 +@export var cost_weight_recovery: float = 50.0 +@export var imbalance_base_factor: float = 0.75 +@export var imbalance_strength_factor: float = 0.05 +@export var imbalance_max: float = 200.0 +## Section 9.3 caps: at most 2 major locks and 3 action taxes in one run. +@export var max_major_locks: int = 2 +@export var max_action_taxes: int = 3 + +@export_group("Health thresholds") +## Section 6.4. `wounded` and `guttering candle` are momentary, not structural. +@export var wounded_threshold: float = 0.30 +@export var guttering_threshold: float = 0.15 +@export var guttering_more_multiplier: float = 1.20 + +@export_group("Player movement") +@export var player_move_speed: float = 110.0 +@export var player_air_control: float = 0.65 +@export var player_jump_velocity: float = -230.0 +@export var player_gravity: float = 780.0 +@export var player_max_fall_speed: float = 420.0 +@export var player_hurt_duration: float = 0.25 +@export var player_hurt_knockback: float = 90.0 +@export var player_invulnerable_after_hit: float = 0.45 + +@export_group("Player light attack") +## Light attack costs no stamina (section 6.1) so the player is never fully +## locked out of acting. +@export var light_attack_windup: float = 0.10 +@export var light_attack_active: float = 0.12 +@export var light_attack_recovery: float = 0.20 +@export var light_attack_skill_multiplier: float = 1.0 +@export var light_attack_stamina_cost: float = 0.0 + +@export_group("Stamina") +@export var stamina_recovery_delay: float = 0.10 +@export var stamina_exhausted_lock: float = 1.10 + +@export_group("Run flow") +@export var wave_enemy_count: int = 3 +@export var enemy_spawn_interval: float = 0.6 + +@export_group("Camera") +@export var camera_smoothing_speed: float = 6.0 +@export var camera_look_ahead: float = 24.0 diff --git a/core/derived_stats.gd b/core/derived_stats.gd new file mode 100644 index 0000000..5258769 --- /dev/null +++ b/core/derived_stats.gd @@ -0,0 +1,30 @@ +class_name DerivedStats +extends RefCounted +## Read-only projections of RunState used by the HUD, the sacrifice preview and +## telemetry. Takes primitives rather than a RunState so that RunState can +## depend on it without a class-level cycle. + +## Research report section 5.2. Armour is an equivalent-life model, so each +## point buys roughly linear EHP instead of exploding at high values. +static func effective_hp(max_hp: float, armour: float) -> float: + return max_hp * (1.0 + armour / 100.0) + +## Fraction of raw damage that survives armour mitigation. +static func mitigation_factor(armour: float) -> float: + return 100.0 / (100.0 + maxf(0.0, armour)) + +## Section 5.3, without uptime: a comparable number for "how sharp is the +## blade", not a simulation. `offence` comes from RunState.offence_snapshot(). +static func dps_estimate(offence: Dictionary, balance: BalanceConfig) -> float: + var crit_rate: float = SoftCaps.crit_rate(float(offence["crit_rate"]), balance) + var crit_damage: float = SoftCaps.crit_damage(float(offence["crit_damage"]), balance) + var attack_speed: float = SoftCaps.attack_speed(float(offence["attack_speed"]), balance) + var more_product: float = SoftCaps.more_product(float(offence["more_product"]), balance) + var crit_expectation: float = 1.0 + crit_rate * (crit_damage - 1.0) + return ( + float(offence["attack"]) + * attack_speed + * (1.0 + float(offence["additive_sum"])) + * more_product + * crit_expectation + ) diff --git a/core/event_bus.gd b/core/event_bus.gd new file mode 100644 index 0000000..d1de615 --- /dev/null +++ b/core/event_bus.gd @@ -0,0 +1,43 @@ +extends Node +## Global, decoupled notification hub. Autoloaded as `EventBus`. +## +## Contract (frozen — see docs/INTERFACES.md): +## * Events are for UI, telemetry and decoupled reactions ONLY. +## * Deterministic core resolution (damage, integrity, sacrifice) uses explicit +## service calls, never the bus. Do not move a calculation onto a signal. +## * Listeners must not mutate RunState in a handler; they observe. +## +## Every payload carries the fields produced by `context()` so telemetry can +## correlate events without each emitter inventing its own shape. + +signal run_started(payload: Dictionary) +signal run_restarted(payload: Dictionary) +signal hit_dealt(payload: Dictionary) +signal hit_taken(payload: Dictionary) +signal enemy_died(payload: Dictionary) +signal player_died(payload: Dictionary) +signal sacrifice_previewed(payload: Dictionary) +signal sacrifice_applied(payload: Dictionary) +signal boss_started(payload: Dictionary) +signal boss_died(payload: Dictionary) +signal run_completed(payload: Dictionary) + +var _run_id: int = 0 +var _stage_id: StringName = &"none" + +## Called by RunCoordinator when a run begins so later payloads are attributable. +func bind_run(run_id: int) -> void: + _run_id = run_id + +func set_stage(stage_id: StringName) -> void: + _stage_id = stage_id + +## Builds the common envelope. `extra` is merged in and wins on key collision. +func context(extra: Dictionary = {}) -> Dictionary: + var payload := { + "timestamp": Time.get_ticks_msec(), + "run_id": _run_id, + "stage_id": _stage_id, + } + payload.merge(extra, true) + return payload diff --git a/core/game_data.gd b/core/game_data.gd new file mode 100644 index 0000000..c552cea --- /dev/null +++ b/core/game_data.gd @@ -0,0 +1,70 @@ +extends Node +## Autoloaded as `GameData`. Owns the balance configuration, the stateless core +## services built from it, and the sacrifice library loaded from disk. +## +## It deliberately does NOT own the active RunState: that belongs to +## RunCoordinator, which hands it to the systems that need it. Keeping mutable +## run data out of an autoload is what stops "just read it from the global" +## becoming the way every script talks to the run. + +const BALANCE_PATH := "res://data/balance_config.tres" +const SACRIFICE_DIR := "res://data/sacrifices" + +var balance: BalanceConfig +var combat: CombatResolver +var sacrifices: SacrificeService + +var _library: Dictionary = {} + +func _ready() -> void: + var config: BalanceConfig = load(BALANCE_PATH) + if config == null: + push_error("GameData: cannot load %s" % BALANCE_PATH) + config = BalanceConfig.new() + use_balance(config) + _load_sacrifice_library() + +## Rebuilds the services around a different config. Tests use this to check +## behaviour at other tunings without editing the shipped resource. +func use_balance(config: BalanceConfig) -> void: + balance = config + combat = CombatResolver.new(config) + sacrifices = SacrificeService.new(config) + +func definition(id: StringName) -> SacrificeDefinition: + if not _library.has(id): + return null + return _library[id] + +func definition_ids() -> Array: + var ids: Array = _library.keys() + ids.sort() + return ids + +func all_definitions() -> Array[SacrificeDefinition]: + var out: Array[SacrificeDefinition] = [] + for id in definition_ids(): + out.append(_library[id]) + return out + +func _load_sacrifice_library() -> void: + _library.clear() + var dir := DirAccess.open(SACRIFICE_DIR) + if dir == null: + push_error("GameData: cannot open %s" % SACRIFICE_DIR) + return + for file_name in dir.get_files(): + # Exported builds rename .tres to .tres.remap; load() wants the original. + var resource_name := file_name.trim_suffix(".remap") + if not resource_name.ends_with(".tres"): + continue + var definition_resource: SacrificeDefinition = load( + "%s/%s" % [SACRIFICE_DIR, resource_name] + ) + if definition_resource == null: + push_error("GameData: %s is not a SacrificeDefinition" % resource_name) + continue + if _library.has(definition_resource.id): + push_error("GameData: duplicate sacrifice id %s" % definition_resource.id) + continue + _library[definition_resource.id] = definition_resource diff --git a/core/rng_service.gd b/core/rng_service.gd new file mode 100644 index 0000000..a7eb240 --- /dev/null +++ b/core/rng_service.gd @@ -0,0 +1,63 @@ +extends Node +## Deterministic random number source, autoloaded as `RNGService`. +## +## Each subsystem draws from its own named stream so that an extra roll in one +## system cannot shift the sequence another system observes. Given the same +## `run_seed` and the same per-stream call order, results are reproducible. +## +## Contract (frozen — see docs/INTERFACES.md): never call `randi()`/`randf()` +## directly in gameplay code. Always go through a named stream. + +const STREAM_SACRIFICE := &"sacrifice" +const STREAM_COMBAT := &"combat" +const STREAM_SPAWN := &"spawn" + +var _run_seed: int = 0 +var _streams: Dictionary = {} + +func _ready() -> void: + configure(_generate_seed()) + +func run_seed() -> int: + return _run_seed + +## Re-seeds every stream. Call once per run, before anything draws. +func configure(new_seed: int) -> void: + _run_seed = new_seed + _streams.clear() + +## Returns the generator for `stream_name`, creating it on first use. +func stream(stream_name: StringName) -> RandomNumberGenerator: + var existing: RandomNumberGenerator = _streams.get(stream_name) + if existing != null: + return existing + var rng := RandomNumberGenerator.new() + rng.seed = _derive_seed(_run_seed, stream_name) + _streams[stream_name] = rng + return rng + +func randf(stream_name: StringName) -> float: + return stream(stream_name).randf() + +func randi_range(stream_name: StringName, from: int, to: int) -> int: + return stream(stream_name).randi_range(from, to) + +## FNV-1a over the stream name, mixed with the run seed. +## +## Godot's built-in `hash()` is not contractually stable across engine versions, +## and a reproducible seed is the entire point of this service, so the mix is +## spelled out here instead. +func _derive_seed(base_seed: int, stream_name: StringName) -> int: + # FNV-1a 64-bit offset basis (14695981039346656037) as a signed 64-bit int, + # because GDScript integers are signed and the unsigned literal will not parse. + var h: int = -3750763034362895579 + const FNV_PRIME: int = 1099511628211 + for byte in String(stream_name).to_utf8_buffer(): + h = (h ^ byte) * FNV_PRIME + h = (h ^ base_seed) * FNV_PRIME + return h + +func _generate_seed() -> int: + var rng := RandomNumberGenerator.new() + rng.randomize() + return rng.randi() diff --git a/core/run_state.gd b/core/run_state.gd new file mode 100644 index 0000000..45ab450 --- /dev/null +++ b/core/run_state.gd @@ -0,0 +1,296 @@ +class_name RunState +extends RefCounted +## Authoritative state for one run. +## +## Contract (frozen — see docs/INTERFACES.md): +## * Fields are private. Read through getters; write only through the named +## commands below. UI never calls a command — it observes and asks a +## service. +## * Structural values (max HP, max stamina, armour, recovery, locks, taxes) +## feed structural integrity. Momentary values (current HP, current +## stamina) never do. This split is the whole reason the class exists: +## taking a hit must not make the next sacrifice more rewarding. +## * Every structural mutation ends with `recalculate_derived()`, which is the +## single place derived values are produced. + +const STATUS_IDLE := &"idle" +const STATUS_ACTIVE := &"active" +const STATUS_VICTORY := &"victory" +const STATUS_DEFEAT := &"defeat" + +# --- structural ------------------------------------------------------------- +var _max_hp: float = 0.0 +var _max_stamina: float = 0.0 +var _armour: float = 0.0 +var _stamina_recovery: float = 0.0 +var _attack: float = 0.0 +var _crit_rate: float = 0.0 +var _crit_damage: float = 0.0 +var _attack_speed: float = 0.0 +var _additive_sum: float = 0.0 +var _more_product: float = 1.0 +var _structural_locks: Array[StringName] = [] +var _action_taxes: Array[StringName] = [] + +# --- momentary -------------------------------------------------------------- +var _current_hp: float = 0.0 +var _current_stamina: float = 0.0 + +# --- run bookkeeping -------------------------------------------------------- +var _integrity: float = 1.0 +var _imbalance: float = 0.0 +var _effective_hp: float = 0.0 +var _dps_estimate: float = 0.0 +var _sacrifice_history: Array[StringName] = [] +var _build_tags: Dictionary = {} +var _run_seed: int = 0 +var _run_status: StringName = STATUS_IDLE + +var _balance: BalanceConfig + + +static func create(balance: BalanceConfig, run_seed: int) -> RunState: + var state: RunState = RunState.new() + state._balance = balance + state._run_seed = run_seed + state._max_hp = balance.base_max_hp + state._max_stamina = balance.base_max_stamina + state._armour = balance.base_armour + state._stamina_recovery = balance.base_stamina_recovery + state._attack = balance.base_attack + state._crit_rate = balance.base_crit_rate + state._crit_damage = balance.base_crit_damage + state._attack_speed = balance.base_attack_speed + state._current_hp = state._max_hp + state._current_stamina = state._max_stamina + state.recalculate_derived() + return state + + +# --- reads ------------------------------------------------------------------ +func balance() -> BalanceConfig: return _balance +func max_hp() -> float: return _max_hp +func current_hp() -> float: return _current_hp +func max_stamina() -> float: return _max_stamina +func current_stamina() -> float: return _current_stamina +func armour() -> float: return _armour +func stamina_recovery() -> float: return _stamina_recovery +func attack() -> float: return _attack +func crit_rate() -> float: return _crit_rate +func crit_damage() -> float: return _crit_damage +func attack_speed() -> float: return _attack_speed +func additive_sum() -> float: return _additive_sum +func more_product() -> float: return _more_product +func structural_locks() -> Array[StringName]: return _structural_locks.duplicate() +func action_taxes() -> Array[StringName]: return _action_taxes.duplicate() +func integrity() -> float: return _integrity +func imbalance() -> float: return _imbalance +func effective_hp() -> float: return _effective_hp +func dps_estimate() -> float: return _dps_estimate +func sacrifice_history() -> Array[StringName]: return _sacrifice_history.duplicate() +func build_tags() -> Dictionary: return _build_tags.duplicate() +func run_seed() -> int: return _run_seed +func run_status() -> StringName: return _run_status +func is_alive() -> bool: return _current_hp > 0.0 + +func hp_ratio() -> float: + return 0.0 if _max_hp <= 0.0 else clampf(_current_hp / _max_hp, 0.0, 1.0) + +func stamina_ratio() -> float: + return 0.0 if _max_stamina <= 0.0 else clampf(_current_stamina / _max_stamina, 0.0, 1.0) + +## True while the player is in the "guttering candle" band (section 6.4). +## Momentary, deliberately not part of integrity. +func is_guttering() -> bool: + return hp_ratio() <= _balance.guttering_threshold and is_alive() + +func tag_count(tag: StringName) -> int: + return int(_build_tags.get(tag, 0)) + +## Structural inputs for IntegrityService. Passing primitives keeps the service +## pure and avoids a class-level cycle between RunState and the service. +func structural_snapshot() -> Dictionary: + return { + "max_hp": _max_hp, + "max_stamina": _max_stamina, + "armour": _armour, + "stamina_recovery": _stamina_recovery, + "major_locks": _structural_locks.size(), + "action_taxes": _action_taxes.size(), + } + +## Offensive inputs for DerivedStats and CombatResolver, as primitives. +func offence_snapshot() -> Dictionary: + return { + "attack": _attack, + "attack_speed": _attack_speed, + "additive_sum": _additive_sum, + "more_product": _more_product, + "crit_rate": _crit_rate, + "crit_damage": _crit_damage, + } + + +# --- momentary commands ----------------------------------------------------- +## Applies already-mitigated damage. Returns the amount actually removed. +func apply_damage(amount: float) -> float: + var before: float = _current_hp + _current_hp = maxf(0.0, _current_hp - maxf(0.0, amount)) + return before - _current_hp + +## Healing restores current HP only. It can never restore sacrificed structure, +## which is why it does not touch `_max_hp` or trigger a structural recalc. +func heal(amount: float) -> float: + var before: float = _current_hp + _current_hp = minf(_max_hp, _current_hp + maxf(0.0, amount)) + return _current_hp - before + +func set_current_hp(value: float) -> void: + _current_hp = clampf(value, 0.0, _max_hp) + +func spend_stamina(amount: float) -> void: + _current_stamina = clampf(_current_stamina - maxf(0.0, amount), 0.0, _max_stamina) + +func regenerate_stamina(amount: float) -> void: + _current_stamina = clampf(_current_stamina + maxf(0.0, amount), 0.0, _max_stamina) + +func set_run_status(status: StringName) -> void: + _run_status = status + + +# --- structural commands ---------------------------------------------------- +# Called by systems/sacrifice_service.gd and by debug tooling. Each one ends in +# recalculate_derived() so no caller can forget. + +func scale_max_hp(factor: float) -> void: + _max_hp = maxf(_balance.min_max_hp, _max_hp * factor) + _current_hp = minf(_current_hp, _max_hp) + recalculate_derived() + +func scale_max_stamina(factor: float) -> void: + _max_stamina = maxf(_balance.min_max_stamina, _max_stamina * factor) + _current_stamina = minf(_current_stamina, _max_stamina) + recalculate_derived() + +func set_armour(value: float) -> void: + _armour = maxf(_balance.min_armour, value) + recalculate_derived() + +func set_stamina_recovery(value: float) -> void: + _stamina_recovery = maxf(0.0, value) + recalculate_derived() + +func add_additive(value: float) -> void: + _additive_sum += value + recalculate_derived() + +func multiply_more(factor: float) -> void: + _more_product *= factor + recalculate_derived() + +func add_crit_rate(value: float) -> void: + _crit_rate += value + recalculate_derived() + +func add_crit_damage(value: float) -> void: + _crit_damage += value + recalculate_derived() + +func multiply_attack_speed(factor: float) -> void: + _attack_speed *= factor + recalculate_derived() + +func add_structural_lock(lock_id: StringName) -> void: + if not _structural_locks.has(lock_id): + _structural_locks.append(lock_id) + recalculate_derived() + +func add_action_tax(tax_id: StringName) -> void: + if not _action_taxes.has(tax_id): + _action_taxes.append(tax_id) + recalculate_derived() + +func add_imbalance(value: float) -> void: + _imbalance = clampf(_imbalance + value, 0.0, _balance.imbalance_max) + +func record_sacrifice(id: StringName, tags: Array[StringName]) -> void: + _sacrifice_history.append(id) + for tag in tags: + _build_tags[tag] = int(_build_tags.get(tag, 0)) + 1 + + +## The single place derived values are produced. Called by every structural +## command; call it directly only after a bulk restore. +func recalculate_derived() -> void: + _integrity = IntegrityService.compute(structural_snapshot(), _balance) + _effective_hp = DerivedStats.effective_hp(_max_hp, _armour) + _dps_estimate = DerivedStats.dps_estimate(offence_snapshot(), _balance) + + +# --- snapshots -------------------------------------------------------------- +## Immutable value copy, used for preview, rollback, logging and tests. +func snapshot() -> Dictionary: + return { + "max_hp": _max_hp, + "current_hp": _current_hp, + "max_stamina": _max_stamina, + "current_stamina": _current_stamina, + "armour": _armour, + "stamina_recovery": _stamina_recovery, + "attack": _attack, + "crit_rate": _crit_rate, + "crit_damage": _crit_damage, + "attack_speed": _attack_speed, + "additive_sum": _additive_sum, + "more_product": _more_product, + "structural_locks": _structural_locks.duplicate(), + "action_taxes": _action_taxes.duplicate(), + "integrity": _integrity, + "imbalance": _imbalance, + "effective_hp": _effective_hp, + "dps_estimate": _dps_estimate, + "sacrifice_history": _sacrifice_history.duplicate(), + "build_tags": _build_tags.duplicate(), + "run_seed": _run_seed, + "run_status": _run_status, + } + +func restore(snap: Dictionary) -> void: + _max_hp = float(snap["max_hp"]) + _current_hp = float(snap["current_hp"]) + _max_stamina = float(snap["max_stamina"]) + _current_stamina = float(snap["current_stamina"]) + _armour = float(snap["armour"]) + _stamina_recovery = float(snap["stamina_recovery"]) + _attack = float(snap["attack"]) + _crit_rate = float(snap["crit_rate"]) + _crit_damage = float(snap["crit_damage"]) + _attack_speed = float(snap["attack_speed"]) + _additive_sum = float(snap["additive_sum"]) + _more_product = float(snap["more_product"]) + _structural_locks = (snap["structural_locks"] as Array).duplicate() + _action_taxes = (snap["action_taxes"] as Array).duplicate() + _imbalance = float(snap["imbalance"]) + _sacrifice_history = (snap["sacrifice_history"] as Array).duplicate() + _build_tags = (snap["build_tags"] as Dictionary).duplicate() + _run_seed = int(snap["run_seed"]) + _run_status = snap["run_status"] + recalculate_derived() + +## Detached copy sharing the same BalanceConfig. Mutating the clone cannot +## touch the original — this is what `preview` runs against. +func clone() -> RunState: + var copy: RunState = RunState.new() + copy._balance = _balance + copy.restore(snapshot()) + return copy + +## Stable digest of the snapshot, for telemetry correlation and test asserts. +func snapshot_hash() -> String: + var snap: Dictionary = snapshot() + var keys: Array = snap.keys() + keys.sort() + var parts := PackedStringArray() + for key in keys: + parts.append("%s=%s" % [key, snap[key]]) + return "\n".join(parts).sha256_text().substr(0, 16) diff --git a/core/soft_caps.gd b/core/soft_caps.gd new file mode 100644 index 0000000..d8cf011 --- /dev/null +++ b/core/soft_caps.gd @@ -0,0 +1,26 @@ +class_name SoftCaps +extends RefCounted +## Diminishing-returns curves from research report section 5.7. +## +## Pure float maths, no state. Kept separate from CombatResolver because +## DerivedStats needs the identical curves — two copies of a cap is two places +## for the numbers to drift apart. + +## Values at or below `knee` pass through; beyond it only `slope` of the excess +## counts. Continuous at the knee, so no jump when a stat crosses it. +static func compress(value: float, knee: float, slope: float) -> float: + if value <= knee: + return value + return knee + (value - knee) * slope + +static func crit_rate(value: float, balance: BalanceConfig) -> float: + return clampf(value, 0.0, balance.crit_rate_hard_cap) + +static func crit_damage(value: float, balance: BalanceConfig) -> float: + return compress(value, balance.crit_damage_soft_cap, balance.crit_damage_soft_slope) + +static func attack_speed(value: float, balance: BalanceConfig) -> float: + return compress(value, balance.attack_speed_soft_cap, balance.attack_speed_soft_slope) + +static func more_product(value: float, balance: BalanceConfig) -> float: + return compress(value, balance.more_product_soft_cap, balance.more_product_soft_slope) diff --git a/data/balance_config.tres b/data/balance_config.tres new file mode 100644 index 0000000..53fee48 --- /dev/null +++ b/data/balance_config.tres @@ -0,0 +1,70 @@ +[gd_resource type="Resource" script_class="BalanceConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://core/balance_config.gd" id="1_balance"] + +[resource] +script = ExtResource("1_balance") +base_max_hp = 100.0 +base_max_stamina = 100.0 +base_armour = 10.0 +base_stamina_recovery = 12.0 +base_attack = 10.0 +base_crit_rate = 0.05 +base_crit_damage = 1.5 +base_attack_speed = 1.0 +min_max_hp = 10.0 +min_max_stamina = 15.0 +min_armour = 0.0 +crit_rate_hard_cap = 0.8 +crit_damage_soft_cap = 4.5 +crit_damage_soft_slope = 0.35 +attack_speed_soft_cap = 2.4 +attack_speed_soft_slope = 0.35 +more_product_soft_cap = 12.0 +more_product_soft_slope = 0.3 +integrity_weight_hp = 0.34 +integrity_weight_stamina = 0.24 +integrity_weight_armour = 0.16 +integrity_weight_recovery = 0.14 +integrity_weight_freedom = 0.12 +integrity_major_lock_penalty = 0.12 +integrity_action_tax_penalty = 0.06 +deficiency_exponent = 1.35 +reward_base = 0.55 +reward_slope = 1.45 +strength_gain = PackedFloat32Array(0, 0.12, 0.2, 0.32, 0.48, 0.7) +synergy_bonus = 0.12 +dilution_penalty = 0.08 +synergy_quality_min = 0.85 +synergy_quality_max = 1.45 +cost_weight_hp = 100.0 +cost_weight_stamina = 80.0 +cost_weight_armour = 60.0 +cost_weight_recovery = 50.0 +imbalance_base_factor = 0.75 +imbalance_strength_factor = 0.05 +imbalance_max = 200.0 +max_major_locks = 2 +max_action_taxes = 3 +wounded_threshold = 0.3 +guttering_threshold = 0.15 +guttering_more_multiplier = 1.2 +player_move_speed = 110.0 +player_air_control = 0.65 +player_jump_velocity = -230.0 +player_gravity = 780.0 +player_max_fall_speed = 420.0 +player_hurt_duration = 0.25 +player_hurt_knockback = 90.0 +player_invulnerable_after_hit = 0.45 +light_attack_windup = 0.1 +light_attack_active = 0.12 +light_attack_recovery = 0.2 +light_attack_skill_multiplier = 1.0 +light_attack_stamina_cost = 0.0 +stamina_recovery_delay = 0.1 +stamina_exhausted_lock = 1.1 +wave_enemy_count = 3 +enemy_spawn_interval = 0.6 +camera_smoothing_speed = 6.0 +camera_look_ahead = 24.0 From adb07088715a116629e65bd0c1bb873a96ef66d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 04:57:14 +0000 Subject: [PATCH 03/10] [C03][Claude] Add combat and integrity framework One damage pipeline, one integrity formula. CombatResolver implements the bucket order from research report 5.3: base -> additive -> more -> conditional -> crit -> armour. DamageContext carries every input so the resolver never reaches into a scene node; DamageResult exposes the per-stage breakdown so a wrong number can be traced to the bucket that made it. The crit roll is injectable and a context can pin or force it, which is what makes crit behaviour testable rather than a coin flip. IntegrityService implements I = 0.34h + 0.24s + 0.16a + 0.14r + 0.12f over structural values only. Current HP and current stamina are absent by design. Each component is capped at 1 so ordinary buffs cannot push integrity above the intact-body ceiling or launder away a sacrifice. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- core/combat_resolver.gd | 110 +++++++++++++++++++++++++++++++++++ core/damage_context.gd | 63 ++++++++++++++++++++ core/damage_result.gd | 29 +++++++++ systems/integrity_service.gd | 49 ++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 core/combat_resolver.gd create mode 100644 core/damage_context.gd create mode 100644 core/damage_result.gd create mode 100644 systems/integrity_service.gd diff --git a/core/combat_resolver.gd b/core/combat_resolver.gd new file mode 100644 index 0000000..943c3fe --- /dev/null +++ b/core/combat_resolver.gd @@ -0,0 +1,110 @@ +class_name CombatResolver +extends RefCounted +## The one and only damage pipeline — research report section 5.3. +## +## raw = ATK * SkillMult * (1 + AddSum) * MoreProd * CondProd +## crit applies after the buckets, armour after crit +## taken = raw * 100 / (100 + ARM_eff) * vulnerability +## +## Contract (frozen — see docs/INTERFACES.md): Player, Enemy, Boss, HUD and the +## sacrifice preview all call `resolve`. Re-deriving damage anywhere else is a +## defect, not an optimisation — the preview and the hit must agree exactly. + +var _balance: BalanceConfig +## Supplies the crit roll when a context does not pin one. Injected so tests can +## make crits deterministic without seeding the global RNG. +var _roll_provider: Callable + +func _init(balance: BalanceConfig, roll_provider: Callable = Callable()) -> void: + _balance = balance + _roll_provider = roll_provider if roll_provider.is_valid() else _default_roll + +func resolve(ctx: DamageContext) -> DamageResult: + var result := DamageResult.new() + result.source_id = ctx.source_id + result.target_id = ctx.target_id + + var additive_sum := ctx.additive_sum() + var more_product := SoftCaps.more_product(ctx.more_product(), _balance) + var conditional_product := ctx.conditional_product() + + var pre_crit: float = ( + ctx.base_damage + * ctx.skill_multiplier + * (1.0 + additive_sum) + * more_product + * conditional_product + ) + + var crit_rate := SoftCaps.crit_rate(ctx.crit_rate, _balance) + var crit_damage := SoftCaps.crit_damage(ctx.crit_damage, _balance) + var is_critical := _decide_crit(ctx, crit_rate) + var crit_multiplier := crit_damage if is_critical else 1.0 + var raw := pre_crit * crit_multiplier + + var effective_armour := maxf(0.0, ctx.target_armour - ctx.armour_penetration) + var mitigation_factor := DerivedStats.mitigation_factor(effective_armour) + var final_damage := maxf(0.0, raw * mitigation_factor * ctx.vulnerability) + + result.raw_damage = raw + result.final_damage = final_damage + result.is_critical = is_critical + result.mitigation = raw - final_damage + result.is_lethal = final_damage >= ctx.target_current_hp and ctx.target_current_hp > 0.0 + result.breakdown = { + "base_damage": ctx.base_damage, + "skill_multiplier": ctx.skill_multiplier, + "additive_sum": additive_sum, + "more_product": more_product, + "conditional_product": conditional_product, + "pre_crit": pre_crit, + "crit_rate_effective": crit_rate, + "crit_damage_effective": crit_damage, + "crit_multiplier": crit_multiplier, + "effective_armour": effective_armour, + "mitigation_factor": mitigation_factor, + "vulnerability": ctx.vulnerability, + "final_damage": final_damage, + } + return result + +## Builds the player's outgoing context from RunState, so the player's numbers +## come from one place instead of being reassembled at each call site. +func player_attack_context( + state: RunState, + target_id: StringName, + target_armour: float, + target_current_hp: float, + skill_multiplier: float +) -> DamageContext: + var ctx := DamageContext.new() + ctx.source_id = &"player" + ctx.target_id = target_id + ctx.base_damage = state.attack() + ctx.skill_multiplier = skill_multiplier + ctx.additive_modifiers = [state.additive_sum()] as Array[float] + ctx.more_modifiers = [state.more_product() - 1.0] as Array[float] + ctx.crit_allowed = true + ctx.crit_rate = state.crit_rate() + ctx.crit_damage = state.crit_damage() + ctx.target_armour = target_armour + ctx.target_current_hp = target_current_hp + ctx.tags = [&"melee"] as Array[StringName] + # "Guttering candle" band: momentary, so it lives in the conditional bucket + # rather than anywhere near structural integrity (section 6.4). + if state.is_guttering(): + ctx.conditional_modifiers.append(_balance.guttering_more_multiplier - 1.0) + return ctx + +func _decide_crit(ctx: DamageContext, effective_crit_rate: float) -> bool: + if not ctx.crit_allowed: + return false + if ctx.force_crit == 1: + return true + if ctx.force_crit == 0: + return false + var roll: float = ctx.crit_roll if ctx.crit_roll >= 0.0 else float(_roll_provider.call()) + return roll < effective_crit_rate + +func _default_roll() -> float: + return RNGService.randf(RNGService.STREAM_COMBAT) diff --git a/core/damage_context.gd b/core/damage_context.gd new file mode 100644 index 0000000..864e70c --- /dev/null +++ b/core/damage_context.gd @@ -0,0 +1,63 @@ +class_name DamageContext +extends RefCounted +## Input to CombatResolver. Everything the resolver needs, so it never has to +## reach back into a scene node or an autoload for gameplay values. +## +## Contract (frozen — see docs/INTERFACES.md): to add a new damage source, +## populate a context. Do not add a second damage path. + +var source_id: StringName = &"unknown" +var target_id: StringName = &"unknown" + +## Weapon or attack base value before any bucket is applied. +var base_damage: float = 0.0 +## Per-move scalar (light attack 1.0, heavy > 1.0, ...). +var skill_multiplier: float = 1.0 + +## Bucket 1: everything sums, then multiplies once as (1 + sum). +var additive_modifiers: Array[float] = [] +## Bucket 2: a small number of explicitly-"more" effects, multiplied together. +var more_modifiers: Array[float] = [] +## Bucket 3: situational effects (low HP, perfect dodge, backstab). +var conditional_modifiers: Array[float] = [] + +## Critical rules. +var crit_allowed: bool = true +var crit_rate: float = 0.0 +var crit_damage: float = 1.0 +## -1 draws from the combat RNG stream; 0.0..1.0 supplies a fixed roll. Tests +## and replays set this so a crit is never an untestable coin flip. +var crit_roll: float = -1.0 +## Overrides the roll entirely: 1 forces a crit, 0 forbids one, -1 is normal. +var force_crit: int = -1 + +## Armour interaction. Penetration is subtracted before mitigation. +var target_armour: float = 0.0 +var armour_penetration: float = 0.0 +## > 1.0 means the target takes extra damage. +var vulnerability: float = 1.0 + +## Target HP at resolve time, used to mark the result lethal. +var target_current_hp: float = 0.0 + +var tags: Array[StringName] = [] +var metadata: Dictionary = {} + + +func additive_sum() -> float: + var total := 0.0 + for value in additive_modifiers: + total += value + return total + +func more_product() -> float: + var product := 1.0 + for value in more_modifiers: + product *= (1.0 + value) + return product + +func conditional_product() -> float: + var product := 1.0 + for value in conditional_modifiers: + product *= (1.0 + value) + return product diff --git a/core/damage_result.gd b/core/damage_result.gd new file mode 100644 index 0000000..9d2e892 --- /dev/null +++ b/core/damage_result.gd @@ -0,0 +1,29 @@ +class_name DamageResult +extends RefCounted +## Output of CombatResolver. The only object combat, preview and telemetry read +## damage numbers from. + +var source_id: StringName = &"unknown" +var target_id: StringName = &"unknown" +## Damage after all buckets and crit, before armour. +var raw_damage: float = 0.0 +## Damage actually applied to the target. +var final_damage: float = 0.0 +var is_critical: bool = false +## raw_damage - final_damage, i.e. what armour and mitigation absorbed. +var mitigation: float = 0.0 +var is_lethal: bool = false +## Per-stage values, so a wrong number can be traced to the bucket that made it. +var breakdown: Dictionary = {} + +func to_dictionary() -> Dictionary: + return { + "source_id": source_id, + "target_id": target_id, + "raw_damage": raw_damage, + "final_damage": final_damage, + "is_critical": is_critical, + "mitigation": mitigation, + "is_lethal": is_lethal, + "breakdown": breakdown.duplicate(), + } diff --git a/systems/integrity_service.gd b/systems/integrity_service.gd new file mode 100644 index 0000000..c82e766 --- /dev/null +++ b/systems/integrity_service.gd @@ -0,0 +1,49 @@ +class_name IntegrityService +extends RefCounted +## Structural integrity I — research report section 5.4. +## +## I = clamp(0.34h + 0.24s + 0.16a + 0.14r + 0.12f, 0, 1) +## +## The service reads *only* structural values. Current HP and current stamina +## are deliberately absent: the original design mixed them in, which produced a +## "take damage → sacrifices get better → heal → sacrifice again" arbitrage +## loop. Integrity answers "how much of my original body is gone", never "how +## hurt am I right now". +## +## Each component is capped at 1, so ordinary buffs cannot push integrity above +## the intact-body ceiling and cannot launder away a sacrifice. + +## `structural` comes from RunState.structural_snapshot(). +static func compute(structural: Dictionary, balance: BalanceConfig) -> float: + var components := components_of(structural, balance) + var total: float = ( + balance.integrity_weight_hp * components["h"] + + balance.integrity_weight_stamina * components["s"] + + balance.integrity_weight_armour * components["a"] + + balance.integrity_weight_recovery * components["r"] + + balance.integrity_weight_freedom * components["f"] + ) + return clampf(total, 0.0, 1.0) + +## Individual components, exposed so the sacrifice cost score and the debug +## panel can show which part of the body was spent. +static func components_of(structural: Dictionary, balance: BalanceConfig) -> Dictionary: + var armour_now: float = 1.0 + float(structural["armour"]) / 100.0 + var armour_base: float = 1.0 + balance.base_armour / 100.0 + return { + "h": _ratio(float(structural["max_hp"]), balance.base_max_hp), + "s": _ratio(float(structural["max_stamina"]), balance.base_max_stamina), + "a": _ratio(armour_now, armour_base), + "r": _ratio(float(structural["stamina_recovery"]), balance.base_stamina_recovery), + "f": maxf( + 0.0, + 1.0 + - balance.integrity_major_lock_penalty * float(structural["major_locks"]) + - balance.integrity_action_tax_penalty * float(structural["action_taxes"]) + ), + } + +static func _ratio(current: float, baseline: float) -> float: + if baseline <= 0.0: + return 1.0 + return minf(1.0, current / baseline) From 5fa29b5e30edaaac3040a15600b3fdbf79d8965b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 04:57:14 +0000 Subject: [PATCH 04/10] [C04][Claude] Add sacrifice transaction and example definition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SacrificeService runs one transaction in a fixed order: validate, snapshot, apply cost, recalculate integrity, apply reward, recalculate derived, apply imbalance, publish, return. The reward is priced against integrity *after* the cost, which is what makes a more broken body buy a sharper blade. preview() and apply() call the identical _run_transaction; preview runs it on a clone. There is no second code path, so a preview cannot disagree with the result. Any failure restores the pre-transaction snapshot whole. SacrificeDefinition is a Resource with the A8 field groups and a validate() used by the data test. The one M1 card, 断寿之契 (S2), lives entirely in data/sacrifices/severed_lifespan.tres: max HP x0.85, +0.18 additive, generic More x(1+G), +16 imbalance. No card values appear in script. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- data/sacrifices/severed_lifespan.tres | 36 ++++ systems/sacrifice_definition.gd | 106 ++++++++++++ systems/sacrifice_result.gd | 54 ++++++ systems/sacrifice_service.gd | 229 ++++++++++++++++++++++++++ 4 files changed, 425 insertions(+) create mode 100644 data/sacrifices/severed_lifespan.tres create mode 100644 systems/sacrifice_definition.gd create mode 100644 systems/sacrifice_result.gd create mode 100644 systems/sacrifice_service.gd diff --git a/data/sacrifices/severed_lifespan.tres b/data/sacrifices/severed_lifespan.tres new file mode 100644 index 0000000..0ae2533 --- /dev/null +++ b/data/sacrifices/severed_lifespan.tres @@ -0,0 +1,36 @@ +[gd_resource type="Resource" script_class="SacrificeDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://systems/sacrifice_definition.gd" id="1_sacrifice"] + +[resource] +script = ExtResource("1_sacrifice") +id = &"severed_lifespan" +display_name = "断寿之契 — Oath of Severed Lifespan" +strength = 2 +rarity = &"common" +tags = Array[StringName]([&"attack", &"hp_sacrifice"]) +roles = Array[StringName]([&"CONTINUATION"]) +exclusive_group = &"" +base_weight = 100.0 +prerequisite_min_sacrifices = 0 +banned_with = Array[StringName]([]) +cost_max_hp_multiplier = 0.85 +cost_max_stamina_multiplier = 1.0 +cost_armour_multiplier = 1.0 +cost_stamina_recovery_multiplier = 1.0 +cost_structural_locks = Array[StringName]([]) +cost_action_taxes = Array[StringName]([]) +imbalance_flat = 16.0 +reward_additive = 0.18 +reward_generic_more = true +reward_crit_rate = 0.0 +reward_crit_damage = 0.0 +reward_attack_speed_multiplier = 1.0 +triggers = [{ +"condition": "hp_ratio <= 0.30", +"effect": "more_multiplier:1.12", +"event": "OnHitDealt" +}] +short_text = "Cut your lifespan short; the blade drinks what the body loses." +preview_template = "Max lifespan {max_hp_delta}, attack {attack_gain}, imbalance +{imbalance_delta}" +warning_level = &"medium" diff --git a/systems/sacrifice_definition.gd b/systems/sacrifice_definition.gd new file mode 100644 index 0000000..d9bfb31 --- /dev/null +++ b/systems/sacrifice_definition.gd @@ -0,0 +1,106 @@ +class_name SacrificeDefinition +extends Resource +## Data-driven description of one sacrifice. Content lives in +## res://data/sacrifices/*.tres; this file only declares the shape. +## +## Field groups follow Prototype Development Pack A8 (标识 / 分类 / 生成 / +## 代价 / 收益 / 触发 / 显示). + +const ROLE_CONTINUATION := &"CONTINUATION" +const ROLE_RISK_ESCALATION := &"RISK_ESCALATION" +const ROLE_PIVOT_STABILIZE := &"PIVOT_STABILIZE" + +@export_group("Identity") +@export var id: StringName = &"" +@export var display_name: String = "" +## S1..S5. Drives the base gain coefficient g_S and the imbalance factor. +@export_range(1, 5) var strength: int = 1 +@export var rarity: StringName = &"common" + +@export_group("Classification") +@export var tags: Array[StringName] = [] +@export var roles: Array[StringName] = [] +## At most one owned sacrifice per non-empty group. +@export var exclusive_group: StringName = &"" + +@export_group("Generation") +@export var base_weight: float = 100.0 +@export var prerequisite_min_sacrifices: int = 0 +@export var banned_with: Array[StringName] = [] + +@export_group("Costs") +## Multipliers applied to structural stats. 1.0 means untouched. +@export var cost_max_hp_multiplier: float = 1.0 +@export var cost_max_stamina_multiplier: float = 1.0 +@export var cost_armour_multiplier: float = 1.0 +@export var cost_stamina_recovery_multiplier: float = 1.0 +## Rule locks (healing disabled, block removed, ...) and action taxes +## (dodge costs more, exhaustion lasts longer, ...). +@export var cost_structural_locks: Array[StringName] = [] +@export var cost_action_taxes: Array[StringName] = [] +## Authored imbalance. When > 0 this wins over the derived cost formula; see +## ADR-005. 0 falls back to dB = C * (0.75 + 0.05 * S). +@export var imbalance_flat: float = 0.0 + +@export_group("Rewards") +@export var reward_additive: float = 0.0 +## When true the generic More bucket is multiplied by (1 + G), G from the +## saturating reward curve (research report 5.5). +@export var reward_generic_more: bool = false +@export var reward_crit_rate: float = 0.0 +@export var reward_crit_damage: float = 0.0 +@export var reward_attack_speed_multiplier: float = 1.0 + +@export_group("Triggers") +## Declarative only in M1 — nothing consumes these yet. Kept in the schema so +## card data authored by Codex X06 does not need a migration. +@export var triggers: Array = [] + +@export_group("Display") +@export var short_text: String = "" +@export var preview_template: String = "" +## low / medium / high — the sacrifice UI must not bury a high warning. +@export var warning_level: StringName = &"low" + + +## Static data validation, used by the data test. Returns human-readable +## problems; empty means valid. +func validate() -> Array[String]: + var problems: Array[String] = [] + if String(id).is_empty(): + problems.append("missing id") + if display_name.is_empty(): + problems.append("%s: missing display_name" % id) + if strength < 1 or strength > 5: + problems.append("%s: strength %d out of range 1..5" % [id, strength]) + if roles.is_empty(): + problems.append("%s: needs at least one role" % id) + for role in roles: + if role not in [ROLE_CONTINUATION, ROLE_RISK_ESCALATION, ROLE_PIVOT_STABILIZE]: + problems.append("%s: unknown role %s" % [id, role]) + if not _has_any_cost(): + problems.append("%s: has no cost — every power has a price" % id) + if not _has_any_reward(): + problems.append("%s: has no reward" % id) + if warning_level not in [&"low", &"medium", &"high"]: + problems.append("%s: unknown warning_level %s" % [id, warning_level]) + return problems + +func _has_any_cost() -> bool: + return ( + not is_equal_approx(cost_max_hp_multiplier, 1.0) + or not is_equal_approx(cost_max_stamina_multiplier, 1.0) + or not is_equal_approx(cost_armour_multiplier, 1.0) + or not is_equal_approx(cost_stamina_recovery_multiplier, 1.0) + or not cost_structural_locks.is_empty() + or not cost_action_taxes.is_empty() + ) + +func _has_any_reward() -> bool: + return ( + not is_zero_approx(reward_additive) + or reward_generic_more + or not is_zero_approx(reward_crit_rate) + or not is_zero_approx(reward_crit_damage) + or not is_equal_approx(reward_attack_speed_multiplier, 1.0) + ) diff --git a/systems/sacrifice_result.gd b/systems/sacrifice_result.gd new file mode 100644 index 0000000..1716fcb --- /dev/null +++ b/systems/sacrifice_result.gd @@ -0,0 +1,54 @@ +class_name SacrificeResult +extends RefCounted +## Outcome of a sacrifice transaction. The same object is produced by `preview` +## and by `apply`, which is how the UI can promise that what it showed is what +## happened. + +var ok: bool = false +var definition_id: StringName = &"" +var failure_reason: String = "" +## Full RunState snapshots either side of the transaction. +var before: Dictionary = {} +var after: Dictionary = {} +## Signed changes for the values the card is required to display. +var deltas: Dictionary = {} +## G from the saturating reward curve. +var gain: float = 0.0 +## Cost score C from the resource components. +var cost_score: float = 0.0 +var imbalance_delta: float = 0.0 +var warnings: Array[String] = [] + +static func failed(definition_id: StringName, reason: String) -> SacrificeResult: + var result := SacrificeResult.new() + result.ok = false + result.definition_id = definition_id + result.failure_reason = reason + return result + +## True when two results describe the same transaction. The preview/apply +## equality test uses this. +func matches(other: SacrificeResult, tolerance: float = 0.0001) -> bool: + if ok != other.ok or definition_id != other.definition_id: + return false + if deltas.keys().size() != other.deltas.keys().size(): + return false + for key in deltas: + if not other.deltas.has(key): + return false + if absf(float(deltas[key]) - float(other.deltas[key])) > tolerance: + return false + return absf(gain - other.gain) <= tolerance \ + and absf(imbalance_delta - other.imbalance_delta) <= tolerance + +func to_dictionary() -> Dictionary: + return { + "ok": ok, + "definition_id": definition_id, + "failure_reason": failure_reason, + "deltas": deltas.duplicate(), + "gain": gain, + "cost_score": cost_score, + "imbalance_delta": imbalance_delta, + "warnings": warnings.duplicate(), + } diff --git a/systems/sacrifice_service.gd b/systems/sacrifice_service.gd new file mode 100644 index 0000000..40966f7 --- /dev/null +++ b/systems/sacrifice_service.gd @@ -0,0 +1,229 @@ +class_name SacrificeService +extends RefCounted +## The sacrifice transaction — Prototype Development Pack A9. +## +## Ordering is fixed and non-negotiable: +## validate → snapshot → apply cost → recalculate integrity → apply reward +## → recalculate derived → apply imbalance → publish → return +## +## The reward is computed from integrity *after* the cost, which is what makes +## "the more broken you are, the sharper the blade" true. Applying reward first +## would quietly change the whole economy. +## +## Contract (frozen — see docs/INTERFACES.md): `preview` and `apply` run the +## identical `_run_transaction`. `preview` runs it on a clone. There is no +## second code path, so a preview cannot lie. + +var _balance: BalanceConfig + +func _init(balance: BalanceConfig) -> void: + _balance = balance + + +## Returns {ok: bool, reason: String}. Pure — inspects, never mutates. +func can_offer(state: RunState, definition: SacrificeDefinition) -> Dictionary: + if definition == null: + return {"ok": false, "reason": "no definition"} + var owned: Array[StringName] = state.sacrifice_history() + if owned.has(definition.id): + return {"ok": false, "reason": "already taken"} + if owned.size() < definition.prerequisite_min_sacrifices: + return { + "ok": false, + "reason": "requires %d prior sacrifices" % definition.prerequisite_min_sacrifices, + } + for banned in definition.banned_with: + if owned.has(banned): + return {"ok": false, "reason": "excluded by %s" % banned} + if not String(definition.exclusive_group).is_empty(): + if _group_is_taken(state, definition.exclusive_group): + return {"ok": false, "reason": "exclusive group %s already used" % definition.exclusive_group} + var locks_after: int = state.structural_locks().size() + definition.cost_structural_locks.size() + if locks_after > _balance.max_major_locks: + return {"ok": false, "reason": "would exceed %d major locks" % _balance.max_major_locks} + var taxes_after: int = state.action_taxes().size() + definition.cost_action_taxes.size() + if taxes_after > _balance.max_action_taxes: + return {"ok": false, "reason": "would exceed %d action taxes" % _balance.max_action_taxes} + return {"ok": true, "reason": ""} + + +## Simulates the transaction on a detached copy. `state` is untouched. +func preview(state: RunState, definition: SacrificeDefinition) -> SacrificeResult: + var result := _run_transaction(state.clone(), definition) + EventBus.sacrifice_previewed.emit(EventBus.context(result.to_dictionary())) + return result + + +## Commits the transaction to `state`. On any failure the state is rolled back +## whole — a half-applied sacrifice is never a valid outcome. +func apply(state: RunState, definition: SacrificeDefinition) -> SacrificeResult: + var result := _run_transaction(state, definition) + if result.ok: + EventBus.sacrifice_applied.emit(EventBus.context(result.to_dictionary())) + return result + + +func _run_transaction(state: RunState, definition: SacrificeDefinition) -> SacrificeResult: + # 1. validate + var validation: Dictionary = can_offer(state, definition) + if not bool(validation["ok"]): + return SacrificeResult.failed( + definition.id if definition != null else &"", validation["reason"] + ) + + # 2. snapshot (the rollback point) + var before: Dictionary = state.snapshot() + var components_before: Dictionary = IntegrityService.components_of( + state.structural_snapshot(), _balance + ) + + var result := SacrificeResult.new() + result.definition_id = definition.id + result.before = before + + # 3. apply cost, 4. integrity recalculates inside each structural command + var cost_applied: Dictionary = _apply_cost(state, definition) + if not bool(cost_applied["ok"]): + state.restore(before) + return SacrificeResult.failed(definition.id, cost_applied["reason"]) + + var components_after: Dictionary = IntegrityService.components_of( + state.structural_snapshot(), _balance + ) + result.cost_score = _cost_score(components_before, components_after) + + # 5. apply reward, priced against integrity *after* the cost + result.gain = _compute_gain(state, definition) + _apply_reward(state, definition, result.gain) + + # 6. derived values are recalculated by every structural command; this is a + # belt-and-braces call for reward paths that touch nothing structural. + state.recalculate_derived() + + # 7. imbalance + result.imbalance_delta = _imbalance_delta(definition, result.cost_score) + state.add_imbalance(result.imbalance_delta) + + state.record_sacrifice(definition.id, definition.tags) + + result.after = state.snapshot() + result.deltas = _deltas(result.before, result.after) + result.warnings = _warnings(definition, result) + result.ok = true + return result + + +func _apply_cost(state: RunState, definition: SacrificeDefinition) -> Dictionary: + if not is_equal_approx(definition.cost_max_hp_multiplier, 1.0): + state.scale_max_hp(definition.cost_max_hp_multiplier) + if not is_equal_approx(definition.cost_max_stamina_multiplier, 1.0): + state.scale_max_stamina(definition.cost_max_stamina_multiplier) + if not is_equal_approx(definition.cost_armour_multiplier, 1.0): + state.set_armour(state.armour() * definition.cost_armour_multiplier) + if not is_equal_approx(definition.cost_stamina_recovery_multiplier, 1.0): + state.set_stamina_recovery( + state.stamina_recovery() * definition.cost_stamina_recovery_multiplier + ) + for lock_id in definition.cost_structural_locks: + state.add_structural_lock(lock_id) + for tax_id in definition.cost_action_taxes: + state.add_action_tax(tax_id) + return {"ok": true, "reason": ""} + + +func _apply_reward(state: RunState, definition: SacrificeDefinition, gain: float) -> void: + if not is_zero_approx(definition.reward_additive): + state.add_additive(definition.reward_additive) + if definition.reward_generic_more: + state.multiply_more(1.0 + gain) + if not is_zero_approx(definition.reward_crit_rate): + state.add_crit_rate(definition.reward_crit_rate) + if not is_zero_approx(definition.reward_crit_damage): + state.add_crit_damage(definition.reward_crit_damage) + if not is_equal_approx(definition.reward_attack_speed_multiplier, 1.0): + state.multiply_attack_speed(definition.reward_attack_speed_multiplier) + + +## G = g_S * (0.55 + 1.45 * D) * Q, with D = (1 - I)^1.35 (section 5.5). +func _compute_gain(state: RunState, definition: SacrificeDefinition) -> float: + var deficiency: float = pow(maxf(0.0, 1.0 - state.integrity()), _balance.deficiency_exponent) + var base_gain: float = _strength_gain(definition.strength) + var quality: float = _synergy_quality(state, definition) + return base_gain * (_balance.reward_base + _balance.reward_slope * deficiency) * quality + + +func _strength_gain(strength: int) -> float: + var table: PackedFloat32Array = _balance.strength_gain + var index: int = clampi(strength, 0, table.size() - 1) + return table[index] + + +## Q = clamp(1 + 0.12 * N_synergy - 0.08 * N_dilution, 0.85, 1.45). +## Dilution starts once a tag is held four times over (section 9.3). +func _synergy_quality(state: RunState, definition: SacrificeDefinition) -> float: + var synergy := 0 + var dilution := 0 + for tag in definition.tags: + var owned: int = state.tag_count(tag) + if owned > 0: + synergy += 1 + dilution += maxi(0, owned - 2) + return clampf( + 1.0 + _balance.synergy_bonus * synergy - _balance.dilution_penalty * dilution, + _balance.synergy_quality_min, + _balance.synergy_quality_max + ) + + +## C_res = 100*dh + 80*ds + 60*da + 50*dr over integrity component drops. +func _cost_score(before: Dictionary, after: Dictionary) -> float: + return ( + _balance.cost_weight_hp * maxf(0.0, float(before["h"]) - float(after["h"])) + + _balance.cost_weight_stamina * maxf(0.0, float(before["s"]) - float(after["s"])) + + _balance.cost_weight_armour * maxf(0.0, float(before["a"]) - float(after["a"])) + + _balance.cost_weight_recovery * maxf(0.0, float(before["r"]) - float(after["r"])) + ) + + +func _imbalance_delta(definition: SacrificeDefinition, cost_score: float) -> float: + if definition.imbalance_flat > 0.0: + return definition.imbalance_flat + return cost_score * ( + _balance.imbalance_base_factor + _balance.imbalance_strength_factor * definition.strength + ) + + +func _deltas(before: Dictionary, after: Dictionary) -> Dictionary: + var tracked: Array[String] = [ + "max_hp", "current_hp", "max_stamina", "armour", "stamina_recovery", + "attack", "additive_sum", "more_product", "crit_rate", "crit_damage", + "attack_speed", "integrity", "imbalance", "effective_hp", "dps_estimate", + ] + var deltas: Dictionary = {} + for key in tracked: + deltas[key] = float(after[key]) - float(before[key]) + return deltas + + +func _warnings(definition: SacrificeDefinition, result: SacrificeResult) -> Array[String]: + var warnings: Array[String] = [] + if definition.warning_level == &"high": + warnings.append("High-risk rule cost") + for lock_id in definition.cost_structural_locks: + warnings.append("Permanent rule lock: %s" % lock_id) + for tax_id in definition.cost_action_taxes: + warnings.append("Action tax: %s" % tax_id) + if float(result.deltas.get("effective_hp", 0.0)) < 0.0: + warnings.append( + "Effective HP %.1f → %.1f" + % [result.before["effective_hp"], result.after["effective_hp"]] + ) + return warnings + + +func _group_is_taken(state: RunState, group: StringName) -> bool: + for owned_id in state.sacrifice_history(): + var owned: SacrificeDefinition = GameData.definition(owned_id) + if owned != null and owned.exclusive_group == group: + return true + return false From 325ad78e3979c79594ee5e35e1cf2d2348991fd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 05:21:02 +0000 Subject: [PATCH 05/10] [C05][Claude] Add player controller and prototype art integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Player is a CharacterBody2D with movement, jump, light attack, hit reaction, death and camera follow. It holds no HP of its own: current and maximum lifespan live in RunState, injected by RunCoordinator, so a sacrifice applied from the UI is visible to the player with no syncing. The action states (Grounded, Airborne, Attacking, Hurt, Dead) are one small class each under states/, with every transition funnelled through PlayerStateMachine._change_to. Dodge, Exhausted and SameDeath are documented as extension points with their entry conditions, and deliberately not stubbed. Hitbox and Hurtbox are the two halves of a damage exchange. A hitbox emits hit_actor once per actor per swing and knows nothing about damage numbers; the attacker builds the DamageContext. Hitbox keeps monitoring on and gates on an active flag, because toggling monitoring mid-frame makes Godot miss overlaps that started on the same frame. Art integration: tools/generate_sprite_frames.py emits the SpriteFrames resources from the frame table in docs/ART_SPEC.md and fails if a sheet's real dimensions disagree, so the FPS and frame counts exist once rather than in 70 hand-written AtlasTexture blocks. All 62 PNGs verified against the spec; only assets/effects/*.png disagree (single frame, metadata claims 2-4). Arena is one fixed 1024x360 space: tiled floor and walls with collision, three-layer parallax background, props, spawn markers and camera bounds. No procedural generation — a fixed space is what makes two runs comparable. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- actors/player/player.gd | 184 +++++++++++++++++++++ actors/player/player.tscn | 53 ++++++ actors/player/player_frames.tres | 211 ++++++++++++++++++++++++ actors/player/player_state_machine.gd | 61 +++++++ actors/player/states/airborne_state.gd | 20 +++ actors/player/states/attacking_state.gd | 49 ++++++ actors/player/states/dead_state.gd | 18 ++ actors/player/states/grounded_state.gd | 24 +++ actors/player/states/hurt_state.gd | 22 +++ actors/player/states/player_state.gd | 42 +++++ core/hitbox.gd | 48 ++++++ core/hurtbox.gd | 14 ++ scenes/arena.gd | 27 +++ scenes/arena.tscn | 174 +++++++++++++++++++ tools/generate_sprite_frames.py | 147 +++++++++++++++++ 15 files changed, 1094 insertions(+) create mode 100644 actors/player/player.gd create mode 100644 actors/player/player.tscn create mode 100644 actors/player/player_frames.tres create mode 100644 actors/player/player_state_machine.gd create mode 100644 actors/player/states/airborne_state.gd create mode 100644 actors/player/states/attacking_state.gd create mode 100644 actors/player/states/dead_state.gd create mode 100644 actors/player/states/grounded_state.gd create mode 100644 actors/player/states/hurt_state.gd create mode 100644 actors/player/states/player_state.gd create mode 100644 core/hitbox.gd create mode 100644 core/hurtbox.gd create mode 100644 scenes/arena.gd create mode 100644 scenes/arena.tscn create mode 100644 tools/generate_sprite_frames.py diff --git a/actors/player/player.gd b/actors/player/player.gd new file mode 100644 index 0000000..8ac77b6 --- /dev/null +++ b/actors/player/player.gd @@ -0,0 +1,184 @@ +class_name Player +extends CharacterBody2D +## Player actor. Holds movement, animation and hit resolution; the decision of +## *which* of those to run belongs to the state machine in states/. +## +## The player has no HP of its own. Current and maximum lifespan live in +## RunState, which is injected by RunCoordinator via `setup`. That keeps one +## authoritative copy of the run's numbers and means a sacrifice applied from +## the UI is visible here without any syncing. + +const ACTOR_ID := &"player" + +signal died + +@onready var sprite: AnimatedSprite2D = $Sprite +@onready var attack_hitbox: Hitbox = $AttackHitbox +@onready var camera: Camera2D = $Camera + +var input_enabled: bool = true + +var _run_state: RunState +var _balance: BalanceConfig +var _machine: PlayerStateMachine +var _facing: int = 1 +var _invulnerable_for: float = 0.0 +var _stamina_delay_for: float = 0.0 +var _spawn_point: Vector2 = Vector2.ZERO + +func _ready() -> void: + attack_hitbox.hit_actor.connect(_on_attack_connected) + attack_hitbox.set_active(false) + +## Injects the run this player belongs to. Must be called before the first +## physics frame; RunCoordinator does it at spawn time. +func setup(run_state: RunState, balance: BalanceConfig) -> void: + _run_state = run_state + _balance = balance + _spawn_point = global_position + _machine = PlayerStateMachine.new(self) + +func run_state() -> RunState: + return _run_state + +func balance() -> BalanceConfig: + return _balance + +func facing() -> int: + return _facing + +func state_id() -> StringName: + return _machine.current_id() if _machine != null else &"uninitialised" + +func is_dead() -> bool: + return _run_state != null and not _run_state.is_alive() + +func is_invulnerable() -> bool: + return _invulnerable_for > 0.0 + + +func _physics_process(delta: float) -> void: + if _machine == null: + return + _invulnerable_for = maxf(0.0, _invulnerable_for - delta) + _tick_stamina(delta) + _machine.physics_update(delta) + move_and_slide() + + +# --- shared movement helpers used by the states ----------------------------- + +func apply_gravity(delta: float) -> void: + velocity.y = minf( + velocity.y + _balance.player_gravity * delta, _balance.player_max_fall_speed + ) + +## `control` scales the input authority: 1.0 on the ground, less in the air. +func apply_horizontal_input(control: float = 1.0) -> void: + var direction := input_direction() + velocity.x = direction * _balance.player_move_speed * control + if direction != 0: + set_facing(direction) + +func input_direction() -> int: + if not input_enabled: + return 0 + return int(Input.get_axis(&"move_left", &"move_right")) + +func set_facing(direction: int) -> void: + if direction == 0: + return + _facing = signi(direction) + sprite.flip_h = _facing < 0 + # The attack box is authored facing right; mirror it with the sprite. + attack_hitbox.scale.x = absf(attack_hitbox.scale.x) * _facing + +func jump() -> void: + velocity.y = _balance.player_jump_velocity + +func decelerate(delta: float, rate: float = 600.0) -> void: + velocity.x = move_toward(velocity.x, 0.0, rate * delta) + +func play(animation: StringName) -> void: + if sprite.animation != animation: + sprite.play(animation) + +func animation_finished() -> bool: + return not sprite.is_playing() + + +# --- Damageable shape ------------------------------------------------------- +# Mirrors EnemyBase so an attacker can hit either side without special-casing. + +func actor_id() -> StringName: + return ACTOR_ID + +func armour() -> float: + return _run_state.armour() if _run_state != null else 0.0 + +func current_hp() -> float: + return _run_state.current_hp() if _run_state != null else 0.0 + + +## Resolves one incoming attack. `context` is built by the attacker; this method +## does not invent damage, it only applies what CombatResolver returned. +func receive_damage(context: DamageContext) -> DamageResult: + var result := GameData.combat.resolve(context) + if is_dead() or is_invulnerable(): + result.final_damage = 0.0 + result.is_lethal = false + return result + _run_state.apply_damage(result.final_damage) + _invulnerable_for = _balance.player_invulnerable_after_hit + EventBus.hit_taken.emit(EventBus.context(result.to_dictionary())) + if _run_state.is_alive(): + _machine.on_hurt(context.source_id) + else: + _machine.on_death() + EventBus.player_died.emit(EventBus.context({"source_id": context.source_id})) + died.emit() + return result + +func knockback_from(source_position: Vector2) -> void: + var direction := signf(global_position.x - source_position.x) + if is_zero_approx(direction): + direction = -_facing + velocity.x = direction * _balance.player_hurt_knockback + +## Debug-only teleport back to the spawn marker without restarting the run. +func return_to_spawn() -> void: + global_position = _spawn_point + velocity = Vector2.ZERO + + +func _on_attack_connected(target: Node) -> void: + if not target.has_method("receive_damage"): + return + var context := GameData.combat.player_attack_context( + _run_state, + target.call("actor_id"), + float(target.call("armour")), + float(target.call("current_hp")), + _balance.light_attack_skill_multiplier + ) + var result: DamageResult = target.call("receive_damage", context) + EventBus.hit_dealt.emit(EventBus.context(result.to_dictionary())) + + +## Stamina is a structural resource in M1: nothing spends it yet (light attack +## costs 0 by design, section 6.1) but it regenerates and the HUD shows it, so +## a sacrifice that cuts max stamina is visible immediately. Dodge and heavy +## attack, the first real consumers, are Codex work. +func _tick_stamina(delta: float) -> void: + if _run_state == null: + return + _stamina_delay_for = maxf(0.0, _stamina_delay_for - delta) + if _stamina_delay_for > 0.0: + return + _run_state.regenerate_stamina(_run_state.stamina_recovery() * delta) + +func spend_stamina(amount: float) -> void: + if amount <= 0.0: + return + _run_state.spend_stamina(amount) + _stamina_delay_for = _balance.stamina_recovery_delay diff --git a/actors/player/player.tscn b/actors/player/player.tscn new file mode 100644 index 0000000..db804e7 --- /dev/null +++ b/actors/player/player.tscn @@ -0,0 +1,53 @@ +[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://actors/player/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"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(14, 26) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurt"] +size = Vector2(16, 28) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hit"] +size = Vector2(24, 20) + +[node name="Player" type="CharacterBody2D"] +collision_layer = 2 +collision_mask = 1 +script = ExtResource("1_player") + +[node name="Sprite" type="AnimatedSprite2D" parent="."] +position = Vector2(0, -24) +sprite_frames = ExtResource("2_frames") +animation = &"idle" +autoplay = "idle" + +[node name="Body" type="CollisionShape2D" parent="."] +position = Vector2(0, -13) +shape = SubResource("RectangleShape2D_body") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 8 +collision_mask = 0 +script = ExtResource("3_hurtbox") + +[node name="Shape" type="CollisionShape2D" parent="Hurtbox"] +position = Vector2(0, -14) +shape = SubResource("RectangleShape2D_hurt") + +[node name="AttackHitbox" type="Area2D" parent="."] +collision_layer = 32 +collision_mask = 16 +script = ExtResource("4_hitbox") + +[node name="Shape" type="CollisionShape2D" parent="AttackHitbox"] +position = Vector2(18, -14) +shape = SubResource("RectangleShape2D_hit") + +[node name="Camera" type="Camera2D" parent="."] +position = Vector2(0, -24) +position_smoothing_enabled = true +position_smoothing_speed = 6.0 diff --git a/actors/player/player_frames.tres b/actors/player/player_frames.tres new file mode 100644 index 0000000..1fdbe85 --- /dev/null +++ b/actors/player/player_frames.tres @@ -0,0 +1,211 @@ +; 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/player/player_idle.png" id="1_idle"] +[ext_resource type="Texture2D" path="res://assets/player/player_run.png" id="2_run"] +[ext_resource type="Texture2D" path="res://assets/player/player_jump.png" id="3_jump"] +[ext_resource type="Texture2D" path="res://assets/player/player_attack.png" id="4_attack"] +[ext_resource type="Texture2D" path="res://assets/player/player_hurt.png" id="5_hurt"] +[ext_resource type="Texture2D" path="res://assets/player/player_death.png" id="6_death"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] +atlas = ExtResource("1_idle") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] +atlas = ExtResource("1_idle") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] +atlas = ExtResource("1_idle") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] +atlas = ExtResource("1_idle") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] +atlas = ExtResource("2_run") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] +atlas = ExtResource("2_run") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] +atlas = ExtResource("2_run") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] +atlas = ExtResource("2_run") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] +atlas = ExtResource("2_run") +region = Rect2(192, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] +atlas = ExtResource("2_run") +region = Rect2(240, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_jump_0"] +atlas = ExtResource("3_jump") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_jump_1"] +atlas = ExtResource("3_jump") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] +atlas = ExtResource("4_attack") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] +atlas = ExtResource("4_attack") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] +atlas = ExtResource("4_attack") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] +atlas = ExtResource("4_attack") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] +atlas = ExtResource("5_hurt") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] +atlas = ExtResource("5_hurt") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] +atlas = ExtResource("6_death") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] +atlas = ExtResource("6_death") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] +atlas = ExtResource("6_death") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] +atlas = ExtResource("6_death") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_4"] +atlas = ExtResource("6_death") +region = Rect2(192, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_5"] +atlas = ExtResource("6_death") +region = Rect2(240, 0, 48, 48) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_3") +}], +"loop": true, +"name": &"idle", +"speed": 8.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_5") +}], +"loop": true, +"name": &"run", +"speed": 12.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": 14.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_1") +}], +"loop": false, +"name": &"hurt", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_3") +}, { +"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/actors/player/player_state_machine.gd b/actors/player/player_state_machine.gd new file mode 100644 index 0000000..e21c536 --- /dev/null +++ b/actors/player/player_state_machine.gd @@ -0,0 +1,61 @@ +class_name PlayerStateMachine +extends RefCounted +## Owns the player's action states and the transitions between them. +## +## Every transition goes through `_change_to`, so there is exactly one place +## that runs exit/enter and one place to look when a state gets stuck. States +## never call each other. +## +## Extension points (documented for Codex, deliberately not implemented in M1): +## * Dodge — three phases with i-frames; enter from GROUNDED/AIRBORNE on the +## dodge action, and gate it on StaminaService once that exists. +## * Exhausted — enter from anywhere when stamina hits zero; blocks dodge, +## skill and heavy attack while leaving light attack available. +## * SameDeath — must be checked *before* DEAD in `on_death`, since it is a +## lethal-damage interception, not a state the player can walk into. + +var _states: Dictionary = {} +var _current: PlayerState + +func _init(player: Player) -> void: + _register(GroundedState.new(player)) + _register(AirborneState.new(player)) + _register(AttackingState.new(player)) + _register(HurtState.new(player)) + _register(DeadState.new(player)) + _current = _states[PlayerState.GROUNDED] + _current.enter() + +func current_id() -> StringName: + return _current.id() + +func physics_update(delta: float) -> void: + var next: StringName = _current.physics_update(delta) + if not String(next).is_empty(): + _change_to(next) + +## Interruption from outside: taking a survivable hit. +func on_hurt(_source_id: StringName) -> void: + if _current.id() == PlayerState.DEAD: + return + _change_to(PlayerState.HURT) + +## Interruption from outside: lethal damage. When SameDeath lands, its +## eligibility check belongs at the top of this method. +func on_death() -> void: + if _current.id() == PlayerState.DEAD: + return + _change_to(PlayerState.DEAD) + +func _register(state: PlayerState) -> void: + _states[state.id()] = state + +func _change_to(next_id: StringName) -> void: + if not _states.has(next_id): + push_error("PlayerStateMachine: unknown state %s" % next_id) + return + if _current.id() == next_id: + return + _current.exit() + _current = _states[next_id] + _current.enter() diff --git a/actors/player/states/airborne_state.gd b/actors/player/states/airborne_state.gd new file mode 100644 index 0000000..49617ad --- /dev/null +++ b/actors/player/states/airborne_state.gd @@ -0,0 +1,20 @@ +class_name AirborneState +extends PlayerState +## Rising or falling. Air control is reduced but attacking stays available so +## the player is never fully passive mid-jump. + +func id() -> StringName: + return AIRBORNE + +func enter() -> void: + player.play(&"jump") + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.apply_horizontal_input(player.balance().player_air_control) + + if wants_attack(): + return ATTACKING + if player.is_on_floor(): + return GROUNDED + return &"" diff --git a/actors/player/states/attacking_state.gd b/actors/player/states/attacking_state.gd new file mode 100644 index 0000000..7246e86 --- /dev/null +++ b/actors/player/states/attacking_state.gd @@ -0,0 +1,49 @@ +class_name AttackingState +extends PlayerState +## Light attack: wind-up, active window, recovery. Timings and the stamina cost +## (0 by design — see research report 6.1) come from BalanceConfig. +## +## The hitbox is only live during the active window, so the swing's reach in +## time is data, not an animation coincidence. + +enum Phase { WINDUP, ACTIVE, RECOVERY } + +var _phase: int = Phase.WINDUP +var _elapsed: float = 0.0 + +func id() -> StringName: + return ATTACKING + +func enter() -> void: + _phase = Phase.WINDUP + _elapsed = 0.0 + player.play(&"attack") + player.sprite.frame = 0 + player.spend_stamina(player.balance().light_attack_stamina_cost) + +func exit() -> void: + player.attack_hitbox.set_active(false) + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + # Committed: the swing does not steer, but momentum is bled off so the + # player does not slide through the whole animation. + player.decelerate(delta) + _elapsed += delta + + var balance := player.balance() + match _phase: + Phase.WINDUP: + if _elapsed >= balance.light_attack_windup: + _phase = Phase.ACTIVE + _elapsed = 0.0 + player.attack_hitbox.set_active(true) + Phase.ACTIVE: + if _elapsed >= balance.light_attack_active: + _phase = Phase.RECOVERY + _elapsed = 0.0 + player.attack_hitbox.set_active(false) + Phase.RECOVERY: + if _elapsed >= balance.light_attack_recovery: + return GROUNDED if player.is_on_floor() else AIRBORNE + return &"" diff --git a/actors/player/states/dead_state.gd b/actors/player/states/dead_state.gd new file mode 100644 index 0000000..e076696 --- /dev/null +++ b/actors/player/states/dead_state.gd @@ -0,0 +1,18 @@ +class_name DeadState +extends PlayerState +## Terminal. The run is over; RunCoordinator decides what happens next. + +func id() -> StringName: + return DEAD + +func enter() -> void: + player.input_enabled = false + player.velocity = Vector2.ZERO + player.attack_hitbox.set_active(false) + player.play(&"death") + player.sprite.frame = 0 + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.decelerate(delta) + return &"" diff --git a/actors/player/states/grounded_state.gd b/actors/player/states/grounded_state.gd new file mode 100644 index 0000000..2a194f4 --- /dev/null +++ b/actors/player/states/grounded_state.gd @@ -0,0 +1,24 @@ +class_name GroundedState +extends PlayerState +## On the floor: idle, run, jump and attack all start here. + +func id() -> StringName: + return GROUNDED + +func enter() -> void: + player.play(&"idle") + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.apply_horizontal_input() + + if wants_attack(): + return ATTACKING + if player.input_enabled and Input.is_action_just_pressed(&"jump"): + player.jump() + return AIRBORNE + if not player.is_on_floor(): + return AIRBORNE + + player.play(&"run" if player.input_direction() != 0 else &"idle") + return &"" diff --git a/actors/player/states/hurt_state.gd b/actors/player/states/hurt_state.gd new file mode 100644 index 0000000..8cdbf7d --- /dev/null +++ b/actors/player/states/hurt_state.gd @@ -0,0 +1,22 @@ +class_name HurtState +extends PlayerState +## Brief stagger after taking a hit. Input is ignored for the duration, which is +## what makes damage cost tempo as well as lifespan. + +var _remaining: float = 0.0 + +func id() -> StringName: + return HURT + +func enter() -> void: + _remaining = player.balance().player_hurt_duration + player.play(&"hurt") + player.sprite.frame = 0 + +func physics_update(delta: float) -> StringName: + player.apply_gravity(delta) + player.decelerate(delta, 300.0) + _remaining -= delta + if _remaining <= 0.0: + return GROUNDED if player.is_on_floor() else AIRBORNE + return &"" diff --git a/actors/player/states/player_state.gd b/actors/player/states/player_state.gd new file mode 100644 index 0000000..44f6fe6 --- /dev/null +++ b/actors/player/states/player_state.gd @@ -0,0 +1,42 @@ +class_name PlayerState +extends RefCounted +## Base class for the player's action states. +## +## Contract (frozen — see docs/INTERFACES.md): a state changes the player by +## calling the helpers on Player, and requests a transition by returning the +## next state id from `physics_update`. Returning `&""` means "stay". No state +## writes another state's fields, and nothing outside the machine calls +## `enter`/`exit`. +## +## To add a state (Dodge, Exhausted, SameDeath are the planned ones), subclass +## this, register the id in PlayerStateMachine, and give an existing state a +## reason to return the new id. Nothing else needs to change. + +const GROUNDED := &"grounded" +const AIRBORNE := &"airborne" +const ATTACKING := &"attacking" +const HURT := &"hurt" +const DEAD := &"dead" + +var player: Player + +func _init(owner_player: Player) -> void: + player = owner_player + +func id() -> StringName: + return &"unknown" + +func enter() -> void: + pass + +func exit() -> void: + pass + +## Returns the next state id, or &"" to stay in this state. +func physics_update(_delta: float) -> StringName: + return &"" + +## Shared transition test: any state that allows attacking uses this, so the +## rule lives in one place. +func wants_attack() -> bool: + return player.input_enabled and Input.is_action_just_pressed(&"light_attack") diff --git a/core/hitbox.gd b/core/hitbox.gd new file mode 100644 index 0000000..eb960aa --- /dev/null +++ b/core/hitbox.gd @@ -0,0 +1,48 @@ +class_name Hitbox +extends Area2D +## Dealing half of a damage exchange. Emits `hit_actor` once per actor per +## swing; the *attacker* decides what that hit means by building a +## DamageContext. The hitbox itself knows nothing about damage numbers. +## +## Physics layer: `player_hitbox` or `enemy_hitbox`. Mask: the opposing +## hurtbox layer. +## +## `monitoring` stays on for the node's whole life and activity is gated by +## `set_active`, because toggling `monitoring` mid-frame makes Godot drop +## overlaps that began on the same frame. + +signal hit_actor(actor: Node) + +var _active: bool = false +var _already_hit: Array[Node] = [] + +func _ready() -> void: + area_entered.connect(_on_area_entered) + +func is_active() -> bool: + return _active + +## Opens or closes the active window. Opening clears the per-swing memory and +## immediately resolves anything already inside the box, so a target standing on +## top of the attacker is not skipped. +func set_active(value: bool) -> void: + if _active == value: + return + _active = value + if not _active: + return + _already_hit.clear() + for area in get_overlapping_areas(): + _try_hit(area) + +func _on_area_entered(area: Area2D) -> void: + _try_hit(area) + +func _try_hit(area: Area2D) -> void: + if not _active or not (area is Hurtbox): + return + var target: Node = (area as Hurtbox).actor() + if target == null or _already_hit.has(target): + return + _already_hit.append(target) + hit_actor.emit(target) diff --git a/core/hurtbox.gd b/core/hurtbox.gd new file mode 100644 index 0000000..907ad04 --- /dev/null +++ b/core/hurtbox.gd @@ -0,0 +1,14 @@ +class_name Hurtbox +extends Area2D +## Receiving half of a damage exchange. Sits on the damageable actor and points +## back at it, so a Hitbox can find who to hurt without guessing at the scene +## structure. +## +## Physics layer: `player_hurtbox` or `enemy_hurtbox`. Mask stays empty — +## hurtboxes are detected, they do not detect. + +## Relative to this node. Defaults to the parent, which is the usual layout. +@export var actor_path: NodePath = ^".." + +func actor() -> Node: + return get_node_or_null(actor_path) diff --git a/scenes/arena.gd b/scenes/arena.gd new file mode 100644 index 0000000..14b049c --- /dev/null +++ b/scenes/arena.gd @@ -0,0 +1,27 @@ +class_name Arena +extends Node2D +## One fixed combat arena. No procedural generation in the prototype — the +## point of a fixed space is that two runs with the same seed are comparable. +## +## The arena owns geometry and markers only. It does not spawn anything and +## does not know what a wave is; RunCoordinator asks it where things go. + +## World-space bounds the player camera is clamped to. +@export var camera_bounds: Rect2 = Rect2(0.0, 0.0, 1024.0, 360.0) + +@onready var player_spawn: Marker2D = $Spawns/PlayerSpawn +@onready var boss_spawn: Marker2D = $Spawns/BossSpawn +@onready var _enemy_spawn_root: Node2D = $Spawns/EnemySpawns + +func enemy_spawn_points() -> Array[Marker2D]: + var points: Array[Marker2D] = [] + for child in _enemy_spawn_root.get_children(): + if child is Marker2D: + points.append(child) + return points + +func apply_camera_bounds(camera: Camera2D) -> void: + camera.limit_left = int(camera_bounds.position.x) + camera.limit_top = int(camera_bounds.position.y) + camera.limit_right = int(camera_bounds.position.x + camera_bounds.size.x) + camera.limit_bottom = int(camera_bounds.position.y + camera_bounds.size.y) diff --git a/scenes/arena.tscn b/scenes/arena.tscn new file mode 100644 index 0000000..b2a2975 --- /dev/null +++ b/scenes/arena.tscn @@ -0,0 +1,174 @@ +[gd_scene load_steps=14 format=3 uid="uid://bnnarena00001"] + +[ext_resource type="Script" path="res://scenes/arena.gd" id="1_arena"] +[ext_resource type="Texture2D" path="res://assets/background/bg_deep.png" id="2_bg_deep"] +[ext_resource type="Texture2D" path="res://assets/background/bg_tree.png" id="3_bg_tree"] +[ext_resource type="Texture2D" path="res://assets/background/bg_broken_flag.png" id="4_bg_flag"] +[ext_resource type="Texture2D" path="res://assets/background/bg_chains.png" id="5_bg_chains"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_floor.png" id="6_floor"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_stone_brick.png" id="7_brick"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_brazier.png" id="8_brazier"] +[ext_resource type="Texture2D" path="res://assets/tiles/tile_tombstone.png" id="9_tomb"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_floor"] +size = Vector2(1088, 56) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_wall"] +size = Vector2(32, 400) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_wall2"] +size = Vector2(32, 400) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_ceiling"] +size = Vector2(1088, 32) + +[node name="Arena" type="Node2D"] +script = ExtResource("1_arena") + +[node name="Void" type="CanvasLayer" parent="."] +layer = -3 + +[node name="Fill" type="ColorRect" parent="Void"] +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 1) + +[node name="Parallax" type="ParallaxBackground" parent="."] +layer = -2 + +[node name="DeepLayer" type="ParallaxLayer" parent="Parallax"] +motion_scale = Vector2(0.2, 1) + +[node name="Deep" type="Sprite2D" parent="Parallax/DeepLayer"] +texture_repeat = 2 +position = Vector2(0, 124) +centered = false +texture = ExtResource("2_bg_deep") +region_enabled = true +region_rect = Rect2(0, 0, 1920, 180) + +[node name="PropLayer" type="ParallaxLayer" parent="Parallax"] +motion_scale = Vector2(0.5, 1) + +[node name="Tree" type="Sprite2D" parent="Parallax/PropLayer"] +position = Vector2(150, 176) +centered = false +texture = ExtResource("3_bg_tree") + +[node name="Flag" type="Sprite2D" parent="Parallax/PropLayer"] +position = Vector2(470, 208) +centered = false +texture = ExtResource("4_bg_flag") + +[node name="Tree2" type="Sprite2D" parent="Parallax/PropLayer"] +position = Vector2(760, 176) +centered = false +texture = ExtResource("3_bg_tree") + +[node name="ChainLayer" type="ParallaxLayer" parent="Parallax"] +motion_scale = Vector2(0.7, 1) + +[node name="Chains" type="Sprite2D" parent="Parallax/ChainLayer"] +position = Vector2(300, 0) +centered = false +texture = ExtResource("5_bg_chains") + +[node name="Chains2" type="Sprite2D" parent="Parallax/ChainLayer"] +position = Vector2(620, 0) +centered = false +texture = ExtResource("5_bg_chains") + +[node name="Terrain" type="Node2D" parent="."] + +[node name="Floor" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/Floor"] +position = Vector2(512, 332) +shape = SubResource("RectangleShape2D_floor") + +[node name="Surface" type="Sprite2D" parent="Terrain/Floor"] +texture_repeat = 2 +position = Vector2(-32, 304) +centered = false +texture = ExtResource("6_floor") +region_enabled = true +region_rect = Rect2(0, 0, 1088, 56) + +[node name="LeftWall" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/LeftWall"] +position = Vector2(16, 160) +shape = SubResource("RectangleShape2D_wall") + +[node name="Surface" type="Sprite2D" parent="Terrain/LeftWall"] +texture_repeat = 2 +position = Vector2(0, 112) +centered = false +texture = ExtResource("7_brick") +region_enabled = true +region_rect = Rect2(0, 0, 32, 192) + +[node name="RightWall" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/RightWall"] +position = Vector2(1008, 160) +shape = SubResource("RectangleShape2D_wall2") + +[node name="Surface" type="Sprite2D" parent="Terrain/RightWall"] +texture_repeat = 2 +position = Vector2(992, 112) +centered = false +texture = ExtResource("7_brick") +region_enabled = true +region_rect = Rect2(0, 0, 32, 192) + +[node name="Ceiling" type="StaticBody2D" parent="Terrain"] +collision_layer = 1 +collision_mask = 0 + +[node name="Shape" type="CollisionShape2D" parent="Terrain/Ceiling"] +position = Vector2(512, -32) +shape = SubResource("RectangleShape2D_ceiling") + +[node name="Props" type="Node2D" parent="."] + +[node name="BrazierLeft" type="Sprite2D" parent="Props"] +position = Vector2(224, 272) +centered = false +texture = ExtResource("8_brazier") + +[node name="BrazierRight" type="Sprite2D" parent="Props"] +position = Vector2(768, 272) +centered = false +texture = ExtResource("8_brazier") + +[node name="Tombstone" type="Sprite2D" parent="Props"] +position = Vector2(512, 272) +centered = false +texture = ExtResource("9_tomb") + +[node name="Spawns" type="Node2D" parent="."] + +[node name="PlayerSpawn" type="Marker2D" parent="Spawns"] +position = Vector2(96, 304) + +[node name="BossSpawn" type="Marker2D" parent="Spawns"] +position = Vector2(880, 304) + +[node name="EnemySpawns" type="Node2D" parent="Spawns"] + +[node name="A" type="Marker2D" parent="Spawns/EnemySpawns"] +position = Vector2(420, 304) + +[node name="B" type="Marker2D" parent="Spawns/EnemySpawns"] +position = Vector2(640, 304) + +[node name="C" type="Marker2D" parent="Spawns/EnemySpawns"] +position = Vector2(840, 304) diff --git a/tools/generate_sprite_frames.py b/tools/generate_sprite_frames.py new file mode 100644 index 0000000..19b14f2 --- /dev/null +++ b/tools/generate_sprite_frames.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Generate Godot SpriteFrames resources from the art pipeline's sprite sheets. + +The frame counts, frame sizes, FPS and loop flags below are transcribed from +`docs/ART_SPEC.md` section 7 and the per-directory `metadata.md` files, which +are the art pipeline's source of truth. Writing the resources by hand would mean +~70 AtlasTexture blocks maintained by copy-paste; generating them means the +frame table exists once and the .tres files can be regenerated whenever +WorkBuddy replaces a sheet. + +Usage (from the repository root): + + python3 tools/generate_sprite_frames.py + +The generated .tres files are committed. Re-run and commit the diff if a sheet's +frame count changes. +""" + +from __future__ import annotations + +import pathlib +import sys + +REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent + +# actor -> (output path, frame width, frame height, [(anim, sheet, frames, fps, loop)]) +ACTORS = { + "player": ( + "actors/player/player_frames.tres", + 48, + 48, + [ + ("idle", "assets/player/player_idle.png", 4, 8.0, True), + ("run", "assets/player/player_run.png", 6, 12.0, True), + ("jump", "assets/player/player_jump.png", 2, 10.0, False), + ("attack", "assets/player/player_attack.png", 4, 14.0, False), + ("hurt", "assets/player/player_hurt.png", 2, 10.0, False), + ("death", "assets/player/player_death.png", 6, 8.0, False), + ], + ), + "ghost_melee": ( + "actors/enemies/ghost_melee_frames.tres", + 48, + 48, + [ + ("idle", "assets/enemy/ghost_melee_idle.png", 4, 8.0, True), + ("run", "assets/enemy/ghost_melee_run.png", 6, 10.0, True), + ("attack", "assets/enemy/ghost_melee_attack.png", 4, 12.0, False), + ("hurt", "assets/enemy/ghost_melee_hurt.png", 2, 10.0, False), + ("death", "assets/enemy/ghost_melee_death.png", 4, 8.0, False), + ], + ), + "boss": ( + "actors/boss/boss_frames.tres", + 96, + 96, + [ + ("idle", "assets/boss/boss_idle.png", 4, 6.0, True), + ("run", "assets/boss/boss_run.png", 6, 8.0, True), + ("attack", "assets/boss/boss_attack.png", 5, 10.0, False), + ("hurt", "assets/boss/boss_hurt.png", 2, 8.0, False), + ("death", "assets/boss/boss_death.png", 8, 6.0, False), + ], + ), +} + +HEADER = ( + "; Generated by tools/generate_sprite_frames.py from docs/ART_SPEC.md.\n" + "; Do not hand-edit: re-run the generator instead.\n" +) + + +def verify_sheet(sheet: pathlib.Path, frames: int, width: int, height: int) -> None: + """Fail loudly if a sheet does not match the frame table.""" + try: + from PIL import Image # optional; skip verification when unavailable + except ImportError: + return + with Image.open(sheet) as image: + expected = (frames * width, height) + if image.size != expected: + raise SystemExit( + f"{sheet.relative_to(REPO_ROOT)} is {image.size}, " + f"expected {expected} for {frames} frames of {width}x{height}" + ) + + +def build(actor: str) -> str: + output_path, frame_w, frame_h, animations = ACTORS[actor] + ext_lines: list[str] = [] + sub_lines: list[str] = [] + animation_entries: list[str] = [] + + for index, (anim, sheet, frames, fps, loop) in enumerate(animations): + verify_sheet(REPO_ROOT / sheet, frames, frame_w, frame_h) + ext_id = f"{index + 1}_{anim}" + ext_lines.append( + f'[ext_resource type="Texture2D" path="res://{sheet}" id="{ext_id}"]' + ) + frame_refs = [] + for frame in range(frames): + sub_id = f"AtlasTexture_{anim}_{frame}" + sub_lines.append( + f'[sub_resource type="AtlasTexture" id="{sub_id}"]\n' + f'atlas = ExtResource("{ext_id}")\n' + f"region = Rect2({frame * frame_w}, 0, {frame_w}, {frame_h})" + ) + frame_refs.append( + '{\n"duration": 1.0,\n' + f'"texture": SubResource("{sub_id}")\n' + "}" + ) + animation_entries.append( + "{\n" + f'"frames": [{", ".join(frame_refs)}],\n' + f'"loop": {"true" if loop else "false"},\n' + f'"name": &"{anim}",\n' + f'"speed": {fps}\n' + "}" + ) + + load_steps = len(ext_lines) + len(sub_lines) + 1 + body = [ + HEADER + f'[gd_resource type="SpriteFrames" load_steps={load_steps} format=3]', + "", + "\n".join(ext_lines), + "", + "\n\n".join(sub_lines), + "", + "[resource]", + "animations = [" + ", ".join(animation_entries) + "]", + "", + ] + return "\n".join(body) + + +def main() -> int: + for actor in ACTORS: + output_path = REPO_ROOT / ACTORS[actor][0] + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(build(actor), encoding="utf-8") + print(f"wrote {output_path.relative_to(REPO_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 076c898a6fca30b676fe94a2ffbb6638cc84e369 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 05:21:02 +0000 Subject: [PATCH 06/10] [C06][Claude] Add reference enemy and Boss foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EnemyBase is the frozen contract: initialize / acquire_target / change_state / receive_damage / die, over an IDLE-CHASE-WINDUP-ATTACK-RECOVER-HURT-DEAD state machine with a readable wind-up telegraph. die() is idempotent. A second lethal hit in the same frame, a hit after death, or a direct second die() call all resolve to exactly one death event — there is a test for each. A corpse also stops running gravity and move_and_slide, since its body collider is gone and it would otherwise sink through the floor while the death animation plays. The reference enemy is 鬼卒 (Ghost Soldier) with the X01 timings: 180px detection, 65px attack range, 0.40 wind-up / 0.15 active / 0.60 recovery. Every number is in data/actors/ghost_melee.tres. BossActor extends EnemyBase rather than introducing a second combat system: it adds boss-scale stats, its own lifecycle events and a health bar, nothing more. It ships one melee attack because assets/boss/ contains exactly one attack sheet; the charge and ground slam are left to Codex X04 rather than faked by replaying the run cycle. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- actors/boss/boss.tscn | 48 +++++ actors/boss/boss_actor.gd | 31 +++ actors/boss/boss_frames.tres | 212 +++++++++++++++++++ actors/enemies/enemy_base.gd | 276 +++++++++++++++++++++++++ actors/enemies/enemy_base.tscn | 44 ++++ actors/enemies/enemy_config.gd | 41 ++++ actors/enemies/ghost_melee.tscn | 11 + actors/enemies/ghost_melee_frames.tres | 177 ++++++++++++++++ data/actors/boss_gate_guardian.tres | 25 +++ data/actors/ghost_melee.tres | 25 +++ 10 files changed, 890 insertions(+) create mode 100644 actors/boss/boss.tscn create mode 100644 actors/boss/boss_actor.gd create mode 100644 actors/boss/boss_frames.tres create mode 100644 actors/enemies/enemy_base.gd create mode 100644 actors/enemies/enemy_base.tscn create mode 100644 actors/enemies/enemy_config.gd create mode 100644 actors/enemies/ghost_melee.tscn create mode 100644 actors/enemies/ghost_melee_frames.tres create mode 100644 data/actors/boss_gate_guardian.tres create mode 100644 data/actors/ghost_melee.tres diff --git a/actors/boss/boss.tscn b/actors/boss/boss.tscn new file mode 100644 index 0000000..4eaeb2a --- /dev/null +++ b/actors/boss/boss.tscn @@ -0,0 +1,48 @@ +[gd_scene load_steps=8 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://actors/boss/boss_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"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(30, 52) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurt"] +size = Vector2(38, 56) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hit"] +size = Vector2(46, 40) + +[node name="Boss" type="CharacterBody2D"] +collision_layer = 4 +collision_mask = 1 +script = ExtResource("1_boss") + +[node name="Sprite" type="AnimatedSprite2D" parent="."] +position = Vector2(0, -48) +sprite_frames = ExtResource("2_frames") +animation = &"idle" +autoplay = "idle" + +[node name="Body" type="CollisionShape2D" parent="."] +position = Vector2(0, -26) +shape = SubResource("RectangleShape2D_body") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 16 +collision_mask = 0 +script = ExtResource("3_hurtbox") + +[node name="Shape" type="CollisionShape2D" parent="Hurtbox"] +position = Vector2(0, -28) +shape = SubResource("RectangleShape2D_hurt") + +[node name="AttackHitbox" type="Area2D" parent="."] +collision_layer = 64 +collision_mask = 8 +script = ExtResource("4_hitbox") + +[node name="Shape" type="CollisionShape2D" parent="AttackHitbox"] +position = Vector2(34, -26) +shape = SubResource("RectangleShape2D_hit") diff --git a/actors/boss/boss_actor.gd b/actors/boss/boss_actor.gd new file mode 100644 index 0000000..ba211ef --- /dev/null +++ b/actors/boss/boss_actor.gd @@ -0,0 +1,31 @@ +class_name BossActor +extends EnemyBase +## Prototype Boss — 镇关鬼将, the Gate Guardian Ghost General. +## +## Deliberately thin: it is an EnemyBase with boss-scale numbers, its own +## lifecycle events and a health bar. There is no second combat system, no +## second state machine and no phase controller in M1. +## +## Why only one attack: `assets/boss/` ships exactly one attack sheet +## (boss_attack.png, 5 frames). The Prototype Development Pack asks for three +## moves and two phases, and the M1 brief gates the second move on the art +## supporting it. It does not, so the charge and the ground slam are left to +## Codex X04 rather than faked by replaying the run cycle. See +## docs/AI_HANDOFF.md, "Asset integration gaps". +## +## Extension point for X04: add moves as EnemyConfig-driven AttackDefinitions +## selected by an AttackScheduler here. Do not modify EnemyBase or +## CombatResolver to do it. + +signal defeated(boss: BossActor) + +func start_encounter() -> void: + EventBus.boss_started.emit( + EventBus.context({"actor_id": actor_id(), "max_hp": max_hp()}) + ) + +func _publish_death(source_id: StringName) -> void: + EventBus.boss_died.emit( + EventBus.context({"actor_id": actor_id(), "source_id": source_id}) + ) + defeated.emit(self) diff --git a/actors/boss/boss_frames.tres b/actors/boss/boss_frames.tres new file mode 100644 index 0000000..fc3aaa8 --- /dev/null +++ b/actors/boss/boss_frames.tres @@ -0,0 +1,212 @@ +; 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/boss/boss_idle.png" id="1_idle"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_run.png" id="2_run"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_attack.png" id="3_attack"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_hurt.png" id="4_hurt"] +[ext_resource type="Texture2D" path="res://assets/boss/boss_death.png" id="5_death"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] +atlas = ExtResource("1_idle") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] +atlas = ExtResource("1_idle") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] +atlas = ExtResource("1_idle") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] +atlas = ExtResource("1_idle") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] +atlas = ExtResource("2_run") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] +atlas = ExtResource("2_run") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] +atlas = ExtResource("2_run") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] +atlas = ExtResource("2_run") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] +atlas = ExtResource("2_run") +region = Rect2(384, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] +atlas = ExtResource("2_run") +region = Rect2(480, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] +atlas = ExtResource("3_attack") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] +atlas = ExtResource("3_attack") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] +atlas = ExtResource("3_attack") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] +atlas = ExtResource("3_attack") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_4"] +atlas = ExtResource("3_attack") +region = Rect2(384, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] +atlas = ExtResource("4_hurt") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] +atlas = ExtResource("4_hurt") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] +atlas = ExtResource("5_death") +region = Rect2(0, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] +atlas = ExtResource("5_death") +region = Rect2(96, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] +atlas = ExtResource("5_death") +region = Rect2(192, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] +atlas = ExtResource("5_death") +region = Rect2(288, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_4"] +atlas = ExtResource("5_death") +region = Rect2(384, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_5"] +atlas = ExtResource("5_death") +region = Rect2(480, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_6"] +atlas = ExtResource("5_death") +region = Rect2(576, 0, 96, 96) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_7"] +atlas = ExtResource("5_death") +region = Rect2(672, 0, 96, 96) + +[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": 8.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": 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") +}, { +"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/enemy_base.gd b/actors/enemies/enemy_base.gd new file mode 100644 index 0000000..beeffbf --- /dev/null +++ b/actors/enemies/enemy_base.gd @@ -0,0 +1,276 @@ +class_name EnemyBase +extends CharacterBody2D +## Shared contract for every hostile actor, including the Boss. +## +## Contract (frozen — see docs/INTERFACES.md): +## initialize / acquire_target / change_state / receive_damage / die +## +## Rules a concrete enemy must not break: +## * Damage is resolved by CombatResolver. Do not compute it here or in a +## subclass — build a DamageContext and let the resolver answer. +## * `die` is idempotent. A second hit landing in the same frame, or a DoT +## tick arriving after death, must not fire a second death event. +## * Enemies never touch RunState's structural values or the sacrifice +## system. They ask the target for its armour and current HP, nothing more. +## +## Implements the Damageable shape: actor_id / armour / current_hp / +## receive_damage. + +signal died(enemy: EnemyBase) + +enum State { IDLE, CHASE, WINDUP, ATTACK, RECOVER, HURT, DEAD } + +@onready var sprite: AnimatedSprite2D = $Sprite +@onready var attack_hitbox: Hitbox = $AttackHitbox +@onready var hurtbox: Hurtbox = $Hurtbox +@onready var body_shape: CollisionShape2D = $Body + +var config: EnemyConfig + +var _current_hp: float = 0.0 +var _state: int = State.IDLE +var _state_elapsed: float = 0.0 +var _cooldown: float = 0.0 +var _facing: int = -1 +var _target: Node2D +var _is_dead: bool = false +var _initialised: bool = false + + +func _ready() -> void: + attack_hitbox.hit_actor.connect(_on_attack_connected) + attack_hitbox.set_active(false) + + +## Must be called before the enemy takes its first physics step. Spawners do +## this immediately after `add_child`. +func initialize(enemy_config: EnemyConfig, target: Node2D) -> void: + config = enemy_config + _current_hp = config.max_hp + _target = target + _initialised = true + change_state(State.IDLE) + + +func acquire_target(target: Node2D) -> void: + _target = target + + +func actor_id() -> StringName: + return config.actor_id if config != null else &"enemy" + +func armour() -> float: + return config.armour if config != null else 0.0 + +func current_hp() -> float: + return _current_hp + +func max_hp() -> float: + return config.max_hp if config != null else 0.0 + +func hp_ratio() -> float: + if config == null or config.max_hp <= 0.0: + return 0.0 + return clampf(_current_hp / config.max_hp, 0.0, 1.0) + +func is_dead() -> bool: + return _is_dead + +func state() -> int: + return _state + + +func _physics_process(delta: float) -> void: + if not _initialised: + return + if _is_dead: + # A corpse has no body collider, so running gravity and move_and_slide + # would drop it through the floor while the death animation plays. + _state_elapsed += delta + if _state_elapsed >= config.death_duration: + queue_free() + return + _apply_gravity(delta) + _cooldown = maxf(0.0, _cooldown - delta) + _state_elapsed += delta + _update_state(delta) + move_and_slide() + + +## Single entry point for transitions, so enter-effects cannot be skipped. +func change_state(next: int) -> void: + if _state == next: + return + _exit_state(_state) + _state = next + _state_elapsed = 0.0 + _enter_state(next) + + +## Resolves one incoming attack and applies the result. Returns the result so +## the attacker can report it — the attacker does not get to decide the number. +func receive_damage(context: DamageContext) -> DamageResult: + var result := GameData.combat.resolve(context) + if _is_dead: + result.final_damage = 0.0 + result.is_lethal = false + return result + _current_hp = maxf(0.0, _current_hp - result.final_damage) + if _current_hp <= 0.0: + die(context.source_id) + else: + change_state(State.HURT) + return result + + +## Idempotent. The `_is_dead` guard is the reason a second lethal hit in the +## same frame cannot produce two death events or two sets of rewards. +func die(source_id: StringName = &"unknown") -> void: + if _is_dead: + return + _is_dead = true + _current_hp = 0.0 + velocity = Vector2.ZERO + attack_hitbox.set_active(false) + hurtbox.set_deferred(&"monitorable", false) + body_shape.set_deferred(&"disabled", true) + change_state(State.DEAD) + _publish_death(source_id) + died.emit(self) + + +# --- internals -------------------------------------------------------------- + +func _publish_death(source_id: StringName) -> void: + EventBus.enemy_died.emit( + EventBus.context({"actor_id": actor_id(), "source_id": source_id}) + ) + + +func _apply_gravity(delta: float) -> void: + velocity.y = minf(velocity.y + config.gravity * delta, config.max_fall_speed) + + +func _enter_state(next: int) -> void: + match next: + State.IDLE: + _play(&"idle") + State.CHASE: + _play(&"run") + State.WINDUP: + _play(&"attack") + sprite.frame = 0 + sprite.pause() + sprite.modulate = config.telegraph_tint + State.ATTACK: + sprite.modulate = Color.WHITE + sprite.play(&"attack") + attack_hitbox.set_active(true) + State.RECOVER: + attack_hitbox.set_active(false) + State.HURT: + _play(&"hurt") + sprite.frame = 0 + State.DEAD: + _play(&"death") + sprite.frame = 0 + + +func _exit_state(previous: int) -> void: + if previous == State.WINDUP: + sprite.modulate = Color.WHITE + if previous == State.ATTACK: + attack_hitbox.set_active(false) + + +func _update_state(delta: float) -> void: + match _state: + State.IDLE: + _decelerate(delta) + if _target_in_range(config.detection_range): + change_state(State.CHASE) + State.CHASE: + _chase(delta) + State.WINDUP: + _decelerate(delta) + _face_target() + if _state_elapsed >= config.attack_windup: + change_state(State.ATTACK) + State.ATTACK: + _decelerate(delta) + if _state_elapsed >= config.attack_active: + change_state(State.RECOVER) + State.RECOVER: + _decelerate(delta) + if _state_elapsed >= config.attack_recovery: + _cooldown = config.attack_cooldown + change_state(State.IDLE) + State.HURT: + _decelerate(delta, 400.0) + if _state_elapsed >= config.hurt_duration: + change_state(State.CHASE if _target_in_range(config.detection_range) else State.IDLE) + State.DEAD: + pass + + +func _chase(delta: float) -> void: + if not _target_in_range(config.detection_range): + change_state(State.IDLE) + return + _face_target() + var distance := _distance_to_target() + if distance <= config.attack_range and _cooldown <= 0.0: + change_state(State.WINDUP) + return + if distance <= config.preferred_gap: + _decelerate(delta) + return + velocity.x = _facing * config.move_speed + + +func _decelerate(delta: float, rate: float = 600.0) -> void: + velocity.x = move_toward(velocity.x, 0.0, rate * delta) + + +func _distance_to_target() -> float: + if _target == null: + return INF + return absf(_target.global_position.x - global_position.x) + + +func _target_in_range(range_px: float) -> bool: + if _target == null or not is_instance_valid(_target): + return false + return global_position.distance_to(_target.global_position) <= range_px + + +func _face_target() -> void: + if _target == null: + return + var direction := signi(int(_target.global_position.x - global_position.x)) + if direction == 0: + return + _facing = direction + # Every sheet in assets/ is authored facing right (docs/ART_SPEC.md). + sprite.flip_h = _facing < 0 + attack_hitbox.scale.x = absf(attack_hitbox.scale.x) * _facing + + +func _play(animation: StringName) -> void: + if sprite.animation != animation or not sprite.is_playing(): + sprite.play(animation) + + +func _on_attack_connected(target: Node) -> void: + if _is_dead or not target.has_method("receive_damage"): + return + var context := DamageContext.new() + context.source_id = actor_id() + context.target_id = target.call("actor_id") + context.base_damage = config.attack_damage + context.skill_multiplier = config.attack_skill_multiplier + context.crit_allowed = false + context.target_armour = float(target.call("armour")) + context.target_current_hp = float(target.call("current_hp")) + context.tags = [&"melee"] as Array[StringName] + target.call("receive_damage", context) diff --git a/actors/enemies/enemy_base.tscn b/actors/enemies/enemy_base.tscn new file mode 100644 index 0000000..d5804ec --- /dev/null +++ b/actors/enemies/enemy_base.tscn @@ -0,0 +1,44 @@ +[gd_scene load_steps=7 format=3 uid="uid://bnnenemybase01"] + +[ext_resource type="Script" path="res://actors/enemies/enemy_base.gd" id="1_enemy"] +[ext_resource type="Script" path="res://core/hurtbox.gd" id="2_hurtbox"] +[ext_resource type="Script" path="res://core/hitbox.gd" id="3_hitbox"] + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_body"] +size = Vector2(14, 26) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hurt"] +size = Vector2(18, 28) + +[sub_resource type="RectangleShape2D" id="RectangleShape2D_hit"] +size = Vector2(26, 20) + +[node name="EnemyBase" type="CharacterBody2D"] +collision_layer = 4 +collision_mask = 1 +script = ExtResource("1_enemy") + +[node name="Sprite" type="AnimatedSprite2D" parent="."] +position = Vector2(0, -24) + +[node name="Body" type="CollisionShape2D" parent="."] +position = Vector2(0, -13) +shape = SubResource("RectangleShape2D_body") + +[node name="Hurtbox" type="Area2D" parent="."] +collision_layer = 16 +collision_mask = 0 +script = ExtResource("2_hurtbox") + +[node name="Shape" type="CollisionShape2D" parent="Hurtbox"] +position = Vector2(0, -14) +shape = SubResource("RectangleShape2D_hurt") + +[node name="AttackHitbox" type="Area2D" parent="."] +collision_layer = 64 +collision_mask = 8 +script = ExtResource("3_hitbox") + +[node name="Shape" type="CollisionShape2D" parent="AttackHitbox"] +position = Vector2(20, -14) +shape = SubResource("RectangleShape2D_hit") diff --git a/actors/enemies/enemy_config.gd b/actors/enemies/enemy_config.gd new file mode 100644 index 0000000..036ab09 --- /dev/null +++ b/actors/enemies/enemy_config.gd @@ -0,0 +1,41 @@ +class_name EnemyConfig +extends Resource +## Stats and timings for one enemy archetype. Instances live in +## res://data/actors/. No enemy script may hardcode these numbers. +## +## Attack timings are the contract the player reads: a wind-up long enough to +## see is the difference between a fair hit and a cheap one. + +@export var actor_id: StringName = &"enemy" +@export var display_name: String = "" + +@export_group("Vitals") +@export var max_hp: float = 30.0 +@export var armour: float = 0.0 + +@export_group("Movement") +@export var move_speed: float = 46.0 +@export var gravity: float = 780.0 +@export var max_fall_speed: float = 420.0 + +@export_group("Perception") +@export var detection_range: float = 180.0 +@export var attack_range: float = 65.0 +## Distance at which the enemy stops closing, so it does not stand inside the +## player. +@export var preferred_gap: float = 34.0 + +@export_group("Attack") +@export var attack_damage: float = 8.0 +@export var attack_skill_multiplier: float = 1.0 +## Readable telegraph. X01 specifies 0.40 / 0.15 / 0.60. +@export var attack_windup: float = 0.40 +@export var attack_active: float = 0.15 +@export var attack_recovery: float = 0.60 +@export var attack_cooldown: float = 0.35 + +@export_group("Reactions") +@export var hurt_duration: float = 0.18 +@export var death_duration: float = 0.6 +## Colour the sprite is tinted during wind-up. +@export var telegraph_tint: Color = Color(1.7, 1.1, 1.0) diff --git a/actors/enemies/ghost_melee.tscn b/actors/enemies/ghost_melee.tscn new file mode 100644 index 0000000..6dd41d2 --- /dev/null +++ b/actors/enemies/ghost_melee.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 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://actors/enemies/ghost_melee_frames.tres" id="2_frames"] + +[node name="GhostMelee" instance=ExtResource("1_base")] + +[node name="Sprite" parent="." index="0"] +sprite_frames = ExtResource("2_frames") +animation = &"idle" +autoplay = "idle" diff --git a/actors/enemies/ghost_melee_frames.tres b/actors/enemies/ghost_melee_frames.tres new file mode 100644 index 0000000..7628485 --- /dev/null +++ b/actors/enemies/ghost_melee_frames.tres @@ -0,0 +1,177 @@ +; Generated by tools/generate_sprite_frames.py from docs/ART_SPEC.md. +; Do not hand-edit: re-run the generator instead. +[gd_resource type="SpriteFrames" load_steps=26 format=3] + +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_idle.png" id="1_idle"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_run.png" id="2_run"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_attack.png" id="3_attack"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_hurt.png" id="4_hurt"] +[ext_resource type="Texture2D" path="res://assets/enemy/ghost_melee_death.png" id="5_death"] + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_0"] +atlas = ExtResource("1_idle") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_1"] +atlas = ExtResource("1_idle") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_2"] +atlas = ExtResource("1_idle") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_idle_3"] +atlas = ExtResource("1_idle") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_0"] +atlas = ExtResource("2_run") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_1"] +atlas = ExtResource("2_run") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_2"] +atlas = ExtResource("2_run") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_3"] +atlas = ExtResource("2_run") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_4"] +atlas = ExtResource("2_run") +region = Rect2(192, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_run_5"] +atlas = ExtResource("2_run") +region = Rect2(240, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_0"] +atlas = ExtResource("3_attack") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_1"] +atlas = ExtResource("3_attack") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_2"] +atlas = ExtResource("3_attack") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_attack_3"] +atlas = ExtResource("3_attack") +region = Rect2(144, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_0"] +atlas = ExtResource("4_hurt") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_hurt_1"] +atlas = ExtResource("4_hurt") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_0"] +atlas = ExtResource("5_death") +region = Rect2(0, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_1"] +atlas = ExtResource("5_death") +region = Rect2(48, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_2"] +atlas = ExtResource("5_death") +region = Rect2(96, 0, 48, 48) + +[sub_resource type="AtlasTexture" id="AtlasTexture_death_3"] +atlas = ExtResource("5_death") +region = Rect2(144, 0, 48, 48) + +[resource] +animations = [{ +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_idle_3") +}], +"loop": true, +"name": &"idle", +"speed": 8.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_3") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_4") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_run_5") +}], +"loop": true, +"name": &"run", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_attack_3") +}], +"loop": false, +"name": &"attack", +"speed": 12.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_hurt_1") +}], +"loop": false, +"name": &"hurt", +"speed": 10.0 +}, { +"frames": [{ +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_0") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_1") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_2") +}, { +"duration": 1.0, +"texture": SubResource("AtlasTexture_death_3") +}], +"loop": false, +"name": &"death", +"speed": 8.0 +}] diff --git a/data/actors/boss_gate_guardian.tres b/data/actors/boss_gate_guardian.tres new file mode 100644 index 0000000..c068267 --- /dev/null +++ b/data/actors/boss_gate_guardian.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="EnemyConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://actors/enemies/enemy_config.gd" id="1_config"] + +[resource] +script = ExtResource("1_config") +actor_id = &"boss_gate_guardian" +display_name = "镇关鬼将 — Gate Guardian Ghost General" +max_hp = 260.0 +armour = 12.0 +move_speed = 42.0 +gravity = 780.0 +max_fall_speed = 420.0 +detection_range = 420.0 +attack_range = 78.0 +preferred_gap = 48.0 +attack_damage = 22.0 +attack_skill_multiplier = 1.0 +attack_windup = 0.55 +attack_active = 0.18 +attack_recovery = 0.75 +attack_cooldown = 0.6 +hurt_duration = 0.14 +death_duration = 1.4 +telegraph_tint = Color(1.9, 1, 1.4, 1) diff --git a/data/actors/ghost_melee.tres b/data/actors/ghost_melee.tres new file mode 100644 index 0000000..7e45dde --- /dev/null +++ b/data/actors/ghost_melee.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="EnemyConfig" load_steps=2 format=3] + +[ext_resource type="Script" path="res://actors/enemies/enemy_config.gd" id="1_config"] + +[resource] +script = ExtResource("1_config") +actor_id = &"ghost_melee" +display_name = "鬼卒 — Ghost Soldier" +max_hp = 34.0 +armour = 4.0 +move_speed = 48.0 +gravity = 780.0 +max_fall_speed = 420.0 +detection_range = 180.0 +attack_range = 65.0 +preferred_gap = 34.0 +attack_damage = 9.0 +attack_skill_multiplier = 1.0 +attack_windup = 0.4 +attack_active = 0.15 +attack_recovery = 0.6 +attack_cooldown = 0.35 +hurt_duration = 0.18 +death_duration = 0.5 +telegraph_tint = Color(1.7, 1.1, 1, 1) From 8bde15e1486668dfd616e7bdcd8b14f1ff8be2e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 05:21:02 +0000 Subject: [PATCH 07/10] [C07][Claude] Add playable run flow and UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RunCoordinator drives BOOT -> WAVE -> SACRIFICE -> BOSS -> RESULT -> restart and owns the RunState for the run's lifetime. Every mutation of the run goes through it, which is what allows the UI to be a pure observer. UI: HUD (lifespan, stamina, attack, DPS, EHP, integrity, imbalance, boss health, phase), sacrifice preview and confirmation, and a shared death/victory screen. The sacrifice panel previews through SacrificeService — the same transaction on a clone — and confirms through the coordinator, so it cannot show a number the apply will not honour. Its visibility follows the run phase rather than its own button, so it also closes when the sacrifice is applied from debug or a test. Debug panel (F1) shows seed, phase, player state, full RunState and the snapshot hash, with commands for heal, damage, spawn enemy, start boss, preview and apply sacrifice, toggle hitboxes and restart. Every one is a call into RunCoordinator: a debug button that takes a shortcut hides the bug you are hunting. Debug damage in particular goes through the normal receive_damage path so a debug kill exercises real death handling. DebugShapeOverlay draws hit, hurt and body rectangles itself, because Godot's built-in collision debug cannot be switched on mid-session. Main.gd is the composition root: it binds every observer and only then starts the run, so nothing can miss the opening state_ready. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- scenes/main.gd | 24 ++++ scenes/main.tscn | 47 +++++++ systems/run_coordinator.gd | 249 +++++++++++++++++++++++++++++++++++++ ui/debug_panel.gd | 112 +++++++++++++++++ ui/debug_panel.tscn | 47 +++++++ ui/debug_shape_overlay.gd | 71 +++++++++++ ui/hud.gd | 68 ++++++++++ ui/hud.tscn | 105 ++++++++++++++++ ui/result_screen.gd | 54 ++++++++ ui/result_screen.tscn | 42 +++++++ ui/sacrifice_panel.gd | 77 ++++++++++++ ui/sacrifice_panel.tscn | 66 ++++++++++ 12 files changed, 962 insertions(+) create mode 100644 scenes/main.gd create mode 100644 scenes/main.tscn create mode 100644 systems/run_coordinator.gd create mode 100644 ui/debug_panel.gd create mode 100644 ui/debug_panel.tscn create mode 100644 ui/debug_shape_overlay.gd create mode 100644 ui/hud.gd create mode 100644 ui/hud.tscn create mode 100644 ui/result_screen.gd create mode 100644 ui/result_screen.tscn create mode 100644 ui/sacrifice_panel.gd create mode 100644 ui/sacrifice_panel.tscn diff --git a/scenes/main.gd b/scenes/main.gd new file mode 100644 index 0000000..da51e05 --- /dev/null +++ b/scenes/main.gd @@ -0,0 +1,24 @@ +class_name Main +extends Node2D +## Composition root. 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. + +@onready var _coordinator: RunCoordinator = $RunCoordinator +@onready var _hud: Hud = $UIRoot/Hud +@onready var _sacrifice_panel: SacrificePanel = $UIRoot/SacrificePanel +@onready var _result_screen: ResultScreen = $UIRoot/ResultScreen +@onready var _debug_panel: DebugPanel = $DebugRoot/DebugPanel +@onready var _debug_shapes: DebugShapeOverlay = $DebugRoot/DebugShapes + +func _ready() -> void: + _hud.bind(_coordinator) + _sacrifice_panel.bind(_coordinator) + _result_screen.bind(_coordinator) + _debug_panel.bind(_coordinator, _debug_shapes) + _coordinator.start_run(RNGService.run_seed()) + +func coordinator() -> RunCoordinator: + return _coordinator diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..29930a8 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,47 @@ +[gd_scene load_steps=13 format=3 uid="uid://bnnmain0000001"] + +[ext_resource type="Script" path="res://scenes/main.gd" id="1_main"] +[ext_resource type="PackedScene" path="res://scenes/arena.tscn" id="2_arena"] +[ext_resource type="Script" path="res://systems/run_coordinator.gd" id="3_coordinator"] +[ext_resource type="PackedScene" path="res://actors/player/player.tscn" id="4_player"] +[ext_resource type="PackedScene" path="res://actors/enemies/ghost_melee.tscn" id="5_enemy"] +[ext_resource type="PackedScene" path="res://actors/boss/boss.tscn" id="6_boss"] +[ext_resource type="Resource" path="res://data/actors/ghost_melee.tres" id="7_enemy_config"] +[ext_resource type="Resource" path="res://data/actors/boss_gate_guardian.tres" id="8_boss_config"] +[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="9_hud"] +[ext_resource type="PackedScene" path="res://ui/sacrifice_panel.tscn" id="10_sacrifice"] +[ext_resource type="PackedScene" path="res://ui/result_screen.tscn" id="11_result"] +[ext_resource type="PackedScene" path="res://ui/debug_panel.tscn" id="12_debug"] +[ext_resource type="Script" path="res://ui/debug_shape_overlay.gd" id="13_shapes"] + +[node name="Main" type="Node2D"] +script = ExtResource("1_main") + +[node name="Arena" parent="." instance=ExtResource("2_arena")] + +[node name="Actors" type="Node2D" parent="."] + +[node name="RunCoordinator" type="Node" parent="."] +script = ExtResource("3_coordinator") +player_scene = ExtResource("4_player") +enemy_scene = ExtResource("5_enemy") +boss_scene = ExtResource("6_boss") +enemy_config = ExtResource("7_enemy_config") +boss_config = ExtResource("8_boss_config") +arena_path = NodePath("../Arena") +actor_root_path = NodePath("../Actors") + +[node name="UIRoot" type="Node" parent="."] + +[node name="Hud" parent="UIRoot" instance=ExtResource("9_hud")] + +[node name="SacrificePanel" parent="UIRoot" instance=ExtResource("10_sacrifice")] + +[node name="ResultScreen" parent="UIRoot" instance=ExtResource("11_result")] + +[node name="DebugRoot" type="Node" parent="."] + +[node name="DebugPanel" parent="DebugRoot" instance=ExtResource("12_debug")] + +[node name="DebugShapes" type="Node2D" parent="DebugRoot"] +script = ExtResource("13_shapes") diff --git a/systems/run_coordinator.gd b/systems/run_coordinator.gd new file mode 100644 index 0000000..1cc9ac1 --- /dev/null +++ b/systems/run_coordinator.gd @@ -0,0 +1,249 @@ +class_name RunCoordinator +extends Node +## Drives one run through its phases and owns the RunState while it lasts. +## +## BOOT → WAVE → SACRIFICE → BOSS → RESULT → (restart) → WAVE +## +## Everything that mutates the run passes through here: the HUD reads, the +## sacrifice panel asks, the debug panel requests. Keeping the mutations in one +## node is what lets the UI stay an observer. + +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) + +const PHASE_BOOT := &"boot" +const PHASE_WAVE := &"wave" +const PHASE_SACRIFICE := &"sacrifice" +const PHASE_BOSS := &"boss" +const PHASE_RESULT := &"result" + +## The single sacrifice offered in M1. Codex X06 replaces this with the +## three-slot generator over the full library; the offer point does not move. +const M1_SACRIFICE_ID := &"severed_lifespan" + +@export var player_scene: PackedScene +@export var enemy_scene: PackedScene +@export var boss_scene: PackedScene +@export var enemy_config: EnemyConfig +@export var boss_config: EnemyConfig +@export var arena_path: NodePath +@export var actor_root_path: NodePath + +var _arena: Arena +var _actor_root: Node2D +var _state: RunState +var _player: Player +var _boss: BossActor +var _phase: StringName = PHASE_BOOT +var _live_enemies: Array[EnemyBase] = [] +var _run_counter: int = 0 + + +## Resolves scene references only. The run is started by Main once every +## observer has bound, so nothing misses the opening `state_ready`. +func _ready() -> void: + _arena = get_node(arena_path) + _actor_root = get_node(actor_root_path) + + +func state() -> RunState: + return _state + +func player() -> Player: + return _player + +func boss() -> BossActor: + return _boss + +func phase() -> StringName: + return _phase + +## Live wave enemies, excluding the Boss. Read by the debug panel and the +## integration test; the coordinator stays the only thing that mutates the list. +func live_enemies() -> Array[EnemyBase]: + return _live_enemies.duplicate() + +func offered_sacrifice() -> SacrificeDefinition: + return GameData.definition(M1_SACRIFICE_ID) + + +## Fresh run. Reusing the scene rather than reloading it keeps the seed under +## our control, which is what makes a reported bug replayable. +func start_run(run_seed: int, restarted: bool = false) -> void: + get_tree().paused = false + RNGService.configure(run_seed) + _run_counter += 1 + EventBus.bind_run(_run_counter) + + _clear_actors() + _state = RunState.create(GameData.balance, run_seed) + _state.set_run_status(RunState.STATUS_ACTIVE) + _spawn_player() + state_ready.emit(_state) + + var payload := EventBus.context({"seed": run_seed}) + if restarted: + EventBus.run_restarted.emit(payload) + else: + EventBus.run_started.emit(payload) + + _begin_wave() + + +func restart_run(reuse_seed: bool = true) -> void: + # randi() here generates a *new seed*, not a gameplay roll — gameplay always + # draws from a named RNGService stream. + var next_seed: int = _state.run_seed() if (reuse_seed and _state != null) else randi() + start_run(next_seed, true) + + +# --- phases ----------------------------------------------------------------- + +func _set_phase(next: StringName) -> void: + _phase = next + EventBus.set_stage(next) + phase_changed.emit(next) + + +func _begin_wave() -> void: + _set_phase(PHASE_WAVE) + var points := _arena.enemy_spawn_points() + if points.is_empty(): + push_error("RunCoordinator: arena has no enemy spawn points") + _begin_sacrifice() + return + for index in range(GameData.balance.wave_enemy_count): + _spawn_enemy(points[index % points.size()].global_position) + + +func _begin_sacrifice() -> void: + _set_phase(PHASE_SACRIFICE) + var definition := offered_sacrifice() + if definition == null: + push_error("RunCoordinator: sacrifice %s missing from library" % M1_SACRIFICE_ID) + begin_boss() + return + get_tree().paused = true + sacrifice_offered.emit(definition) + + +## Called by the sacrifice panel once the player confirms. The panel showed a +## preview; this runs the same transaction for real. +func confirm_sacrifice(definition: SacrificeDefinition) -> SacrificeResult: + var result := GameData.sacrifices.apply(_state, definition) + if not result.ok: + push_warning("Sacrifice %s rejected: %s" % [definition.id, result.failure_reason]) + get_tree().paused = false + begin_boss() + return result + + +func begin_boss() -> void: + if _phase == PHASE_BOSS or _phase == PHASE_RESULT: + return + _set_phase(PHASE_BOSS) + _boss = boss_scene.instantiate() + _actor_root.add_child(_boss) + _boss.global_position = _arena.boss_spawn.global_position + _boss.initialize(boss_config, _player) + _boss.defeated.connect(_on_boss_defeated) + _boss.start_encounter() + boss_spawned.emit(_boss) + + +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) + + +# --- spawning --------------------------------------------------------------- + +func _spawn_player() -> void: + _player = player_scene.instantiate() + _actor_root.add_child(_player) + _player.global_position = _arena.player_spawn.global_position + _player.setup(_state, GameData.balance) + _player.died.connect(_on_player_died) + _arena.apply_camera_bounds(_player.camera) + _player.camera.make_current() + + +## Also the debug "spawn reference enemy" command, so the debug path exercises +## the same code the wave does. +func spawn_enemy_at(position: Vector2) -> EnemyBase: + return _spawn_enemy(position) + + +func _spawn_enemy(position: Vector2) -> EnemyBase: + var enemy: EnemyBase = enemy_scene.instantiate() + _actor_root.add_child(enemy) + enemy.global_position = position + enemy.initialize(enemy_config, _player) + enemy.died.connect(_on_enemy_died) + _live_enemies.append(enemy) + return enemy + + +func _clear_actors() -> void: + _live_enemies.clear() + _boss = null + _player = null + for child in _actor_root.get_children(): + _actor_root.remove_child(child) + child.queue_free() + + +# --- reactions -------------------------------------------------------------- + +func _on_enemy_died(enemy: EnemyBase) -> void: + _live_enemies.erase(enemy) + if _phase == PHASE_WAVE and _live_enemies.is_empty(): + _begin_sacrifice() + + +func _on_boss_defeated(_boss_actor: BossActor) -> void: + _finish_run(RunState.STATUS_VICTORY) + + +func _on_player_died() -> void: + _finish_run(RunState.STATUS_DEFEAT) + + +# --- debug commands --------------------------------------------------------- +# Routed through the coordinator so the debug panel never touches RunState. + +func debug_heal(amount: float) -> void: + _state.heal(amount) + +## Goes through the normal receive_damage path so a debug kill exercises the +## same death handling as a real killing blow. +func debug_damage(amount: float) -> void: + if _player == null: + return + var context := DamageContext.new() + context.source_id = &"debug" + context.target_id = Player.ACTOR_ID + context.base_damage = amount + context.crit_allowed = false + context.target_armour = 0.0 + context.target_current_hp = _state.current_hp() + _player.receive_damage(context) + +func debug_spawn_reference_enemy() -> void: + if _player == null: + return + var offset := 90.0 * (1 if _player.facing() >= 0 else -1) + spawn_enemy_at(_player.global_position + Vector2(offset, 0.0)) + +func debug_preview_sacrifice() -> SacrificeResult: + return GameData.sacrifices.preview(_state, offered_sacrifice()) + +func debug_apply_sacrifice() -> SacrificeResult: + return GameData.sacrifices.apply(_state, offered_sacrifice()) diff --git a/ui/debug_panel.gd b/ui/debug_panel.gd new file mode 100644 index 0000000..82d15d9 --- /dev/null +++ b/ui/debug_panel.gd @@ -0,0 +1,112 @@ +class_name DebugPanel +extends CanvasLayer +## Development-only inspector and command surface. +## +## Every command is a call into RunCoordinator, so debug tooling exercises the +## same code paths as play. Nothing here reaches into RunState, an actor's +## internals, or a private field — a debug button that takes a shortcut is a +## debug button that hides the bug you are hunting. +## +## Keys: F1 panel · F2 heal · F3 damage · F4 spawn enemy · F5 start boss +## F6 preview sacrifice · F7 apply sacrifice · F8 hitboxes · R restart + +const HEAL_AMOUNT := 25.0 +const DAMAGE_AMOUNT := 15.0 + +@onready var _panel: Panel = $Root/Panel +@onready var _readout: Label = $Root/Panel/Readout +@onready var _log_label: Label = $Root/Panel/Log + +var _coordinator: RunCoordinator +var _overlay: DebugShapeOverlay +var _last_message: String = "F1 toggles this panel." + +func bind(coordinator: RunCoordinator, overlay: DebugShapeOverlay) -> void: + _coordinator = coordinator + _overlay = overlay + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + # The readout starts closed so it cannot sit on top of the sacrifice card; + # the key hint below it stays visible so F1 is discoverable. + _panel.visible = false + +func _unhandled_input(event: InputEvent) -> void: + if not event.is_pressed() or event.is_echo(): + return + if event.is_action_pressed(&"debug_toggle_panel"): + _panel.visible = not _panel.visible + elif event.is_action_pressed(&"debug_heal_player"): + _coordinator.debug_heal(HEAL_AMOUNT) + _note("healed %.0f" % HEAL_AMOUNT) + elif event.is_action_pressed(&"debug_damage_player"): + _coordinator.debug_damage(DAMAGE_AMOUNT) + _note("damaged %.0f" % DAMAGE_AMOUNT) + elif event.is_action_pressed(&"debug_spawn_enemy"): + _coordinator.debug_spawn_reference_enemy() + _note("spawned reference enemy") + elif event.is_action_pressed(&"debug_start_boss"): + _coordinator.begin_boss() + _note("boss encounter started") + elif event.is_action_pressed(&"debug_preview_sacrifice"): + _note(_describe(_coordinator.debug_preview_sacrifice(), "preview")) + elif event.is_action_pressed(&"debug_apply_sacrifice"): + _note(_describe(_coordinator.debug_apply_sacrifice(), "applied")) + elif event.is_action_pressed(&"debug_toggle_hitboxes"): + _overlay.toggle() + _note("hitboxes %s" % ("on" if _overlay.is_enabled() else "off")) + elif event.is_action_pressed(&"restart_run"): + _coordinator.restart_run() + _note("run restarted") + else: + return + get_viewport().set_input_as_handled() + +func _process(_delta: float) -> void: + if not _panel.visible or _coordinator == null: + return + var state := _coordinator.state() + if state == null: + return + var player := _coordinator.player() + _readout.text = ( + "seed %d\nphase %s\nplayer %s\nstatus %s\n" + + "hp %.1f / %.1f\nstamina %.1f / %.1f\n" + + "attack %.2f add %.2f more x%.3f\n" + + "armour %.1f recovery %.1f\n" + + "integrity %.4f\nimbalance %.1f\nEHP %.1f\nDPS est. %.1f\n" + + "locks %d taxes %d\nsacrifices %s\nsnapshot %s" + ) % [ + state.run_seed(), + _coordinator.phase(), + player.state_id() if player != null else &"none", + state.run_status(), + state.current_hp(), state.max_hp(), + state.current_stamina(), state.max_stamina(), + state.attack(), state.additive_sum(), state.more_product(), + state.armour(), state.stamina_recovery(), + state.integrity(), + state.imbalance(), + state.effective_hp(), + state.dps_estimate(), + state.structural_locks().size(), state.action_taxes().size(), + str(state.sacrifice_history()), + state.snapshot_hash(), + ] + _log_label.text = _last_message + +func _describe(result: SacrificeResult, verb: String) -> String: + if not result.ok: + return "%s failed: %s" % [verb, result.failure_reason] + return "%s %s: HP %+.1f, DPS %+.1f, I %+.4f, B %+.0f" % [ + verb, + result.definition_id, + result.deltas["max_hp"], + result.deltas["dps_estimate"], + result.deltas["integrity"], + result.deltas["imbalance"], + ] + +func _note(message: String) -> void: + _last_message = message + print("[debug] ", message) diff --git a/ui/debug_panel.tscn b/ui/debug_panel.tscn new file mode 100644 index 0000000..c8d7628 --- /dev/null +++ b/ui/debug_panel.tscn @@ -0,0 +1,47 @@ +[gd_scene load_steps=2 format=3 uid="uid://bnndebugpanel1"] + +[ext_resource type="Script" path="res://ui/debug_panel.gd" id="1_debug"] + +[node name="DebugPanel" type="CanvasLayer"] +layer = 8 +script = ExtResource("1_debug") + +[node name="Root" type="Control" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 +mouse_filter = 2 + +[node name="Panel" type="Panel" parent="Root"] +offset_left = 396.0 +offset_top = 46.0 +offset_right = 636.0 +offset_bottom = 322.0 +modulate = Color(1, 1, 1, 0.88) +mouse_filter = 2 + +[node name="Readout" type="Label" parent="Root/Panel"] +offset_left = 6.0 +offset_top = 4.0 +offset_right = 234.0 +offset_bottom = 224.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +text = "debug" + +[node name="Log" type="Label" parent="Root/Panel"] +offset_left = 6.0 +offset_top = 228.0 +offset_right = 234.0 +offset_bottom = 272.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 + +[node name="Keys" type="Label" parent="Root"] +offset_left = 6.0 +offset_top = 330.0 +offset_right = 636.0 +offset_bottom = 356.0 +theme_override_colors/font_color = Color(0.463, 0.451, 0.416, 1) +theme_override_font_sizes/font_size = 9 +text = "A/D move Space jump J attack | F1 debug F2 heal F3 hurt F4 enemy F5 boss F6 preview F7 apply F8 boxes R restart" diff --git a/ui/debug_shape_overlay.gd b/ui/debug_shape_overlay.gd new file mode 100644 index 0000000..00cd845 --- /dev/null +++ b/ui/debug_shape_overlay.gd @@ -0,0 +1,71 @@ +class_name DebugShapeOverlay +extends Node2D +## Draws hit, hurt and body collision rectangles in world space. +## +## Godot only renders collision shapes when the whole run was started with +## debug collision hints, which is not something a key can turn on mid-session. +## Drawing them here makes the F8 toggle actually work while playing. +## +## Every shape in the prototype is a RectangleShape2D; anything else is skipped +## rather than approximated, so the overlay never draws a box that is not there. + +const HITBOX_COLOUR := Color(0.9, 0.25, 0.25, 0.85) +const HITBOX_INACTIVE_COLOUR := Color(0.45, 0.2, 0.2, 0.45) +const HURTBOX_COLOUR := Color(0.3, 0.85, 0.45, 0.7) +const BODY_COLOUR := Color(0.6, 0.6, 0.7, 0.5) + +var _enabled: bool = false + +func _ready() -> void: + z_index = 100 + visible = false + +func is_enabled() -> bool: + return _enabled + +func toggle() -> void: + set_enabled(not _enabled) + +func set_enabled(value: bool) -> void: + _enabled = value + visible = value + queue_redraw() + +func _process(_delta: float) -> void: + if _enabled: + queue_redraw() + +func _draw() -> void: + if not _enabled: + return + _draw_subtree(get_tree().current_scene) + +func _draw_subtree(node: Node) -> void: + if node == null: + return + if node is CollisionShape2D: + _draw_shape(node as CollisionShape2D) + for child in node.get_children(): + _draw_subtree(child) + +func _draw_shape(shape_node: CollisionShape2D) -> void: + var rectangle := shape_node.shape as RectangleShape2D + if rectangle == null or shape_node.disabled: + return + var half := rectangle.size * 0.5 + var transform := shape_node.global_transform + var corners := PackedVector2Array([ + to_local(transform * Vector2(-half.x, -half.y)), + to_local(transform * Vector2(half.x, -half.y)), + to_local(transform * Vector2(half.x, half.y)), + to_local(transform * Vector2(-half.x, half.y)), + ]) + corners.append(corners[0]) + draw_polyline(corners, _colour_for(shape_node.get_parent()), 1.0) + +func _colour_for(owner_node: Node) -> Color: + if owner_node is Hitbox: + return HITBOX_COLOUR if (owner_node as Hitbox).is_active() else HITBOX_INACTIVE_COLOUR + if owner_node is Hurtbox: + return HURTBOX_COLOUR + return BODY_COLOUR diff --git a/ui/hud.gd b/ui/hud.gd new file mode 100644 index 0000000..37f8660 --- /dev/null +++ b/ui/hud.gd @@ -0,0 +1,68 @@ +class_name Hud +extends CanvasLayer +## Read-only run display. +## +## The HUD holds a reference to RunState but only ever calls getters. It has no +## path to a command and never talks to an actor — if something on screen is +## wrong, the bug is upstream in a service, not here. + +const FILL_INSET := 1.0 +const BAR_INTERIOR_WIDTH := 94.0 + +@onready var _hp_fill: ColorRect = $Player/HealthBar/Fill +@onready var _stamina_fill: ColorRect = $Player/StaminaBar/Fill +@onready var _stats: Label = $Player/Stats +@onready var _boss_root: Control = $Boss +@onready var _boss_fill: ColorRect = $Boss/BossBar/Fill +@onready var _boss_name: Label = $Boss/Name +@onready var _phase_label: Label = $Phase + +var _state: RunState +var _boss: EnemyBase + +func bind(coordinator: RunCoordinator) -> void: + coordinator.state_ready.connect(_on_state_ready) + coordinator.boss_spawned.connect(_on_boss_spawned) + coordinator.phase_changed.connect(_on_phase_changed) + if coordinator.state() != null: + _on_state_ready(coordinator.state()) + +func _ready() -> void: + _boss_root.visible = false + +func _process(_delta: float) -> void: + if _state == null: + return + _set_fill(_hp_fill, _state.hp_ratio()) + _set_fill(_stamina_fill, _state.stamina_ratio()) + _stats.text = ( + "ATK %.1f DPS %.1f\nEHP %.0f HP %.0f/%.0f\nIntegrity %.3f Imbalance %.0f" + % [ + _state.attack(), + _state.dps_estimate(), + _state.effective_hp(), + _state.current_hp(), + _state.max_hp(), + _state.integrity(), + _state.imbalance(), + ] + ) + if _boss != null and is_instance_valid(_boss): + _set_fill(_boss_fill, _boss.hp_ratio()) + +func _on_state_ready(state: RunState) -> void: + _state = state + _boss = null + _boss_root.visible = false + +func _on_boss_spawned(boss: BossActor) -> void: + _boss = boss + _boss_name.text = boss.config.display_name + _boss_root.visible = true + +func _on_phase_changed(phase: StringName) -> void: + _phase_label.text = String(phase).to_upper() + +func _set_fill(fill: ColorRect, ratio: float) -> void: + fill.size.x = BAR_INTERIOR_WIDTH * clampf(ratio, 0.0, 1.0) + fill.visible = fill.size.x > 0.0 diff --git a/ui/hud.tscn b/ui/hud.tscn new file mode 100644 index 0000000..c4b68a0 --- /dev/null +++ b/ui/hud.tscn @@ -0,0 +1,105 @@ +[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/icons/ui/icon_hp.png" id="4_icon_hp"] +[ext_resource type="Texture2D" path="res://assets/icons/ui/icon_boss.png" id="5_icon_boss"] + +[node name="Hud" type="CanvasLayer"] +script = ExtResource("1_hud") + +[node name="Player" type="Control" parent="."] +offset_right = 240.0 +offset_bottom = 120.0 + +[node name="Icon" type="TextureRect" parent="Player"] +offset_left = 4.0 +offset_top = 4.0 +offset_right = 36.0 +offset_bottom = 36.0 +texture = ExtResource("4_icon_hp") + +[node name="HealthBar" type="TextureRect" parent="Player"] +offset_left = 40.0 +offset_top = 6.0 +offset_right = 136.0 +offset_bottom = 22.0 +texture = ExtResource("2_health_bar") + +[node name="Fill" type="ColorRect" parent="Player/HealthBar"] +offset_left = 1.0 +offset_top = 1.0 +offset_right = 95.0 +offset_bottom = 15.0 +color = Color(0.541, 0.157, 0.157, 1) + +[node name="StaminaBar" type="TextureRect" parent="Player"] +offset_left = 40.0 +offset_top = 24.0 +offset_right = 136.0 +offset_bottom = 40.0 +texture = ExtResource("3_stamina_bar") + +[node name="Fill" type="ColorRect" parent="Player/StaminaBar"] +offset_left = 1.0 +offset_top = 1.0 +offset_right = 95.0 +offset_bottom = 15.0 +color = Color(0.165, 0.541, 0.29, 1) + +[node name="Stats" type="Label" parent="Player"] +offset_left = 6.0 +offset_top = 44.0 +offset_right = 236.0 +offset_bottom = 96.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 10 +text = "ATK +EHP +Integrity" + +[node name="Boss" type="Control" parent="."] +offset_left = 220.0 +offset_right = 480.0 +offset_bottom = 48.0 + +[node name="Icon" type="TextureRect" parent="Boss"] +offset_left = 4.0 +offset_top = 4.0 +offset_right = 36.0 +offset_bottom = 36.0 +texture = ExtResource("5_icon_boss") + +[node name="BossBar" type="TextureRect" parent="Boss"] +offset_left = 40.0 +offset_top = 6.0 +offset_right = 136.0 +offset_bottom = 22.0 +texture = ExtResource("2_health_bar") + +[node name="Fill" type="ColorRect" parent="Boss/BossBar"] +offset_left = 1.0 +offset_top = 1.0 +offset_right = 95.0 +offset_bottom = 15.0 +color = Color(0.706, 0.392, 0.118, 1) + +[node name="Name" type="Label" parent="Boss"] +offset_left = 40.0 +offset_top = 24.0 +offset_right = 256.0 +offset_bottom = 40.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 8 +text = "Boss" + +[node name="Phase" type="Label" parent="."] +offset_left = 560.0 +offset_top = 6.0 +offset_right = 636.0 +offset_bottom = 22.0 +theme_override_colors/font_color = Color(0.463, 0.451, 0.416, 1) +theme_override_font_sizes/font_size = 10 +text = "BOOT" +horizontal_alignment = 2 diff --git a/ui/result_screen.gd b/ui/result_screen.gd new file mode 100644 index 0000000..d71ef2c --- /dev/null +++ b/ui/result_screen.gd @@ -0,0 +1,54 @@ +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. + +@onready var _title: Label = $Root/Title +@onready var _summary: Label = $Root/Summary +@onready var _restart_button: Button = $Root/Restart + +var _coordinator: RunCoordinator + +func bind(coordinator: RunCoordinator) -> void: + _coordinator = coordinator + coordinator.run_finished.connect(show_outcome) + coordinator.state_ready.connect(func(_state: RunState) -> void: visible = false) + +func _ready() -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _restart_button.pressed.connect(_on_restart) + +func show_outcome(outcome: StringName) -> void: + var state := _coordinator.state() + var victory := outcome == RunState.STATUS_VICTORY + # 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) + _summary.text = ( + "Seed %d\nSacrifices %d\nAttack %.1f Estimated DPS %.1f\n" + + "Max lifespan %.0f Effective HP %.0f\nIntegrity %.3f Imbalance %.0f" + ) % [ + state.run_seed(), + state.sacrifice_history().size(), + state.attack(), + state.dps_estimate(), + state.max_hp(), + state.effective_hp(), + state.integrity(), + state.imbalance(), + ] + 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() + get_viewport().set_input_as_handled() + +func _on_restart() -> void: + if not visible: + return + visible = false + _coordinator.restart_run() diff --git a/ui/result_screen.tscn b/ui/result_screen.tscn new file mode 100644 index 0000000..890b37e --- /dev/null +++ b/ui/result_screen.tscn @@ -0,0 +1,42 @@ +[gd_scene load_steps=2 format=3 uid="uid://bnnresult00001"] + +[ext_resource type="Script" path="res://ui/result_screen.gd" id="1_result"] + +[node name="ResultScreen" type="CanvasLayer"] +layer = 6 +script = ExtResource("1_result") + +[node name="Root" type="Control" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Dim" type="ColorRect" parent="Root"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 0.88) + +[node name="Title" type="Label" parent="Root"] +offset_left = 120.0 +offset_top = 86.0 +offset_right = 520.0 +offset_bottom = 118.0 +theme_override_font_sizes/font_size = 20 +text = "DEATH" +horizontal_alignment = 1 + +[node name="Summary" type="Label" parent="Root"] +offset_left = 120.0 +offset_top = 132.0 +offset_right = 520.0 +offset_bottom = 232.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 11 +horizontal_alignment = 1 + +[node name="Restart" type="Button" parent="Root"] +offset_left = 258.0 +offset_top = 246.0 +offset_right = 382.0 +offset_bottom = 272.0 +theme_override_font_sizes/font_size = 11 +text = "Restart (R)" diff --git a/ui/sacrifice_panel.gd b/ui/sacrifice_panel.gd new file mode 100644 index 0000000..64c5c4f --- /dev/null +++ b/ui/sacrifice_panel.gd @@ -0,0 +1,77 @@ +class_name SacrificePanel +extends CanvasLayer +## Sacrifice preview and confirmation. +## +## The panel calls SacrificeService.preview, which runs the real transaction on +## a clone, and renders the numbers that came back. It never computes a benefit +## or a cost itself, and on confirm it hands the definition to RunCoordinator +## rather than applying anything — the rule "UI is an observer" only holds if +## the preview and the apply are the same code, which they are. +## +## Codex X05 replaces this with the A/B/C three-slot card layout. The contract +## it must keep: preview through the service, confirm through the coordinator. + +@onready var _title: Label = $Root/Panel/Title +@onready var _body: Label = $Root/Panel/Body +@onready var _warnings: Label = $Root/Panel/Warnings +@onready var _confirm_button: Button = $Root/Panel/Confirm + +var _coordinator: RunCoordinator +var _definition: SacrificeDefinition + +func bind(coordinator: RunCoordinator) -> void: + _coordinator = coordinator + coordinator.sacrifice_offered.connect(show_offer) + # Visibility follows the phase rather than the confirm button, so the panel + # also closes when the sacrifice is applied from the debug panel or a test. + coordinator.phase_changed.connect(_on_phase_changed) + +func _ready() -> void: + # The run is paused while this panel is up, so it must keep processing. + process_mode = Node.PROCESS_MODE_ALWAYS + visible = false + _confirm_button.pressed.connect(_on_confirmed) + +func show_offer(definition: SacrificeDefinition) -> void: + _definition = definition + var preview := GameData.sacrifices.preview(_coordinator.state(), definition) + _title.text = definition.display_name + _body.text = _format_preview(definition, preview) + _warnings.text = "\n".join(preview.warnings) + visible = true + _confirm_button.grab_focus() + +func _unhandled_input(event: InputEvent) -> void: + if visible and event.is_action_pressed(&"confirm"): + _on_confirmed() + get_viewport().set_input_as_handled() + +func _on_phase_changed(phase: StringName) -> void: + if phase != RunCoordinator.PHASE_SACRIFICE: + visible = false + + +func _on_confirmed() -> void: + if not visible or _definition == null: + return + visible = false + _coordinator.confirm_sacrifice(_definition) + +func _format_preview(definition: SacrificeDefinition, preview: SacrificeResult) -> String: + if not preview.ok: + return "Unavailable: %s" % preview.failure_reason + return ( + "%s\n\nGAIN\n Attack additive %+.2f\n Damage multiplier x%.3f\n" + + " Estimated DPS %.1f → %.1f\n\nCOST\n Max lifespan %.0f → %.0f\n" + + " Effective HP %.0f → %.0f\n Structural integrity %.3f → %.3f\n" + + " Imbalance %.0f → %.0f" + ) % [ + definition.short_text, + definition.reward_additive, + 1.0 + preview.gain, + preview.before["dps_estimate"], preview.after["dps_estimate"], + preview.before["max_hp"], preview.after["max_hp"], + preview.before["effective_hp"], preview.after["effective_hp"], + preview.before["integrity"], preview.after["integrity"], + preview.before["imbalance"], preview.after["imbalance"], + ] diff --git a/ui/sacrifice_panel.tscn b/ui/sacrifice_panel.tscn new file mode 100644 index 0000000..71c551d --- /dev/null +++ b/ui/sacrifice_panel.tscn @@ -0,0 +1,66 @@ +[gd_scene load_steps=3 format=3 uid="uid://bnnsacpanel001"] + +[ext_resource type="Script" path="res://ui/sacrifice_panel.gd" id="1_panel"] +[ext_resource type="Texture2D" path="res://assets/icons/ui/icon_sacrifice.png" id="2_icon"] + +[node name="SacrificePanel" type="CanvasLayer"] +layer = 5 +script = ExtResource("1_panel") + +[node name="Root" type="Control" parent="."] +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Dim" type="ColorRect" parent="Root"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.058, 0.047, 0.07, 0.78) + +[node name="Panel" type="Panel" parent="Root"] +offset_left = 150.0 +offset_top = 22.0 +offset_right = 490.0 +offset_bottom = 322.0 + +[node name="Icon" type="TextureRect" parent="Root/Panel"] +offset_left = 8.0 +offset_top = 8.0 +offset_right = 40.0 +offset_bottom = 40.0 +texture = ExtResource("2_icon") + +[node name="Title" type="Label" parent="Root/Panel"] +offset_left = 46.0 +offset_top = 8.0 +offset_right = 332.0 +offset_bottom = 46.0 +theme_override_colors/font_color = Color(0.706, 0.392, 0.118, 1) +theme_override_font_sizes/font_size = 12 +autowrap_mode = 2 +text = "Sacrifice" + +[node name="Body" type="Label" parent="Root/Panel"] +offset_left = 12.0 +offset_top = 50.0 +offset_right = 328.0 +offset_bottom = 244.0 +theme_override_colors/font_color = Color(0.769, 0.722, 0.604, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 + +[node name="Warnings" type="Label" parent="Root/Panel"] +offset_left = 12.0 +offset_top = 246.0 +offset_right = 328.0 +offset_bottom = 276.0 +theme_override_colors/font_color = Color(0.831, 0.353, 0.294, 1) +theme_override_font_sizes/font_size = 9 +autowrap_mode = 2 + +[node name="Confirm" type="Button" parent="Root/Panel"] +offset_left = 108.0 +offset_top = 278.0 +offset_right = 232.0 +offset_bottom = 298.0 +theme_override_font_sizes/font_size = 11 +text = "Accept (Enter)" From ff92ff4c43954d13169efc3d1b6ef936027d28df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 05:21:51 +0000 Subject: [PATCH 08/10] [C08][Claude] Add tests and Godot CI workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 52 tests, 205 assertions, all green against Godot 4.3 headless. Coverage is aimed at the rules that would be expensive to get wrong: - Combat: bucket order, armour as an equivalent-life model, penetration, forced and pinned crits, the crit-rate hard cap and the crit-damage/more soft caps, lethality boundaries, and a check that the breakdown multiplies back to the final number. - Integrity: current HP and stamina do not move integrity while max HP does — the arbitrage bug the research report was written to fix. Also the intact-body ceiling, that a buff cannot offset a structural loss, structural floors, and exact snapshot round-tripping. - Sacrifice: preview leaves the real state byte-identical, apply matches the preview it showed, a rejected transaction rolls back whole, the shipped card's numbers come from its .tres, the reward is priced against post-cost integrity, and a more broken body buys a bigger reward. - Enemy/Boss: damage intake through the resolver, death fires exactly once across four routes, a corpse takes no damage and stops behaving, and the Boss reuses EnemyBase rather than a second system. - RNG: same seed replays, different seeds diverge, and draining one stream does not shift another. - Run loop: the real Main scene is driven through wave, a real hitbox connect, sacrifice, boss, victory, restart and death, plus every debug command and a smoke check that all ten critical scenes instantiate. The harness is ~90 lines rather than a vendored plugin, because the Prototype Development Pack requires the project to test straight after clone with no extra downloads. If the suite outgrows plain asserts, swap in gdUnit4 rather than growing the harness. Local command: godot --headless --import godot --headless --path . res://tests/test_runner.tscn The new `godot-tests` workflow runs exactly that and leaves `repository-validation` untouched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- .github/workflows/godot-tests.yml | 71 +++++++++++ tests/cases/test_actors.gd | 126 ++++++++++++++++++++ tests/cases/test_combat_resolver.gd | 132 +++++++++++++++++++++ tests/cases/test_integrity.gd | 105 +++++++++++++++++ tests/cases/test_rng.gd | 60 ++++++++++ tests/cases/test_run_loop.gd | 175 ++++++++++++++++++++++++++++ tests/cases/test_sacrifice.gd | 155 ++++++++++++++++++++++++ tests/framework/test_case.gd | 78 +++++++++++++ tests/test_runner.gd | 81 +++++++++++++ tests/test_runner.tscn | 6 + 10 files changed, 989 insertions(+) create mode 100644 .github/workflows/godot-tests.yml create mode 100644 tests/cases/test_actors.gd create mode 100644 tests/cases/test_combat_resolver.gd create mode 100644 tests/cases/test_integrity.gd create mode 100644 tests/cases/test_rng.gd create mode 100644 tests/cases/test_run_loop.gd create mode 100644 tests/cases/test_sacrifice.gd create mode 100644 tests/framework/test_case.gd create mode 100644 tests/test_runner.gd create mode 100644 tests/test_runner.tscn diff --git a/.github/workflows/godot-tests.yml b/.github/workflows/godot-tests.yml new file mode 100644 index 0000000..b59902e --- /dev/null +++ b/.github/workflows/godot-tests.yml @@ -0,0 +1,71 @@ +name: godot-tests + +# Separate from `repository-validation`, which checks repository hygiene and +# must keep passing on its own. This workflow only answers one question: does +# the game still build its resources and pass its automated tests? + +on: + pull_request: + branches: [develop, main] + workflow_dispatch: + +permissions: + contents: read + +env: + GODOT_VERSION: 4.3-stable + +jobs: + godot-tests: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Cache Godot binary + id: godot-cache + uses: actions/cache@v4 + with: + path: ~/godot-bin + key: godot-${{ env.GODOT_VERSION }}-linux-x86_64 + + - name: Download Godot + if: steps.godot-cache.outputs.cache-hit != 'true' + shell: bash + run: | + set -euo pipefail + mkdir -p ~/godot-bin + archive="Godot_v${GODOT_VERSION}_linux.x86_64" + url="https://github.com/godotengine/godot/releases/download/${GODOT_VERSION}/${archive}.zip" + curl -fsSL -o /tmp/godot.zip "$url" + unzip -q -o /tmp/godot.zip -d /tmp/godot + mv "/tmp/godot/${archive}" ~/godot-bin/godot + chmod +x ~/godot-bin/godot + + - name: Report Godot version + shell: bash + run: | + echo "$HOME/godot-bin" >> "$GITHUB_PATH" + ~/godot-bin/godot --version + + # Import must run first and on its own: it generates the .godot/ cache and + # the global class-name registry that the test scripts resolve against. + - name: Import project resources + shell: bash + run: godot --headless --import 2>&1 | tee /tmp/import.log + + - name: Fail on import errors + shell: bash + run: | + if grep -qE '^(SCRIPT )?ERROR:' /tmp/import.log; then + echo "Import reported errors:" + grep -nE '^(SCRIPT )?ERROR:' /tmp/import.log + exit 1 + fi + + # Runs as a scene, not via --script: a MainLoop script is compiled before + # the autoload singletons exist, so classes naming EventBus or RNGService + # would fail to compile and every service would silently be null. + - name: Run tests + shell: bash + run: godot --headless --path . res://tests/test_runner.tscn diff --git a/tests/cases/test_actors.gd b/tests/cases/test_actors.gd new file mode 100644 index 0000000..d40227f --- /dev/null +++ b/tests/cases/test_actors.gd @@ -0,0 +1,126 @@ +extends TestCase +## EnemyBase and BossActor: damage intake, idempotent death, and the shared +## Damageable shape. + +const ENEMY_SCENE := "res://actors/enemies/ghost_melee.tscn" +const BOSS_SCENE := "res://actors/boss/boss.tscn" +const ENEMY_CONFIG := "res://data/actors/ghost_melee.tres" +const BOSS_CONFIG := "res://data/actors/boss_gate_guardian.tres" + +var _spawned: Array[Node] = [] + +func after_each() -> void: + for node in _spawned: + if is_instance_valid(node): + node.queue_free() + _spawned.clear() + +func _spawn(scene_path: String, config_path: String) -> EnemyBase: + var enemy: EnemyBase = (load(scene_path) as PackedScene).instantiate() + tree.root.add_child(enemy) + _spawned.append(enemy) + enemy.initialize(load(config_path) as EnemyConfig, null) + return enemy + +func _blow(target: EnemyBase, amount: float) -> DamageContext: + var context := DamageContext.new() + context.source_id = &"test" + context.target_id = target.actor_id() + context.base_damage = amount + context.crit_allowed = false + context.target_armour = target.armour() + context.target_current_hp = target.current_hp() + return context + + +func test_enemy_exposes_the_damageable_shape() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + assert_equal(enemy.actor_id(), &"ghost_melee", "actor_id comes from the config") + assert_almost(enemy.max_hp(), 34.0, "max HP comes from the config") + assert_almost(enemy.current_hp(), 34.0, "spawns at full HP") + assert_almost(enemy.armour(), 4.0, "armour comes from the config") + + +func test_enemy_takes_damage_through_the_resolver() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + var result := enemy.receive_damage(_blow(enemy, 10.0)) + # Armour 4 mitigates: 10 * 100 / 104 + assert_almost(result.final_damage, 1000.0 / 104.0, "armour applied by CombatResolver", 1e-4) + assert_almost(enemy.current_hp(), 34.0 - 1000.0 / 104.0, "HP dropped by exactly that", 1e-4) + assert_false(enemy.is_dead(), "still alive") + + +func test_death_fires_exactly_once_however_many_lethal_hits_land() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + var signal_count := [0] + var bus_count := [0] + enemy.died.connect(func(_e: EnemyBase) -> void: signal_count[0] += 1) + var bus_handler := func(_payload: Dictionary) -> void: bus_count[0] += 1 + EventBus.enemy_died.connect(bus_handler) + + enemy.receive_damage(_blow(enemy, 1000.0)) + enemy.receive_damage(_blow(enemy, 1000.0)) + enemy.die(&"test") + enemy.die(&"test") + + EventBus.enemy_died.disconnect(bus_handler) + assert_equal(signal_count[0], 1, "the died signal fired once") + assert_equal(bus_count[0], 1, "the enemy_died event fired once") + + +func test_damage_after_death_is_ignored() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + enemy.die(&"test") + var result := enemy.receive_damage(_blow(enemy, 50.0)) + assert_almost(result.final_damage, 0.0, "a corpse takes no damage") + assert_almost(enemy.current_hp(), 0.0, "HP stays at zero") + + +func test_death_disables_behaviour() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + enemy.die(&"test") + await step_physics(2) + assert_equal(enemy.state(), EnemyBase.State.DEAD, "state machine parked in DEAD") + assert_false(enemy.attack_hitbox.is_active(), "attack box is closed") + assert_false(enemy.hurtbox.monitorable, "hurtbox no longer registers hits") + assert_true(enemy.body_shape.disabled, "body collision is off") + assert_almost(enemy.velocity.x, 0.0, "no residual movement", 1.0) + + +func test_boss_reuses_the_enemy_foundation() -> void: + var boss := _spawn(BOSS_SCENE, BOSS_CONFIG) as BossActor + assert_true(boss is EnemyBase, "BossActor extends EnemyBase — one combat system") + assert_almost(boss.max_hp(), 260.0, "boss vitals come from its own config") + boss.receive_damage(_blow(boss, 100.0)) + assert_less(boss.current_hp(), 260.0, "boss takes damage through the same resolver") + + +func test_boss_death_publishes_its_own_events_once() -> void: + var boss := _spawn(BOSS_SCENE, BOSS_CONFIG) as BossActor + var defeated := [0] + var bus := [0] + boss.defeated.connect(func(_b: BossActor) -> void: defeated[0] += 1) + var handler := func(_payload: Dictionary) -> void: bus[0] += 1 + EventBus.boss_died.connect(handler) + + boss.receive_damage(_blow(boss, 9999.0)) + boss.die(&"test") + + EventBus.boss_died.disconnect(handler) + assert_equal(defeated[0], 1, "defeated fired once") + assert_equal(bus[0], 1, "boss_died fired once") + + +func test_enemy_chases_a_target_in_range() -> void: + var enemy := _spawn(ENEMY_SCENE, ENEMY_CONFIG) + var target := Node2D.new() + tree.root.add_child(target) + _spawned.append(target) + enemy.global_position = Vector2.ZERO + target.global_position = Vector2(140.0, 0.0) + enemy.acquire_target(target) + await step_physics(4) + assert_true( + enemy.state() == EnemyBase.State.CHASE, + "a target inside the 180px detection range is chased" + ) diff --git a/tests/cases/test_combat_resolver.gd b/tests/cases/test_combat_resolver.gd new file mode 100644 index 0000000..d046778 --- /dev/null +++ b/tests/cases/test_combat_resolver.gd @@ -0,0 +1,132 @@ +extends TestCase +## CombatResolver — the bucket pipeline, crit rules, armour and lethality. + +var balance: BalanceConfig +var resolver: CombatResolver + +func before_each() -> void: + balance = make_balance() + resolver = CombatResolver.new(balance) + +func _context(base: float, armour: float = 0.0, target_hp: float = 1000.0) -> DamageContext: + var context := DamageContext.new() + context.source_id = &"test_source" + context.target_id = &"test_target" + context.base_damage = base + context.target_armour = armour + context.target_current_hp = target_hp + context.force_crit = 0 + return context + + +func test_normal_damage_applies_every_bucket_in_order() -> void: + var context := _context(10.0) + context.skill_multiplier = 2.0 + context.additive_modifiers = [0.25, 0.25] as Array[float] + context.more_modifiers = [0.5] as Array[float] + context.conditional_modifiers = [0.2] as Array[float] + var result := resolver.resolve(context) + # 10 * 2 * (1 + 0.5) * 1.5 * 1.2 + assert_almost(result.raw_damage, 54.0, "raw damage follows base->add->more->cond", 1e-4) + assert_almost(result.final_damage, 54.0, "no armour means no mitigation", 1e-4) + + +func test_armour_mitigates_by_equivalent_life_model() -> void: + var result := resolver.resolve(_context(100.0, 100.0)) + # 100 * 100 / (100 + 100) + assert_almost(result.final_damage, 50.0, "100 armour halves incoming damage", 1e-4) + assert_almost(result.mitigation, 50.0, "mitigation is raw minus final", 1e-4) + + +func test_armour_penetration_reduces_effective_armour() -> void: + var context := _context(100.0, 100.0) + context.armour_penetration = 60.0 + var result := resolver.resolve(context) + assert_almost(result.breakdown["effective_armour"], 40.0, "penetration subtracts", 1e-6) + assert_almost(result.final_damage, 100.0 * 100.0 / 140.0, "mitigation uses effective armour", 1e-4) + + +func test_forced_critical_multiplies_by_crit_damage() -> void: + var context := _context(10.0) + context.crit_damage = 2.5 + context.force_crit = 1 + var result := resolver.resolve(context) + assert_true(result.is_critical, "force_crit = 1 always crits") + assert_almost(result.final_damage, 25.0, "crit multiplies by crit damage", 1e-4) + + +func test_forbidden_critical_never_crits_even_at_full_rate() -> void: + var context := _context(10.0) + context.crit_rate = 1.0 + context.crit_damage = 5.0 + context.force_crit = 0 + assert_false(resolver.resolve(context).is_critical, "force_crit = 0 suppresses the roll") + + +func test_pinned_roll_makes_crit_deterministic() -> void: + var context := _context(10.0) + context.crit_rate = 0.30 + context.crit_damage = 2.0 + context.force_crit = -1 + + context.crit_roll = 0.29 + assert_true(resolver.resolve(context).is_critical, "roll below rate crits") + + context.crit_roll = 0.31 + assert_false(resolver.resolve(context).is_critical, "roll above rate does not crit") + + +func test_crit_rate_is_hard_capped_and_crit_damage_is_soft_capped() -> void: + var context := _context(10.0) + context.crit_rate = 0.99 + context.crit_damage = 6.5 + context.crit_roll = 0.85 + var result := resolver.resolve(context) + assert_false(result.is_critical, "0.85 is above the 0.80 hard cap so no crit") + assert_almost( + result.breakdown["crit_damage_effective"], + 4.5 + (6.5 - 4.5) * 0.35, + "crit damage compresses past the soft cap", + 1e-6 + ) + + +func test_more_product_is_soft_capped() -> void: + var context := _context(1.0) + context.more_modifiers = [19.0] as Array[float] # raw product 20 + var result := resolver.resolve(context) + assert_almost( + result.breakdown["more_product"], + 12.0 + (20.0 - 12.0) * 0.30, + "more product compresses past 12", + 1e-6 + ) + + +func test_lethal_is_flagged_only_when_the_hit_finishes_the_target() -> void: + assert_true(resolver.resolve(_context(30.0, 0.0, 30.0)).is_lethal, "exactly lethal counts") + assert_true(resolver.resolve(_context(31.0, 0.0, 30.0)).is_lethal, "overkill counts") + assert_false(resolver.resolve(_context(29.9, 0.0, 30.0)).is_lethal, "a survivable hit does not") + + +func test_breakdown_reproduces_the_final_number() -> void: + var context := _context(12.0, 25.0) + context.skill_multiplier = 1.4 + context.additive_modifiers = [0.3] as Array[float] + context.more_modifiers = [0.25] as Array[float] + context.conditional_modifiers = [0.1] as Array[float] + context.crit_damage = 2.0 + context.force_crit = 1 + var result := resolver.resolve(context) + var breakdown := result.breakdown + var recomputed: float = ( + float(breakdown["base_damage"]) + * float(breakdown["skill_multiplier"]) + * (1.0 + float(breakdown["additive_sum"])) + * float(breakdown["more_product"]) + * float(breakdown["conditional_product"]) + * float(breakdown["crit_multiplier"]) + * float(breakdown["mitigation_factor"]) + * float(breakdown["vulnerability"]) + ) + assert_almost(recomputed, result.final_damage, "breakdown multiplies back to final damage", 1e-6) diff --git a/tests/cases/test_integrity.gd b/tests/cases/test_integrity.gd new file mode 100644 index 0000000..58a1ef5 --- /dev/null +++ b/tests/cases/test_integrity.gd @@ -0,0 +1,105 @@ +extends TestCase +## IntegrityService and the structural/momentary split. +## +## The first test is the one that matters most: it is the bug the research +## report was written to fix. + +var balance: BalanceConfig +var state: RunState + +func before_each() -> void: + balance = make_balance() + state = RunState.create(balance, 1) + + +func test_intact_run_starts_at_full_integrity() -> void: + assert_almost(state.integrity(), 1.0, "a fresh body is whole") + + +func test_current_hp_changes_do_not_move_integrity() -> void: + var before := state.integrity() + state.apply_damage(70.0) + assert_almost(state.integrity(), before, "taking damage is momentary, not structural") + assert_almost(state.hp_ratio(), 0.30, "the damage did land", 1e-6) + state.heal(70.0) + assert_almost(state.integrity(), before, "healing does not restore structure either") + + +func test_current_stamina_changes_do_not_move_integrity() -> void: + var before := state.integrity() + state.spend_stamina(80.0) + assert_almost(state.integrity(), before, "spending stamina is momentary") + + +func test_max_hp_loss_lowers_integrity_by_its_weight() -> void: + state.scale_max_hp(0.85) + # h drops 0.15, weighted 0.34 + assert_almost(state.integrity(), 1.0 - 0.34 * 0.15, "integrity falls by weight * component drop", 1e-6) + + +func test_every_structural_stat_lowers_integrity() -> void: + var checks := { + "max stamina": func() -> void: state.scale_max_stamina(0.5), + "armour": func() -> void: state.set_armour(0.0), + "recovery": func() -> void: state.set_stamina_recovery(6.0), + "lock": func() -> void: state.add_structural_lock(&"no_healing"), + "tax": func() -> void: state.add_action_tax(&"dodge_costs_more"), + } + for label in checks: + var fresh := RunState.create(balance, 1) + state = fresh + (checks[label] as Callable).call() + assert_less(state.integrity(), 1.0, "%s should reduce integrity" % label) + + +func test_buffs_cannot_push_integrity_above_the_intact_ceiling() -> void: + state.scale_max_hp(3.0) + state.set_armour(500.0) + state.set_stamina_recovery(90.0) + assert_almost(state.integrity(), 1.0, "components are capped at 1 — no laundering a sacrifice") + + +func test_a_buff_cannot_offset_a_structural_loss() -> void: + state.scale_max_hp(0.5) + var wounded := state.integrity() + state.set_armour(400.0) + assert_almost(state.integrity(), wounded, "over-capped armour cannot repay lost lifespan") + + +func test_structural_floors_are_respected() -> void: + for _i in range(40): + state.scale_max_hp(0.5) + state.scale_max_stamina(0.5) + assert_almost(state.max_hp(), balance.min_max_hp, "max HP stops at the floor") + assert_almost(state.max_stamina(), balance.min_max_stamina, "max stamina stops at the floor") + + +func test_snapshot_restore_round_trips_exactly() -> void: + state.scale_max_hp(0.7) + state.add_additive(0.25) + state.multiply_more(1.4) + state.add_imbalance(31.0) + state.record_sacrifice(&"probe", [&"attack"] as Array[StringName]) + state.apply_damage(12.0) + var snapshot := state.snapshot() + var digest := state.snapshot_hash() + + state.scale_max_hp(0.1) + state.add_imbalance(50.0) + assert_true(state.snapshot_hash() != digest, "the disturbance actually changed the state") + + state.restore(snapshot) + assert_equal(state.snapshot_hash(), digest, "restore reproduces the exact snapshot") + assert_almost(state.integrity(), float(snapshot["integrity"]), "restore recomputes derived values") + + +func test_clone_is_detached_from_the_original() -> void: + var copy := state.clone() + copy.scale_max_hp(0.1) + copy.add_imbalance(99.0) + assert_almost(state.max_hp(), balance.base_max_hp, "the original max HP is untouched") + assert_almost(state.imbalance(), 0.0, "the original imbalance is untouched") + + +func test_effective_hp_follows_the_equivalent_life_model() -> void: + assert_almost(state.effective_hp(), 100.0 * 1.1, "EHP = max HP * (1 + armour/100)", 1e-6) diff --git a/tests/cases/test_rng.gd b/tests/cases/test_rng.gd new file mode 100644 index 0000000..bb85350 --- /dev/null +++ b/tests/cases/test_rng.gd @@ -0,0 +1,60 @@ +extends TestCase +## RNGService determinism and stream isolation. + +func _sequence(stream: StringName, count: int) -> Array[float]: + var values: Array[float] = [] + for _i in range(count): + values.append(RNGService.randf(stream)) + return values + + +func test_same_seed_reproduces_the_same_sequence() -> void: + RNGService.configure(20260802) + var first := _sequence(RNGService.STREAM_SACRIFICE, 24) + RNGService.configure(20260802) + var second := _sequence(RNGService.STREAM_SACRIFICE, 24) + assert_equal(first, second, "identical seeds produce identical draws") + + +func test_different_seeds_diverge() -> void: + RNGService.configure(1) + var first := _sequence(RNGService.STREAM_SACRIFICE, 8) + RNGService.configure(2) + var second := _sequence(RNGService.STREAM_SACRIFICE, 8) + assert_true(first != second, "a different seed produces a different sequence") + + +func test_streams_are_independent() -> void: + # Draining one stream must not shift another: this is what stops an extra + # loot roll from changing which sacrifices appear. + RNGService.configure(4242) + var baseline := _sequence(RNGService.STREAM_SACRIFICE, 6) + + RNGService.configure(4242) + for _i in range(50): + RNGService.randf(RNGService.STREAM_COMBAT) + var after_noise := _sequence(RNGService.STREAM_SACRIFICE, 6) + + assert_equal(after_noise, baseline, "combat draws do not perturb the sacrifice stream") + + +func test_streams_with_different_names_differ() -> void: + RNGService.configure(99) + var sacrifice := _sequence(RNGService.STREAM_SACRIFICE, 6) + RNGService.configure(99) + var spawn := _sequence(RNGService.STREAM_SPAWN, 6) + assert_true(sacrifice != spawn, "stream name is mixed into the derived seed") + + +func test_randi_range_stays_within_bounds_and_is_reproducible() -> void: + RNGService.configure(7) + var first: Array[int] = [] + for _i in range(40): + var value := RNGService.randi_range(RNGService.STREAM_SPAWN, 3, 9) + assert_true(value >= 3 and value <= 9, "value %d is inside [3, 9]" % value) + first.append(value) + RNGService.configure(7) + var second: Array[int] = [] + for _i in range(40): + second.append(RNGService.randi_range(RNGService.STREAM_SPAWN, 3, 9)) + assert_equal(first, second, "integer draws replay identically") diff --git a/tests/cases/test_run_loop.gd b/tests/cases/test_run_loop.gd new file mode 100644 index 0000000..e28fc4b --- /dev/null +++ b/tests/cases/test_run_loop.gd @@ -0,0 +1,175 @@ +extends TestCase +## End-to-end: the M1 loop, plus the scene smoke checks. +## +## This case is the one that would notice if the game stopped being playable — +## it drives the real Main scene through wave, sacrifice, boss, victory, +## restart and death. + +const MAIN_SCENE := "res://scenes/main.tscn" +const CRITICAL_SCENES := [ + "res://scenes/main.tscn", + "res://scenes/arena.tscn", + "res://actors/player/player.tscn", + "res://actors/enemies/enemy_base.tscn", + "res://actors/enemies/ghost_melee.tscn", + "res://actors/boss/boss.tscn", + "res://ui/hud.tscn", + "res://ui/sacrifice_panel.tscn", + "res://ui/result_screen.tscn", + "res://ui/debug_panel.tscn", +] + +var _main: Main + +func after_each() -> void: + tree.paused = false + if _main != null and is_instance_valid(_main): + _main.queue_free() + _main = null + +func _launch() -> RunCoordinator: + _main = (load(MAIN_SCENE) as PackedScene).instantiate() + tree.root.add_child(_main) + await step_physics(2) + return _main.coordinator() + +func _clear_wave(coordinator: RunCoordinator) -> void: + for enemy in coordinator.live_enemies(): + enemy.die(&"test") + await step_physics(2) + + +func test_critical_scenes_instantiate_without_missing_dependencies() -> void: + for path in CRITICAL_SCENES: + var packed: PackedScene = load(path) + assert_not_null(packed, "%s loads" % path) + if packed == null: + continue + var instance := packed.instantiate() + assert_not_null(instance, "%s instantiates" % path) + if instance != null: + instance.free() + + +func test_main_scene_boots_into_a_playable_wave() -> void: + var coordinator := await _launch() + assert_equal(coordinator.phase(), RunCoordinator.PHASE_WAVE, "boots straight into the wave") + assert_not_null(coordinator.player(), "a player exists") + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "the wave spawned the configured number of enemies" + ) + var state := coordinator.state() + assert_almost(state.current_hp(), state.max_hp(), "the player starts intact") + assert_equal(state.run_status(), RunState.STATUS_ACTIVE, "run is active") + + +func test_player_light_attack_damages_an_enemy_through_the_hitbox() -> void: + var coordinator := await _launch() + var player := coordinator.player() + var enemy := coordinator.live_enemies()[0] + enemy.acquire_target(null) + enemy.global_position = player.global_position + Vector2(20.0, 0.0) + await step_physics(2) + var before := enemy.current_hp() + + Input.action_press(&"light_attack") + await step_physics(2) + Input.action_release(&"light_attack") + # Long enough to cover wind-up plus the active window. + await step_physics(30) + + assert_less(enemy.current_hp(), before, "the swing actually connected") + + +func test_clearing_the_wave_offers_the_sacrifice() -> void: + var coordinator := await _launch() + await _clear_wave(coordinator) + assert_equal(coordinator.phase(), RunCoordinator.PHASE_SACRIFICE, "wave clear offers a choice") + assert_true(tree.paused, "the run pauses while the player decides") + assert_not_null(coordinator.offered_sacrifice(), "a card is on offer") + + +func test_full_loop_reaches_victory_and_restarts() -> void: + var coordinator := await _launch() + await _clear_wave(coordinator) + + var before := coordinator.state().snapshot() + coordinator.confirm_sacrifice(coordinator.offered_sacrifice()) + await step_physics(2) + + assert_false(tree.paused, "confirming resumes the run") + assert_equal(coordinator.phase(), RunCoordinator.PHASE_BOSS, "the boss follows the sacrifice") + assert_not_null(coordinator.boss(), "the boss exists") + + var state := coordinator.state() + assert_less(state.max_hp(), float(before["max_hp"]), "structurally weaker") + assert_greater(state.dps_estimate(), float(before["dps_estimate"]), "offensively stronger") + assert_greater(state.imbalance(), float(before["imbalance"]), "imbalance rose") + + coordinator.boss().die(&"test") + await step_physics(2) + assert_equal(coordinator.phase(), RunCoordinator.PHASE_RESULT, "the run ends") + assert_equal(coordinator.state().run_status(), RunState.STATUS_VICTORY, "as a victory") + + coordinator.restart_run() + await step_physics(2) + var restarted := coordinator.state() + assert_equal(coordinator.phase(), RunCoordinator.PHASE_WAVE, "restart returns to the wave") + assert_almost(restarted.max_hp(), GameData.balance.base_max_hp, "structure is restored") + assert_true(restarted.sacrifice_history().is_empty(), "the sacrifice history is cleared") + assert_equal( + coordinator.live_enemies().size(), GameData.balance.wave_enemy_count, + "a fresh wave spawned" + ) + + +func test_player_death_ends_the_run_as_a_defeat() -> void: + var coordinator := await _launch() + coordinator.debug_damage(10000.0) + await step_physics(2) + assert_false(coordinator.state().is_alive(), "the player is dead") + assert_equal(coordinator.player().state_id(), PlayerState.DEAD, "player parked in DEAD") + assert_equal(coordinator.phase(), RunCoordinator.PHASE_RESULT, "the run ended") + assert_equal(coordinator.state().run_status(), RunState.STATUS_DEFEAT, "as a defeat") + + +func test_restart_with_the_same_seed_reproduces_the_run_setup() -> void: + var coordinator := await _launch() + var seed_before := coordinator.state().run_seed() + var digest := coordinator.state().snapshot_hash() + coordinator.restart_run(true) + await step_physics(2) + assert_equal(coordinator.state().run_seed(), seed_before, "the seed is reused") + assert_equal(coordinator.state().snapshot_hash(), digest, "the opening state is identical") + + +func test_debug_commands_route_through_the_coordinator() -> void: + var coordinator := await _launch() + var state := coordinator.state() + + coordinator.debug_damage(20.0) + await step_physics(2) + assert_less(state.current_hp(), state.max_hp(), "debug damage landed") + + coordinator.debug_heal(1000.0) + assert_almost(state.current_hp(), state.max_hp(), "debug heal tops the player up") + + var enemies_before := coordinator.live_enemies().size() + coordinator.debug_spawn_reference_enemy() + assert_equal( + coordinator.live_enemies().size(), enemies_before + 1, "debug spawn added an enemy" + ) + + var digest := state.snapshot_hash() + var preview := coordinator.debug_preview_sacrifice() + assert_true(preview.ok, "debug preview succeeds") + assert_equal(state.snapshot_hash(), digest, "debug preview did not mutate the run") + + var applied := coordinator.debug_apply_sacrifice() + assert_true(applied.ok, "debug apply succeeds") + assert_true(preview.matches(applied), "the debug preview matched the debug apply") + + coordinator.begin_boss() + await step_physics(2) + assert_equal(coordinator.phase(), RunCoordinator.PHASE_BOSS, "debug can start the boss") diff --git a/tests/cases/test_sacrifice.gd b/tests/cases/test_sacrifice.gd new file mode 100644 index 0000000..e52841c --- /dev/null +++ b/tests/cases/test_sacrifice.gd @@ -0,0 +1,155 @@ +extends TestCase +## The sacrifice transaction: preview honesty, rollback, and the shipped card. + +var balance: BalanceConfig +var service: SacrificeService +var state: RunState + +func before_each() -> void: + balance = make_balance() + service = SacrificeService.new(balance) + state = RunState.create(balance, 7) + +func _shipped() -> SacrificeDefinition: + return GameData.definition(RunCoordinator.M1_SACRIFICE_ID) + +## A definition built in memory, so a test can describe the exact shape it needs +## without adding a card to the shipped library. +func _definition(overrides: Dictionary = {}) -> SacrificeDefinition: + var definition := SacrificeDefinition.new() + definition.id = &"test_card" + definition.display_name = "Test Card" + definition.strength = 2 + definition.roles = [SacrificeDefinition.ROLE_CONTINUATION] as Array[StringName] + definition.tags = [&"attack"] as Array[StringName] + definition.cost_max_hp_multiplier = 0.85 + definition.reward_additive = 0.18 + definition.reward_generic_more = true + for key in overrides: + definition.set(key, overrides[key]) + return definition + + +func test_preview_does_not_touch_the_real_state() -> void: + var digest := state.snapshot_hash() + var preview := service.preview(state, _shipped()) + assert_true(preview.ok, "the shipped card previews cleanly") + assert_equal(state.snapshot_hash(), digest, "preview ran on a clone") + + +func test_apply_matches_the_preview_it_showed() -> void: + var definition := _shipped() + var preview := service.preview(state, definition) + var applied := service.apply(state, definition) + assert_true(applied.ok, "apply succeeded") + assert_true(preview.matches(applied), "preview and apply describe the same transaction") + assert_almost(preview.gain, applied.gain, "gain is identical", 1e-9) + for key in preview.deltas: + assert_almost( + float(applied.deltas[key]), float(preview.deltas[key]), + "delta %s matches the preview" % key, 1e-9 + ) + + +func test_shipped_card_trades_structure_for_damage() -> void: + var before := state.snapshot() + var result := service.apply(state, _shipped()) + assert_true(result.ok, "apply succeeded") + assert_less(state.max_hp(), float(before["max_hp"]), "max lifespan falls") + assert_less(state.integrity(), float(before["integrity"]), "structural integrity falls") + assert_less(state.effective_hp(), float(before["effective_hp"]), "effective HP falls") + assert_greater(state.additive_sum(), float(before["additive_sum"]), "attack additive rises") + assert_greater(state.more_product(), float(before["more_product"]), "damage multiplier rises") + assert_greater(state.dps_estimate(), float(before["dps_estimate"]), "estimated DPS rises") + assert_greater(state.imbalance(), float(before["imbalance"]), "imbalance rises") + + +func test_shipped_card_values_come_from_the_data_file() -> void: + var definition := _shipped() + assert_not_null(definition, "the shipped card loads from res://data/sacrifices") + service.apply(state, definition) + assert_almost( + state.max_hp(), balance.base_max_hp * definition.cost_max_hp_multiplier, + "max HP uses the multiplier from the .tres, not a literal in script", 1e-6 + ) + assert_almost( + state.additive_sum(), definition.reward_additive, + "additive reward comes from the .tres", 1e-6 + ) + assert_almost( + state.imbalance(), definition.imbalance_flat, + "authored imbalance wins over the derived formula", 1e-6 + ) + + +func test_reward_is_priced_after_the_cost() -> void: + # G = g_S * (0.55 + 1.45 * D) * Q, with D taken from post-cost integrity. + var definition := _shipped() + var result := service.apply(state, definition) + var integrity_after_cost := IntegrityService.compute( + { + "max_hp": balance.base_max_hp * definition.cost_max_hp_multiplier, + "max_stamina": balance.base_max_stamina, + "armour": balance.base_armour, + "stamina_recovery": balance.base_stamina_recovery, + "major_locks": 0, + "action_taxes": 0, + }, + balance + ) + var deficiency := pow(1.0 - integrity_after_cost, balance.deficiency_exponent) + var expected := balance.strength_gain[definition.strength] * ( + balance.reward_base + balance.reward_slope * deficiency + ) + assert_almost(result.gain, expected, "gain uses post-cost integrity", 1e-6) + + +func test_a_more_broken_body_buys_a_bigger_reward() -> void: + var healthy := RunState.create(balance, 1) + var broken := RunState.create(balance, 1) + broken.scale_max_stamina(0.4) + broken.set_armour(0.0) + + var healthy_gain := service.preview(healthy, _definition()).gain + var broken_gain := service.preview(broken, _definition()).gain + assert_greater(broken_gain, healthy_gain, "the sharper trade needs a more damaged body") + + +func test_failed_transaction_rolls_the_state_back_whole() -> void: + # Already damaged, so an integrity component is below its cap and can move. + state.scale_max_hp(0.5) + # A cost multiplier above 1 *raises* max HP, which no sacrifice may do. + var malformed := _definition({"cost_max_hp_multiplier": 1.5}) + var digest := state.snapshot_hash() + var result := service.apply(state, malformed) + assert_false(result.ok, "a sacrifice that restores integrity is rejected") + assert_equal(state.snapshot_hash(), digest, "no half-applied state survives the failure") + assert_true(state.sacrifice_history().is_empty(), "a rejected card is not recorded") + + +func test_the_same_card_cannot_be_taken_twice() -> void: + assert_true(service.apply(state, _shipped()).ok, "first take succeeds") + var second := service.apply(state, _shipped()) + assert_false(second.ok, "second take is rejected") + assert_equal(second.failure_reason, "already taken", "with a readable reason") + + +func test_exclusive_groups_and_prerequisites_gate_offers() -> void: + var gated := _definition({"prerequisite_min_sacrifices": 2}) + assert_false(bool(service.can_offer(state, gated)["ok"]), "prerequisite blocks the offer") + + var locked := _definition({ + "cost_structural_locks": [&"a", &"b", &"c"] as Array[StringName], + }) + assert_false(bool(service.can_offer(state, locked)["ok"]), "lock budget blocks the offer") + + +func test_shipped_library_is_valid_data() -> void: + var definitions := GameData.all_definitions() + assert_greater(float(definitions.size()), 0.0, "the library is not empty") + var seen: Array[StringName] = [] + for definition in definitions: + for problem in definition.validate(): + fail(problem) + assert_false(seen.has(definition.id), "duplicate sacrifice id %s" % definition.id) + seen.append(definition.id) diff --git a/tests/framework/test_case.gd b/tests/framework/test_case.gd new file mode 100644 index 0000000..c770906 --- /dev/null +++ b/tests/framework/test_case.gd @@ -0,0 +1,78 @@ +class_name TestCase +extends RefCounted +## Minimal assertion base for the headless suite. +## +## Why not GUT or gdUnit4: the Prototype Development Pack requires the project +## to run and test straight after clone with no extra downloads (A2). Vendoring +## a plugin for a dozen asserts would cost more than it saves. If the suite +## outgrows this — parameterised cases, fixtures, mocking — swap in a real +## framework rather than growing this file. + +var failures: Array[String] = [] +var assertions: int = 0 +## Injected by the runner. Tests that need real frames await tree.process_frame +## or tree.physics_frame. +var tree: SceneTree + +## Test methods are discovered by name: anything starting with `test_`. +func method_names() -> Array[String]: + var names: Array[String] = [] + for method in get_method_list(): + var method_name: String = method["name"] + if method_name.begins_with("test_"): + names.append(method_name) + names.sort() + return names + +## Per-test setup hook. +func before_each() -> void: + pass + +func after_each() -> void: + pass + +## Advances the engine by `count` physics frames. Only the integration case +## needs this; unit tests should not. +func step_physics(count: int) -> void: + for _i in range(count): + await tree.physics_frame + +## 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: + return (load(GameData.BALANCE_PATH) as BalanceConfig).duplicate(true) + +func fail(message: String) -> void: + failures.append(message) + +func check(condition: bool, message: String) -> void: + assertions += 1 + if not condition: + fail(message) + +func assert_true(condition: bool, message: String) -> void: + check(condition, "expected true — %s" % message) + +func assert_false(condition: bool, message: String) -> void: + check(not condition, "expected false — %s" % message) + +func assert_equal(actual: Variant, expected: Variant, message: String) -> void: + check(actual == expected, "expected %s, got %s — %s" % [expected, actual, message]) + +func assert_almost(actual: float, expected: float, message: String, tolerance: float = 1e-6) -> void: + check( + absf(actual - expected) <= tolerance, + "expected %.9f, got %.9f (tolerance %.9f) — %s" % [expected, actual, tolerance, message] + ) + +func assert_greater(actual: float, threshold: float, message: String) -> void: + check(actual > threshold, "expected > %.6f, got %.6f — %s" % [threshold, actual, message]) + +func assert_less(actual: float, threshold: float, message: String) -> void: + check(actual < threshold, "expected < %.6f, got %.6f — %s" % [threshold, actual, message]) + +func assert_null(value: Variant, message: String) -> void: + check(value == null, "expected null, got %s — %s" % [value, message]) + +func assert_not_null(value: Variant, message: String) -> void: + check(value != null, "expected non-null — %s" % message) diff --git a/tests/test_runner.gd b/tests/test_runner.gd new file mode 100644 index 0000000..07c6837 --- /dev/null +++ b/tests/test_runner.gd @@ -0,0 +1,81 @@ +extends Node +## Headless test entry point. +## +## godot --headless --path . res://tests/test_runner.tscn +## +## Runs as a scene rather than via `--script` on purpose: a MainLoop script is +## compiled before the autoload singletons are registered, so any class that +## names EventBus or RNGService fails to compile and the whole graph silently +## degrades to null services. Booting a scene means the run behaves exactly like +## the game does. +## +## Discovers every res://tests/cases/*.gd, runs each `test_*` method on a fresh +## instance, and exits non-zero if any assertion failed. + +const CASE_DIR := "res://tests/cases" + +var _total: int = 0 +var _failed: int = 0 +var _assertions: int = 0 +var _failed_names: Array[String] = [] + +func _ready() -> void: + _run_all() + +func _run_all() -> void: + await get_tree().process_frame + + var script_paths := _discover_cases() + if script_paths.is_empty(): + push_error("No test cases found in %s" % CASE_DIR) + get_tree().quit(1) + return + + for path in script_paths: + await _run_case(path) + + print("") + print("──────────────────────────────────────────────") + print("%d tests, %d assertions, %d failed" % [_total, _assertions, _failed]) + for name in _failed_names: + print(" failed: %s" % name) + print("──────────────────────────────────────────────") + get_tree().quit(1 if _failed > 0 else 0) + +func _discover_cases() -> Array[String]: + var paths: Array[String] = [] + var dir := DirAccess.open(CASE_DIR) + if dir == null: + return paths + for file_name in dir.get_files(): + var script_name := file_name.trim_suffix(".remap") + if script_name.ends_with(".gd"): + paths.append("%s/%s" % [CASE_DIR, script_name]) + paths.sort() + return paths + +func _run_case(path: String) -> void: + var script: GDScript = load(path) + if script == null: + push_error("Cannot load test case %s" % path) + _failed += 1 + return + print("\n%s" % path.get_file()) + var probe: TestCase = script.new() + for method_name in probe.method_names(): + var instance: TestCase = script.new() + instance.tree = get_tree() + _total += 1 + instance.before_each() + # Works for both plain and coroutine test methods in Godot 4. + await instance.call(method_name) + await instance.after_each() + _assertions += instance.assertions + if instance.failures.is_empty(): + print(" ok %s" % method_name) + else: + _failed += 1 + _failed_names.append("%s::%s" % [path.get_file(), method_name]) + print(" FAIL %s" % method_name) + for failure in instance.failures: + print(" %s" % failure) diff --git a/tests/test_runner.tscn b/tests/test_runner.tscn new file mode 100644 index 0000000..902b3f1 --- /dev/null +++ b/tests/test_runner.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3 uid="uid://bnntestrunner1"] + +[ext_resource type="Script" path="res://tests/test_runner.gd" id="1_runner"] + +[node name="TestRunner" type="Node"] +script = ExtResource("1_runner") From 3e508a8b76323b7fa8d8bb3bfafabe2518f4efef Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 05:22:14 +0000 Subject: [PATCH 09/10] [C07][Claude] Keep sacrifice warnings non-redundant The warning line was restating effective HP, which the cost block on the card already shows. A warning that repeats the numbers next to it trains players to skip warnings, and the sacrifice contract needs high-risk rule costs to land. Warnings now cover what the cost block does not: a high warning level, each rule lock and action tax by name, and the permanent structural loss with its integrity delta and the reminder that healing cannot undo it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- systems/sacrifice_service.gd | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/systems/sacrifice_service.gd b/systems/sacrifice_service.gd index 40966f7..84f81e3 100644 --- a/systems/sacrifice_service.gd +++ b/systems/sacrifice_service.gd @@ -14,6 +14,9 @@ extends RefCounted ## identical `_run_transaction`. `preview` runs it on a clone. There is no ## second code path, so a preview cannot lie. +## Float slack for the "a sacrifice cannot restore integrity" invariant. +const INTEGRITY_EPSILON := 1e-9 + var _balance: BalanceConfig func _init(balance: BalanceConfig) -> void: @@ -82,10 +85,18 @@ func _run_transaction(state: RunState, definition: SacrificeDefinition) -> Sacri result.before = before # 3. apply cost, 4. integrity recalculates inside each structural command - var cost_applied: Dictionary = _apply_cost(state, definition) - if not bool(cost_applied["ok"]): + var integrity_before: float = float(before["integrity"]) + _apply_cost(state, definition) + + # A sacrifice can never make the body more whole. If a card's data says + # otherwise — a designer typing 1.15 where 0.85 was meant — the transaction + # is rejected and rolled back rather than silently rewarding the player. + if state.integrity() > integrity_before + INTEGRITY_EPSILON: state.restore(before) - return SacrificeResult.failed(definition.id, cost_applied["reason"]) + return SacrificeResult.failed( + definition.id, + "cost raised integrity %.4f → %.4f" % [integrity_before, state.integrity()] + ) var components_after: Dictionary = IntegrityService.components_of( state.structural_snapshot(), _balance @@ -113,7 +124,7 @@ func _run_transaction(state: RunState, definition: SacrificeDefinition) -> Sacri return result -func _apply_cost(state: RunState, definition: SacrificeDefinition) -> Dictionary: +func _apply_cost(state: RunState, definition: SacrificeDefinition) -> void: if not is_equal_approx(definition.cost_max_hp_multiplier, 1.0): state.scale_max_hp(definition.cost_max_hp_multiplier) if not is_equal_approx(definition.cost_max_stamina_multiplier, 1.0): @@ -128,7 +139,6 @@ func _apply_cost(state: RunState, definition: SacrificeDefinition) -> Dictionary state.add_structural_lock(lock_id) for tax_id in definition.cost_action_taxes: state.add_action_tax(tax_id) - return {"ok": true, "reason": ""} func _apply_reward(state: RunState, definition: SacrificeDefinition, gain: float) -> void: @@ -213,10 +223,13 @@ func _warnings(definition: SacrificeDefinition, result: SacrificeResult) -> Arra warnings.append("Permanent rule lock: %s" % lock_id) for tax_id in definition.cost_action_taxes: warnings.append("Action tax: %s" % tax_id) - if float(result.deltas.get("effective_hp", 0.0)) < 0.0: + # Deliberately not restating the numbers already on the card: a warning that + # repeats the cost block trains players to skip warnings. + var integrity_delta := float(result.deltas.get("integrity", 0.0)) + if integrity_delta <= -0.01: warnings.append( - "Effective HP %.1f → %.1f" - % [result.before["effective_hp"], result.after["effective_hp"]] + "Permanent structural loss — integrity %+.3f, and healing cannot undo it" + % integrity_delta ) return warnings From 27bee02315485b4652a205730779b92529416b30 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 05:29:44 +0000 Subject: [PATCH 10/10] [C09][Claude] Finalize architecture docs and AI handoff Documentation now describes the code that exists rather than the code that was planned. - ARCHITECTURE.md: directory layout, scene tree, state ownership, the damage pipeline, the integrity formula, the sacrifice transaction ordering, the events/RNG rules, the player state machine with its extension points, the full pixel-art rendering configuration, and an explicit list of architectural gaps so absence is not mistaken for oversight. - INTERFACES.md: the first interface freeze. Every frozen signature and guarantee, the data contract, the list of files Codex must not modify, and the extension points that are open for work today. - DECISIONS.md: eleven ADRs, including deriving M1 from the Development Pack while leaving the PRD to the Game Director, the viewport deviation from import_presets.md, authored imbalance winning over the derived formula, the no-second-damage-path invariant that gives the rollback a real trigger, and why the Boss ships one attack. - TESTING.md: how to run the suite, why it runs as a scene rather than via --script, what is covered, and an honest list of what is not. - AI_HANDOFF.md: branch, playable status, implemented systems, frozen interfaces, protected files, the eight Codex tasks with their real blockers, known issues, test status, and a per-directory asset integration audit. - TASKS/README.md: C01-C09 marked done, X01-X08 marked ready or blocked with reasons, plus the framework work still owned by Claude. - README.md: how to launch and play, controls, debug keys, test commands. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jo4LHdYmCJBrqzgB5j8KGa --- README.md | 63 ++++++++- docs/AI_HANDOFF.md | 151 ++++++++++++++++++++-- docs/ARCHITECTURE.md | 266 +++++++++++++++++++++++++++++++++++++- docs/DECISIONS.md | 202 ++++++++++++++++++++++++++++- docs/INTERFACES.md | 298 ++++++++++++++++++++++++++++++++++++++++++- docs/TASKS/README.md | 69 +++++++--- docs/TESTING.md | 114 ++++++++++++++++- 7 files changed, 1118 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index db0a2cf..62b8df0 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,63 @@ # Nine Nether -Nine Nether is a private Godot 4.x greybox prototype exploring a core trade-off: sacrifice structural survival and action freedom for offensive power. +Nine Nether is a Godot 4 greybox prototype exploring a core trade-off: sacrifice structural survival and action freedom for offensive power. Every power has a price. -- **Phase:** repository and framework setup -- **Playable status:** no playable build yet -- **Engine/language:** Godot 4.x / GDScript +- **Phase:** M1 — playable vertical slice foundation +- **Playable status:** playable end to end (wave → sacrifice → boss → victory or death → restart) +- **Engine/language:** Godot 4.3 stable / GDScript - **Branches:** `main` contains stable playable releases; `develop` is the integration branch; work occurs on one `claude/*` or `codex/*` branch per task. -Start with [the PRD](docs/PRD.md), [Prototype Contract](docs/PROTOTYPE_CONTRACT.md), and [contributor rules](AGENTS.md). Follow [the GitHub workflow](docs/GITHUB_WORKFLOW.md), test and update [the handoff](docs/AI_HANDOFF.md), then open a pull request to `develop`. +## Running it -The next milestone is Claude's `claude/core-framework` branch. This repository is a private greybox prototype, not a finished or publicly supported game. +Open the project folder in Godot 4.3 and press F5, or from the command line: + +```bash +godot --path . +``` + +The main scene is `res://scenes/main.tscn`. There are no external dependencies — clone and run. + +### Controls + +| Action | Keys | +| --- | --- | +| Move | `A` / `D` or `←` / `→` | +| Jump | `Space` or `W` | +| Light attack | `J` or left mouse | +| Confirm sacrifice | `Enter` | +| Restart run | `R` | + +### Debug (development builds) + +| Key | Command | +| --- | --- | +| `F1` | Toggle the RunState readout (seed, phase, player state, every stat, snapshot hash) | +| `F2` / `F3` | Heal / damage the player | +| `F4` | Spawn a reference enemy next to the player | +| `F5` | Start the boss encounter | +| `F6` / `F7` | Preview / apply the sacrifice | +| `F8` | Toggle hit, hurt and body boxes | + +## Tests + +```bash +godot --headless --import +godot --headless --path . res://tests/test_runner.tscn +``` + +See [docs/TESTING.md](docs/TESTING.md). CI runs `repository-validation` and `godot-tests` on every pull request into `develop`. + +## Where to start reading + +Product first, then implementation: + +1. [PRD](docs/PRD.md) and [Prototype Contract](docs/PROTOTYPE_CONTRACT.md) — the product source of truth +2. [Architecture](docs/ARCHITECTURE.md) — what the code does and why +3. [Interfaces](docs/INTERFACES.md) — the frozen contracts and where to extend them +4. [Decisions](docs/DECISIONS.md) — the trade-offs already made +5. [AI handoff](docs/AI_HANDOFF.md) — current state, known issues, next tasks +6. [Contributor rules](AGENTS.md) and [GitHub workflow](docs/GITHUB_WORKFLOW.md) + +Art assets are governed by [docs/ART_SPEC.md](docs/ART_SPEC.md). All sprites are placeholders, marked with a magenta border. + +This repository is a greybox prototype, not a finished or publicly supported game. diff --git a/docs/AI_HANDOFF.md b/docs/AI_HANDOFF.md index d26648b..ef2bf06 100644 --- a/docs/AI_HANDOFF.md +++ b/docs/AI_HANDOFF.md @@ -1,16 +1,139 @@ # AI Handoff -- **Current phase:** Art asset pipeline complete; core framework not started. -- **Current playable state:** No playable build exists. -- **Completed repository work:** Collaboration rules, ownership, templates, task tracking, repository hygiene validation, and **prototype art asset pipeline** (63 placeholder sprites, 8 concept docs, master art spec, Godot import guide, all directory READMEs and metadata). -- **In progress:** Art PR (`workbuddy/prototype-art`) awaiting review/merge. -- **Next owner:** Claude. -- **Next task:** Create `claude/core-framework` from the latest `develop` branch. Art assets are ready in `assets/` — read `docs/ART_SPEC.md` and each directory's `metadata.md` for sprite sheet frame counts, sizes, and FPS values. -- **Frozen interfaces:** None; no gameplay interfaces are frozen yet. -- **Do not modify:** Product requirements without Game Director approval; `main`/`develop` directly; future frozen interfaces without the decision process; art concept documents (`docs/art/CONCEPT_*.md`) without consulting the Art Director. -- **Known blockers:** Approved detailed PRD and Prototype Contract source material is not present in the repository. -- **Art asset notes:** All PNGs in `assets/` are placeholders (magenta border). Replace by saving a new PNG with the same filename and dimensions. See `docs/ART_SPEC.md` Section 5 for replacement workflow. Godot import settings documented in `docs/art/GODOT_IMPORT_GUIDE.md` and `godot/import_presets.md`. -- **Last successful test:** Repository validation checks (2026-08-02). -- **Last updated:** 2026-08-02 (Australia/Melbourne). - -The PRD and Prototype Contract are the current highest-priority sources of truth. `docs/ART_SPEC.md` is the source of truth for all visual art assets. +- **Current branch:** `claude/core-framework` (PR open into `develop`, unmerged) +- **Current phase:** M1 — playable vertical slice foundation, delivered +- **Next owner:** Game Director (review), then Codex for X01–X08 +- **Last updated:** 2026-08-02 (Australia/Melbourne) + +--- + +## M1 playable status + +**Playable end to end.** Launch → arena → move, jump, attack → clear a wave of +three ghost soldiers → preview and accept one sacrifice → become measurably +sharper and structurally weaker → fight the prototype Boss → victory or death → +restart. + +Verified by the automated run-loop case and by capturing frames under a virtual +display during development. Engine: Godot 4.3 stable. + +## Implemented systems + +| System | State | +| --- | --- | +| `RunState` | Authoritative; private fields, named commands, snapshot/restore/clone/hash | +| `CombatResolver` | Single damage path, bucket order frozen, breakdown reported | +| `IntegrityService` | Five-component structural formula; momentary state excluded | +| `SacrificeService` | Shared preview/apply transaction with whole-state rollback | +| `EventBus` | 11 signals with a uniform correlation envelope | +| `RNGService` | Per-subsystem streams derived from the run seed | +| `BalanceConfig` | Every tunable number, in `data/balance_config.tres` | +| Player | Move, jump, light attack, hit reaction, death, restart, camera follow, 5-state machine | +| `EnemyBase` | Detect, approach, telegraphed wind-up, attack, recovery, hurt, idempotent death | +| Reference enemy | 鬼卒 Ghost Soldier at X01 timings | +| `BossActor` | Extends `EnemyBase`; idle, chase, one telegraphed melee attack, health bar, victory | +| Arena | One fixed 1024×360 space, collision, spawns, parallax, camera bounds | +| UI | HUD, sacrifice preview/confirm, death and victory screens, restart | +| Debug | Panel plus 9 commands, all routed through `RunCoordinator` | +| Tests + CI | 52 tests / 205 assertions; `godot-tests` workflow | + +## Frozen interfaces + +See `docs/INTERFACES.md` section 1 for signatures and guarantees. + +`RunState` · `DamageContext` / `DamageResult` / `CombatResolver` · +`IntegrityService` · `SacrificeDefinition` · `SacrificeService` · +the Damageable shape (`actor_id` / `armour` / `current_hp` / `receive_damage`) · +`EnemyBase` · `EventBus` · `RNGService` · `RunCoordinator` · `Hitbox` / `Hurtbox` + +Changing any of them requires a `docs/DECISIONS.md` proposal and Claude review. + +## Files Codex must not modify + +``` +core/run_state.gd core/combat_resolver.gd +core/damage_context.gd core/damage_result.gd +core/soft_caps.gd core/derived_stats.gd +core/event_bus.gd core/rng_service.gd +core/game_data.gd core/hitbox.gd core/hurtbox.gd +systems/integrity_service.gd systems/sacrifice_service.gd +systems/sacrifice_definition.gd systems/sacrifice_result.gd +systems/run_coordinator.gd +actors/enemies/enemy_base.gd actors/enemies/enemy_base.tscn +actors/enemies/enemy_config.gd +actors/player/player.gd actors/player/player_state_machine.gd +actors/player/states/player_state.gd +scenes/main.gd project.godot +.github/workflows/repository-validation.yml +``` + +Also unchanged without their owner: product requirements (Game Director) and +`docs/art/CONCEPT_*.md` (Art Director). + +## Available Codex tasks + +All interfaces these depend on are now frozen. See `docs/TASKS/README.md`. + +| ID | Task | Depends on | Notes | +| --- | --- | --- | --- | +| X01 | Melee ghost, full behaviour | `EnemyBase` | The reference enemy is a starting point, not the finished X01 | +| X02 | Ranged ghost | `EnemyBase` | Art present: `ghost_archer_*`. Needs a projectile; do not add a second damage path | +| X03 | Charger ghost | `EnemyBase` | Art present: `corpse_beast_*` at 64×48 — needs its own collider sizes | +| X04 | Boss attack pack | `BossActor` | Blocked on art or a ruling — see gaps below | +| X05 | Sacrifice selection UI | `SacrificeService` | Three A/B/C slots; preview through the service, confirm through the coordinator | +| X06 | Twelve sacrifice definitions | `SacrificeDefinition` | Data only. S4/S5 cards need at least one rule-type cost | +| X07 | Debug and telemetry panel | `EventBus`, `RunCoordinator` | Event timeline and JSON/CSV export; the current panel is the baseline | +| X08 | Test expansion | harness | Stamina/dodge and same-death cases once those systems exist | + +Not yet unblocked, because Claude has not built the framework they extend: the +three-slot generator, `StaminaService`, `SameDeathController`, the world +director, and the Boss `PhaseController`. + +## Known issues + +1. **`assets/effects/*.png` are single-frame.** All four are 48×48 while + `assets/effects/metadata.md` specifies 2–4 frames (96×48 to 192×48). No VFX + are used in M1. WorkBuddy needs to re-export the strips. +2. **The Boss has one attack.** `assets/boss/` ships one attack sheet, so the + charge and the ground slam from A14/X04 are absent (ADR-010). The fight is a + readable pattern with one answer — thin on purpose, not by oversight. +3. **`docs/PRD.md` and `docs/PROTOTYPE_CONTRACT.md` are still stubs.** M1 was + derived from the Development Pack and research report under ADR-001. Land the + real documents before Codex starts, so X01–X08 have something to be reviewed + against. +4. **Stamina has no consumer** (ADR-011). The bar sits full during normal play. +5. **Imbalance is tracked but inert.** Nothing reads it yet; the world director + is the consumer. +6. **No telemetry persistence.** Events are emitted with a correlation envelope + but nothing writes them to disk (X07). +7. **No audio.** Out of scope for M1. +8. **Placeholder art everywhere.** Magenta border = placeholder, per + `docs/ART_SPEC.md` section 5. + +## Test status + +- **Last successful run:** 2026-08-02 — 52 tests, 205 assertions, 0 failures, + Godot 4.3 stable headless. +- **Command:** `godot --headless --import` then + `godot --headless --path . res://tests/test_runner.tscn` +- **CI:** `repository-validation` (unchanged) and `godot-tests` (new). Both + should be required checks on `develop`. + +## Asset integration gaps + +| Asset group | Status | +| --- | --- | +| `assets/player/` | All 6 sheets used; dimensions match `docs/ART_SPEC.md` exactly | +| `assets/enemy/ghost_melee_*` | All 5 sheets used | +| `assets/enemy/ghost_archer_*`, `corpse_beast_*` | Verified, unused — X02 and X03 | +| `assets/boss/` | idle, run, attack, hurt, death used; only one attack sheet exists | +| `assets/tiles/` | floor, stone_brick, brazier, tombstone used; `tile_wall`, `tile_wood_bridge`, `tile_ground_spike` unused (no platforms or hazards in M1) | +| `assets/background/` | bg_deep, bg_tree, bg_broken_flag, bg_chains all used in three parallax layers | +| `assets/ui/` | ui_health_bar, ui_stamina_bar used; `ui_frame`, `ui_minimap_frame` unused (no inventory, no minimap) | +| `assets/icons/ui/` | icon_hp, icon_boss, icon_sacrifice used; the other 8 unused | +| `assets/icons/weapons/` | All 6 unused — no weapon system in M1 | +| `assets/effects/` | **Unusable as animations** — see known issue 1 | + +Nothing was stretched, scaled by a non-integer factor, or resized. Regenerate +`SpriteFrames` with `python3 tools/generate_sprite_frames.py` after replacing a +sheet; it aborts if the new dimensions disagree with the spec. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7e162b6..2d44a0f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,267 @@ # Architecture -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE — M1 (playable vertical slice foundation)** +> **Owner:** Claude · **Branch:** `claude/core-framework` · **Engine:** Godot 4.3 stable / GDScript -No framework architecture has been implemented. Claude will document system boundaries, dependencies, state ownership, and extension points during `claude/core-framework` without overriding higher-priority product documents. +This document describes what the code actually does. It does not define product +rules; those come from `docs/PRD.md` and `docs/PROTOTYPE_CONTRACT.md`. + +--- + +## 1. Priorities + +From the Prototype Development Pack (A1): **runnable > testable > configurable > +extensible > elegant**. The architecture serves the current scope and does not +pre-build for systems that do not exist yet. + +Three rules shape almost every decision below: + +1. **One source of truth per calculation.** Damage is resolved in exactly one + place. Integrity is computed in exactly one place. The sacrifice preview and + the sacrifice apply are the same function. +2. **Structural and momentary state are different things.** Max HP is + structural; current HP is momentary. Confusing them is the bug the research + report was written to fix. +3. **UI observes, services mutate.** The HUD, the sacrifice card and the debug + panel all read state and call the coordinator. None of them writes a stat. + +--- + +## 2. Directory layout + +Follows Prototype Development Pack A3, plus `scenes/` (see ADR-009). + +| Path | Contents | Owner | +| --- | --- | --- | +| `core/` | EventBus, RNGService, GameData, RunState, BalanceConfig, damage pipeline, SoftCaps, DerivedStats, Hitbox/Hurtbox | Claude | +| `systems/` | IntegrityService, sacrifice definition/result/service, RunCoordinator | Claude | +| `actors/player/` | Player, state machine, action states, SpriteFrames | Claude | +| `actors/enemies/` | EnemyBase contract and scene, EnemyConfig schema, concrete enemies | Claude (base) / Codex (concrete) | +| `actors/boss/` | BossActor and scene | Codex + Claude review | +| `scenes/` | `main.tscn` (composition root), `arena.tscn` | Claude | +| `ui/` | HUD, sacrifice panel, result screen, debug panel, shape overlay | Codex | +| `data/` | `balance_config.tres`, `sacrifices/*.tres`, `actors/*.tres` | Shared, validated by tests | +| `tests/` | Harness and cases | Codex primary | +| `tools/` | Generators and asset utilities | Shared | + +--- + +## 3. Scene tree + +`scenes/main.tscn` matches Prototype Development Pack A4: + +``` +Main (Node2D, scenes/main.gd) ← composition root +├── Arena (scenes/arena.tscn) ← terrain, parallax, props, spawn markers, camera bounds +├── Actors (Node2D) ← player, wave enemies and boss are spawned here +├── RunCoordinator (systems/run_coordinator.gd) +├── UIRoot +│ ├── Hud +│ ├── SacrificePanel +│ └── ResultScreen +└── DebugRoot + ├── DebugPanel + └── 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. + +Actor scenes: + +- `actors/player/player.tscn` — CharacterBody2D + AnimatedSprite2D + body + collider + Hurtbox + AttackHitbox + Camera2D. +- `actors/enemies/enemy_base.tscn` — the 48×48 humanoid base; `ghost_melee.tscn` + inherits it and supplies SpriteFrames only. +- `actors/boss/boss.tscn` — standalone because the Boss is 96×96 and needs its + own collider sizes; it runs `BossActor`, which extends `EnemyBase`. + +No core calculation lives in `Main`, in `Arena` or in any UI node. + +--- + +## 4. State ownership + +| State | Owner | Lifetime | +| --- | --- | --- | +| Balance configuration | `GameData` (autoload) | Process | +| Sacrifice library | `GameData` (autoload) | Process | +| RNG streams | `RNGService` (autoload) | Re-seeded per run | +| Active `RunState` | `RunCoordinator` | One run | +| Enemy and Boss HP | The actor instance | One actor | +| Everything the UI shows | Nobody — it is read each frame | — | + +`GameData` holds the stateless services (`CombatResolver`, `SacrificeService`) +and the loaded data. It deliberately does **not** hold the active `RunState`: +keeping mutable run data out of an autoload is what stops "just read it from the +global" becoming the way every script talks to the run. + +### RunState + +Fields are private. Reads go through getters; writes go through named commands +(`apply_damage`, `heal`, `scale_max_hp`, `add_additive`, `add_imbalance`, …). +Structural commands all end in `recalculate_derived()`, which is the single +place integrity, effective HP and the DPS estimate are produced. + +`snapshot()` / `restore()` / `clone()` / `snapshot_hash()` support preview, +rollback, telemetry correlation and test assertions. + +**Structural** (feeds integrity): max HP, max stamina, armour, stamina recovery, +structural locks, action taxes. +**Momentary** (never feeds integrity): current HP, current stamina. + +--- + +## 5. Combat + +`CombatResolver.resolve(DamageContext) -> DamageResult` is the only damage path, +implementing research report 5.3: + +``` +raw = ATK × SkillMult × (1 + AddSum) × MoreProd × CondProd × CritMult +final = raw × 100 / (100 + max(0, ARM − Pen)) × Vulnerability +``` + +- `DamageContext` carries every input, so the resolver never reaches into a + scene node or an autoload for a gameplay value. +- `DamageResult.breakdown` records each stage, so a wrong number can be traced + to the bucket that produced it. A test multiplies the breakdown back to the + final damage. +- The crit roll is injectable (`CombatResolver._roll_provider`) and a context can + pin it (`crit_roll`) or force it (`force_crit`), which is what makes crit + behaviour testable rather than a coin flip. +- Soft caps live in `SoftCaps` and are shared with `DerivedStats`, so the HUD's + DPS estimate and an actual hit compress the same way. + +**Damageable shape.** `Player` and `EnemyBase` both expose +`actor_id() / armour() / current_hp() / receive_damage(DamageContext)`. An +attacker can therefore hit either side without special-casing, and neither side +computes its own damage. + +--- + +## 6. Integrity + +`IntegrityService.compute(structural, balance)` — research report 5.4: + +``` +I = clamp(0.34h + 0.24s + 0.16a + 0.14r + 0.12f, 0, 1) +h = min(1, HPmax/100) s = min(1, STmax/100) +a = min(1, (1+ARM/100)/(1+ARM0/100)) r = min(1, SR/12) +f = max(0, 1 − 0.12·majorLocks − 0.06·actionTaxes) +``` + +The service takes primitives rather than a `RunState`, which keeps it pure and +avoids a class-level cycle. Each component is capped at 1 so an ordinary buff +cannot push integrity above the intact-body ceiling or launder away a sacrifice. + +--- + +## 7. Sacrifice transaction + +`SacrificeService` runs one ordered transaction (Prototype Development Pack A9): + +1. validate (`can_offer`) +2. snapshot — the rollback point +3. apply cost +4. recalculate integrity (each structural command does this) +5. apply reward, priced against post-cost integrity +6. recalculate derived values +7. apply imbalance +8. publish the event +9. return `SacrificeResult` + +`preview(state, def)` runs it on `state.clone()`. `apply(state, def)` runs it on +the real state. **Same function.** A preview therefore cannot disagree with the +result — there is a test asserting field-by-field equality. + +Reward curve (5.5): `D = (1 − I)^1.35`, `G = g_S × (0.55 + 1.45·D) × Q`, with +`Q = clamp(1 + 0.12·synergy − 0.08·dilution, 0.85, 1.45)`. Pricing the reward +*after* the cost is what makes a more broken body buy a sharper blade. + +Cost score (5.6): `C = 100Δh + 80Δs + 60Δa + 50Δr` over integrity component +drops. Imbalance uses the card's authored `imbalance_flat` when set, otherwise +`ΔB = C × (0.75 + 0.05·S)` — see ADR-005. + +**Invariant:** a sacrifice may never raise integrity. If a card's data would do +so, the transaction fails and the state is restored whole (ADR-008). + +--- + +## 8. Events and randomness + +`EventBus` (autoload) carries `run_started`, `run_restarted`, `hit_dealt`, +`hit_taken`, `enemy_died`, `player_died`, `sacrifice_previewed`, +`sacrifice_applied`, `boss_started`, `boss_died`, `run_completed`. Every payload +is wrapped by `EventBus.context()` so telemetry can correlate without each +emitter inventing a shape. + +Events are for UI, telemetry and decoupled reactions **only**. Deterministic +core resolution uses explicit service calls. Moving a calculation onto a signal +is a defect. + +`RNGService` (autoload) gives each subsystem its own stream, seeded from the run +seed via FNV-1a over the stream name. An extra roll in one system therefore +cannot shift the sequence another system observes; there is a test for that. +Gameplay must never call `randi()`/`randf()` directly. + +--- + +## 9. Player state machine + +States: `Grounded`, `Airborne`, `Attacking`, `Hurt`, `Dead` — one small class +each under `actors/player/states/`. A state changes the player through helpers +on `Player` and requests a transition by returning the next state id; every +transition runs through `PlayerStateMachine._change_to`, so exit/enter cannot be +skipped and there is one place to look when a state sticks. + +Documented extension points, deliberately not stubbed: + +- **Dodge** — three phases with i-frames; entered from Grounded/Airborne, gated + on a StaminaService that does not exist yet. +- **Exhausted** — entered from anywhere at zero stamina; blocks dodge, skill and + heavy attack while leaving light attack available. +- **SameDeath** — must be checked at the top of `on_death`, before `DEAD`, since + it is a lethal-damage interception rather than a state you walk into. + +--- + +## 10. Pixel art rendering configuration + +| Setting | Value | Reason | +| --- | --- | --- | +| Viewport | 640 × 360 | 48px player ≈ 13% of screen height; the 320×180 backdrop tiles cleanly | +| Window | 1280 × 720 | Exactly 2× the viewport | +| `stretch/mode` | `viewport` | Renders at the low resolution and upscales the whole frame | +| `stretch/scale_mode` | `integer` | No fractional scaling, so no shimmer at any window size | +| `default_texture_filter` | 0 (Nearest) | No smoothing on any canvas texture | +| `snap_2d_transforms_to_pixel` | true | Sprites land on whole pixels | +| `snap_2d_vertices_to_pixel` | true | No sub-pixel seams between tiles | +| `msaa_2d` | 0 | Anti-aliasing would soften pixel edges | +| Renderer | `gl_compatibility` | As recommended in `godot/import_presets.md` | +| Texture import | lossless, no mipmaps, `detect_3d/compress_to = 0` | Matches `docs/art/GODOT_IMPORT_GUIDE.md` | + +This deviates from the 1280×720 viewport suggested in `godot/import_presets.md` +— see ADR-002. No asset is scaled by a non-integer factor and no sprite +dimensions were changed. + +`SpriteFrames` resources are generated by `tools/generate_sprite_frames.py` from +the frame table in `docs/ART_SPEC.md`; the generator fails if a sheet's real +dimensions disagree with the table (ADR-004). + +--- + +## 11. Known architectural gaps + +Listed so nobody mistakes absence for oversight. Each is scoped to a later task. + +- No StaminaService: stamina regenerates and is displayed, but nothing spends it + (light attack costs 0 by design). Dodge and heavy attack are its first + consumers. +- No world director: imbalance is tracked but does not yet drive enemy pressure. +- No same-death controller. +- 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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index e091f7f..876fa15 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -1,6 +1,6 @@ # Architecture Decisions -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE** Append decisions in this format; do not rewrite accepted history. @@ -13,3 +13,203 @@ Append decisions in this format; do not rewrite accepted history. - **Decision:** - **Consequences:** - **Interfaces affected:** + +--- + +## ADR-001: Derive M1 scope from the Prototype Development Pack, not from a rewritten PRD + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** `docs/PRD.md` and `docs/PROTOTYPE_CONTRACT.md` are still + `AWAITING GAME DIRECTOR SOURCE MATERIAL` stubs. The Game Director supplied + *《九幽》白盒原型联合开发包 v1.0* and *《残躯换锋》2D 动作肉鸽核心机制深度研究报告* as + reference material for M1. `AGENTS.md` forbids engineers inventing product + rules, and the M1 brief forbids rewriting the PRD or replacing the Contract. +- **Decision:** Implement M1 against the Development Pack (Part I contract, + Part II architecture) and the research report's formulas. Leave `PRD.md` and + `PROTOTYPE_CONTRACT.md` untouched. Record every derived number's source + section in code comments and in `docs/ARCHITECTURE.md`. +- **Consequences:** The repository's declared highest source of truth is still + a stub, so the traceability chain runs through this ADR rather than through + the PRD. **The Game Director should land the real PRD and Prototype Contract + before Codex starts X01–X08**, so later work has a document to be reviewed + against rather than an ADR. +- **Interfaces affected:** none directly; all balance constants in + `data/balance_config.tres`. + +## ADR-002: 640×360 viewport upscaled to 1280×720 with integer scaling + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** `godot/import_presets.md` recommends `viewport_width = 1280`, + `viewport_height = 720` with `stretch/mode = viewport`. At that setting the + game renders 1:1, so a 48×48 player is about 6% of screen height and a 32px + tile is barely visible — the art reads as tiny rather than as pixel art. +- **Decision:** Viewport 640×360, window 1280×720, `stretch/mode = viewport`, + `stretch/scale_mode = integer`. Nearest filtering, no mipmaps, 2D pixel + snapping on, MSAA off, `gl_compatibility` renderer as recommended. +- **Consequences:** Every asset renders at an exact integer scale at any window + size; no blur and no shimmer during camera movement. 320×180 `bg_deep` tiles + cleanly at 1:1. The recommendation in `godot/import_presets.md` is superseded + for the viewport size only; every other setting in that file is honoured. + Asset dimensions were not changed. +- **Interfaces affected:** `project.godot` `[display]` and `[rendering]`. + +## ADR-003: Use the WorkBuddy pixel art rather than the whitebox rectangles + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack P12 specifies whitebox readability — blue-grey + rectangle player, dark red enemies, purple Boss. Since that pack was written, + WorkBuddy delivered a complete placeholder sprite pipeline (62 PNGs plus + `docs/ART_SPEC.md`, which declares itself the source of truth for visual + assets), and the M1 brief requires those assets to be used. +- **Decision:** Use the pixel art. Keep P12's *readability* requirements — the + enemy wind-up is a colour flash driven by `EnemyConfig.telegraph_tint`, and + F8 draws hit/hurt/body boxes. +- **Consequences:** The prototype looks further along than it is; the magenta + placeholder borders make that visible. Readability obligations are met by + behaviour rather than by flat colours. +- **Interfaces affected:** none. + +## ADR-004: Generate SpriteFrames from the art spec instead of hand-authoring + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Sixteen animations across player, reference enemy and Boss come + to about 70 `AtlasTexture` sub-resources. Hand-writing them duplicates the + frame counts and FPS that already exist in `docs/ART_SPEC.md` and each + directory's `metadata.md`. +- **Decision:** `tools/generate_sprite_frames.py` owns the frame table and emits + `actors/*/**_frames.tres`. The generated files are committed. The generator + verifies each sheet's real pixel dimensions against the table and aborts on a + mismatch. +- **Consequences:** When WorkBuddy replaces a sheet, re-run the generator and + commit the diff. A sheet whose frame count silently changes fails loudly + instead of producing sliced animations. +- **Interfaces affected:** none. + +## ADR-005: Authored `imbalance_flat` wins over the derived imbalance formula + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Research report 5.6 gives `ΔB = C × (0.75 + 0.05·S)`. For + 断寿之契 that yields 12.75, but the card table in section 9.2 lists +16, and + the JSON example in 13.3 carries an explicit `imbalanceFlat`. The two sources + disagree, and the card tables are the ones a designer will tune. +- **Decision:** `SacrificeDefinition.imbalance_flat` is authoritative when + greater than zero; otherwise the formula applies. The derived cost score `C` + is always computed and returned in `SacrificeResult.cost_score` for telemetry + and for the future slot `ValueRatio` scoring. +- **Consequences:** Designers tune imbalance per card without fighting a + formula, and the formula remains the default for cards that do not specify. + Reconcile if the Game Director rules one way. +- **Interfaces affected:** `SacrificeDefinition`, `SacrificeService`. + +## ADR-006: In-repo test harness, run as a scene rather than via `--script` + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack A2 requires the project to run and test straight + after clone with no extra downloads. Vendoring GUT or gdUnit4 for a dozen + assertions costs more than it saves. Separately, running the suite through + `godot --headless --script res://tests/run_tests.gd` was tried and failed: + a MainLoop script is compiled *before* the autoload singletons are registered, + so `CombatResolver` (which names `RNGService`) and `SacrificeService` (which + names `EventBus`) fail to compile, `GameData` degrades to null services, and + most tests fail with `Nonexistent function ... in base 'Nil'`. +- **Decision:** A ~90 line harness (`tests/framework/test_case.gd` + + `tests/test_runner.gd`) launched as a scene: + `godot --headless --path . res://tests/test_runner.tscn`. +- **Consequences:** No plugin dependency and no parse-order trap; the test + process boots exactly like the game. If the suite outgrows plain assertions — + parameterised cases, fixtures, mocking — swap in gdUnit4 rather than growing + the harness. +- **Interfaces affected:** `docs/TESTING.md`, `.github/workflows/godot-tests.yml`. + +## ADR-007: No TileMap in M1 + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** The arena is one fixed space with four collision rectangles. + Authoring a `TileSet` resource plus `TileMapLayer` data by hand adds a lot of + generated content for a layout that does not vary. +- **Decision:** Terrain visuals are `Sprite2D` nodes with repeating texture + regions; collision is explicit `StaticBody2D` + `RectangleShape2D`. +- **Consequences:** Simpler diffs and exact collision bounds. If the arena ever + needs varied terrain, one-way platforms or hazards, move to `TileMapLayer` — + the spawn markers and camera bounds on `Arena` do not change. +- **Interfaces affected:** `scenes/arena.gd` (`camera_bounds`, + `enemy_spawn_points`, `player_spawn`, `boss_spawn`). + +## ADR-008: A sacrifice may never raise structural integrity + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** The transaction needs a real failure mode for its rollback path + to be meaningful. The most likely data defect is a designer typing a cost + multiplier above 1 — `1.15` where `0.85` was meant — which would *increase* a + structural stat and quietly hand the player a reward for free. +- **Decision:** After the cost step, `SacrificeService` compares integrity with + its pre-transaction value. If integrity rose, the transaction fails with a + readable reason and the state is restored from the snapshot. +- **Consequences:** The rollback path is exercised by a real, reachable defect + rather than being dead code. Note the guard only trips when the affected + integrity component is below its cap, since components are clamped at 1. +- **Interfaces affected:** `SacrificeService.apply` / `preview` failure results. + +## ADR-009: `scenes/` directory beyond the A3 layout + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack A3 lists `core/`, `systems/`, `actors/*`, `ui/`, + `data/`, `tests/` and `docs/`, but A4 requires `Main.tscn` and `Arena.tscn`, + which belong to none of them. +- **Decision:** Add `res://scenes/` for `main.tscn`, `main.gd`, `arena.tscn` and + `arena.gd`. Claude-owned, same rules as `core/`. +- **Consequences:** One directory beyond the pack; the ownership table in + `docs/ARCHITECTURE.md` covers it. +- **Interfaces affected:** none. + +## ADR-010: The prototype Boss ships one attack + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Development Pack A14/X04 asks for three moves and two phases. The + M1 brief allows a second move "only if supported by the available animation + assets". `assets/boss/` contains exactly one attack sheet + (`boss_attack.png`, 5 frames) alongside idle, run, hurt and death. +- **Decision:** Ship one telegraphed melee attack. Do not fake a charge by + replaying the run cycle and do not add a phase controller. +- **Consequences:** The Boss fight is thin — a readable pattern with one answer. + Codex X04 adds the charge and the ground slam; it needs either new art from + WorkBuddy or an explicit ruling that reusing the run cycle for a charge is + acceptable. +- **Interfaces affected:** none; `BossActor` extends `EnemyBase` unchanged. + +## ADR-011: Stamina is structural in M1 with no consumer + +- **Status:** Accepted +- **Date:** 2026-08-02 +- **Owner:** Claude +- **Context:** Research report 6.1 makes light attack cost 0 stamina on purpose, + so a player is never fully locked out of acting. M1's only player action is + the light attack, so nothing spends stamina. But a sacrifice that cuts max + stamina must still be visible, and `IntegrityService` weights it at 0.24. +- **Decision:** `RunState` tracks current and max stamina with delayed + regeneration; the HUD shows the bar; `Player.spend_stamina` exists and is + routed. No StaminaService, no exhaustion lock, no dodge. +- **Consequences:** The stamina bar sits at full during normal play, which is + correct rather than broken. A future StaminaService can wrap + `Player.spend_stamina` without changing its callers. +- **Interfaces affected:** `RunState` stamina commands, `Player.spend_stamina`. diff --git a/docs/INTERFACES.md b/docs/INTERFACES.md index 9b75ea5..0224289 100644 --- a/docs/INTERFACES.md +++ b/docs/INTERFACES.md @@ -1,5 +1,299 @@ # Interfaces -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE — first interface freeze (M1)** +> **Owner:** Claude · **Frozen on:** `claude/core-framework` -No gameplay interfaces are frozen. Claude will record released contracts, schemas, invariants, and extension examples here. Codex module work must wait for the relevant interface freeze. +Everything in section 1 is **frozen**. Codex may call it, extend it at the +documented points, and read it — but not change a signature, a field name or an +ordering guarantee without opening a proposal in `docs/DECISIONS.md` and waiting +for Claude review, per `AGENTS.md`. + +Section 3 lists the files Codex must not modify at all. Section 4 lists the +extension points that are open for work right now. + +--- + +## 1. Frozen contracts + +### 1.1 `RunState` — `core/run_state.gd` + +Authoritative run-level player state. + +```gdscript +static func create(balance: BalanceConfig, run_seed: int) -> RunState + +# reads +func max_hp() -> float func current_hp() -> float +func max_stamina() -> float func current_stamina() -> float +func armour() -> float func stamina_recovery() -> float +func attack() -> float func additive_sum() -> float +func more_product() -> float func crit_rate() -> float +func crit_damage() -> float func attack_speed() -> float +func structural_locks() -> Array[StringName] +func action_taxes() -> Array[StringName] +func integrity() -> float func imbalance() -> float +func effective_hp() -> float func dps_estimate() -> float +func sacrifice_history() -> Array[StringName] +func build_tags() -> Dictionary func tag_count(tag) -> int +func run_seed() -> int func run_status() -> StringName +func hp_ratio() -> float func stamina_ratio() -> float +func is_alive() -> bool func is_guttering() -> bool +func structural_snapshot() -> Dictionary func offence_snapshot() -> Dictionary + +# momentary commands +func apply_damage(amount: float) -> float func heal(amount: float) -> float +func set_current_hp(value: float) -> void +func spend_stamina(amount: float) -> void +func regenerate_stamina(amount: float) -> void +func set_run_status(status: StringName) -> void + +# structural commands (each ends in recalculate_derived) +func scale_max_hp(factor: float) -> void +func scale_max_stamina(factor: float) -> void +func set_armour(value: float) -> void +func set_stamina_recovery(value: float) -> void +func add_additive(value: float) -> void func multiply_more(factor: float) -> void +func add_crit_rate(value: float) -> void func add_crit_damage(value: float) -> void +func multiply_attack_speed(factor: float) -> void +func add_structural_lock(lock_id: StringName) -> void +func add_action_tax(tax_id: StringName) -> void +func add_imbalance(value: float) -> void +func record_sacrifice(id: StringName, tags: Array[StringName]) -> void +func recalculate_derived() -> void + +# snapshots +func snapshot() -> Dictionary func restore(snap: Dictionary) -> void +func clone() -> RunState func snapshot_hash() -> String +``` + +**Invariants.** + +- Fields are private. There is no path to a stat that is not a command above. +- Current HP and current stamina never affect integrity. +- Healing never restores structure. +- Structural stats are clamped to the `BalanceConfig` floors. +- Derived values are produced only by `recalculate_derived()`. +- `clone()` shares the `BalanceConfig` and nothing else. + +**UI rule.** UI code may call reads only. Mutations go through +`RunCoordinator`. + +### 1.2 Damage pipeline — `core/damage_context.gd`, `core/damage_result.gd`, `core/combat_resolver.gd` + +```gdscript +CombatResolver.new(balance: BalanceConfig, roll_provider := Callable()) +func resolve(ctx: DamageContext) -> DamageResult +func player_attack_context(state, target_id, target_armour, target_current_hp, + skill_multiplier) -> DamageContext +``` + +`DamageContext` inputs: `source_id`, `target_id`, `base_damage`, +`skill_multiplier`, `additive_modifiers`, `more_modifiers`, +`conditional_modifiers`, `crit_allowed`, `crit_rate`, `crit_damage`, +`crit_roll`, `force_crit`, `target_armour`, `armour_penetration`, +`vulnerability`, `target_current_hp`, `tags`, `metadata`. + +`DamageResult` outputs: `raw_damage`, `final_damage`, `is_critical`, +`mitigation`, `is_lethal`, `breakdown`, plus `to_dictionary()`. + +**Ordering guarantee (frozen).** base → additive → more → conditional → crit → +armour/vulnerability. A new effect joins an existing bucket; it does not add a +stage. + +**Rule.** There is one damage path. Player, Enemy, Boss, HUD and the sacrifice +preview all call `resolve`. Re-deriving damage anywhere else is a defect. + +### 1.3 `IntegrityService` — `systems/integrity_service.gd` + +```gdscript +static func compute(structural: Dictionary, balance: BalanceConfig) -> float +static func components_of(structural: Dictionary, balance: BalanceConfig) -> Dictionary +``` + +`structural` comes from `RunState.structural_snapshot()`. `components_of` +returns `{h, s, a, r, f}`, each in `[0, 1]`. + +### 1.4 `SacrificeDefinition` — `systems/sacrifice_definition.gd` + +Resource schema; content lives in `res://data/sacrifices/*.tres`. + +| Group | Fields | +| --- | --- | +| Identity | `id`, `display_name`, `strength` (1–5), `rarity` | +| Classification | `tags`, `roles`, `exclusive_group` | +| Generation | `base_weight`, `prerequisite_min_sacrifices`, `banned_with` | +| Costs | `cost_max_hp_multiplier`, `cost_max_stamina_multiplier`, `cost_armour_multiplier`, `cost_stamina_recovery_multiplier`, `cost_structural_locks`, `cost_action_taxes`, `imbalance_flat` | +| Rewards | `reward_additive`, `reward_generic_more`, `reward_crit_rate`, `reward_crit_damage`, `reward_attack_speed_multiplier` | +| Triggers | `triggers` (declarative; nothing consumes them yet) | +| Display | `short_text`, `preview_template`, `warning_level` | + +`roles` must contain one or more of `CONTINUATION`, `RISK_ESCALATION`, +`PIVOT_STABILIZE`. `validate() -> Array[String]` returns data problems; the test +suite fails on any non-empty result for a shipped card. + +**Adding cards (X06) requires no code change.** Adding a *new kind of cost or +reward* does, and that is an interface change. + +### 1.5 `SacrificeService` — `systems/sacrifice_service.gd` + +```gdscript +SacrificeService.new(balance: BalanceConfig) +func can_offer(state: RunState, definition) -> Dictionary # {ok: bool, reason: String} +func preview(state: RunState, definition) -> SacrificeResult # runs on a clone +func apply(state: RunState, definition) -> SacrificeResult # runs on the real state +``` + +`SacrificeResult`: `ok`, `definition_id`, `failure_reason`, `before`, `after`, +`deltas`, `gain`, `cost_score`, `imbalance_delta`, `warnings`, plus +`matches(other)` and `to_dictionary()`. + +**Frozen guarantees.** + +- `preview` and `apply` execute the identical transaction function. +- `preview` leaves the passed state byte-identical. +- Transaction order is validate → snapshot → cost → integrity → reward → + derived → imbalance → publish → return. +- Any failure restores the pre-transaction snapshot whole. +- A sacrifice may never raise integrity. + +### 1.6 Damageable shape — `Player` and `EnemyBase` + +```gdscript +func actor_id() -> StringName +func armour() -> float +func current_hp() -> float +func receive_damage(context: DamageContext) -> DamageResult +``` + +Any new damageable actor implements all four. An attacker builds a +`DamageContext` and calls `receive_damage`; it never applies damage itself. + +### 1.7 `EnemyBase` — `actors/enemies/enemy_base.gd` + +```gdscript +func initialize(enemy_config: EnemyConfig, target: Node2D) -> void +func acquire_target(target: Node2D) -> void +func change_state(next: int) -> void # enum State +func receive_damage(context: DamageContext) -> DamageResult +func die(source_id: StringName = &"unknown") -> void +signal died(enemy: EnemyBase) +enum State { IDLE, CHASE, WINDUP, ATTACK, RECOVER, HURT, DEAD } +``` + +**Frozen guarantees.** + +- `die` is idempotent: one death event however many lethal hits land. +- A dead enemy takes no further damage and runs no behaviour. +- Every transition goes through `change_state`. +- Enemies never touch RunState structure or the sacrifice system. + +`EnemyConfig` (`actors/enemies/enemy_config.gd`) is the stat and timing schema; +instances live in `res://data/actors/`. + +### 1.8 `EventBus` — `core/event_bus.gd` + +Signals: `run_started`, `run_restarted`, `hit_dealt`, `hit_taken`, +`enemy_died`, `player_died`, `sacrifice_previewed`, `sacrifice_applied`, +`boss_started`, `boss_died`, `run_completed`. Each carries a `Dictionary` built +by `EventBus.context(extra)` containing `timestamp`, `run_id`, `stage_id` plus +the emitter's own fields. + +**Rule.** Observation and telemetry only. Never a calculation. + +### 1.9 `RNGService` — `core/rng_service.gd` + +```gdscript +func configure(new_seed: int) -> void +func run_seed() -> int +func stream(stream_name: StringName) -> RandomNumberGenerator +func randf(stream_name: StringName) -> float +func randi_range(stream_name: StringName, from: int, to: int) -> int +const STREAM_SACRIFICE, STREAM_COMBAT, STREAM_SPAWN +``` + +**Rule.** No gameplay code calls `randi()`/`randf()` directly. Add a new stream +constant rather than reusing an unrelated one. + +### 1.10 `RunCoordinator` — `systems/run_coordinator.gd` + +```gdscript +signal phase_changed(phase: StringName) signal state_ready(state: RunState) +signal sacrifice_offered(definition) signal boss_spawned(boss: BossActor) +signal run_finished(outcome: StringName) + +func start_run(run_seed: int, restarted := false) -> void +func restart_run(reuse_seed := true) -> void +func confirm_sacrifice(definition) -> SacrificeResult +func begin_boss() -> void +func state() -> RunState func player() -> Player +func boss() -> BossActor func phase() -> StringName +func live_enemies() -> Array[EnemyBase] func offered_sacrifice() -> SacrificeDefinition +func spawn_enemy_at(position: Vector2) -> EnemyBase + +# debug commands — the only route from a debug UI to the run +func debug_heal(amount: float) -> void func debug_damage(amount: float) -> void +func debug_spawn_reference_enemy() -> void +func debug_preview_sacrifice() -> SacrificeResult +func debug_apply_sacrifice() -> SacrificeResult +``` + +Phases: `boot`, `wave`, `sacrifice`, `boss`, `result`. + +### 1.11 `Hitbox` / `Hurtbox` — `core/hitbox.gd`, `core/hurtbox.gd` + +`Hitbox.set_active(bool)` opens and closes the damage window and emits +`hit_actor(actor)` at most once per actor per window. `Hurtbox.actor()` returns +the damageable that owns it. Neither knows anything about damage numbers. + +Physics layers: `world`(1) `player_body`(2) `enemy_body`(3) `player_hurtbox`(4) +`enemy_hurtbox`(5) `player_hitbox`(6) `enemy_hitbox`(7). + +--- + +## 2. Data contracts + +`BalanceConfig` (`core/balance_config.gd`, instance +`res://data/balance_config.tres`) holds every tunable number. Adding a field is +additive and safe. **Hardcoding a balance value in a script is a review +failure.** + +--- + +## 3. Files Codex must not modify + +Without a `docs/DECISIONS.md` proposal and Claude review: + +``` +core/run_state.gd core/combat_resolver.gd +core/damage_context.gd core/damage_result.gd +core/soft_caps.gd core/derived_stats.gd +core/event_bus.gd core/rng_service.gd +core/game_data.gd core/hitbox.gd core/hurtbox.gd +systems/integrity_service.gd systems/sacrifice_service.gd +systems/sacrifice_definition.gd systems/sacrifice_result.gd +systems/run_coordinator.gd +actors/enemies/enemy_base.gd actors/enemies/enemy_base.tscn +actors/enemies/enemy_config.gd +actors/player/player.gd actors/player/player_state_machine.gd +actors/player/states/player_state.gd +scenes/main.gd project.godot +.github/workflows/repository-validation.yml +``` + +Freely editable: `data/**`, `ui/**`, `tests/**`, concrete enemy scenes such as +`actors/enemies/ghost_*.tscn`, attack content in `actors/boss/boss_actor.gd`, +**new** files under `actors/player/states/`, and `tools/**`. + +--- + +## 4. Open extension points + +| Point | Where | What to do | +| --- | --- | --- | +| New enemy archetype | `actors/enemies/` + `data/actors/` | New scene inheriting `enemy_base.tscn`, new `EnemyConfig` .tres, add behaviour states — do not edit `EnemyBase` | +| Boss moves | `actors/boss/boss_actor.gd` | Add an attack scheduler over config-driven definitions; do not add a second damage path | +| Sacrifice cards | `data/sacrifices/*.tres` | Data only; `validate()` must pass | +| Three-slot generator | new file in `systems/` | Use `RNGService.STREAM_SACRIFICE`; call `can_offer`/`preview`; never re-implement scoring inside the UI | +| Player states | `actors/player/states/` | Add a file, register the id in `PlayerStateMachine._init`, give an existing state a reason to return it | +| Stamina consumers | `Player.spend_stamina` | Already routed through `RunState`; a StaminaService can wrap it without changing callers | +| Telemetry sink | subscribe to `EventBus` | Payloads already carry the correlation envelope | diff --git a/docs/TASKS/README.md b/docs/TASKS/README.md index acf0131..48640d5 100644 --- a/docs/TASKS/README.md +++ b/docs/TASKS/README.md @@ -1,22 +1,57 @@ # Task Register -No future task is started. Update status and PR only when work actually begins. +Update status and PR only when work actually begins. + +## Milestone M1 — playable vertical slice foundation + +All Claude tasks are delivered on `claude/core-framework` in one PR to +`develop`. The interfaces they release are frozen in `docs/INTERFACES.md`. + +| ID | Task | Owner | Status | Branch | Dependency | PR | +| --- | --- | --- | --- | --- | --- | --- | +| C01 | Repository and document review, Godot project skeleton | Claude | Done | `claude/core-framework` | Repository setup | M1 PR | +| C02 | RunState, EventBus, RNGService, BalanceConfig | Claude | Done | `claude/core-framework` | C01 | M1 PR | +| C03 | CombatResolver and IntegrityService | Claude | Done | `claude/core-framework` | C02 | M1 PR | +| C04 | Sacrifice transaction and example card | Claude | Done | `claude/core-framework` | C03 | M1 PR | +| C05 | Player controller, state machine, art integration, arena | Claude | Done | `claude/core-framework` | C03 | M1 PR | +| C06 | EnemyBase, reference enemy, Boss foundation | Claude | Done | `claude/core-framework` | C04 | M1 PR | +| C07 | Run flow, HUD, sacrifice panel, result screen, debug tools | Claude | Done | `claude/core-framework` | C05, C06 | M1 PR | +| C08 | Test harness, test suite, `godot-tests` workflow | Claude | Done | `claude/core-framework` | C07 | M1 PR | +| C09 | Architecture, interfaces, decisions, testing and handoff docs | Claude | Done | `claude/core-framework` | C08 | M1 PR | + +## Codex module tasks + +Unblocked once the M1 PR merges into `develop`. Each takes one branch and one PR, +stays inside `docs/INTERFACES.md` section 4, and must not touch the protected +files in section 3. | ID | Task | Owner | Status | Branch | Dependency | PR | | --- | --- | --- | --- | --- | --- | --- | -| C01 | Repository and document review | Claude | Waiting | `claude/core-framework` | Repository setup | — | -| C02 | Godot project skeleton | Claude | Waiting | TBD | C01 | — | -| C03 | RunState and core models | Claude | Blocked | TBD | C02 | — | -| C04 | CombatResolver | Claude | Blocked | TBD | C03 | — | -| C05 | Sacrifice framework | Claude | Blocked | TBD | C03 | — | -| C06 | EnemyBase reference | Claude | Blocked | TBD | C04 | — | -| C07 | Boss framework | Claude | Blocked | TBD | C06 | — | -| C08 | Tests and CI | Claude | Blocked | TBD | Core framework | — | -| X01 | Melee ghost | Codex | Waiting for interface freeze | `codex/x01-melee-ghost` | C06 | — | -| X02 | Ranged ghost | Codex | Waiting for interface freeze | `codex/x02-ranged-ghost` | C06 | — | -| X03 | Charger ghost | Codex | Waiting for interface freeze | `codex/x03-charger-ghost` | C06 | — | -| X04 | Placeholder Boss attack pack | Codex | Waiting for interface freeze | `codex/x04-boss-attacks` | C07 | — | -| X05 | Sacrifice UI | Codex | Waiting for interface freeze | `codex/x05-sacrifice-ui` | C05 | — | -| X06 | Twelve sacrifice definitions | Codex | Waiting for interface freeze | `codex/x06-sacrifice-data` | C05 | — | -| X07 | Debug and telemetry panel | Codex | Waiting for interface freeze | `codex/x07-debug-panel` | Core framework | — | -| X08 | Automated test expansion | Codex | Waiting for interface freeze | `codex/x08-test-expansion` | C08 | — | +| X01 | Melee ghost — full behaviour | Codex | Ready | `codex/x01-melee-ghost` | `EnemyBase` frozen | — | +| X02 | Ranged ghost — projectile, spacing, reposition | Codex | Ready | `codex/x02-ranged-ghost` | `EnemyBase` frozen | — | +| X03 | Charger ghost — telegraphed line charge, wall stagger | Codex | Ready | `codex/x03-charger-ghost` | `EnemyBase` frozen | — | +| X04 | Boss attack pack — three moves | Codex | Blocked on art | `codex/x04-boss-attacks` | ADR-010 | — | +| X05 | Sacrifice selection UI — A/B/C slots | Codex | Ready | `codex/x05-sacrifice-ui` | `SacrificeService` frozen | — | +| X06 | Twelve sacrifice definitions | Codex | Ready | `codex/x06-sacrifice-data` | `SacrificeDefinition` frozen | — | +| X07 | Debug and telemetry panel — timeline, JSON/CSV export | Codex | Ready | `codex/x07-debug-panel` | `EventBus` frozen | — | +| X08 | Automated test expansion | Codex | Partly blocked | `codex/x08-test-expansion` | Stamina and same-death systems | — | + +**X04 is blocked** because `assets/boss/` ships one attack sheet. It needs new +art from WorkBuddy, or a Game Director ruling that reusing the run cycle for a +charge is acceptable. + +**X08 is partly blocked**: the combat, integrity, sacrifice, actor, RNG and +run-loop cases exist. The stamina/dodge and same-death cases the Development +Pack asks for cannot be written until those systems do. + +## Framework work still owned by Claude + +Not yet scheduled; each unblocks further Codex tasks. + +| Task | Unblocks | +| --- | --- | +| Three-slot sacrifice generator (A/B/C roles, weighting, pity) | X05 at full scope | +| `StaminaService` — costs, recovery delay, exhaustion lock, consecutive dodge | Dodge, heavy attack, X08 | +| `SameDeathController` — lethal-damage priority chain and the 0.65 s window | X08 | +| World director — imbalance and build-threat driving enemy pressure | Elite affixes, tag counters | +| Boss `PhaseController` and `AttackScheduler` | X04 at full scope | diff --git a/docs/TESTING.md b/docs/TESTING.md index 655a56b..7f4a03d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,7 +1,115 @@ # Testing -> **STATUS: DRAFT — TO BE UPDATED BY CLAUDE AFTER FRAMEWORK IMPLEMENTATION** +> **STATUS: ACTIVE — M1 harness in place** -Every task must add or update proportionate automated tests, run existing tests, record commands and manual checks in its PR, and report the last successful result in `docs/AI_HANDOFF.md`. +Every task must add or update proportionate automated tests, run existing tests, +record commands and manual checks in its PR, and report the last successful +result in `docs/AI_HANDOFF.md`. -At present, CI runs only `repository-validation`; it checks repository structure and hygiene, not gameplay. Claude will define the Godot test harness and later add a separately named `godot-tests` workflow after it can run successfully. +--- + +## Running the suite locally + +Requires Godot **4.3 stable** on `PATH`. + +```bash +godot --headless --import # generates .godot/ and the class registry +godot --headless --path . res://tests/test_runner.tscn +``` + +The runner exits `0` when everything passes and `1` on the first failure it +records, so it drops straight into CI or a pre-push hook. The import step is not +optional on a fresh clone: it builds the global class-name registry the test +scripts resolve `RunState`, `TestCase` and friends against. + +Run it as a **scene**, not with `--script`. A MainLoop script is compiled before +the autoload singletons are registered, so any class naming `EventBus` or +`RNGService` fails to compile and every service silently becomes `null`. See +ADR-006. + +## Continuous integration + +Two workflows, both on pull requests into `develop` and `main`: + +| Workflow | Checks | +| --- | --- | +| `repository-validation` | Required files present and non-empty, no committed secrets, no oversized files, YAML/JSON parse. Pre-existing; unchanged. | +| `godot-tests` | Downloads and caches Godot 4.3, imports the project, fails on any import error, then runs the suite. | + +Both check names should be required in the branch ruleset for `develop`. + +## Layout + +``` +tests/ +├── test_runner.tscn / test_runner.gd entry point and discovery +├── framework/test_case.gd assertions, physics stepping, config helper +└── cases/ + ├── test_actors.gd EnemyBase and BossActor + ├── test_combat_resolver.gd damage pipeline + ├── test_integrity.gd structural integrity and snapshots + ├── test_rng.gd determinism and stream isolation + ├── test_run_loop.gd end-to-end run plus scene smoke checks + └── test_sacrifice.gd preview/apply transaction +``` + +Discovery is by convention: every `tests/cases/*.gd` extending `TestCase`, and +every method named `test_*`, on a fresh instance per method. Methods may `await`; +`step_physics(n)` advances real physics frames. + +`make_balance()` returns a detached copy of `data/balance_config.tres` so a test +can retune a value without leaking it into the next test. + +## What is covered + +**Combat** — bucket order, armour as an equivalent-life model, penetration, +forced and pinned criticals, the crit-rate hard cap and the crit-damage and +more-product soft caps, lethality boundaries, and a check that the reported +breakdown multiplies back to the final damage. + +**Integrity** — that current HP and current stamina do not move integrity while +max HP does (the arbitrage bug the research report was written to fix), the +intact-body ceiling, that a buff cannot offset a structural loss, structural +floors, exact snapshot round-tripping, and clone detachment. + +**Sacrifice** — preview leaves the real state byte-identical; apply matches the +preview field by field; a rejected transaction rolls back whole; the shipped +card's numbers come from its `.tres`; the reward is priced against post-cost +integrity; a more broken body buys a bigger reward; duplicate, prerequisite and +lock-budget gating; and every shipped card passes `validate()`. + +**Enemy and Boss** — the Damageable shape, damage through the resolver, death +firing exactly once across four routes (two lethal hits, a hit after death, and +two direct `die()` calls), a corpse taking no damage and running no behaviour, +target acquisition, and that `BossActor` extends `EnemyBase`. + +**RNG** — same seed replays, different seeds diverge, draining one stream does +not shift another, stream names change the derived seed, and integer draws stay +in range and replay. + +**Run loop** — the real `main.tscn` driven through wave spawn, a genuine hitbox +connection from simulated input, wave clear, sacrifice offer and confirm, boss +spawn, victory, restart, and player death — plus every debug command and a smoke +check that all ten critical scenes load and instantiate. + +## What is not covered + +Honest gaps, so nobody reads a green suite as more than it is: + +- No visual regression. The pixel-art rendering settings were verified by + capturing frames under a virtual display during development; nothing asserts + them automatically. +- No performance or frame-budget assertions. +- No input-remapping or controller coverage. +- No test for the enemy attack landing on the player through physics; the + player-side path is covered, and the enemy side uses the same `Hitbox`. +- Timing-sensitive assertions use physics-frame counts, not wall clock, so they + are deterministic but coarse. + +## Adding a test + +1. Add `tests/cases/test_.gd` extending `TestCase`. +2. Name methods `test_*`; use `before_each` / `after_each` for fixtures. +3. Assert on behaviour and invariants, not on implementation details. +4. If a test needs elaborate scaffolding to reach the thing it checks, treat + that as a signal about the design before reaching for a bigger hammer.