From 4f650aeb39ee3235af1105bc11d6e3a95dc43258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=9F=BA=E5=B2=A9=EF=BC=88engineering-lead?= =?UTF-8?q?=EF=BC=89?= Date: Tue, 20 Oct 2026 01:39:14 +0800 Subject: [PATCH] =?UTF-8?q?W3=EF=BC=9A=E7=9B=B8=E6=9C=BA=20rig=20+=20band?= =?UTF-8?q?=20=E7=BB=84=E8=A3=85=E5=99=A8=20+=20=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=20+=20=E6=AE=BF=E7=8E=8B=E6=88=98=EF=BC=88?= =?UTF-8?q?=E7=81=B0=E7=9B=92=EF=BC=89=EF=BC=9BADR-5=20=E8=90=BD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 文档 - 架构文档:殿王战 HP 系数 50× → 15×(Balance Bible [B§5] 口径),全文档无残留 - 新增 ADR-5:2D 骨骼动画(Spine)取代 ADR-1 的单位/殿王 billboard 条目 * 背景/决策/后果/三项被否决方案(多帧 billboard、Skeleton2D、DragonBones) * Godot 4.7 接入方式标注"需立项时复核",不臆造 API;挂探针 P12 * 资产规格约束:部件分离 PNG / 图集 / 锚点命名 / 动画命名 / AI 绑骨质检门槛 * 数据行纪律影响:sprite_row → 动画资源路径行,坚持"新资产 = 加行" * 美术工时对照(pipeline 报告 115-185h) * 3D 特效落地约定:走 flipbook 序列帧,理由是 3D 粒子直塞 2D 会绕过单趟 后处理(ADR-2 红线)导致风格撕裂;挂探针 P13 - ADR-1 标注"单位/殿王条目被 ADR-5 取代",降级为回退规格(§5.1 保留) - 架构 §8 清单 / §11 探针表 / 【确定】【探针】标注段同步;CLAUDE.md ADR 节同步 P1 横版核心 - camera.csv(10 行,一行一殿)+ camera.schema.json + core/data/camera_def.gd * 独立成表的四条理由写入 schema description,不污染 halls.csv * 全局标量(弹簧刚度/阻尼/平滑 tau/偷看上限/战前预告分钟)进 balance_tuning.csv - presentation/stage:camera_rig.gd(进度锚定 + 偷看回弹 + 殿王战推近) + hall_band_world.gd + band_world_root.gd(四层视差)+ stage_world.gd - stage_root 接入横版舞台,取代 W2 十殿色带(hall_band.gd 保留作回退) P2 玩法系统 - game/event_director.gd:events.csv 四事件 → 触发/持续/效果/二选一/失败哗变 * 效果全走 EconomyState 既有 API(瞬时区 set_event_mult / set_event_click_mult / set_mutiny),不直接改字段;四个出口统一 _clear_runtime 防"漏清永久 ×N" - game/boss_battle.gd:30s 状态机(INTRO/ACTIVE/RESOLVED) * HP = 15 × 进入时产出/s × 殿系数(1.0→2.2),连击 +2%/层封顶 +60% * 胜 = 阴德爆发 HP×0.2(进本轮累计口径);败 = 60s 冷却(购路引可消) * 不用随机数 → sim 门 #11 可直接复用 - EconomyState 接事件瞬时区与哗变惩罚;GameState 接线 + 快照扩展 - presentation:event_panel.gd(事件卡/二选一)+ boss_arena.gd (预告/HP/连击/倒计时/结算/再战);stage_root 布局接入 - debug 构建 dev 热键 F1-F6/F9,走 GameState 既有命令口,不改数值 P3 质量 - 新增 3 个用例:test_camera_data / test_event_director / test_boss_battle (进 test_runner.CASE_FILES,共 7 个用例文件) - 用户验证手册:docs/engineering/2026-10-19-w3-verification-manual.md(文档区) 修掉的两个潜伏缺陷(从根因修,非打补丁) - gen_balance_sheets.py:halls.is_prestige_gate 落成字符串 "false", Godot 的 bool("false") == true → 十殿会被全部判为 prestige gate。 改为真布尔,并把自检查从 == "true" 收紧为 is True(再犯即断言失败) - boss_battles.schema.json:hp_factor 漏 type,GDScript SheetImporter 会产出 字符串 "15"(Python 生成器产出的是数字),两条路径不一致。补 type: number 上报(未擅自改动,等裁定) - H9/H10 殿王战数学上不可胜:纯军列耗时 = 15 × 殿系数(与产出/s 无关), 殿系数 >2.0 → 31.5s/33.0s > 30s 限时;而缺口 ∝ 产出/s,点击伤害是常数 (上限 25.6,不随产出/s 增长)→ 玩家越强越打不过,难度曲线是反的。 已在 test_boss_battle._test_winnability_doc 把当前事实钉成断言哨兵, 一旦有人改 hall_factor/active_sec/hp_factor 立刻变红强制重评。修数权属文策渊。 本机验证(无 Godot,靠移植仿真,非代替引擎实跑) - 把 CameraDef/BossBattle/EventDirector 逐行移植成 JS 喂真实 build JSON, 134 条断言全绿;9 张表 schema 校验通过且 build 逐字节一致(含新增 camera) - 静态检查:跨文件方法/字段引用审计、静态调用合法性、src/core 禁 extends Node、 缩进/括号配平/空函数体 —— 全部 0 问题 - 待本机验证:全部 GDScript 从未被引擎执行过;SubViewport 舞台与 Camera2D 在 gl_compatibility 下的行为未实测(探针 P12 范畴) --- CLAUDE.md | 21 +- data/build/balance_tuning.json | 49 ++++ data/build/camera.json | 149 ++++++++++ data/build/halls.json | 20 +- data/schema/boss_battles.schema.json | 3 +- data/schema/camera.schema.json | 104 +++++++ data/sheets/balance_tuning.csv | 7 + data/sheets/camera.csv | 11 + src/core/data/balance_data.gd | 34 +++ src/core/data/camera_def.gd | 123 ++++++++ src/core/economy/economy_state.gd | 53 +++- src/game/boss_battle.gd | 265 +++++++++++++++++ src/game/data_registry.gd | 5 + src/game/event_director.gd | 328 ++++++++++++++++++++++ src/game/game_state.gd | 119 +++++++- src/presentation/hud/event_panel.gd | 128 +++++++++ src/presentation/stage/band_world_root.gd | 157 +++++++++++ src/presentation/stage/boss_arena.gd | 148 ++++++++++ src/presentation/stage/camera_rig.gd | 103 +++++++ src/presentation/stage/hall_band_world.gd | 174 ++++++++++++ src/presentation/stage/stage_root.gd | 167 +++++++++-- src/presentation/stage/stage_world.gd | 149 ++++++++++ src/tools/gen_balance_sheets.py | 11 +- tests/unit/cases/test_boss_battle.gd | 211 ++++++++++++++ tests/unit/cases/test_camera_data.gd | 111 ++++++++ tests/unit/cases/test_event_director.gd | 152 ++++++++++ tests/unit/test_runner.gd | 4 + 27 files changed, 2753 insertions(+), 53 deletions(-) create mode 100644 data/build/camera.json create mode 100644 data/schema/camera.schema.json create mode 100644 data/sheets/camera.csv create mode 100644 src/core/data/camera_def.gd create mode 100644 src/game/boss_battle.gd create mode 100644 src/game/event_director.gd create mode 100644 src/presentation/hud/event_panel.gd create mode 100644 src/presentation/stage/band_world_root.gd create mode 100644 src/presentation/stage/boss_arena.gd create mode 100644 src/presentation/stage/camera_rig.gd create mode 100644 src/presentation/stage/hall_band_world.gd create mode 100644 src/presentation/stage/stage_world.gd create mode 100644 tests/unit/cases/test_boss_battle.gd create mode 100644 tests/unit/cases/test_camera_data.gd create mode 100644 tests/unit/cases/test_event_director.gd diff --git a/CLAUDE.md b/CLAUDE.md index 1c2568b..87d8cd0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,10 +36,12 @@ ## ADR 一句话(架构 §8,均已接受) -- **ADR-1**:预渲染 2 朝向 billboard,不用实时 3D(用户比较优势在批渲染); +- **ADR-1**:预渲染 2 朝向 billboard,不用实时 3D(用户比较优势在批渲染)——**⚠ 单位/殿王条目 W3 起被 ADR-5 取代**;背景/套件/后处理部分仍有效,并作为 ADR-5 的**回退规格**(探针 P12 不过则军列带退回本条); - **ADR-2**:单趟 fused 全屏后处理 shader(posterize→LUT→纸纹→暗角),UI 层在其上; -- **ADR-3**:美术数据行纪律(新资产=加行;唯一逐资产代码 = sprite 路径); -- **ADR-4**:AI 仅内部使用(mood board/概念),不进包体、不出厂、商店页披露填 No。 +- **ADR-3**:美术数据行纪律(新资产=加行;逐资产代码 = 资产路径;ADR-5 扩展为 `animations.csv` 动画行); +- **ADR-4**:AI 仅内部使用(mood board/概念),不进包体、不出厂、商店页披露填 No——ADR-5 的"AI 自动绑骨"属**工具层**(对自己渲染的部件做蒙皮,不产出图像),不触本条;若未来变成 AI 生成部件图像,立即回到本条管辖; +- **ADR-5**(W3 新增):**单位/殿王/军列单位动画改用 2D 骨骼动画 Spine**(简单动作 = AI 自动绑骨 + 人工精修;复杂动作 = 纯手工 Spine);背景/后处理/UI 皮肤不动。**3D 特效只走 flipbook 序列帧**(3D 渲染成帧序列当 2D 素材),运行时不放任何 3D node——否则绕过单趟后处理=风格撕裂(ADR-2 红线)。 + - ⚠ **知识缺口**:spine-godot GDExtension × Godot 4.7.2 × gl_compatibility 未实测;**版本矩阵/节点类名/许可条款均为空白**,由**探针 P12** 回填,**禁止臆造 API**;美术工时口径 billboard 115-185h → Spine **142-232h【估计】**(Δ +28-48h)。 ## 参考模板(只读参照,非项目基底) @@ -81,6 +83,19 @@ godot --headless --path . -s res://src/tools/import_main.gd -- --validate-only `data/sheets/*.csv` →(SheetImporter,本仓库 `src/tools/`)→ `data/build/*.json`(提交)→(W3 `.tres`)→ DataRegistry。 单向流动:游戏永不写 `data/`;sim 与游戏读同一份 build。契约见 `README.md` + 架构 §3。 +**表清单(W3 = 9 张)**:`generators` / `upgrades` / `meta_upgrades` / `halls` / `hall_palettes` / +`boss_battles` / `events` / `balance_tuning` / **`camera`(W3 新增,一行一殿)**。 +新增表 = 加 schema + 加 `.csv` + 进 `SheetImporter` 白名单,**不重编号既有 ID**。 + +**W3 相机参数为什么独立成表**(`camera.csv`,不是给 `halls.csv` 加列): +① `halls.csv` 是"世界身份行",被经济核心 / sim / UI / 引用校验广泛消费,加相机列会让每次手感 +微调都重写整张十殿表;② 相机参数在灰盒期会高频手调,生命周期与数值表不同;③ 仍是"一行一殿", +年更新冤案包 = 加一行;④ 全局标量(弹簧刚度/阻尼/平滑时间常数)不随殿变,按既有纪律进 +`balance_tuning.csv`(`tune_cam_*` 五行)。逐条理由见架构 §4.3 与 `data/schema/camera.schema.json`。 + +**验证手册**:`docs/engineering/2026-10-19-w3-verification-manual.md`(文档区,不入库)。 +W3 的 GDScript 从未被引擎执行过(本机无 Godot)——**跑通第 1 节 headless 单测才算 W3 验过**。 + ## 纪律提醒 - 写代码前**先写测试**(GUT W3 起);每个 Story 留测试证据路径; diff --git a/data/build/balance_tuning.json b/data/build/balance_tuning.json index dea4ce0..3a49323 100644 --- a/data/build/balance_tuning.json +++ b/data/build/balance_tuning.json @@ -218,6 +218,55 @@ "num": 60, "text": "", "desc_cn": "[G§3.4] 阴司敕封解锁条件:宿慧 ≥60([B§4.4] 仅给 8-12h 体感,采信 GDD 数值)" + }, + { + "id": "tune_cam_peek_max_px", + "kind": "number", + "num": 320, + "text": "", + "desc_cn": "[W3 相机] 偷看(peek)偏移的世界像素上限;超出即夹到此值(架构 §4.3 偷看回弹)" + }, + { + "id": "tune_cam_peek_spring", + "kind": "number", + "num": 42, + "text": "", + "desc_cn": "[W3 相机] 偷看回弹弹簧刚度 k(越大回弹越快)" + }, + { + "id": "tune_cam_peek_damp", + "kind": "number", + "num": 11, + "text": "", + "desc_cn": "[W3 相机] 偷看回弹阻尼 c;近临界阻尼约 2*sqrt(k)≈13,取略欠阻尼以保留手感" + }, + { + "id": "tune_cam_follow_tau", + "kind": "number", + "num": 0.35, + "text": "", + "desc_cn": "[W3 相机] 进度锚定跟随的指数平滑时间常数(秒)" + }, + { + "id": "tune_cam_zoom_tau", + "kind": "number", + "num": 0.3, + "text": "", + "desc_cn": "[W3 相机] 缩放与殿王战推近的指数平滑时间常数(秒)" + }, + { + "id": "tune_boss_preview_min", + "kind": "number", + "num": 15, + "text": "", + "desc_cn": "[B§6] 殿王战战前预告提前分钟数(与 H9-H10 prestige 预告同口径)" + }, + { + "id": "tune_event_first_min", + "kind": "number", + "num": 3, + "text": "", + "desc_cn": "[B§7] 事件系统解锁后首个事件的延迟分钟数(给玩家缓冲,避免刚解锁就来)" } ] } diff --git a/data/build/camera.json b/data/build/camera.json new file mode 100644 index 0000000..7013764 --- /dev/null +++ b/data/build/camera.json @@ -0,0 +1,149 @@ +{ + "sheet": "camera", + "schema_version": 0, + "columns": [ + "id", + "hall_id", + "band_screens", + "layer_far_scroll", + "layer_mid_scroll", + "layer_near_scroll", + "layer_fore_scroll", + "focus_ratio", + "idle_zoom", + "arena_zoom", + "arena_focus_ratio" + ], + "rows": [ + { + "id": "cam_hall_01", + "hall_id": "hall_01_qinguang", + "band_screens": 2.2, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_02", + "hall_id": "hall_02_chujiang", + "band_screens": 2.2, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_03", + "hall_id": "hall_03_songdi", + "band_screens": 2.4, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_04", + "hall_id": "hall_04_wuguan", + "band_screens": 2.4, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_05", + "hall_id": "hall_05_yama", + "band_screens": 2.6, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_06", + "hall_id": "hall_06_biancheng", + "band_screens": 2.6, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_07", + "hall_id": "hall_07_taishan", + "band_screens": 2.8, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_08", + "hall_id": "hall_08_dushi", + "band_screens": 2.8, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_09", + "hall_id": "hall_09_pingdeng", + "band_screens": 3, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + }, + { + "id": "cam_hall_10", + "hall_id": "hall_10_zhuanlun", + "band_screens": 3, + "layer_far_scroll": 0.12, + "layer_mid_scroll": 0.4, + "layer_near_scroll": 1, + "layer_fore_scroll": 1.25, + "focus_ratio": 0.66, + "idle_zoom": 1, + "arena_zoom": 1.6, + "arena_focus_ratio": 0.5 + } + ] +} diff --git a/data/build/halls.json b/data/build/halls.json index e399900..30e122a 100644 --- a/data/build/halls.json +++ b/data/build/halls.json @@ -28,7 +28,7 @@ "unlock_gen": "gen_03_guizu", "unlock_gate": "up_gate_guizu", "palette_id": "pal_hall_01", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_02_chujiang", @@ -42,7 +42,7 @@ "unlock_gen": "gen_04_biliao", "unlock_gate": "up_gate_biliao", "palette_id": "pal_hall_02", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_03_songdi", @@ -56,7 +56,7 @@ "unlock_gen": "gen_05_tangpu", "unlock_gate": "up_gate_tangpu", "palette_id": "pal_hall_03", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_04_wuguan", @@ -70,7 +70,7 @@ "unlock_gen": "gen_06_nanmin", "unlock_gate": "up_gate_nanmin", "palette_id": "pal_hall_04", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_05_yama", @@ -84,7 +84,7 @@ "unlock_gen": "gen_07_shuban", "unlock_gate": "up_gate_shuban", "palette_id": "pal_hall_05", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_06_biancheng", @@ -98,7 +98,7 @@ "unlock_gen": "gen_08_bingfang", "unlock_gate": "up_gate_bingfang", "palette_id": "pal_hall_06", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_07_taishan", @@ -112,7 +112,7 @@ "unlock_gen": "gen_09_wodi", "unlock_gate": "up_gate_wodi", "palette_id": "pal_hall_07", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_08_dushi", @@ -126,7 +126,7 @@ "unlock_gen": "gen_10_xingzhe", "unlock_gate": "up_gate_xingzhe", "palette_id": "pal_hall_08", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_09_pingdeng", @@ -140,7 +140,7 @@ "unlock_gen": "gen_11_yinhun", "unlock_gate": "up_gate_yinhun", "palette_id": "pal_hall_09", - "is_prestige_gate": "false" + "is_prestige_gate": false }, { "id": "hall_10_zhuanlun", @@ -154,7 +154,7 @@ "unlock_gen": "none", "unlock_gate": "none", "palette_id": "pal_hall_10", - "is_prestige_gate": "true" + "is_prestige_gate": true } ] } diff --git a/data/schema/boss_battles.schema.json b/data/schema/boss_battles.schema.json index 0a1d585..aa7d3c0 100644 --- a/data/schema/boss_battles.schema.json +++ b/data/schema/boss_battles.schema.json @@ -45,8 +45,9 @@ "description": "殿系数([B§5] H1-H10 = 1.0/1.1/1.2/1.3/1.4/1.6/1.8/2.0/2.1/2.2)" }, "hp_factor": { + "type": "number", "const": 15, - "description": "HP 系数([B§5]:15×产出/s×殿系数;[B§11] 由 GDD 50 修正)" + "description": "HP 系数([B§5]:15×产出/s×殿系数;[B§11] 由 GDD 50 修正)。W3 补齐缺失的 type:此前未声明 type 时 SheetImporter 按 string 处理,build 产物出现 \"15\" 字符串(且依赖 Godot Variant 的类型宽松相等才能通过 const 校验),是不该存在的隐式假设。" }, "army_dps_factor": { "type": "number", "minimum": 0, "description": "军列 DPS = 产出/s × 本值([B§5] 1.0)" }, "intro_sec": { "type": "integer", "minimum": 0, "description": "INTRO 冲锋过场秒数(3s)" }, diff --git a/data/schema/camera.schema.json b/data/schema/camera.schema.json new file mode 100644 index 0000000..9d99aae --- /dev/null +++ b/data/schema/camera.schema.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "camera.schema.json", + "title": "camera 表 schema v0(per-hall 相机 rig 数据行,架构 §4.3 / W3 新增)", + "description": "为什么独立成表而不是给 halls.csv 加列:① halls.csv 是「世界身份行」(殿门/解锁批/色板/pacing),被经济核心、sim、UI、引用完整性校验广泛消费;给相机参数加列会让每次手感微调都重写整张十殿表并波及所有消费者;② 相机参数生命周期与数值表不同(灰盒试玩期会高频手调),独立成表后调整 = 只改自己的行;③ 仍是「一行一殿」,年更「新冤案包」= 加一行,check_references 可断言每殿恰一行;④ 全局标量(弹簧刚度/阻尼/平滑时间常数)不随殿变,按既有纪律进 balance_tuning.csv。band_screens = 该 band 占几屏宽(@1280 基准,架构 §4.3 的 2-3 屏);layer_*_scroll = 四层视差滚动系数(0 = 无限远静止,1.0 = 与近景地面同步,>1 = 前景走得更快);focus_ratio = 进度焦点钉在视口的横向比例(0.66 = 右 1/3,GDD §2.4 焦点可见性);arena_* = 殿王战推近时的缩放与构图锚点。", + "x-supported-keywords": [ + "type", "properties", "required", "items", "enum", "pattern", + "minimum", "maximum", "minLength", "minItems", "maxItems", + "additionalProperties", "const" + ], + "x-unique": ["id", "hall_id"], + "type": "object", + "required": ["sheet", "schema_version", "columns", "rows"], + "properties": { + "sheet": { "const": "camera" }, + "schema_version": { "const": 0 }, + "columns": { + "type": "array", + "items": { "type": "string" }, + "minItems": 11 + }, + "rows": { + "type": "array", + "minItems": 10, + "maxItems": 10, + "items": { + "type": "object", + "required": [ + "id", "hall_id", "band_screens", + "layer_far_scroll", "layer_mid_scroll", "layer_near_scroll", "layer_fore_scroll", + "focus_ratio", "idle_zoom", "arena_zoom", "arena_focus_ratio" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^cam_hall_[0-9]{2}$", + "description": "稳定字符串 ID;永不重编号(年更=加行)" + }, + "hall_id": { + "type": "string", + "pattern": "^hall_[0-9]{2}_[a-z]+$", + "description": "halls.csv 行 ID(每殿恰一行,由 BalanceData.check_references 断言)" + }, + "band_screens": { + "type": "number", + "minimum": 1, + "maximum": 6, + "description": "该 band 横向屏宽数(@1280 基准;架构 §4.3 规定 2-3 屏)" + }, + "layer_far_scroll": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "远景水墨层滚动系数(越小越远)" + }, + "layer_mid_scroll": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "中景剪影套件层滚动系数(架构 §4.3 定 0.4)" + }, + "layer_near_scroll": { + "type": "number", + "minimum": 0, + "maximum": 1.5, + "description": "近景地面层滚动系数(架构 §4.3 定 1.0)" + }, + "layer_fore_scroll": { + "type": "number", + "minimum": 1, + "maximum": 2, + "description": "前景层滚动系数(>1 = 比相机走得快)" + }, + "focus_ratio": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "军列前线/下一道殿门钉在视口的比例(0.66 = 右 1/3)" + }, + "idle_zoom": { + "type": "number", + "minimum": 0.5, + "maximum": 3, + "description": "巡游状态基准缩放" + }, + "arena_zoom": { + "type": "number", + "minimum": 0.5, + "maximum": 4, + "description": "殿王战推近缩放(应 > idle_zoom)" + }, + "arena_focus_ratio": { + "type": "number", + "minimum": 0, + "maximum": 1, + "description": "殿王战 arena 中心钉在视口的比例(0.5 = 居中)" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/data/sheets/balance_tuning.csv b/data/sheets/balance_tuning.csv index c089418..387ded0 100644 --- a/data/sheets/balance_tuning.csv +++ b/data/sheets/balance_tuning.csv @@ -29,3 +29,10 @@ tune_restart_curve,text,0,1:0.6:0.4:0.3,[B§4.3] 重开曲线目标(第 5 轮 tune_event_timeout_default,text,0,opt_a,[B§7] 冤魂申诉超时默认 = 超度(opt_a) tune_souls_div,number,10000,,"[B§6] 超度亡魂 = floor(本轮累计阴德 ÷ 10,000)(叙事计数器,不设闸门;P-14)" tune_enthrone_suihui_req,number,60,,[G§3.4] 阴司敕封解锁条件:宿慧 ≥60([B§4.4] 仅给 8-12h 体感,采信 GDD 数值) +tune_cam_peek_max_px,number,320,,[W3 相机] 偷看(peek)偏移的世界像素上限;超出即夹到此值(架构 §4.3 偷看回弹) +tune_cam_peek_spring,number,42,,[W3 相机] 偷看回弹弹簧刚度 k(越大回弹越快) +tune_cam_peek_damp,number,11,,[W3 相机] 偷看回弹阻尼 c;近临界阻尼约 2*sqrt(k)≈13,取略欠阻尼以保留手感 +tune_cam_follow_tau,number,0.35,,[W3 相机] 进度锚定跟随的指数平滑时间常数(秒) +tune_cam_zoom_tau,number,0.3,,[W3 相机] 缩放与殿王战推近的指数平滑时间常数(秒) +tune_boss_preview_min,number,15,,[B§6] 殿王战战前预告提前分钟数(与 H9-H10 prestige 预告同口径) +tune_event_first_min,number,3,,[B§7] 事件系统解锁后首个事件的延迟分钟数(给玩家缓冲,避免刚解锁就来) diff --git a/data/sheets/camera.csv b/data/sheets/camera.csv new file mode 100644 index 0000000..ec9a033 --- /dev/null +++ b/data/sheets/camera.csv @@ -0,0 +1,11 @@ +id,hall_id,band_screens,layer_far_scroll,layer_mid_scroll,layer_near_scroll,layer_fore_scroll,focus_ratio,idle_zoom,arena_zoom,arena_focus_ratio +cam_hall_01,hall_01_qinguang,2.2,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_02,hall_02_chujiang,2.2,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_03,hall_03_songdi,2.4,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_04,hall_04_wuguan,2.4,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_05,hall_05_yama,2.6,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_06,hall_06_biancheng,2.6,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_07,hall_07_taishan,2.8,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_08,hall_08_dushi,2.8,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_09,hall_09_pingdeng,3,0.12,0.4,1,1.25,0.66,1,1.6,0.5 +cam_hall_10,hall_10_zhuanlun,3,0.12,0.4,1,1.25,0.66,1,1.6,0.5 diff --git a/src/core/data/balance_data.gd b/src/core/data/balance_data.gd index 2011a1e..5b9e796 100644 --- a/src/core/data/balance_data.gd +++ b/src/core/data/balance_data.gd @@ -19,6 +19,7 @@ extends RefCounted const SHEET_FILES: Array[String] = [ "generators", "upgrades", "halls", "hall_palettes", "boss_battles", "events", "meta_upgrades", "balance_tuning", + "camera", # W3:per-hall 相机 rig 行(camera.csv;每殿恰一行) ] var generators: Array[GeneratorDef] = [] @@ -28,6 +29,7 @@ var halls: Array[HallDef] = [] var palettes: Array[PaletteDef] = [] var bosses: Array[BossDef] = [] var events: Array[EventDef] = [] +var cameras: Array[CameraDef] = [] # W3:per-hall 相机 rig var load_errors: Array[String] = [] @@ -37,6 +39,7 @@ var _meta_by_id := {} var _hall_by_id := {} var _hall_by_no := {} var _boss_by_hall := {} +var _camera_by_hall := {} var _palette_by_hall := {} var _event_by_id := {} var _tune := {} # id -> {"kind": String, "num": float, "text": String} @@ -65,6 +68,7 @@ static func load_from_dir(dir: String) -> BalanceData: "halls": bd._load_halls(rows) "hall_palettes": bd._load_palettes(rows) "boss_battles": bd._load_bosses(rows) + "camera": bd._load_cameras(rows) "events": bd._load_events(rows) "meta_upgrades": bd._load_meta(rows) "balance_tuning": bd._load_tuning(rows) @@ -168,6 +172,23 @@ func _load_bosses(rows: Array) -> void: bosses.append(d) +func _load_cameras(rows: Array) -> void: + for r in rows: + var d := CameraDef.new() + d.id = str(r.get("id", "")) + d.hall_id = str(r.get("hall_id", "")) + d.band_screens = float(r.get("band_screens", 2)) + d.layer_far_scroll = float(r.get("layer_far_scroll", 0.12)) + d.layer_mid_scroll = float(r.get("layer_mid_scroll", 0.4)) + d.layer_near_scroll = float(r.get("layer_near_scroll", 1)) + d.layer_fore_scroll = float(r.get("layer_fore_scroll", 1.25)) + d.focus_ratio = float(r.get("focus_ratio", 0.66)) + d.idle_zoom = float(r.get("idle_zoom", 1)) + d.arena_zoom = float(r.get("arena_zoom", 1.6)) + d.arena_focus_ratio = float(r.get("arena_focus_ratio", 0.5)) + cameras.append(d) + + func _load_events(rows: Array) -> void: for r in rows: var d := EventDef.new() @@ -239,6 +260,8 @@ func _build_indexes() -> void: _hall_by_no[d.no] = d for d in bosses: _boss_by_hall[d.hall_id] = d + for d in cameras: + _camera_by_hall[d.hall_id] = d for d in palettes: _palette_by_hall[d.hall_id] = d for d in events: @@ -275,6 +298,10 @@ func palette_for_hall(hall_id: String) -> PaletteDef: return _palette_by_hall.get(hall_id) +func camera_for_hall(hall_id: String) -> CameraDef: + return _camera_by_hall.get(hall_id) + + func event(id: String) -> EventDef: return _event_by_id.get(id) @@ -333,4 +360,11 @@ func check_references() -> Array[String]: for h in halls: if _boss_by_hall.get(h.id) == null: errs.append("殿 %s 缺少 boss_battles 行" % h.id) + # 每殿恰一行相机 rig(W3) + for h in halls: + if _camera_by_hall.get(h.id) == null: + errs.append("殿 %s 缺少 camera 行(camera.csv)" % h.id) + for c in cameras: + if _hall_by_id.get(c.hall_id) == null: + errs.append("camera.%s.hall_id 引用了不存在的殿:%s" % [c.id, c.hall_id]) return errs diff --git a/src/core/data/camera_def.gd b/src/core/data/camera_def.gd new file mode 100644 index 0000000..4d2ef78 --- /dev/null +++ b/src/core/data/camera_def.gd @@ -0,0 +1,123 @@ +class_name CameraDef +extends RefCounted + +# ============================================================================ +# CameraDef —— per-hall 相机 rig 数据行(架构 §4.3;W3 新增) +# +# 为什么独立成表(camera.csv)而不是给 halls.csv 加列: +# ① halls.csv 是"世界身份行"(殿门/解锁批/色板/pacing),被经济核心、sim、UI 与 +# 引用完整性校验广泛消费;给它加相机参数,等于让每次手感微调都重写整张十殿表; +# ② 相机参数生命周期与数值表不同(灰盒试玩期会高频手调),独立成表后调整只改自己的行; +# ③ 仍是"一行一殿" —— 年更"新冤案包" = 加一行(架构 §3.2 / ADR-3); +# ④ 全局标量(弹簧刚度/阻尼/平滑时间常数)不随殿变,按既有纪律进 balance_tuning.csv。 +# +# 本类是 core/data 层:纯 RefCounted、无 Node、无场景树, +# → 单测可无头调用(tests/unit/cases/test_camera_data.gd),相机 rig(presentation) +# 只消费本类算好的数字,自己不持有任何参数常量(零硬编码纪律)。 +# +# 知识缺口:Godot 4.7 的 Camera2D 语义未实测,故本类只做纯数学; +# 涉及引擎行为(anchor/zoom 实际起点)的部分由探针验证,见 src/presentation/stage/camera_rig.gd。 +# ============================================================================ + +## 设计基准视口宽度(project.godot 1280×720;架构 §5.5 基线) +const VIEW_BASE_W := 1280.0 + +## 四层索引(架构 §4.3:远景 → 中景 → 近景 → 前景) +const LAYER_FAR := 0 +const LAYER_MID := 1 +const LAYER_NEAR := 2 +const LAYER_FORE := 3 +const LAYER_COUNT := 4 + +var id: String # "cam_hall_01" +var hall_id: String # halls.csv 行 ID(每殿恰一行) +var band_screens: float # 该 band 横向占几屏宽(@1280 基准,架构 §4.3 的 2-3 屏) +var layer_far_scroll: float # 远景滚动系数(越小越远) +var layer_mid_scroll: float # 中景滚动系数(架构 §4.3 定 0.4) +var layer_near_scroll: float # 近景滚动系数(架构 §4.3 定 1.0) +var layer_fore_scroll: float # 前景滚动系数(>1 = 比相机走得快) +var focus_ratio: float # 进度焦点钉在视口的横向比例(0.66 = 右 1/3) +var idle_zoom: float # 巡游基准缩放 +var arena_zoom: float # 殿王战推近缩放(应 > idle_zoom) +var arena_focus_ratio: float # 殿王战 arena 中心钉在视口的比例(0.5 = 居中) + + +# ---------------------------------------------------------------------------- +# 几何(纯函数;base_w 可注入便于单测) +# ---------------------------------------------------------------------------- + +## 该 band 的世界宽度 +func band_width(base_w: float = VIEW_BASE_W) -> float: + return band_screens * base_w + + +## 第 layer_index 层的滚动系数(越界返回近景系数兜底,避免静默错位) +func layer_scroll(layer_index: int) -> float: + match layer_index: + LAYER_FAR: + return layer_far_scroll + LAYER_MID: + return layer_mid_scroll + LAYER_NEAR: + return layer_near_scroll + LAYER_FORE: + return layer_fore_scroll + return layer_near_scroll + + +## 缩放 z 下可视世界的宽度 +static func view_width(z: float, base_w: float = VIEW_BASE_W) -> float: + if z <= 0.0: + return base_w + return base_w / z + + +## 使世界点 front_x 出现在视口 focus_ratio 处的相机 x +## 推导:screen = (world - cam) * z + W/2 = focus * W → cam = front_x - W*(focus - 0.5)/z +static func camera_x_for_front(front_x: float, z: float, focus: float, + base_w: float = VIEW_BASE_W) -> float: + var zz := maxf(z, 0.0001) + return front_x - base_w * (focus - 0.5) / zz + + +## 相机 x 下可视世界的左边界(用于最后一段 clamp,防止看穿世界边) +static func world_left_of_camera(cam_x: float, z: float, base_w: float = VIEW_BASE_W) -> float: + return cam_x - view_width(z, base_w) * 0.5 + + +# ---------------------------------------------------------------------------- +# 运动学(纯静态函数,单测直接调用) +# ---------------------------------------------------------------------------- + +## 临界阻尼弹簧积分一步(半隐式欧拉):返回 Vector2(新位移, 新速度) +## 用法:偷看回弹 —— 松手后 offset 由本函数趋回 0。 +static func spring_step(x: float, v: float, dt: float, k: float, c: float) -> Vector2: + if dt <= 0.0: + return Vector2(x, v) + var nv := v + (-k * x - c * v) * dt + var nx := x + nv * dt + return Vector2(nx, nv) + + +## 指数平滑:tau 越小跟得越紧(tau<=0 视作瞬时) +static func exp_smooth(cur: float, target: float, dt: float, tau: float) -> float: + if tau <= 0.0 or dt <= 0.0: + return target + var a: float = 1.0 - exp(-dt / tau) + return cur + (target - cur) * a + + +## 偷看偏移夹逼(±max_px) +static func clamp_peek(offset: float, max_px: float) -> float: + if max_px <= 0.0: + return 0.0 + return clampf(offset, -max_px, max_px) + + +## 相机可见区间 [left,right] 限制在世界 [0, world_w] 内(世界比视口窄时居中) +static func clamp_camera_x(cam_x: float, z: float, world_w: float, + base_w: float = VIEW_BASE_W) -> float: + var half := view_width(z, base_w) * 0.5 + if world_w <= half * 2.0: + return world_w * 0.5 + return clampf(cam_x, half, world_w - half) diff --git a/src/core/economy/economy_state.gd b/src/core/economy/economy_state.gd index be2e367..e8c5c2f 100644 --- a/src/core/economy/economy_state.gd +++ b/src/core/economy/economy_state.gd @@ -17,11 +17,12 @@ extends RefCounted # output/s = Σ_g(rate_g × n_g) × M_gen(g) × M_global × 事件瞬时倍率 # 事件倍率是独立瞬时区(set_event_mult),不进全局乘区([B§1/§5])。 # -# W2 范围声明(诚实标注): -# - 殿王战 / 事件系统为 W3+;prestige 触发暂以"H10 殿门达成"为代理 -# ([B§5] 殿门与殿王战解耦,W3 换真 boss 胜负判定) -# - 冤气入口(事件吞噬/天狗/放弃超度,[B§3.2])随 W3 事件到位; -# 本类已实现完整 devour / 冤煞层 API 供 sim 门 #12 与 W3 接线 +# W3 范围声明(诚实标注): +# - 事件系统(game/EventDirector)与殿王战(game/BossBattle)已接线; +# 事件瞬时区走本类的 event_mult(产出端)+ event_click_mult(点击端), +# 哗变惩罚走 set_mutiny —— 均为 data-driven,events.csv 一行一个事件 +# - prestige 触发仍以"H10 殿门达成"为代理;W4 起由 sim 门 #11 用 BossBattle 标定 +# 是否把"殿王战胜负"接进轮回触发条件([B§5] 明确:两者解耦) # ============================================================================ ## 购买结果 ok 值 @@ -54,6 +55,11 @@ var wrath_layers: int = 0 # 怨煞层([B§3.2] 22 层上限) # ---- 事件瞬时区(game/EventDirector 写入;W2 默认 1.0)---- var event_mult: float = 1.0 +# ---- W3:事件瞬时区(点击端)与阴兵哗变限时惩罚 ---- +var event_click_mult: float = 1.0 # 点击事件倍率(天狗食月 ×10),独立瞬时区 +var mutiny_gen_index: int = -1 # 哗变打击的班底索引(-1 = 无) +var mutiny_mult: float = 1.0 # 哗变期间该班底的产出乘子(0.5 = -50%) +var mutiny_left: float = 0.0 # 剩余秒数 # ---- 调参数缓存(init 时从 data 读取一次;禁硬编码)---- var _click_base: float = 1.0 @@ -119,13 +125,16 @@ func output_per_sec() -> float: var lv: int = gen_levels[i] if lv <= 0: continue - sum += data.generators[i].base_rate * float(lv) * _mult.gen_mult(data.generators[i].id) + var gm := 1.0 + if i == mutiny_gen_index and mutiny_left > 0.0: + gm = mutiny_mult + sum += data.generators[i].base_rate * float(lv) * _mult.gen_mult(data.generators[i].id) * gm return sum * _mult.global_mult() * event_mult ## 点击价值([B§2.2]:基础值 × 点击区倍率;法器附加走全局区,P-15) func click_value() -> float: - return _click_base * _mult.click_mult() + return _click_base * _mult.click_mult() * event_click_mult ## 执行一次点击:加阴德(累计口径单调)+ 过殿轮询;返回获得量 @@ -152,6 +161,11 @@ func souls_saved() -> int: func tick(dt: float) -> void: if dt <= 0.0: return + if mutiny_left > 0.0: + mutiny_left = maxf(0.0, mutiny_left - dt) + if mutiny_left <= 0.0: + mutiny_gen_index = -1 + mutiny_mult = 1.0 var gain := output_per_sec() * dt if gain > 0.0: merit += gain @@ -530,6 +544,26 @@ func set_event_mult(m: float) -> void: event_mult = maxf(0.0, m) +## W3:事件瞬时区(点击端)——天狗食月一类事件的点击倍率(p_clicks) +func set_event_click_mult(m: float) -> void: + event_click_mult = maxf(0.0, m) + + +## W3:阴兵哗变限时惩罚(click_challenge 失败;events.csv 的 p_penalty = 0.5 → ×0.5) +func set_mutiny(gen_index: int, mult: float, dur_sec: float) -> void: + if gen_index < 0 or gen_index >= data.generators.size() or dur_sec <= 0.0: + return + mutiny_gen_index = gen_index + mutiny_mult = clampf(mult, 0.0, 1.0) + mutiny_left = dur_sec + + +func clear_mutiny() -> void: + mutiny_gen_index = -1 + mutiny_mult = 1.0 + mutiny_left = 0.0 + + # ---------------------------------------------------------------------------- # 快照(10Hz;UI 拉取。W2 为 Dictionary;W3 换预分配 RefCounted 结构, # 字段与脏检查纪律见架构 §4.2——当前 UI 层自己做脏检查兜底) @@ -616,6 +650,11 @@ func _metas_view() -> Array: ## 下一座未过殿的门进度(UI"距 XX 还差 X"梯度条,[B§6]) +## W3:公开读写口给 game 层(GameState.boss_preview_info 复用) +func next_hall_info() -> Dictionary: + return _next_hall_view() + + func _next_hall_view() -> Dictionary: if halls_passed >= data.halls.size(): return {} diff --git a/src/game/boss_battle.gd b/src/game/boss_battle.gd new file mode 100644 index 0000000..2be32eb --- /dev/null +++ b/src/game/boss_battle.gd @@ -0,0 +1,265 @@ +class_name BossBattle +extends RefCounted + +# ============================================================================ +# BossBattle —— 殿王战逻辑状态机(架构 §4.3;Balance Bible [B§5] 的 W3 落地) +# +# 公式全部来自 boss_battles.csv 的 BossDef(代码零硬编码): +# HP = hp_factor(15) × 进入时产出/s × hall_factor(1.0→2.2) +# 军列 DPS = 进入时产出/s × army_dps_factor(1.0) +# 点击伤害 = 进入时点击价值 × (1 + min(连击层数×combo_step, combo_cap)) [上限 +60%] +# INTRO = intro_sec(3s) 军列冲锋;开场伤害 = min(超度×0.5, HP×0.2) +# ACTIVE = active_sec(30s);HP≤0 胜 / 超时败 +# 通过奖励 = 阴德爆发 = HP × reward_hp_ratio(0.2)(进本轮累计口径 → 可推殿门) +# 失败 = retry_cooldown_sec(60s) 冷却可再战 +# +# 纪律: +# - RefCounted(不用 Node):马上可在 tests/unit 无头跑(W4 sim 门 #11 直接复用本类); +# - 全部写入口经 EconomyState 现有 API(save_soul),自己不改经济字段; +# - **确定性**:本类不用随机数 → 同一份存档在 sim 里跑出同一结果(sim 门 #11 前提); +# - 点击期间不产出点击阴德(避免"一边屠杀一边收钱"的双蘸),由 GameState 路由决定。 +# +# 工程裁定(待文策渊确认):购买了 up_gate_boss_retry(过 H7 后出现)后,战败冷却可 +# 立即清零重来;未购买则按数据行走完 60s 冷却([B§5]"H7 前败即推进冷却"的读法)。 +# ============================================================================ + +signal started(hall_no: int, hp_max: float) +signal phase_changed(phase: int) +signal finished(hall_no: int, win: bool, reward: float) + +enum Phase { IDLE, INTRO, ACTIVE, RESOLVED } + +var data: BalanceData +var def: BossDef +var hall_no: int = 0 +var phase: int = Phase.IDLE + +var hp: float = 0.0 +var hp_max: float = 0.0 +var combo: int = 0 +var combo_left: float = 0.0 +var timer: float = 0.0 +var cooldown: float = 0.0 +var win: bool = false +var entry_output: float = 0.0 +var entry_click: float = 0.0 +var last_reward: float = 0.0 +var cleared: Dictionary = {} # boss id -> true(同一轮回内不重复挑战) + + +func _init(p_data: BalanceData) -> void: + data = p_data + + +# ---------------------------------------------------------------------------- +# 查询 / 视图(UI 只读) +# ---------------------------------------------------------------------------- + +func is_active() -> bool: + return phase == Phase.INTRO or phase == Phase.ACTIVE + + +func combo_mult() -> float: + if def == null: + return 1.0 + return 1.0 + minf(float(combo) * def.combo_step, def.combo_cap) + + +## 当前可挑战的殿王:已过该殿门 + 未通关 + 不在冷却/进行中 +## 原因串要**准确**:RESOLVED(结算面板挂着)与 INTRO/ACTIVE(真在打)是两回事, +## UI 与单测都靠它区分"等玩家点确认"和"别打断战斗",不能一律回"战斗中"。 +func available(econ: EconomyState) -> Dictionary: + var out := {"ok": false, "hall_index": -1, "reason": "", "cooldown": cooldown} + if phase == Phase.RESOLVED: + out.reason = "胜败已定,待确认结算" if win else "败北结算中" + return out + if phase != Phase.IDLE: + out.reason = "战斗中" + return out + if cooldown > 0.0: + out.reason = "冷却中 %.0fs" % [cooldown] + return out + if econ.halls_passed <= 0: + out.reason = "尚未通过任何殿门" + return out + var idx := econ.halls_passed - 1 # 刚过的那座殿,殿王在等你 + if idx < 0 or idx >= data.halls.size(): + out.reason = "殿序越界" + return out + var hall: HallDef = data.halls[idx] + var d := data.boss_for_hall(hall.id) + if d == null: + out.reason = "该殿无 boss_battles 行" + return out + if cleared.has(d.id): + out.reason = "本轮已通关 %s" % hall.king_cn + return out + if econ.output_per_sec() <= 0.0: + out.reason = "军列未成(产出为 0)" + return out + out.ok = true + out.hall_index = idx + out.king_cn = hall.king_cn + return out + + +## 视图(UI 只读;can_retry 需要 econ 才能查 up_gate_boss_retry) +func view(econ: EconomyState = null) -> Dictionary: + var can_retry := cooldown <= 0.0 + if not can_retry and econ != null: + can_retry = econ.bought.has("up_gate_boss_retry") + return { + "phase": phase, + "active": is_active(), + "hall_no": hall_no, + "king_cn": _king_name(), + "hp": hp, + "hp_max": hp_max, + "hp_ratio": 0.0 if hp_max <= 0.0 else clampf(hp / hp_max, 0.0, 1.0), + "timer": timer, + "combo": combo, + "combo_mult": combo_mult(), + "cooldown": cooldown, + "can_retry": can_retry, + "win": win, + "reward": last_reward, + } + + +func _king_name() -> String: + var h := data.hall_by_no(hall_no) + return h.king_cn if h != null else "" + + +# ---------------------------------------------------------------------------- +# 生命周期 +# ---------------------------------------------------------------------------- + +func start(econ: EconomyState, hall_index: int) -> Dictionary: + if is_active(): + return {"ok": false, "reason": "战斗进行中"} + if hall_index < 0 or hall_index >= data.halls.size(): + return {"ok": false, "reason": "殿序越界"} + var hall: HallDef = data.halls[hall_index] + var d := data.boss_for_hall(hall.id) + if d == null: + return {"ok": false, "reason": "该殿无 boss_battles 行"} + if cleared.has(d.id): + return {"ok": false, "reason": "本轮已通关"} + if cooldown > 0.0: + return {"ok": false, "reason": "冷却中"} + var out := econ.output_per_sec() + if out <= 0.0: + return {"ok": false, "reason": "军列未成(产出为 0)"} + def = d + hall_no = hall.no + entry_output = out + entry_click = econ.click_value() + hp_max = def.hp_at(entry_output) + hp = hp_max + combo = 0 + combo_left = 0.0 + win = false + last_reward = 0.0 + timer = def.intro_sec + phase = Phase.INTRO + started.emit(hall_no, hp_max) + phase_changed.emit(phase) + return {"ok": true, "reason": "ok", "hp_max": hp_max, "hall_no": hall_no} + + +func tick(dt: float, econ: EconomyState) -> void: + if dt <= 0.0: + return + if phase == Phase.IDLE: + if cooldown > 0.0: + cooldown = maxf(0.0, cooldown - dt) + return + if phase == Phase.RESOLVED: + return + timer -= dt + if combo_left > 0.0: + combo_left -= dt + if combo_left <= 0.0: + combo = 0 + combo_left = 0.0 + if phase == Phase.INTRO: + if timer <= 0.0: + _apply_intro_damage(econ) + timer = def.active_sec + phase = Phase.ACTIVE + phase_changed.emit(phase) + return + # ACTIVE + hp -= entry_output * def.army_dps_factor * dt + if hp <= 0.0: + hp = 0.0 + _resolve(econ, true) + return + if timer <= 0.0: + _resolve(econ, false) + + +## 玩家挥刃(ACTIVE 阶段有效);返回本次伤害,0 = 未生效 +func register_click(econ: EconomyState) -> float: + if phase != Phase.ACTIVE or def == null: + return 0.0 + combo += 1 + combo_left = def.combo_window_sec + var dmg := entry_click * combo_mult() + hp = maxf(0.0, hp - dmg) + if hp <= 0.0: + _resolve(econ, true) + return dmg + + +## 战败后无条件重来(UI"再战"按钮在冷却未到、且已购 up_gate_boss_retry 时可用) +func force_retry(econ: EconomyState) -> bool: + if cooldown <= 0.0: + return true + if econ.bought.has("up_gate_boss_retry"): + cooldown = 0.0 + return true + return false + + +## UI 确认过结算面板后调用 → 回到 IDLE +func reset() -> void: + phase = Phase.IDLE + timer = 0.0 + combo = 0 + combo_left = 0.0 + + +## 轮回/新开局时清空通关记录(cleared 是进度态,随本轮重置) +func reset_for_new_run() -> void: + cleared.clear() + reset() + cooldown = 0.0 + hp = 0.0 + hp_max = 0.0 + + +# ---------------------------------------------------------------------------- +# 内部 +# ---------------------------------------------------------------------------- + +func _apply_intro_damage(econ: EconomyState) -> void: + # 开场伤害 = min(超度值 × intro_dmg_souls_factor, HP × intro_dmg_cap_ratio)([B§5]) + var raw := float(econ.souls_saved()) * def.intro_dmg_souls_factor + var capv := hp_max * def.intro_dmg_cap_ratio + hp = maxf(0.0, hp - minf(raw, capv)) + + +func _resolve(econ: EconomyState, win_flag: bool) -> void: + phase = Phase.RESOLVED + win = win_flag + if win_flag: + if def != null: + cleared[def.id] = true + last_reward = hp_max * (def.reward_hp_ratio if def != null else 0.0) + econ.save_soul(last_reward) + else: + cooldown = def.retry_cooldown_sec if def != null else 0.0 + finished.emit(hall_no, win_flag, last_reward) + phase_changed.emit(phase) diff --git a/src/game/data_registry.gd b/src/game/data_registry.gd index 4a665dc..9466ce0 100644 --- a/src/game/data_registry.gd +++ b/src/game/data_registry.gd @@ -77,6 +77,11 @@ func palette_for_hall(hall_id: String) -> PaletteDef: return data.palette_for_hall(hall_id) +## W3:per-hall 相机 rig 行(camera.csv → CameraDef) +func camera_for_hall(hall_id: String) -> CameraDef: + return data.camera_for_hall(hall_id) + + func event(id: String) -> EventDef: return data.event(id) diff --git a/src/game/event_director.gd b/src/game/event_director.gd new file mode 100644 index 0000000..b8bee01 --- /dev/null +++ b/src/game/event_director.gd @@ -0,0 +1,328 @@ +class_name EventDirector +extends RefCounted + +# ============================================================================ +# EventDirector —— 事件系统运行时(架构 §4.4;Balance Bible [B§7] 的 W3 落地) +# +# 4 事件全部来自 events.csv,代码零硬编码: +# output_mult → 全产出 ×p_mult,持续 duration_sec(百鬼夜行 ×5 / 60s) +# click_output_mult → 点击 ×p_clicks + 全产出 ×p_mult(天狗食月 ×10 / ×2 / 30s) +# click_challenge → p_window_sec 内点击 ≥p_clicks 次;失败 → 随机已解锁班底 +# 产出 ×(1-p_penalty) 持续 p_penalty_dur_sec(阴兵闹饷 -50% / 60s) +# choice → 非阻塞二选一(冤魂申诉 45s):opt_a = 超度(阴德爆发), +# opt_b = 吞噬(冤气);超时默认由 tune_event_timeout_default 指定 +# +# 纪律: +# - RefCounted(不是 Node):不碰场景树 → tests/unit 可无头跑,sim 未来也可复用; +# 按架构 §4.5,进度状态所有者是 game/GameState,本类由它持有并 tick; +# - 效果一律经 EconomyState 的既有 API(set_event_mult / set_event_click_mult / +# set_mutiny / save_soul / devour),不直接改字段; +# - 事件倍率走**独立瞬时区**,不进全局乘区([B§7]/架构 §4.2); +# - 随机源可注入种子 → 单测可复现。 +# +# 出现前提:购买 up_gate_events(过 H2 后出现,见 upgrades.csv)。 +# ============================================================================ + +signal event_started(id: String, name_cn: String) +signal event_resolved(id: String, summary: Dictionary) + +enum State { IDLE, RUNNING, CHOOSING } + +var data: BalanceData +var rng: RandomNumberGenerator +var state: int = State.IDLE +var current: EventDef +var remain: float = 0.0 # RUNNING 剩余秒数 +var cooldown: float = 0.0 # 距下一次触发的剩余秒数 +var challenge_clicks: int = 0 +var challenge_left: float = 0.0 +var choice_left: float = 0.0 +var last_summary: Dictionary = {} + +var _first_min: float = 3.0 +var _timeout_default: String = "opt_a" + + +func _init(p_data: BalanceData, seed_value: int = 20261019) -> void: + data = p_data + rng = RandomNumberGenerator.new() + rng.seed = seed_value + _first_min = data.tune_num("tune_event_first_min", 3.0) + _timeout_default = data.tune_text("tune_event_timeout_default", "opt_a") + cooldown = _first_min * 60.0 + + +# ---------------------------------------------------------------------------- +# 查询 / 视图(UI 只读) +# ---------------------------------------------------------------------------- + +## 事件系统是否解锁(路引 up_gate_events;过 H2 后出现) +func unlocked(econ: EconomyState) -> bool: + return econ.bought.has("up_gate_events") + + +func is_busy() -> bool: + return state != State.IDLE + + +func _to_state(s: int) -> void: + state = s + + +func view(econ: EconomyState) -> Dictionary: + var v := { + "state": state, + "active": is_busy(), + "unlocked": unlocked(econ), + "id": "", + "name_cn": "", + "flavor_cn": "", + "type": "", + "remain": remain, + "cooldown": cooldown, + "need_clicks": 0, + "clicks": challenge_clicks, + "window_left": challenge_left, + "choice_left": choice_left, + "opt_a": {}, + "opt_b": {}, + # 以下四个是**只读**的数据行参数,供 UI 呈现(唯一真相仍在 events.csv / 本类) + "mult": 1.0, + "click_mult": 1.0, + "penalty": 0.0, + "penalty_dur": 0.0, + } + if state == State.IDLE or current == null: + return v + v.id = current.id + v.name_cn = current.name_cn + v.flavor_cn = current.flavor_cn + v.type = current.type + v.mult = current.p_mult + v.click_mult = current.p_clicks + v.penalty = current.p_penalty + v.penalty_dur = current.p_penalty_dur_sec + if current.type == "click_challenge": + v.need_clicks = int(current.p_clicks) + if current.type == "choice": + v.remain = choice_left + v.opt_a = _option_view(econ, current.opt_a_type, current.opt_a_param, "超度") + v.opt_b = _option_view(econ, current.opt_b_type, current.opt_b_param, "吞噬") + return v + + +func _option_view(econ: EconomyState, opt_type: String, param: float, label: String) -> Dictionary: + var amount := 0.0 + match opt_type: + "merit_burst_sec": + amount = econ.output_per_sec() * param + "wrath_gain": + amount = wrath_amount(econ) + return {"type": opt_type, "param": param, "label": label, "amount": amount} + + +## 吞噬所得冤气 = lerp(p_clicks, p_window_sec, 殿序进度)(events.csv 注释口径) +func wrath_amount(econ: EconomyState) -> float: + if current == null: + return 0.0 + var halls_total := maxf(float(data.halls.size()), 1.0) + var t := clampf(float(econ.halls_passed) / halls_total, 0.0, 1.0) + return lerp(current.p_clicks, current.p_window_sec, t) + + +# ---------------------------------------------------------------------------- +# tick(GameState 的 10Hz 步长驱动) +# ---------------------------------------------------------------------------- + +func tick(dt: float, econ: EconomyState) -> void: + if dt <= 0.0: + return + if not unlocked(econ): + return + match state: + State.IDLE: + cooldown -= dt + if cooldown <= 0.0: + _trigger(econ) + State.RUNNING: + remain -= dt + if current.type == "click_challenge": + challenge_left -= dt + if challenge_left <= 0.0: + _resolve_challenge(econ) + return + if remain <= 0.0: + _finish(econ, "timeout") + State.CHOOSING: + choice_left -= dt + if choice_left <= 0.0: + choose(econ, 0 if _timeout_default == "opt_a" else 1, true) + + +## 玩家点击 → 挑战计数(是否在窗口内由本类判定) +## 提前达标的行为(与 tick 联动,勿只看本函数):把 challenge_left 归零 → 窗口关闭, +## tick 的 RUNNING 分支下一拍(10Hz = 0.1s 内)即以 challenge_left<=0 判成功结算。 +## click_challenge 不挂任何增益(_begin 不给倍率),所以"点够即出结果"是正确体感: +## 玩家不必干等到窗口走完。UI 的"已稳住"提示只在结算前这 0.1s 内闪现,属预期。 +func notify_click() -> void: + if state != State.RUNNING or current == null: + return + if current.type != "click_challenge": + return + if challenge_left <= 0.0: # 窗口已闭:不再计数(防"结算后再点"污染计数) + return + challenge_clicks += 1 + if challenge_clicks >= int(current.p_clicks): + _settle_challenge_ok() + + +## 二选一(opt_index: 0 = opt_a 超度 / 1 = opt_b 吞噬) +func choose(econ: EconomyState, opt_index: int, timed_out: bool = false) -> Dictionary: + if current == null or current.type != "choice": + return {"ok": false, "reason": "当前无可选项"} + var opt_type := current.opt_a_type if opt_index == 0 else current.opt_b_type + var opt_param := current.opt_a_param if opt_index == 0 else current.opt_b_param + var result := {"ok": true, "option": opt_index, "type": opt_type, "timed_out": timed_out} + match opt_type: + "merit_burst_sec": + var amount := econ.output_per_sec() * opt_param + econ.save_soul(amount) + result.merit = amount + "wrath_gain": + var amount := wrath_amount(econ) + econ.devour(amount) + result.wrath = amount + _: + pass + _clear_runtime(econ) + _schedule_next() + _to_state(State.IDLE) + last_summary = result + last_summary.id = current.id + last_summary.name_cn = current.name_cn + event_resolved.emit(current.id, last_summary) + current = null + return result + + +# ---------------------------------------------------------------------------- +# 内部:触发 / 结算 +# ---------------------------------------------------------------------------- + +func _trigger(econ: EconomyState) -> void: + var row: EventDef = data.events[rng.randi_range(0, data.events.size() - 1)] + _begin(econ, row) + + +## dev / 单测入口:**强制**开一个事件(生产路径仍是随机触发 _trigger)。 +## 用途:dev 面板手动复现某个事件、tests/unit 逐个类型断言(不需要靠随机撞类型)。 +func force_start(econ: EconomyState, event_id: String) -> Dictionary: + var row := data.event(event_id) + if row == null: + return {"ok": false, "reason": "事件不存在:%s" % event_id} + if is_busy(): + _clear_runtime(econ) + _to_state(State.IDLE) + current = null + _begin(econ, row) + return {"ok": true, "reason": "ok", "id": row.id, "type": row.type} + + +func _begin(econ: EconomyState, row: EventDef) -> void: + current = row + remain = row.duration_sec + challenge_clicks = 0 + challenge_left = row.p_window_sec + choice_left = row.duration_sec + match row.type: + "output_mult", "click_output_mult": + econ.set_event_mult(row.p_mult) + if row.type == "click_output_mult": + econ.set_event_click_mult(row.p_clicks) + _to_state(State.RUNNING) + "click_challenge": + remain = row.p_window_sec + _to_state(State.RUNNING) + "choice": + _to_state(State.CHOOSING) + _: + _to_state(State.RUNNING) + event_started.emit(row.id, row.name_cn) + + +func _resolve_challenge(econ: EconomyState) -> void: + var ok := challenge_clicks >= int(current.p_clicks) + if not ok: + _apply_penalty(econ) + var pen_id := "" + if econ.mutiny_gen_index >= 0 and econ.mutiny_gen_index < econ.data.generators.size(): + pen_id = econ.data.generators[econ.mutiny_gen_index].id + _clear_runtime(econ) + _schedule_next() + _to_state(State.IDLE) + last_summary = { + "ok": true, + "challenge_ok": ok, + "clicks": challenge_clicks, + "need": int(current.p_clicks), + "penalty": not ok, + "penalty_gen": pen_id, + "penalty_mult": econ.mutiny_mult, + "penalty_dur": econ.mutiny_left, + } + last_summary.id = current.id + last_summary.name_cn = current.name_cn + event_resolved.emit(current.id, last_summary) + current = null + + +func _settle_challenge_ok() -> void: + # 达标即不再计时(窗口已闭,UI 仍显示事件直到 remain 走完) + challenge_left = 0.0 + + +func _apply_penalty(econ: EconomyState) -> void: + var pool: Array[int] = [] + for i in econ.data.generators.size(): + if econ.generator_unlocked(i) and econ.gen_levels[i] > 0: + pool.append(i) + if pool.is_empty(): + return + var pick: int = pool[rng.randi_range(0, pool.size() - 1)] + var mult := clampf(1.0 - current.p_penalty, 0.0, 1.0) + econ.set_mutiny(pick, mult, current.p_penalty_dur_sec) + + +func _finish(econ: EconomyState, why: String) -> void: + var name_cn := current.name_cn + var id := current.id + _clear_runtime(econ) + _schedule_next() + _to_state(State.IDLE) + last_summary = {"ok": true, "why": why, "type": current.type if current != null else ""} + last_summary.id = id + last_summary.name_cn = name_cn + event_resolved.emit(id, last_summary) + current = null + + +## 事件结束时的统一清理:瞬时区必须复位(漏清一次就会永久 ×N,是最典型的隐蔽事故) +func _clear_runtime(econ: EconomyState) -> void: + remain = 0.0 + challenge_clicks = 0 + challenge_left = 0.0 + choice_left = 0.0 + econ.set_event_mult(1.0) + econ.set_event_click_mult(1.0) + + +## 排下一次:间隔 = rng ∈ [trigger_min_min, trigger_max_min](数据行) +## 注意:绝不能把 cooldown 置 0 —— IDLE 里 `cooldown -= dt` 会立刻 ≤0 再次触发, +## 形成"事件连发"死循环。缺少数据行时退回首事件间隔(_first_min),而非 0。 +func _schedule_next() -> void: + if current == null: + cooldown = _first_min * 60.0 + return + var lo := current.trigger_min_min * 60.0 + var hi := maxf(current.trigger_max_min, current.trigger_min_min) * 60.0 + cooldown = rng.randf_range(lo, hi) diff --git a/src/game/game_state.gd b/src/game/game_state.gd index 91e9a37..9b459c2 100644 --- a/src/game/game_state.gd +++ b/src/game/game_state.gd @@ -12,26 +12,45 @@ extends Node # 纪律: # - game/ 不直连 Steam(W6 SteamService);不碰渲染(presentation 的活) # - UI 永不直接写 econ;一切经 cmd_*(校验+扣费在 core 完成) -# - W2 不含事件导演/解锁导演/殿王战(W3+);殿门轮询在 EconomyState.tick 内 +# - W3 已接:EventDirector(events.csv 4 事件)+ BossBattle(boss_battles.csv 十场); +# 殿门轮询仍在 EconomyState.tick 内;殿王战期间点击 = 挥刃(不产出点击阴德,避免双蘸) # ============================================================================ const STEP := 0.1 # 经济固定步长 10Hz(架构 §4.2) var econ: EconomyState +var events: EventDirector # W3:事件导演(RefCounted,本节点持有并 tick) +var boss: BossBattle # W3:殿王战逻辑状态机(RefCounted) var _acc: float = 0.0 var _last_halls_passed: int = 0 +var _boss_preview_sec: float = 900.0 # tune_boss_preview_min × 60 signal snapshot_changed(snap: Dictionary) # 10Hz signal purchase_result(ok: bool, reason: String) signal hall_passed(hall_no: int) signal prestige_done(suihui_gain: int, rebirths: int) signal enthrone_done() +# ---- W3:事件 / 殿王战 ---- +signal event_started(id: String, name_cn: String) +signal event_resolved(id: String, summary: Dictionary) +signal boss_started(hall_no: int, hp_max: float) +signal boss_finished(hall_no: int, win: bool, reward: float) +signal boss_phase_changed(phase: int) func _ready() -> void: DataRegistry.ensure_loaded() econ = EconomyState.new(DataRegistry.data) _last_halls_passed = econ.halls_passed + _boss_preview_sec = DataRegistry.data.tune_num("tune_boss_preview_min", 15.0) * 60.0 + # W3:两个运行时都拥有独立随机/状态,不需要节点树(单测可无头构造) + events = EventDirector.new(DataRegistry.data, 20261019) + boss = BossBattle.new(DataRegistry.data) + events.event_started.connect(_on_event_started) + events.event_resolved.connect(_on_event_resolved) + boss.started.connect(_on_boss_started) + boss.finished.connect(_on_boss_finished) + boss.phase_changed.connect(_on_boss_phase) snapshot_changed.emit(econ.snapshot()) @@ -41,6 +60,8 @@ func _process(_dt: float) -> void: var steps := 0 while _acc >= STEP and steps < 10: econ.tick(STEP) + events.tick(STEP, econ) + boss.tick(STEP, econ) _acc -= STEP steps += 1 if steps >= 10: @@ -55,12 +76,42 @@ func _emit_hall_events() -> void: hall_passed.emit(_last_halls_passed) +# ---- W3:事件 / 殿王战信号转发(内部系统 → 表现层;game 层只做透传)---- + +func _on_event_started(id: String, name_cn: String) -> void: + event_started.emit(id, name_cn) + + +func _on_event_resolved(id: String, s: Dictionary) -> void: + event_resolved.emit(id, s) + + +func _on_boss_started(hall_no: int, hp_max: float) -> void: + boss_started.emit(hall_no, hp_max) + + +func _on_boss_finished(hall_no: int, win: bool, reward: float) -> void: + boss_finished.emit(hall_no, win, reward) + + +func _on_boss_phase(phase: int) -> void: + boss_phase_changed.emit(phase) + + # ---------------------------------------------------------------------------- # 命令 API(UI 唯一写入口) # ---------------------------------------------------------------------------- ## 点击(返回获得阴德;UI 可做飘字) +## W3:殿王战 ACTIVE 期间点击 = 挥刃(走 BossBattle,不产出点击阴德,避免"边打边赚"); +## 事件系统的点击计数照常走(阴兵闹饷挑战要数点击次数)。 func cmd_click() -> float: + events.notify_click() + if boss.is_active(): + var dmg := boss.register_click(econ) + if boss.phase != BossBattle.Phase.ACTIVE: + _emit_hall_events() + return dmg var gain := econ.click() _emit_hall_events() return gain @@ -91,11 +142,65 @@ func cmd_buy_wrath_layer() -> Dictionary: return r -## 事件入口(W3 EventDirector 接线;API 先行) +## 事件入口(W3 EventDirector 已接线) func cmd_devour(lump: float) -> void: econ.devour(lump) +## W3:事件二选一(0 = opt_a 超度 / 1 = opt_b 吞噬) +func cmd_event_choose(opt_index: int) -> Dictionary: + return events.choose(econ, opt_index) + + +## W3:开打殿王战(hall_index = halls.csv 行序 index) +func cmd_boss_start(hall_index: int) -> Dictionary: + var r := boss.start(econ, hall_index) + if not bool(r.get("ok", false)): + purchase_result.emit(false, str(r.get("reason", ""))) + return r + + +## W3:殿王战中的一次挥刃(等价于 cmd_click 在战斗期的语义;供 UI 显式绑定) +func cmd_boss_click() -> float: + return boss.register_click(econ) + + +## W3:战败后重来(冷却未到时,需已购 up_gate_boss_retry) +func cmd_boss_retry() -> bool: + return boss.force_retry(econ) + + +## W3:结算面板确认 → 战斗回到 IDLE +func cmd_boss_ack() -> void: + boss.reset() + + +## W3:当前可战的殿王(连同不可战原因,UI 直接显示) +func boss_available() -> Dictionary: + return boss.available(econ) + + +## W3:殿王战战前预告(下一场 boss 的倒计时;ETA ≤ tune_boss_preview_min 分钟才给) +func boss_preview_info() -> Dictionary: + var out := {"hall_no": 0, "king_cn": "", "eta_sec": 0.0, "visible": false, "ready": false, + "preview_min": _boss_preview_sec / 60.0} + var info := boss.available(econ) + out.ready = bool(info.get("ok", false)) + var halls := DataRegistry.data.halls + if econ.halls_passed >= halls.size(): + return out + var h: HallDef = halls[econ.halls_passed] + var need := h.gate_merit + var have := econ.merit_run_total + var ops := econ.output_per_sec() + var eta := INF if ops <= 0.0 else maxf(0.0, (need - have) / ops) + out.hall_no = h.no + out.king_cn = h.king_cn + out.eta_sec = eta + out.visible = eta <= _boss_preview_sec + return out + + func cmd_save_soul(lump: float) -> void: econ.save_soul(lump) @@ -104,6 +209,7 @@ func cmd_prestige() -> Dictionary: var r := econ.do_prestige() if bool(r.get("ok", false)): _last_halls_passed = 0 + boss.reset_for_new_run() prestige_done.emit(int(r.get("suihui_gain", 0)), int(r.get("rebirths", 0))) else: purchase_result.emit(false, str(r.get("reason", ""))) @@ -135,4 +241,11 @@ func bulk_unlocked(mode: int) -> bool: ## 当前快照(UI _draw/刷新时拉取;10Hz 信号已广播,此为按需补拉) func get_snapshot() -> Dictionary: - return econ.snapshot() + var snap := econ.snapshot() + snap["event"] = events.view(econ) + snap["boss"] = boss.view(econ) + snap["boss_available"] = boss.available(econ) + snap["boss_preview"] = boss_preview_info() + snap["mutiny"] = {"gen_index": econ.mutiny_gen_index, "mult": econ.mutiny_mult, + "left": econ.mutiny_left} + return snap diff --git a/src/presentation/hud/event_panel.gd b/src/presentation/hud/event_panel.gd new file mode 100644 index 0000000..b60db04 --- /dev/null +++ b/src/presentation/hud/event_panel.gd @@ -0,0 +1,128 @@ +class_name EventPanel +extends VBoxContainer + +# ============================================================================ +# EventPanel —— 事件卡 HUD(架构 §4.4;[B§7] 4 事件的 W3 灰盒呈现) +# +# 纪律: +# - 只读 GameState 快照的 "event" 视图 + 发命令(choose_requested); +# 本面板不持有事件状态(架构 §4.5); +# - 空闲时 visible = false(不占布局高度;灰盒竖向预算紧,见验证手册); +# - 灰盒规范 §10.4:默认主题 + 系统字体 + 纯文字,零图标零动画。 +# ============================================================================ + +signal choose_requested(opt_index: int) + +var _title: Label +var _flavor: Label +var _effect: Label +var _timer: Label +var _opt_a: Button +var _opt_b: Button + +var _last_title := "" +var _last_effect := "" +var _last_flavor := "" +var _last_timer := "" + + +func _ready() -> void: + add_theme_constant_override("separation", 4) + _title = _mk_label(20) + add_child(_title) + _flavor = _mk_label(13) + add_child(_flavor) + _effect = _mk_label(15) + add_child(_effect) + _timer = _mk_label(14) + add_child(_timer) + _opt_a = _mk_button("超度") + _opt_a.pressed.connect(func(): choose_requested.emit(0)) + add_child(_opt_a) + _opt_b = _mk_button("吞噬") + _opt_b.pressed.connect(func(): choose_requested.emit(1)) + add_child(_opt_b) + visible = false + + +func _mk_label(size: int) -> Label: + var l := Label.new() + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", Color("F2EDE4")) + return l + + +func _mk_button(text: String) -> Button: + var b := Button.new() + b.text = text + b.custom_minimum_size = Vector2(240, 34) + b.focus_mode = Control.FOCUS_NONE + return b + + +## 快照刷新(view = GameState 快照里的 "event" 字典) +func refresh(view: Dictionary) -> void: + var active := bool(view.get("active", false)) + visible = active + if not active: + return + var type := str(view.get("type", "")) + _set_text(_title, "%s(%s)" % [str(view.get("name_cn", "")), _type_cn(type)]) + _set_text(_flavor, str(view.get("flavor_cn", ""))) + _set_text(_effect, _effect_text(view, type)) + _set_text(_timer, _timer_text(view, type)) + var is_choice := type == "choice" + _opt_a.visible = is_choice + _opt_b.visible = is_choice + if is_choice: + var a: Dictionary = view.get("opt_a", {}) + var b: Dictionary = view.get("opt_b", {}) + _opt_a.text = "%s(+%s 阴德)" % [str(a.get("label", "超度")), + NumberFormat.short(float(a.get("amount", 0.0)))] + _opt_b.text = "%s(+%s 冤气)" % [str(b.get("label", "吞噬")), + NumberFormat.short(float(b.get("amount", 0.0)))] + + +func _set_text(label: Label, text: String) -> void: + if label.text != text: + label.text = text + + +func _type_cn(type: String) -> String: + match type: + "output_mult": + return "产出爆发" + "click_output_mult": + return "点击 + 产出爆发" + "click_challenge": + return "点击挑战" + "choice": + return "二选一" + return type + + +## 效果行文案:数值全部来自事件数据行(events.csv),UI 不做任何换算 +func _effect_text(view: Dictionary, type: String) -> String: + match type: + "output_mult": + return "全产出 ×%s" % NumberFormat.short(float(view.get("mult", 1.0))) + "click_output_mult": + return "点击 ×%s · 全产出 ×%s" % [ + NumberFormat.short(float(view.get("click_mult", 1.0))), + NumberFormat.short(float(view.get("mult", 1.0)))] + "click_challenge": + var got := int(view.get("clicks", 0)) + var need := int(view.get("need_clicks", 0)) + var pen := float(view.get("penalty", 0.0)) * 100.0 + return "窗口内点击 %d/%d 次(失败:随机班底产出 -%.0f%% 持续 %s)" % [ + got, need, pen, NumberFormat.duration(float(view.get("penalty_dur", 0.0)))] + "choice": + return "他跪在地上喊冤枉。" + return "" + + +func _timer_text(view: Dictionary, type: String) -> String: + var left := float(view.get("remain", 0.0)) + if type == "click_challenge": + left = float(view.get("window_left", 0.0)) + return "剩余 %s" % NumberFormat.duration(left) diff --git a/src/presentation/stage/band_world_root.gd b/src/presentation/stage/band_world_root.gd new file mode 100644 index 0000000..6422e9a --- /dev/null +++ b/src/presentation/stage/band_world_root.gd @@ -0,0 +1,157 @@ +class_name BandWorldRoot +extends Node2D + +# ============================================================================ +# BandWorldRoot —— 十殿世界容器(bands 沿 x 排布 + 进度/状态搬运;架构 §4.3) +# +# 纪律: +# - 数据行驱动:halls.csv 的每一行 = 一个 HallBandWorld,位置由 camera.csv 的 +# band_screens 推算;加殿 = 加行,本文件零改动(ADR-3); +# - 纯数学(ranges/front/arena)为 static 函数 → tests/unit 可无头调用; +# - presentation 层:只读数据 + 快照,不持有游戏状态(架构 §4.5)。 +# ============================================================================ + +const FRONT_MARKER_W := 10.0 +const ARENA_RATIO := 0.65 # 殿王在 band 内的位置(偏殿门一侧;纯视觉构图常量) + +var bands: Array[HallBandWorld] = [] +var ranges: Array[Vector2] = [] +var widths: Array[float] = [] +var world_width: float = 0.0 + +var _front: Polygon2D +var _front_label: Label + + +# ---------------------------------------------------------------------------- +# 纯几何(可单测;不依赖实例) +# ---------------------------------------------------------------------------- + +## cameras: Array[CameraDef](按殿序)→ 每殿 [left, right] 的世界区间 +static func compute_ranges(cameras: Array) -> Array: + var out: Array = [] + var cursor := 0.0 + for c in cameras: + var w: float = (c as CameraDef).band_width() if c is CameraDef else 2560.0 + out.append(Vector2(cursor, cursor + w)) + cursor += w + return out + + +## 进度 → 前线世界 x(in_hall ∈ [0,1] = 该殿内的推进度) +static func front_x_of(rng: Array, hall_index: int, in_hall: float) -> float: + if rng.is_empty(): + return 0.0 + var i := clampi(hall_index, 0, rng.size() - 1) + var r: Vector2 = rng[i] + return lerp(r.x, r.y, clampf(in_hall, 0.0, 1.0)) + + +## 殿王 arena 锚点(band 内 ARENA_RATIO 处) +static func arena_anchor_x(rng: Array, hall_index: int) -> float: + if rng.is_empty(): + return 0.0 + var i := clampi(hall_index, 0, rng.size() - 1) + var r: Vector2 = rng[i] + return lerp(r.x, r.y, ARENA_RATIO) + + +# ---------------------------------------------------------------------------- +# 组装(数据 → 场景) +# ---------------------------------------------------------------------------- + +func build(data: BalanceData) -> bool: + var cams: Array = [] + for h in data.halls: + var c := data.camera_for_hall(h.id) + if c == null: + push_error("BandWorldRoot:殿 %s 缺 camera 行" % h.id) + return false + cams.append(c) + ranges = compute_ranges(cams) + widths.clear() + var i := 0 + while i < ranges.size(): + var r: Vector2 = ranges[i] + widths.append(r.y - r.x) + var hall_def: HallDef = data.halls[i] + var b := HallBandWorld.assemble(hall_def, data.palette_for_hall(hall_def.id), + cams[i] as CameraDef, r.x) + b.name = "Band_%02d" % hall_def.no + add_child(b) + bands.append(b) + i += 1 + world_width = 0.0 if ranges.is_empty() else ranges[ranges.size() - 1].y + _build_front_marker() + return true + + +func _build_front_marker() -> void: + _front = Polygon2D.new() + _front.polygon = PackedVector2Array([ + Vector2(-FRONT_MARKER_W * 0.5, 30.0), Vector2(FRONT_MARKER_W * 0.5, 30.0), + Vector2(FRONT_MARKER_W * 0.5, 230.0), Vector2(-FRONT_MARKER_W * 0.5, 230.0)]) + _front.color = Color("B4402C") + add_child(_front) + _front_label = Label.new() + _front_label.add_theme_font_size_override("font_size", 18) + _front_label.add_theme_color_override("font_color", Color("F2EDE4")) + _front_label.text = "军列前线" + add_child(_front_label) + + +# ---------------------------------------------------------------------------- +# 快照上行(每帧/每快照调用;只读) +# ---------------------------------------------------------------------------- + +## 进度信息 → StageWorld 据此算相机目标 +func progress_info(halls_passed: int, have: float, need: float) -> Dictionary: + var info := { + "hall_index": 0, "in_hall": 1.0, "front_x": 0.0, "cam": null, + } + if bands.is_empty(): + return info + var idx := clampi(halls_passed, 0, bands.size() - 1) + var done := halls_passed >= bands.size() + var t := 1.0 if done else (0.0 if need <= 0.0 else clampf(have / need, 0.0, 1.0)) + info.hall_index = idx + info.in_hall = t + info.front_x = front_x_of(ranges, idx, t) + info.cam = bands[idx].cam + return info + + +func arena_info(hall_index: int) -> Dictionary: + var info := {"hall_index": hall_index, "anchor_x": 0.0, "cam": null} + if bands.is_empty(): + return info + var i := clampi(hall_index, 0, bands.size() - 1) + info.anchor_x = arena_anchor_x(ranges, i) + info.cam = bands[i].cam + return info + + +## 三态刷新(passed/current/locked) +func refresh_states(halls_passed: int, have: float, need: float) -> void: + for i in bands.size(): + var passed := i < halls_passed + var current := i == halls_passed + bands[i].set_state(passed, current, have if current else 0.0, need) + + +## 相机驱动:层视差 + 邻近 band 显隐 + 前线标记 +func update_visuals(cam_x: float, view_w: float) -> void: + var half := view_w * 0.5 + var lo := cam_x - half * 1.6 + var hi := cam_x + half * 1.6 + for i in bands.size(): + var r: Vector2 = ranges[i] + var vis := r.y >= lo and r.x <= hi + bands[i].visible = vis + if vis: + bands[i].update_layers(cam_x) + + +func update_front(front_x: float) -> void: + _front.position.x = front_x + _front_label.position = Vector2(front_x - 30.0, 18.0) diff --git a/src/presentation/stage/boss_arena.gd b/src/presentation/stage/boss_arena.gd new file mode 100644 index 0000000..ce9a337 --- /dev/null +++ b/src/presentation/stage/boss_arena.gd @@ -0,0 +1,148 @@ +class_name BossArena +extends VBoxContainer + +# ============================================================================ +# BossArena —— 殿王战面板(架构 §4.3;[B§5] 十场殿王战的 W3 灰盒呈现) +# +# 呈现三件事:① 战前 15 分钟预告("殿王将至"倒计时,[B§6] pacing 的 UI 梯度); +# ② 战斗中的 HP 条 / 连击 / 剩余时间 / 挥刃按钮; +# ③ 胜负结算 + 失败重试(60s 冷却;已购 up_gate_boss_retry 可立即再战)。 +# +# 纪律: +# - 只读快照的 "boss" / "boss_available" / "boss_preview" + 发命令;不持有战斗状态 +# (唯一状态在 game/BossBattle;架构 §4.5); +# - 灰盒规范 §10.4:默认主题 ProgressBar + 纯文字,零特效零动画。 +# ============================================================================ + +signal start_requested(hall_index: int) +signal strike_requested() +signal retry_requested() +signal ack_requested() + +var _title: Label +var _bar: ProgressBar +var _status: Label +var _strike: Button +var _start: Button +var _retry: Button +var _ack: Button + +var _last_title := "" +var _last_status := "" + + +func _ready() -> void: + add_theme_constant_override("separation", 4) + _title = _mk_label(20) + add_child(_title) + _bar = ProgressBar.new() + _bar.min_value = 0.0 + _bar.max_value = 100.0 + _bar.value = 0.0 + _bar.custom_minimum_size = Vector2(0, 18) + _bar.show_percentage = false + add_child(_bar) + _status = _mk_label(14) + add_child(_status) + _strike = _mk_button("挥刃", 200) + _strike.pressed.connect(func(): strike_requested.emit()) + add_child(_strike) + _start = _mk_button("挑战殿王", 200) + _start.pressed.connect(_on_start) + add_child(_start) + _retry = _mk_button("再战(消冷却)", 200) + _retry.pressed.connect(func(): retry_requested.emit()) + add_child(_retry) + _ack = _mk_button("收兵", 160) + _ack.pressed.connect(func(): ack_requested.emit()) + add_child(_ack) + visible = false + + +func _mk_label(size: int) -> Label: + var l := Label.new() + l.add_theme_font_size_override("font_size", size) + l.add_theme_color_override("font_color", Color("F2EDE4")) + return l + + +func _mk_button(text: String, width: int) -> Button: + var b := Button.new() + b.text = text + b.custom_minimum_size = Vector2(width, 36) + b.focus_mode = Control.FOCUS_NONE + return b + + +func _on_start() -> void: + if _pending_hall_index >= 0: + start_requested.emit(_pending_hall_index) + + +## 快照刷新 +## boss: BossBattle.view();avail: BossBattle.available();preview: GameState.boss_preview_info() +func refresh(boss: Dictionary, avail: Dictionary, preview: Dictionary) -> void: + var phase := int(boss.get("phase", 0)) + var active := bool(boss.get("active", false)) + var resolved := phase == BossBattle.Phase.RESOLVED + var ready := bool(avail.get("ok", false)) + var show_preview := bool(preview.get("visible", false)) + visible = active or resolved or ready or show_preview + if not visible: + return + + _pending_hall_index = int(avail.get("hall_index", -1)) + if active or resolved: + _set_title("H%d %s · 殿王战" % [int(boss.get("hall_no", 0)), str(boss.get("king_cn", ""))]) + _bar.visible = true + _bar.value = float(boss.get("hp_ratio", 0.0)) * 100.0 + _set_status(_battle_status(boss, phase)) + _strike.visible = phase == BossBattle.Phase.ACTIVE + _start.visible = false + _retry.visible = resolved and not bool(boss.get("win", false)) \ + and bool(boss.get("can_retry", false)) + _ack.visible = resolved + return + + # 未开战:预告 / 可挑战 + _bar.visible = false + _strike.visible = false + _ack.visible = false + _retry.visible = false + _start.visible = ready + if ready: + _set_title("H%d %s · 可挑战" % [int(preview.get("hall_no", 0)), + str(avail.get("king_cn", ""))]) + _set_status("HP = 15 × 当前产出/s × 殿系数(进入时锁定)|30s 内打完即胜") + return + _set_title("H%d %s · 殿王将至" % [int(preview.get("hall_no", 0)), + str(preview.get("king_cn", ""))]) + _set_status("距殿门约 %s(距开战 %s 内预告)" % [ + NumberFormat.duration(float(preview.get("eta_sec", 0.0))), + NumberFormat.minutes(float(preview.get("preview_min", 15.0)))]) + + +func _set_title(t: String) -> void: + if _title.text != t: + _title.text = t + + +func _set_status(t: String) -> void: + if _status.text != t: + _status.text = t + + +func _battle_status(boss: Dictionary, phase: int) -> String: + if phase == BossBattle.Phase.RESOLVED: + if bool(boss.get("win", false)): + return "胜!阴德爆发 +%s(HP × 0.2)" % NumberFormat.short(float(boss.get("reward", 0.0))) + return "败。冷却 %s(购「殿王再战」可立即重来)" % NumberFormat.duration( + float(boss.get("cooldown", 0.0))) + if phase == BossBattle.Phase.INTRO: + return "军列冲锋 %s…" % NumberFormat.duration(float(boss.get("timer", 0.0))) + var hp := float(boss.get("hp", 0.0)) + var hp_max := float(boss.get("hp_max", 0.0)) + return "HP %s / %s · 剩余 %s · 连击 ×%.2f(%d 层)" % [ + NumberFormat.short(hp), NumberFormat.short(hp_max), + NumberFormat.duration(float(boss.get("timer", 0.0))), + float(boss.get("combo_mult", 1.0)), int(boss.get("combo", 0))] diff --git a/src/presentation/stage/camera_rig.gd b/src/presentation/stage/camera_rig.gd new file mode 100644 index 0000000..61ca97f --- /dev/null +++ b/src/presentation/stage/camera_rig.gd @@ -0,0 +1,103 @@ +class_name CameraRig +extends Camera2D + +# ============================================================================ +# CameraRig —— 横版舞台相机(架构 §4.3;W3 交付) +# +# 三件事:① 进度锚定(前线 + 下一道殿门钉在视口右 1/3,GDD §2.4 焦点可见性); +# ② 偷看回弹(peek & snap back,管线报告 §3.1 A+ 增强); +# ③ 殿王战推近(push-in,架构 §4.3/§3.4 舞台模式切换)。 +# +# 纪律: +# - **零硬编码**:一切参数来自 BalanceData(per-hall 行 camera.csv + balance_tuning.csv); +# 纯数学函数在 core/data/camera_def.gd(CameraDef),本类只做引擎侧的搬运与插值; +# - presentation 层:只读世界/数据 + 应用 transform,**不持有任何游戏状态**(§4.5); +# - 知识缺口:Godot 4.7 Camera2D 的 anchor/缩放行为未实测(待本机验证);本文件只用 +# 4.x 稳定属性(position / zoom / enabled),不用任何版本相关新 API。 +# ============================================================================ + +enum Mode { ROAM, ARENA } + +signal mode_changed(to_arena: bool) + +var mode: int = Mode.ROAM + +# ---- 全局标量(configure 时从 balance_tuning 读入;缺失 → BalanceData 记 load_errors)---- +var follow_tau: float = 0.35 +var zoom_tau: float = 0.30 +var peek_max_px: float = 320.0 +var peek_spring: float = 42.0 +var peek_damp: float = 11.0 + +# ---- 运行时状态(视图本地态,允许存在;非游戏状态)---- +var _target_x: float = 0.0 +var _target_zoom: float = 1.0 +var _smooth_x: float = 0.0 +var _smooth_zoom: float = 1.0 +var _peek: float = 0.0 +var _peek_vel: float = 0.0 +var _peeking: bool = false + + +## 从数据表读入全局相机标量(缺失值由 BalanceData.tune_num 兜底并报错) +func configure(data: BalanceData) -> void: + follow_tau = data.tune_num("tune_cam_follow_tau", 0.35) + zoom_tau = data.tune_num("tune_cam_zoom_tau", 0.30) + peek_max_px = data.tune_num("tune_cam_peek_max_px", 320.0) + peek_spring = data.tune_num("tune_cam_peek_spring", 42.0) + peek_damp = data.tune_num("tune_cam_peek_damp", 11.0) + enabled = true + + +## 巡游目标(世界坐标 + 缩放) +func set_roam_target(cam_x: float, z: float) -> void: + mode = Mode.ROAM + _target_x = cam_x + _target_zoom = z + + +## 殿王战推近到 arena 锚点 +func push_in_to_arena(cam_x: float, z: float) -> void: + if mode != Mode.ARENA: + mode = Mode.ARENA + mode_changed.emit(true) + _target_x = cam_x + _target_zoom = z + + +func pop_out_to_roam(cam_x: float, z: float) -> void: + if mode != Mode.ROAM: + mode = Mode.ROAM + mode_changed.emit(false) + set_roam_target(cam_x, z) + + +## 拖拽偷看:横向增量(世界像素,右拖为正) +func drag_peek(delta_world_x: float) -> void: + _peeking = true + _peek_vel = 0.0 + _peek = CameraDef.clamp_peek(_peek + delta_world_x, peek_max_px) + + +## 松手 → 弹簧回位 +func end_peek() -> void: + _peeking = false + + +func _process(delta: float) -> void: + if delta <= 0.0: + return + _smooth_x = CameraDef.exp_smooth(_smooth_x, _target_x, delta, follow_tau) + _smooth_zoom = CameraDef.exp_smooth(_smooth_zoom, _target_zoom, delta, zoom_tau) + if not _peeking: + var s := CameraDef.spring_step(_peek, _peek_vel, delta, peek_spring, peek_damp) + _peek = s.x + _peek_vel = s.y + position.x = _smooth_x + _peek + zoom.x = _smooth_zoom + zoom.y = _smooth_zoom + + +## 单测/调试用:当前是否仍在偷看(视图本地态读取口) +func peek_offset() -> float: + return _peek diff --git a/src/presentation/stage/hall_band_world.gd b/src/presentation/stage/hall_band_world.gd new file mode 100644 index 0000000..e33f3b4 --- /dev/null +++ b/src/presentation/stage/hall_band_world.gd @@ -0,0 +1,174 @@ +class_name HallBandWorld +extends Node2D + +# ============================================================================ +# HallBandWorld —— 单殿 band 的四层视差组装(架构 §4.3;W3 灰盒版) +# +# 数据行 → 场景:一条 halls.csv 行 + 一条 hall_palettes.csv 行 + 一条 camera.csv 行 +# → 远景 / 中景 / 近景 / 前景 四层。改一张表 = 改一次组装,改代码 = 设计失败(ADR-3)。 +# +# 灰盒纪律(架构 §10.4):纯色 Polygon2D + 文字标签 + 默认字体; +# 禁 sprite / shader / 粒子;本文件在 W5 进美术阶段时 100% 可弃。 +# +# 视差数学:层内投影 screen_x = p + left*(1-k) ... 采用 band 内相对位移 +# layer.position.x = (cam_x - band_left) * k,其中 k = 1 - layer_scroll +# → 内容相对相机的有效移动速度恰为 layer_scroll(远 0.12 慢 / 近 1.0 同步 / 前景 1.25 快); +# 位移上界 = k × band 宽度,因而只要按 k 扩展每层内容就不会露缝(见 _EXT_SCALE)。 +# ============================================================================ + +const WORLD_H := 240.0 # 舞台世界高(= StageWorld 视口高;两者必须同值才能看全地面) +const GROUND_H := 74.0 # 近景地面带高 +const FORE_H := 40.0 # 前景压暗带高 +const SIL_COUNT := 5 # 中景剪影件数(对应架构 §5.4 共享剪影套件 6-8 件的规模) +const _EXT_SCALE := 1.0 # 层内容横向扩展系数(= k × 宽度,见 update_layers) + +var def: HallDef +var pal: PaletteDef +var cam: CameraDef +var range_x: Vector2 = Vector2.ZERO # (left, right) 世界坐标 +var _layers: Array[Node2D] = [] +var _title: Label +var _state: Label + + +static func assemble(hall_def: HallDef, palette: PaletteDef, cam_def: CameraDef, + left_x: float) -> HallBandWorld: + var b := HallBandWorld.new() + b.def = hall_def + b.pal = palette + b.cam = cam_def + var w := cam_def.band_width() + b.range_x = Vector2(left_x, left_x + w) + b._build(w) + return b + + +# ---------------------------------------------------------------------------- +# 组装(数据行 → 四层;全是 ColorRect 的 Node2D 等价物 = Polygon2D) +# ---------------------------------------------------------------------------- + +func _build(width: float) -> void: + for i in CameraDef.LAYER_COUNT: + var layer := Node2D.new() + layer.name = "L%d" % i + add_child(layer) + _layers.append(layer) + _build_far(_layers[CameraDef.LAYER_FAR], width) + _build_mid(_layers[CameraDef.LAYER_MID], width) + _build_near(_layers[CameraDef.LAYER_NEAR], width) + _build_fore(_layers[CameraDef.LAYER_FORE], width) + + +func _build_far(layer: Node2D, width: float) -> void: + # 远景:整块该殿 accent 色(身后很远的天/雾),灰盒阶段不做渐变与 alpha 混合 + var ext := _extent(width, CameraDef.LAYER_FAR) + var q := Polygon2D.new() + q.polygon = PackedVector2Array([ + Vector2(-ext, 0.0), Vector2(width + ext, 0.0), + Vector2(width + ext, WORLD_H), Vector2(-ext, WORLD_H)]) + q.color = _pal_color(pal.accent_hex, Color("1A1A1A")) if pal != null else Color("2A2A2A") + layer.add_child(q) + + +func _build_mid(layer: Node2D, width: float) -> void: + # 中景:SIL_COUNT 件剪影矩形(横向位置由 silhouette_x() 的确定性布局给出) + var ext := _extent(width, CameraDef.LAYER_MID) + var base_y := WORLD_H - GROUND_H + var ink := Color("141414") + for i in SIL_COUNT: + var x := silhouette_x(i, width) + var h := 60.0 + 46.0 * float((def.no + i) % 3) if def != null else 80.0 + var w := 26.0 + 16.0 * float(i % 2) + var q := Polygon2D.new() + q.polygon = PackedVector2Array([ + Vector2(x - w * 0.5, base_y), Vector2(x + w * 0.5, base_y), + Vector2(x + w * 0.5, base_y - h), Vector2(x - w * 0.5, base_y - h)]) + q.color = ink + layer.add_child(q) + # 殿门:band 右端的朱砂竖墙(下一个目标的空间化身,GDD §2.4 焦点可见性) + var gate := Polygon2D.new() + gate.polygon = PackedVector2Array([ + Vector2(width - 16.0, 0.0), Vector2(width, 0.0), + Vector2(width, WORLD_H), Vector2(width - 16.0, WORLD_H)]) + gate.color = Color("B4402C") + layer.add_child(gate) + _ = ext + + +func _build_near(layer: Node2D, width: float) -> void: + var ext := _extent(width, CameraDef.LAYER_NEAR) + var base_y := WORLD_H - GROUND_H + var q := Polygon2D.new() + q.polygon = PackedVector2Array([ + Vector2(-ext, base_y), Vector2(width + ext, base_y), + Vector2(width + ext, WORLD_H), Vector2(-ext, WORLD_H)]) + q.color = Color("39332C") + layer.add_child(q) + + +func _build_fore(layer: Node2D, width: float) -> void: + var ext := _extent(width, CameraDef.LAYER_FORE) + var base_y := WORLD_H - FORE_H + var q := Polygon2D.new() + q.polygon = PackedVector2Array([ + Vector2(-ext, base_y), Vector2(width + ext, base_y), + Vector2(width + ext, WORLD_H), Vector2(-ext, WORLD_H)]) + q.color = Color("121212") + layer.add_child(q) + _title = Label.new() + _title.position = Vector2(width * 0.5 - 120.0, base_y - 46.0) + _title.add_theme_font_size_override("font_size", 30) + _title.add_theme_color_override("font_color", Color("F2EDE4")) + _title.text = "H%d %s" % [def.no, def.king_cn] if def != null else "—" + layer.add_child(_title) + _state = Label.new() + _state.position = Vector2(width * 0.5 - 120.0, base_y - 18.0) + _state.add_theme_font_size_override("font_size", 18) + _state.add_theme_color_override("font_color", Color("F2EDE4")) + _state.text = "未解锁" + layer.add_child(_state) + + +## 确定性剪影布局(同一条数据行永远出同一张图;便于 W5 换成真实套件也保持 composition) +static func silhouette_x(index: int, width: float) -> float: + var step := width / float(SIL_COUNT + 1) + return step * float(index + 1) + + +## 该层需要向两侧扩展多少才能不露缝:= (1 - scroll) × 宽度 + 一屏 +func _extent(width: float, layer_index: int) -> float: + var k := 1.0 - cam.layer_scroll(layer_index) + return absf(k) * width * _EXT_SCALE + CameraDef.VIEW_BASE_W + + +# ---------------------------------------------------------------------------- +# 相机驱动 / 状态刷新 +# ---------------------------------------------------------------------------- + +## 相机横移 → 各层相对位移(k = 1 - scroll;见文件头视差数学) +func update_layers(cam_x: float) -> void: + for i in _layers.size(): + var k := 1.0 - cam.layer_scroll(i) + _layers[i].position.x = (cam_x - range_x.x) * k + + +## 三态碎片(passed/current/locked,GDD §2.4 三级分层) +func set_state(passed: bool, current: bool, have: float, need: float) -> void: + if _state == null: + return + if passed: + _state.text = "已过" + modulate = Color(1, 1, 1, 1) + return + modulate = Color(1, 1, 1, 0.4) if not current else Color(1, 1, 1, 1) + if current: + var pct := 0.0 if need <= 0.0 else clampf(have / need, 0.0, 1.0) * 100.0 + _state.text = "%.0f%%" % pct + else: + _state.text = "未解锁" + + +func _pal_color(hex: String, fallback: Color) -> Color: + if pal == null or hex == "": + return fallback + return Color.html(hex) diff --git a/src/presentation/stage/stage_root.gd b/src/presentation/stage/stage_root.gd index 19783e7..dae53a3 100644 --- a/src/presentation/stage/stage_root.gd +++ b/src/presentation/stage/stage_root.gd @@ -3,10 +3,11 @@ extends Node # ============================================================================ # stage_root.gd —— 灰盒主场景(W2 可玩性验证版;架构 §10.4 灰盒规范) # -# 验证循环(W3 出口标准的提前落地):点 → 买 → 涨 → 解锁 → prestige 全循环, -# 零美术资产。布局即 §10.2 纸面原型的程序化落地: -# 顶栏(阴德/产出/点击/冤气/宿慧/转世/超度)|十殿色带|军列带 12 槽| -# 升级列表 + 宿慧商店(含轮回/敕封/怨煞)|日志行 +# 验证循环(W3 出口标准):点 → 买 → 涨 → 解锁 → 事件 → 殿王战 → prestige 全循环, +# 零美术资产。布局即 §10.2 纸面原型的程序化落地(W3 修订): +# 顶栏(阴德/产出/点击/冤气/宿慧/转世/超度)|**横版舞台世界(十殿 band + 相机 rig)**| +# 事件卡(自动显隐)|殿王战面板(预告/HP/连击/结算,自动显隐)|军列带 12 槽| +# 升级列表 + 宿慧商店(含轮回/敕封/怨煞)|进度行|日志行 # # 纪律:只读 GameState 快照 + 发命令;本场景不持有任何游戏状态。 # ============================================================================ @@ -26,7 +27,9 @@ var _progress_label: Label var _log_label: Label var _army: ArmyBand var _hud: HudRoot -var _hall_bands: Array[HallBand] = [] +var _world: StageWorld # W3:横版舞台(十殿 band + 相机 rig) +var _event_panel: EventPanel # W3:事件卡 +var _boss: BossArena # W3:殿王战面板 var _progress_shown := "" @@ -75,16 +78,19 @@ func _build_ui() -> void: _souls_label = _big_label(16) top.add_child(_souls_label) - # ---- 十殿色带(数据行 → 色带;§10.4:10 条色带 + 殿名文字)---- - var hall_row := HBoxContainer.new() - hall_row.add_theme_constant_override("separation", 4) - hall_row.custom_minimum_size = Vector2(0, 100) - root_v.add_child(hall_row) - for h in DataRegistry.data.halls: - var pal := DataRegistry.data.palette_for_hall(h.id) - var band := HallBand.make(h, pal) - hall_row.add_child(band) - _hall_bands.append(band) + # ---- W3:横版舞台世界(SubViewport + 十殿 band 四层视差 + 相机 rig)---- + # 取代 W2 的十殿色带:它是同一份数据(halls.csv + hall_palettes.csv + camera.csv) + # 的空间化呈现,且把"进度 = 位置"这条 GDD §2.4 焦点可见性真正落地。 + _world = StageWorld.new() + root_v.add_child(_world) + + # ---- W3:事件卡(events.csv 4 事件;空闲时 visible=false,不占布局高度)---- + _event_panel = EventPanel.new() + root_v.add_child(_event_panel) + + # ---- W3:殿王战面板(战前预告 / HP / 连击 / 结算;同样自动显隐)---- + _boss = BossArena.new() + root_v.add_child(_boss) # ---- 军列带 12 槽 ---- _army = ArmyBand.new() @@ -139,10 +145,18 @@ func _connect() -> void: _hud.buy_wrath_layer_requested.connect(func(): GameState.cmd_buy_wrath_layer()) _hud.prestige_requested.connect(func(): GameState.cmd_prestige()) _hud.enthrone_requested.connect(func(): GameState.cmd_enthrone()) + _event_panel.choose_requested.connect(func(i: int): GameState.cmd_event_choose(i)) + _boss.start_requested.connect(_on_boss_start) + _boss.strike_requested.connect(func(): GameState.cmd_boss_click()) + _boss.retry_requested.connect(_on_boss_retry) + _boss.ack_requested.connect(func(): GameState.cmd_boss_ack()) GameState.snapshot_changed.connect(_on_snapshot) GameState.hall_passed.connect(_on_hall_passed) GameState.prestige_done.connect(_on_prestige_done) GameState.enthrone_done.connect(_on_enthrone_done) + GameState.event_started.connect(_on_event_started) + GameState.boss_started.connect(_on_boss_started) + GameState.boss_finished.connect(_on_boss_finished) _on_snapshot(GameState.get_snapshot()) @@ -205,7 +219,12 @@ func _on_snapshot(snap: Dictionary) -> void: NumberFormat.duration(eta) if eta > 0.0 else "—"]) _army.refresh(snap) _hud.refresh(snap) - _refresh_hall_bands(snap) + var nh2: Dictionary = snap.get("next_hall", {}) + _world.set_progress(int(snap.get("halls_passed", 0)), + float(nh2.get("have", 0.0)), float(nh2.get("gate_merit", 0.0))) + _event_panel.refresh(snap.get("event", {})) + _boss.refresh(snap.get("boss", {}), snap.get("boss_available", {}), + snap.get("boss_preview", {})) func _progress_label_text(t: String) -> void: @@ -214,16 +233,6 @@ func _progress_label_text(t: String) -> void: _progress_label.text = t -func _refresh_hall_bands(snap: Dictionary) -> void: - var passed := int(snap.get("halls_passed", 0)) - var nh: Dictionary = snap.get("next_hall", {}) - for i in _hall_bands.size(): - var is_passed := i < passed - var is_current := i == passed - var have := float(nh.get("have", 0.0)) if is_current else 0.0 - var need := DataRegistry.data.halls[i].gate_merit if is_passed else \ - float(nh.get("gate_merit", 0.0)) - _hall_bands[i].set_state(is_passed, is_current, have, need) func _on_hall_passed(hall_no: int) -> void: @@ -237,3 +246,109 @@ func _on_prestige_done(gain: int, rebirths: int) -> void: func _on_enthrone_done() -> void: _log_label.text = "阴司敕封:幽冥天子可用了。" + + +# ---- W3:事件 / 殿王战命令与反馈 ---- + +func _on_event_started(_id: String, name_cn: String) -> void: + _log_label.text = "事件:%s" % name_cn + + +func _on_boss_start(hall_index: int) -> void: + var r := GameState.cmd_boss_start(hall_index) + if bool(r.get("ok", false)): + _log_label.text = "殿王战开:HP %s。" % NumberFormat.short(float(r.get("hp_max", 0.0))) + else: + _log_label.text = "开战不得:%s" % str(r.get("reason", "")) + + +func _on_boss_started(hall_no: int, hp_max: float) -> void: + _world.begin_boss(hall_no - 1) + _log_label.text = "H%d 殿王现身(HP %s)—— 30 秒。" % [hall_no, NumberFormat.short(hp_max)] + + +func _on_boss_finished(hall_no: int, win: bool, reward: float) -> void: + _world.end_boss() + if win: + _log_label.text = "H%d 殿王伏诛:阴德爆发 +%s。" % [hall_no, NumberFormat.short(reward)] + else: + _log_label.text = "H%d 败退 —— 冷却后可再战(购「殿王再战」可立即重来)。" % hall_no + + +func _on_boss_retry() -> void: + if GameState.cmd_boss_retry(): + _log_label.text = "再战冷却已消 —— 点「挑战殿王」重来。" + else: + _log_label.text = "再战未解锁:需路引「殿王再战」(过 H7 后出现)。" + + +# ---------------------------------------------------------------------------- +# W3 dev 热键(灰盒验证用) +# +# 存在的理由:事件要买 up_gate_events、殿王战要先过殿门,正常流程得先玩十几分钟 +# 才看得到 W3 的两个新系统。灰盒阶段我们要的是"30 秒内把四事件与殿王战各看一遍"。 +# +# 纪律: +# - 仅 debug 构建可用(OS.is_debug_build()),导出包自动失效; +# - 不改任何游戏数值,只走 GameState 既有命令口(与 UI 按钮同一条路); +# - 不新增 core 字段:dev 过殿门走 econ 已有的 save_soul(累计口径,合法路径)。 +# ---------------------------------------------------------------------------- + +const DEV_HELP := "dev:F1-F4 强制开事件 / F5 跳到下一座殿门 / F6 挑战殿王 / F9 本帮助" + +const DEV_EVENTS: Array[String] = ["ev_01_baigui", "ev_02_naoxiang", "ev_03_shensu", + "ev_04_tiangou"] + + +func _unhandled_key_input(event: InputEvent) -> void: + if not OS.is_debug_build(): + return + if not (event is InputEventKey) or not event.pressed or event.echo: + return + match event.keycode: + KEY_F1, KEY_F2, KEY_F3, KEY_F4: + _dev_force_event(event.keycode - KEY_F1) + KEY_F5: + _dev_skip_hall() + KEY_F6: + _dev_start_boss() + KEY_F9: + _log_label.text = DEV_HELP + _: + return + get_viewport().set_input_as_handled() + + +## F1-F4:强制开一个事件(事件系统需已解锁,否则先给路引) +func _dev_force_event(index: int) -> void: + if index < 0 or index >= DEV_EVENTS.size(): + return + if not GameState.events.unlocked(GameState.econ): + GameState.econ.bought["up_gate_events"] = true + _log_label.text = "dev:已给路引「事件」,开 %s" % DEV_EVENTS[index] + var r := GameState.events.force_start(GameState.econ, DEV_EVENTS[index]) + if not bool(r.get("ok", false)): + _log_label.text = "dev 事件失败:%s" % str(r.get("reason", "")) + + +## F5:把本轮累计阴德顶到下一座殿门(走 save_soul,合法累计口径) +func _dev_skip_hall() -> void: + var snap := GameState.get_snapshot() + var nh: Dictionary = snap.get("next_hall", {}) + if nh.is_empty(): + _log_label.text = "dev:十殿已穿,无可跳之门。" + return + var need := float(nh.get("gate_merit", 0.0)) - float(nh.get("have", 0.0)) + GameState.cmd_save_soul(maxf(need, 0.0)) + var after := GameState.get_snapshot() + _log_label.text = "dev:跳到 H%d(已过 %d 殿)" % [ + int(after.get("next_hall", {}).get("no", 0)), int(after.get("halls_passed", 0))] + + +## F6:挑战当前可战的殿王(把 available 的原因直接显示出来,便于排查) +func _dev_start_boss() -> void: + var info := GameState.boss_available() + if not bool(info.get("ok", false)): + _log_label.text = "dev:殿王不可战 —— %s" % str(info.get("reason", "")) + return + _on_boss_start(int(info.get("hall_index", -1))) diff --git a/src/presentation/stage/stage_world.gd b/src/presentation/stage/stage_world.gd new file mode 100644 index 0000000..1926ec6 --- /dev/null +++ b/src/presentation/stage/stage_world.gd @@ -0,0 +1,149 @@ +class_name StageWorld +extends SubViewportContainer + +# ============================================================================ +# StageWorld —— 横版舞台的 UI 容器(SubViewport + 世界 + 相机 rig;架构 §4.3) +# +# 为什么用 SubViewportContainer:世界层是 Node2D(相机/视差/世界坐标),而灰盒主场景 +# stage_root 是 Control 布局树;SubViewport 让两套坐标系各自干净,Camera2D 只作用于 +# 视口内部,UI 不必为了装下 Node2D 而扭曲自己的布局(W3 灰盒:零 shader、零后处理)。 +# +# 纪律: +# - presentation 层:只读 GameState 快照 + 发命令;本节点只持有**视图本地态** +# (拖拽捕获、当前进度缓存),不持有任何游戏状态(架构 §4.5); +# - 一切相机参数来自 CameraDef + balance_tuning(零硬编码); +# - 灰盒规范 §10.4:纯色块 + 文字,禁 sprite/shader/粒子。 +# +# 待本机验证:SubViewportContainer.stretch / SubViewport.disable_3d 在 4.7.2 的行为, +# 以及容器内输入透传(偷看拖拽)是否按预期;不通过则退回"世界根部直接挂 Node2D + +# 相机 Rig"的简化方案(见 docs/engineering/2026-10-19-f-w3-verification-manual.md)。 +# ============================================================================ + +const VIEW_W := 1280.0 +const VIEW_H := 240.0 # 必须与 HallBandWorld.WORLD_H 同值 + +var _vp: SubViewport +var _root: BandWorldRoot +var _rig: CameraRig +var _ready_ok: bool = false +var _drag: bool = false +var _boss_active: bool = false +var _last: Array = [0, 0.0, 0.0] # halls_passed / have / need(视图本地缓存) + + +func _ready() -> void: + stretch = true + custom_minimum_size = Vector2(0, VIEW_H) + size_flags_horizontal = Control.SIZE_EXPAND_FILL + _setup_world() + resized.connect(_on_resized) + + +func _setup_world() -> void: + var data := DataRegistry.data + if data == null: + push_error("StageWorld:DataRegistry 无数据") + return + _vp = SubViewport.new() + _vp.size = Vector2i(int(VIEW_W), int(VIEW_H)) + _vp.disable_3d = true + _vp.transparent_bg = false + add_child(_vp) + _root = BandWorldRoot.new() + _vp.add_child(_root) + if not _root.build(data): + push_error("StageWorld:世界组装失败(检查 camera.csv 是否每殿一行)") + return + _rig = CameraRig.new() + _vp.add_child(_rig) + _rig.configure(data) + _rig.enabled = true + _ready_ok = true + _apply_progress(0, 0.0, 0.0) + + +func _on_resized() -> void: + if _vp == null: + return + var w := int(maxf(size.x, 64.0)) + var h := int(maxf(size.y, 64.0)) + _vp.size = Vector2i(w, h) + + +# ---------------------------------------------------------------------------- +# 快照上行(stage_root 在每个快照回调里调用) +# ---------------------------------------------------------------------------- + +func set_progress(halls_passed: int, have: float, need: float) -> void: + if not _ready_ok: + return + _last = [halls_passed, have, need] + _apply_progress(halls_passed, have, need) + + +func _apply_progress(halls_passed: int, have: float, need: float) -> void: + var info: Dictionary = _root.progress_info(halls_passed, have, need) + var cam: CameraDef = info.cam as CameraDef + if cam == null: + return + var front_x := float(info.front_x) + _root.refresh_states(halls_passed, have, need) + _root.update_front(front_x) + if _boss_active: + return + var z := cam.idle_zoom + var cx := CameraDef.camera_x_for_front(front_x, z, cam.focus_ratio) + cx = CameraDef.clamp_camera_x(cx, z, _root.world_width) + _rig.set_roam_target(cx, z) + + +# ---------------------------------------------------------------------------- +# 殿王战:相机推近 / 退出 arena +# ---------------------------------------------------------------------------- + +func begin_boss(hall_index: int) -> void: + if not _ready_ok: + return + var info: Dictionary = _root.arena_info(hall_index) + var cam: CameraDef = info.cam as CameraDef + if cam == null: + return + _boss_active = true + var z := cam.arena_zoom + var cx := CameraDef.camera_x_for_front(float(info.anchor_x), z, cam.arena_focus_ratio) + cx = CameraDef.clamp_camera_x(cx, z, _root.world_width) + _rig.push_in_to_arena(cx, z) + + +func end_boss() -> void: + if not _ready_ok: + return + _boss_active = false + _apply_progress(int(_last[0]), float(_last[1]), float(_last[2])) + + +# ---------------------------------------------------------------------------- +# 偷看拖拽(peek & snap back) +# ---------------------------------------------------------------------------- + +func _gui_input(event: InputEvent) -> void: + if not _ready_ok: + return + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_LEFT: + _drag = mb.pressed + if not _drag: + _rig.end_peek() + return + if event is InputEventMouseMotion and _drag: + var mm := event as InputEventMouseMotion + var z := maxf(_rig.zoom.x, 0.0001) + _rig.drag_peek(-mm.relative.x / z) + + +func _process(_delta: float) -> void: + if not _ready_ok: + return + var z := maxf(_rig.zoom.x, 0.0001) + _root.update_visuals(_rig.position.x, CameraDef.view_width(z)) diff --git a/src/tools/gen_balance_sheets.py b/src/tools/gen_balance_sheets.py index fb39a5f..c6ce7be 100644 --- a/src/tools/gen_balance_sheets.py +++ b/src/tools/gen_balance_sheets.py @@ -383,7 +383,12 @@ def build_rows(): "id": hid, "no": no, "king_cn": kcn, "king_en": ken, "motif_cn": mcn, "motif_en": men, "target_min": tmin, "gate_merit": gate_merit, "unlock_gen": ugen, "unlock_gate": ugate, - "palette_id": pal, "is_prestige_gate": pres, + # W3 修复:HALLS 元组末位是 CSV 字符串 "true"/"false";这里必须落成真正的 + # 布尔值。原因:data/build/*.json 是 SheetImporter 的产物口径(boolean 列 → + # 真布尔),而不是 CSV 字符串。此前写成字符串导致 halls.json 的 + # is_prestige_gate = "false",BalanceData 里 bool("false") 在 Godot 中 + # 判为 true(非空字符串)→ 十殿全部误判为轮回触发门,且 CI 漂移门必红。 + "palette_id": pal, "is_prestige_gate": (pres == "true"), }) sheets["halls"] = (HALL_COLS, rows) @@ -474,8 +479,8 @@ def self_check(sheets) -> None: 120000000, 350000000, 800000000, 1500000000] for h, eg in zip(halls, expect_gates): assert h["gate_merit"] == eg, "gate %s" % h["id"] - assert sum(1 for h in halls if h["is_prestige_gate"] == "true") == 1 - assert halls[-1]["is_prestige_gate"] == "true" + assert sum(1 for h in halls if h["is_prestige_gate"] is True) == 1 + assert halls[-1]["is_prestige_gate"] is True # 升级 108 条 + meta 12 条([B§2] 配比) ups = sheets["upgrades"][1] assert len(ups) == 108, "upgrades 行数 %d" % len(ups) diff --git a/tests/unit/cases/test_boss_battle.gd b/tests/unit/cases/test_boss_battle.gd new file mode 100644 index 0000000..b066c38 --- /dev/null +++ b/tests/unit/cases/test_boss_battle.gd @@ -0,0 +1,211 @@ +extends RefCounted + +# 用例:W3 殿王战逻辑状态机(game/BossBattle;boss_battles.csv 十场) +# 口径全部来自数据行([B§5]):HP = 15 × 进入时产出/s × 殿系数(1.0→2.2)。 +# 本类不用随机 → 结果可复现(W4 起 sim 门 #11 直接复用)。 + +func run(a, data) -> void: + _test_hp_formula(a, data) + _test_win(a, data) + _test_combo_cap(a, data) + _test_lose_and_retry(a, data) + _test_availability(a, data) + _test_winnability_doc(a, data) + + +func _new_econ(data, merit_run: float, gen0_level: int = 10) -> EconomyState: + var e := EconomyState.new(data) + e.gen_levels[0] = gen0_level # 默认 10 级 → 产出 1.0/s(gen1 rate 0.1) + e.merit_run_total = merit_run + e.poll_halls() + return e + + +# ---- HP = 15 × 产出/s × 殿系数([B§5],GDD 旧值 50× 已废止) ---- +func _test_hp_formula(a, data) -> void: + var e := _new_econ(data, 2000.0) # 过 H1 + a.eq(e.halls_passed, 1, "H1 门达成(2000 阴德)") + var out := e.output_per_sec() + a.near(out, 1.0, 1e-6, "基线产出 1.0/s") + for i in data.halls.size(): + var h: HallDef = data.halls[i] + var def := data.boss_for_hall(h.id) + a.true_(def != null, "殿 %s 有 boss 行" % h.id) + if def == null: + continue + a.near(float(def.hp_factor), 15.0, 1e-9, "%s:hp_factor = 15" % h.id) + a.near(def.hp_at(out), 15.0 * out * float(def.hall_factor), 1e-6, + "%s:HP = 15 × 产出/s × 殿系数" % h.id) + # H1 殿系数 1.0 → HP = 15;H10 = 2.2 → 2.2 倍 + var d1 := data.boss_for_hall("hall_01_qinguang") + var d10 := data.boss_for_hall("hall_10_zhuanlun") + a.near(float(d1.hall_factor), 1.0, 1e-9, "H1 殿系数 1.0") + a.near(float(d10.hall_factor), 2.2, 1e-9, "H10 殿系数 2.2") + a.near(d1.hp_at(out), 15.0, 1e-6, "H1:产出 1/s → HP 15") + a.near(d10.hp_at(out), 33.0, 1e-6, "H10:产出 1/s → HP 33") + + +# ---- 军列 DPS = 产出/s × 1.0;30s 内打完 → 胜 + 阴德爆发 HP×0.2 ---- +func _test_win(a, data) -> void: + var e := _new_econ(data, 2000.0) + var b := BossBattle.new(data) + var info: Dictionary = b.available(e) + a.true_(bool(info.get("ok", false)), "过 H1 后 H1 殿王可挑战") + a.eq(int(info.get("hall_index", -1)), 0, "可挑战的殿 index = 0") + var r := b.start(e, 0) + a.true_(bool(r.get("ok", false)), "开战") + a.near(float(r.get("hp_max", 0.0)), 15.0, 1e-6, "进入时锁定 HP = 15") + a.eq(b.phase, BossBattle.Phase.INTRO, "先走 INTRO(3s 军列冲锋)") + # 超度值为 0 → 开场伤害 0(min(0×0.5, HP×0.2)) + a.near(b.hp, 15.0, 1e-6, "开场伤害 = 0(超度值 0)") + # 走完 INTRO + for _i in 31: + b.tick(0.1, e) + a.eq(b.phase, BossBattle.Phase.ACTIVE, "3s 后进入 ACTIVE") + a.near(float(b.view().get("timer", 0.0)), 30.0, 1.0, "ACTIVE 计时 30s 起步") + # 军列 DPS = 1.0 → 15s 打完 + var before := e.merit_run_total + for _i in 152: + b.tick(0.1, e) + if b.phase == BossBattle.Phase.RESOLVED: + break + a.eq(b.phase, BossBattle.Phase.RESOLVED, "HP 归零 → RESOLVED") + a.true_(bool(b.view().get("win", false)), "判定为胜") + a.near(float(b.view().get("reward", 0.0)), 15.0 * 0.2, 1e-6, "奖励 = HP × 0.2 = 3") + a.near(e.merit_run_total, before + 15.0 * 0.2, 1e-6, "阴德爆发进本轮累计口径") + # RESOLVED(结算面板挂着)与"真在打"要能区分:原因串不许一律是"战斗中" + var during: Dictionary = b.available(e) + a.false_(bool(during.get("ok", false)), "结算未确认 → 不可开新战") + a.true_(str(during.get("reason", "")).contains("结算"), "RESOLVED 的原因写明待确认结算") + # UI 点确认(cmd_boss_ack → reset)后回到 IDLE,此时才该报"已通关" + b.reset() + var after: Dictionary = b.available(e) + a.false_(bool(after.get("ok", false)), "本轮已通关 → 不可再战") + a.true_(str(after.get("reason", "")).contains("已通关"), "原因写明已通关") + b.reset_for_new_run() + a.true_(bool(b.available(e).get("ok", false)), "新轮回后重新可挑战") + + +# ---- 连击:+2%/层,封顶 +60% ---- +# 注意:本用例必须让 HP 大到"100 刀砍不死",否则第 14 刀就把 boss 打死(HP=15×产出/s, +# 而点击价值恒为 1.0),战斗提前进 RESOLVED,连击永远涨不到封顶 —— 这是用例设计陷阱, +# 不是被测代码的 bug。故这里把军列拉到 200 级(产出 20/s → HP 300 >> 100 刀总伤 ≈151)。 +func _test_combo_cap(a, data) -> void: + var e := _new_econ(data, 2000.0, 200) + var b := BossBattle.new(data) + b.start(e, 0) + for _i in 31: + b.tick(0.1, e) + a.eq(b.phase, BossBattle.Phase.ACTIVE, "进入 ACTIVE") + a.true_(b.hp_max > 100.0 * e.click_value() * 1.6, + "用例前提:HP 足够撑过 100 刀(HP=%s)" % NumberFormat.short(b.hp_max)) + a.near(b.combo_mult(), 1.0, 1e-9, "初始连击 ×1.0") + var dmg1 := b.register_click(e) # 第 1 刀:1 层 → ×1.02 + a.near(b.combo_mult(), 1.02, 1e-6, "1 层 → +2%") + a.near(dmg1, e.click_value() * 1.02, 1e-6, "伤害 = 点击价值 × 连击") + # 连点 100 下(窗口内)→ 封顶 +60% + for _i in 99: + b.register_click(e) + a.eq(b.phase, BossBattle.Phase.ACTIVE, "100 刀后仍在 ACTIVE(未被打死)") + a.eq(b.combo, 100, "连击计数到 100") + a.near(b.combo_mult(), 1.6, 1e-9, "连击封顶 +60%(combo_cap 0.6)") + # 窗口过期(0.6s)后连击清零 + for _i in 7: + b.tick(0.1, e) + a.eq(b.combo, 0, "窗口过期 → 连击清零") + a.near(b.combo_mult(), 1.0, 1e-9, "清零后回到 ×1.0") + # INTRO 阶段点击无效 + var b2 := BossBattle.new(data) + var e2 := _new_econ(data, 2000.0) + b2.start(e2, 0) + a.near(b2.register_click(e2), 0.0, 1e-9, "INTRO 期间点击不生效") + + +# ---- 败北:超时 → 冷却 60s;购路引可立即再战 ---- +func _test_lose_and_retry(a, data) -> void: + var e := _new_econ(data, 2000.0) + var b := BossBattle.new(data) + b.start(e, 0) + for _i in 31: + b.tick(0.1, e) + a.eq(b.phase, BossBattle.Phase.ACTIVE, "进入 ACTIVE") + # 单测手段:把进入时产出压到 0(军列不出力)→ 30s 必然打不完 + b.entry_output = 0.0 + for _i in 302: + b.tick(0.1, e) + if b.phase == BossBattle.Phase.RESOLVED: + break + a.eq(b.phase, BossBattle.Phase.RESOLVED, "超时 → RESOLVED") + a.false_(bool(b.view().get("win", false)), "判定为败") + a.near(float(b.view().get("cooldown", 0.0)), 60.0, 1e-6, "败 → 60s 冷却(数据行)") + a.near(float(b.view().get("reward", 0.0)), 0.0, 1e-9, "败无奖励") + # 未购路引 → 不能消冷却 + a.false_(b.force_retry(e), "未购「殿王再战」→ 不能立即重来") + b.reset() + a.false_(bool(b.available(e).get("ok", false)), "冷却中不可挑战") + # 购路引 → 可立即再战 + e.bought["up_gate_boss_retry"] = true + a.true_(b.force_retry(e), "购路引 → 消冷却") + a.near(float(b.view().get("cooldown", 0.0)), 0.0, 1e-9, "冷却清零") + a.true_(bool(b.available(e).get("ok", false)), "可立即再战") + + +# ---- 出现条件:未过任何殿门 / 产出为 0 → 不可挑战 ---- +func _test_availability(a, data) -> void: + var e0 := EconomyState.new(data) + var b0 := BossBattle.new(data) + a.false_(bool(b0.available(e0).get("ok", false)), "未过殿门 + 产出 0 → 不可挑战") + a.true_(str(b0.available(e0).get("reason", "")).contains("殿门"), "原因写明殿门条件") + # 有殿门但产出为 0(军列未成) + var e1 := EconomyState.new(data) + e1.merit_run_total = 2000.0 + e1.poll_halls() + var b1 := BossBattle.new(data) + var r1: Dictionary = b1.available(e1) + a.false_(bool(r1.get("ok", false)), "军列未成 → 不可挑战") + a.true_(str(r1.get("reason", "")).contains("军列"), "原因写明军列未成") + # 战斗中不可再开 + var e2 := _new_econ(data, 2000.0) + var b2 := BossBattle.new(data) + b2.start(e2, 0) + a.false_(bool(b2.available(e2).get("ok", false)), "战斗中 → 不可挑战") + # 越界保护 + a.false_(bool(b2.start(e2, 99).get("ok", false)), "殿序越界被拒") + + +# ---- 可胜性结构(**事实记录**,不是"应当如此"的断言) ---- +# 推导(与产出/s 无关,故对任何存档都成立): +# 纯军列耗时 = HP ÷ (产出/s × army_dps_factor) +# = (hp_factor × 产出/s × hall_factor) ÷ (产出/s × army_dps_factor) +# = hp_factor × hall_factor ÷ army_dps_factor ← 产出/s 被约掉了 +# 于是:殿系数 > active_sec ÷ (hp_factor ÷ army_dps_factor) = 30 ÷ 15 = 2.0 的殿, +# 军列单推必定超时;而缺口 ∝ 产出/s,点击伤害却是常数(不随产出/s 增长) +# → **玩家越强,缺口越大,越打不过**。这是 W3 交付时点名上报文策渊的平衡缺陷, +# 本用例把当前数据的事实钉死:一旦有人改 hall_factor / active_sec / hp_factor, +# 这里会立刻变红,提醒重新评估可胜性。修数值的权力在文策渊,不在本文件。 +func _test_winnability_doc(a, data) -> void: + var ratio: float = float(data.bosses[0].hp_factor) / float(data.bosses[0].army_dps_factor) + a.near(ratio, 15.0, 1e-9, "hp_factor ÷ army_dps_factor = 15(纯军列耗时的基准秒数)") + var hard: Array = [] + var easy: Array = [] + for d in data.bosses: + var t := float(d.hp_factor) * float(d.hall_factor) / float(d.army_dps_factor) + var label := "%s(%.1fs)" % [d.hall_id, t] + if t > float(d.active_sec): + hard.append(label) + else: + easy.append(label) + # 事实:殿系数 ≤2.0 的殿(H1-H8)军列可胜;>2.0 的殿(H9/H10)必超时 + a.eq(easy.size(), 8, "军列单推可胜的殿 = 8 座:%s" % ", ".join(easy)) + a.eq(hard.size(), 2, "军列单推必超时的殿 = 2 座:%s(上报文策渊的平衡缺陷)" % ", ".join(hard)) + # 点击侧天花板(常数,不随产出/s 增长)—— 后期补不上缺口的根源。 + # 2^升级数 不写死:从 upgrades.csv 里数 click_mult 型的行数(数据行纪律)。 + var click_up_count := 0 + for u in data.upgrades: + if u.effect_type == "click_mult": + click_up_count += 1 + var click_cap := data.tune_num("tune_click_base", 1.0) \ + * pow(2.0, float(click_up_count)) * (1.0 + float(data.bosses[0].combo_cap)) + a.near(click_cap, 25.6, 1e-6, + "点击单次伤害天花板 = 基数1 × 2^%d(点击升级) × 1.6(连击封顶) = 25.6(常数)" % click_up_count) + # 若哪天把点击伤害改成随产出/s 增长,上面这条会红 → 说明可胜性结构已变,需重评 diff --git a/tests/unit/cases/test_camera_data.gd b/tests/unit/cases/test_camera_data.gd new file mode 100644 index 0000000..9189068 --- /dev/null +++ b/tests/unit/cases/test_camera_data.gd @@ -0,0 +1,111 @@ +extends RefCounted + +# 用例:W3 相机 rig 数据行 + CameraDef 纯数学(camera.csv / balance_tuning.csv) +# 说明:这些都是纯函数/静态函数,故可在无头 SceneTree 下直接跑,不需要 Godot 渲染。 + +func run(a, data) -> void: + _test_rows(a, data) + _test_geometry(a, data) + _test_kinematics(a) + + +# ---- 数据行:每殿恰一行 + 参数合法 ---- +func _test_rows(a, data) -> void: + a.eq(data.cameras.size(), 10, "camera.csv 十行(每殿一行)") + for h in data.halls: + var c := data.camera_for_hall(h.id) + a.true_(c != null, "殿 %s 有 camera 行" % h.id) + if c == null: + continue + a.true_(c.band_screens >= 1.0 and c.band_screens <= 6.0, + "%s.band_screens 在 [1,6]" % h.id) + a.true_(c.arena_zoom > c.idle_zoom, "%s:殿王战推近缩放 > 巡游缩放" % h.id) + a.true_(c.layer_far_scroll < c.layer_mid_scroll, + "%s:远景比中景慢" % h.id) + a.true_(c.layer_near_scroll <= c.layer_fore_scroll, + "%s:前景不慢于近景" % h.id) + # 全局标量已进 balance_tuning(缺失会被 tune_num 记 load_errors,此处兜底断言) + a.true_(data.has_tune("tune_cam_peek_max_px"), "tune_cam_peek_max_px 存在") + a.true_(data.has_tune("tune_cam_peek_spring"), "tune_cam_peek_spring 存在") + a.true_(data.has_tune("tune_cam_peek_damp"), "tune_cam_peek_damp 存在") + a.true_(data.has_tune("tune_cam_follow_tau"), "tune_cam_follow_tau 存在") + a.true_(data.has_tune("tune_cam_zoom_tau"), "tune_cam_zoom_tau 存在") + a.true_(data.has_tune("tune_boss_preview_min"), "tune_boss_preview_min 存在") + + +# ---- 几何:band 排布 + 进度焦点锚定 ---- +func _test_geometry(a, data) -> void: + var cams: Array = [] + for h in data.halls: + cams.append(data.camera_for_hall(h.id)) + var rng := BandWorldRoot.compute_ranges(cams) + a.eq(rng.size(), 10, "ranges 十段") + a.near(float(rng[0].x), 0.0, 1e-9, "首段左端 = 0") + var total := 0.0 + for c in cams: + total += (c as CameraDef).band_width() + a.near(float(rng[9].y), total, 1e-6, "末段右端 = Σ band 宽度") + + var w0 := float(rng[0].y) - float(rng[0].x) + a.near(BandWorldRoot.front_x_of(rng, 0, 0.0), 0.0, 1e-9, "H1 进度 0 → 段首") + a.near(BandWorldRoot.front_x_of(rng, 0, 0.5), w0 * 0.5, 1e-6, "H1 进度 0.5 → 段中") + a.near(BandWorldRoot.front_x_of(rng, 0, 1.0), w0, 1e-6, "H1 进度 1.0 → 段尾") + # 越界夹逼(保底:不会算出段外坐标) + a.near(BandWorldRoot.front_x_of(rng, 99, 0.5), float(rng[9].x) + ( + float(rng[9].y) - float(rng[9].x)) * 0.5, 1e-6, "殿序越界夹到末段") + a.near(BandWorldRoot.front_x_of(rng, -5, 0.5), w0 * 0.5, 1e-6, "负序夹到首段") + + # arena 锚点在段内偏殿门一侧 + var anchor := BandWorldRoot.arena_anchor_x(rng, 0) + a.true_(anchor > 0.0 and anchor < w0, "arena 锚点落在段内") + a.near(anchor, w0 * 0.65, 1e-6, "arena 锚点 = 段内 65%(构图常量)") + + # 焦点锚定:focus=0.66 时相机比 front 靠左 0.16 屏宽 + var cam_x := CameraDef.camera_x_for_front(1000.0, 1.0, 0.66, 1280.0) + a.near(cam_x, 1000.0 - 1280.0 * 0.16, 1e-6, "焦点 0.66 → 相机左移 0.16 屏") + var cam_x2 := CameraDef.camera_x_for_front(1000.0, 2.0, 0.66, 1280.0) + a.near(cam_x2, 1000.0 - 1280.0 * 0.16 / 2.0, 1e-6, "缩放 2× 时位移减半") + + # 视口宽度与夹逼 + a.near(CameraDef.view_width(2.0, 1280.0), 640.0, 1e-9, "缩放 2× → 可视宽 640") + a.near(CameraDef.clamp_camera_x(100.0, 1.0, 400.0, 1280.0), 200.0, 1e-9, + "世界比视口窄 → 居中") + a.near(CameraDef.clamp_camera_x(-9999.0, 1.0, 5000.0, 1280.0), 640.0, 1e-9, + "左侧越界夹到半屏") + a.near(CameraDef.clamp_camera_x(9999.0, 1.0, 5000.0, 1280.0), 4360.0, 1e-9, + "右侧越界夹到 world_w - 半屏") + + # 确定性剪影布局(同一数据行永远同一张图) + a.near(HallBandWorld.silhouette_x(0, 1000.0), 1000.0 / 6.0, 1e-6, "剪影 0 位置") + a.near(HallBandWorld.silhouette_x(4, 1000.0), 1000.0 / 6.0 * 5.0, 1e-6, "剪影 4 位置") + + +# ---- 运动学:弹簧回弹 / 指数平滑 / 偷看夹逼 ---- +func _test_kinematics(a, data) -> void: + var k := data.tune_num("tune_cam_peek_spring", 42.0) + var c := data.tune_num("tune_cam_peek_damp", 11.0) + var max_px := data.tune_num("tune_cam_peek_max_px", 320.0) + + # 偷看夹逼 + a.near(CameraDef.clamp_peek(9999.0, max_px), max_px, 1e-9, "偷看上限夹逼") + a.near(CameraDef.clamp_peek(-9999.0, max_px), -max_px, 1e-9, "偷看下限夹逼") + + # 弹簧:给 200px 初位移,2 秒后应回到 ~0(欠阻尼也收敛) + var x := 200.0 + var v := 0.0 + for _i in 120: + var s := CameraDef.spring_step(x, v, 1.0 / 60.0, k, c) + x = s.x + v = s.y + a.true_(absf(x) < 1.0, "松手 2s 后偷看位移回到 ≈0(got %.4f)" % x) + a.true_(absf(v) < 5.0, "速度同时收敛(got %.4f)" % v) + + # 指数平滑:tau=0.35 → 一步后应走到目标的一部分且单调 + var y := 0.0 + var prev := 0.0 + for _i in 5: + y = CameraDef.exp_smooth(y, 100.0, 0.1, 0.35) + a.true_(y >= prev, "平滑单调不回头") + prev = y + a.true_(y > 20.0 and y < 100.0, "0.5s 后走到目标的 20%%-100%% 之间(got %.2f)" % y) + a.near(CameraDef.exp_smooth(0.0, 100.0, 0.1, 0.0), 100.0, 1e-9, "tau=0 → 瞬时") diff --git a/tests/unit/cases/test_event_director.gd b/tests/unit/cases/test_event_director.gd new file mode 100644 index 0000000..2e8f87e --- /dev/null +++ b/tests/unit/cases/test_event_director.gd @@ -0,0 +1,152 @@ +extends RefCounted + +# 用例:W3 事件系统运行时(game/EventDirector;events.csv 4 事件) +# 说明:EventDirector 是 RefCounted,不依赖场景树 → 无头可跑。 +# 断言口径:效果必经 EconomyState(瞬时区/惩罚/双货币),UI 只读 view()。 + +func run(a, data) -> void: + _test_gate(a, data) + _test_output_mult(a, data) + _test_click_output_mult(a, data) + _test_challenge_fail(a, data) + _test_choice(a, data) + _test_timeout_default(a, data) + + +func _new_econ(data) -> EconomyState: + var e := EconomyState.new(data) + e.bought["up_gate_events"] = true # 事件系统路引(过 H2 后出现) + e.gen_levels[0] = 10 # 游魂 10 级 → 产出 1.0/s(gen1 rate 0.1) + return e + + +# ---- 出现前提 ---- +func _test_gate(a, data) -> void: + var d := EventDirector.new(data, 7) + var e := EconomyState.new(data) + e.gen_levels[0] = 10 + a.false_(d.unlocked(e), "未买路引 → 事件系统未解锁") + d.tick(600.0, e) # 10 分钟(远超首延迟) + a.false_(d.is_busy(), "未解锁时不触发事件") + e.bought["up_gate_events"] = true + a.true_(d.unlocked(e), "买路引 → 解锁") + + +# ---- 百鬼夜行:全产出 ×5,60s 后清零 ---- +func _test_output_mult(a, data) -> void: + var d := EventDirector.new(data, 11) + var e := _new_econ(data) + var base := e.output_per_sec() + a.near(base, 1.0, 1e-6, "基线产出 1.0/s") + var r := d.force_start(e, "ev_01_baigui") + a.true_(bool(r.get("ok", false)), "强制开百鬼夜行") + a.eq(str(r.get("type", "")), "output_mult", "类型 output_mult") + a.near(e.output_per_sec(), base * 5.0, 1e-6, "全产出 ×5 生效(瞬时区)") + var v: Dictionary = d.view(e) + a.true_(bool(v.get("active", false)), "view 报告事件进行中") + a.eq(str(v.get("id", "")), "ev_01_baigui", "view 的 id") + a.near(float(v.get("mult", 1.0)), 5.0, 1e-9, "view 的产出倍率来自数据行") + # 跑到结束 + var steps := int(float(data.event("ev_01_baigui").duration_sec) / 0.1) + 2 + for _i in steps: + d.tick(0.1, e) + a.false_(d.is_busy(), "持续时间到 → 事件结束") + a.near(e.output_per_sec(), base, 1e-6, "瞬时区复位(不会永久 ×5)") + + +# ---- 天狗食月:点击 ×10 + 全产出 ×2 ---- +func _test_click_output_mult(a, data) -> void: + var d := EventDirector.new(data, 13) + var e := _new_econ(data) + var base_out := e.output_per_sec() + var base_click := e.click_value() + a.near(base_click, 1.0, 1e-6, "基线点击价值 1.0") + var r := d.force_start(e, "ev_04_tiangou") + a.true_(bool(r.get("ok", false)), "强制开天狗食月") + a.near(e.output_per_sec(), base_out * 2.0, 1e-6, "全产出 ×2") + a.near(e.click_value(), base_click * 10.0, 1e-6, "点击价值 ×10(独立瞬时区)") + # 点击仍能被计数(挑战计数只对 click_challenge 生效,这里不应污染挑战状态) + d.notify_click() + a.eq(int(d.challenge_clicks), 0, "非挑战事件不计数点击") + # 结束复位 + var steps := int(float(data.event("ev_04_tiangou").duration_sec) / 0.1) + 2 + for _i in steps: + d.tick(0.1, e) + a.near(e.click_value(), base_click, 1e-6, "点击瞬时区复位") + a.near(e.output_per_sec(), base_out, 1e-6, "产出瞬时区复位") + + +# ---- 阴兵闹饷:窗口内点击不足 → 随机班底产出 -50% × 60s ---- +func _test_challenge_fail(a, data) -> void: + var d := EventDirector.new(data, 17) + var e := _new_econ(data) + var ev := data.event("ev_02_naoxiang") + var need := int(ev.p_clicks) + var r := d.force_start(e, "ev_02_naoxiang") + a.true_(bool(r.get("ok", false)), "强制开阴兵闹饷") + # 只点了一半 + for _i in need / 2: + d.notify_click() + a.eq(int(d.challenge_clicks), need / 2, "点击计数 %d" % (need / 2)) + a.eq(e.mutiny_gen_index, -1, "窗口未结束 → 尚无惩罚") + # 走完窗口(p_window_sec = 15s) + var steps := int(float(ev.p_window_sec) / 0.1) + 2 + for _i in steps: + d.tick(0.1, e) + a.false_(d.is_busy(), "窗口结束 → 事件结束") + a.true_(e.mutiny_gen_index >= 0, "失败 → 哗变惩罚落到某个已解锁班底") + a.near(e.mutiny_mult, 1.0 - float(ev.p_penalty), 1e-9, "惩罚倍率 = 1 - p_penalty(-50%)") + a.near(e.mutiny_left, float(ev.p_penalty_dur_sec), 1e-6, "惩罚持续时间来自数据行") + # 惩罚到期自动清除 + for _i in int(float(ev.p_penalty_dur_sec) / 0.1) + 2: + e.tick(0.1) + a.eq(e.mutiny_gen_index, -1, "惩罚到期 → 自动清除") + a.near(e.mutiny_mult, 1.0, 1e-9, "惩罚倍率复位") + + +# ---- 冤魂申诉:二选一(超度 = 阴德爆发 30s 量 / 吞噬 = 冤气) ---- +func _test_choice(a, data) -> void: + var ev := data.event("ev_03_shensu") + # opt_a:超度 + var d1 := EventDirector.new(data, 19) + var e1 := _new_econ(data) + var out1 := e1.output_per_sec() + d1.force_start(e1, "ev_03_shensu") + a.eq(d1.state, EventDirector.State.CHOOSING, "choice 事件进入 CHOOSING") + var before1 := e1.merit_run_total + var r1 := d1.choose(e1, 0) + a.true_(bool(r1.get("ok", false)), "选 opt_a(超度)") + a.near(float(r1.get("merit", 0.0)), out1 * float(ev.opt_a_param), 1e-6, + "超度 = 产出/s × 30s 量") + a.near(e1.merit_run_total, before1 + out1 * float(ev.opt_a_param), 1e-6, + "阴德进本轮累计口径(可推殿门)") + a.near(e1.wrath, 0.0, 1e-9, "超度不给冤气") + + # opt_b:吞噬 + var d2 := EventDirector.new(data, 23) + var e2 := _new_econ(data) + d2.force_start(e2, "ev_03_shensu") + var r2 := d2.choose(e2, 1) + a.true_(bool(r2.get("ok", false)), "选 opt_b(吞噬)") + var want := lerp(float(ev.p_clicks), float(ev.p_window_sec), + clampf(float(e2.halls_passed) / float(data.halls.size()), 0.0, 1.0)) + a.near(float(r2.get("wrath", 0.0)), want, 1e-6, "吞噬冤气 = lerp(p_clicks, p_window_sec, 殿序进度)") + a.near(e2.wrath, want, 1e-6, "冤气入账") + a.eq(e2.devour_count, 1, "吞噬计数 +1") + + +# ---- 超时默认 = tune_event_timeout_default(opt_a 超度) ---- +func _test_timeout_default(a, data) -> void: + var d := EventDirector.new(data, 29) + var e := _new_econ(data) + var default_opt := data.tune_text("tune_event_timeout_default", "opt_a") + a.eq(default_opt, "opt_a", "超时默认 = opt_a([B§7])") + d.force_start(e, "ev_03_shensu") + var before := e.merit_run_total + # 决策窗 45s 走完 + var steps := int(float(data.event("ev_03_shensu").duration_sec) / 0.1) + 2 + for _i in steps: + d.tick(0.1, e) + a.false_(d.is_busy(), "超时 → 事件结束") + a.true_(e.merit_run_total > before, "超时走默认分支(超度 → 阴德入账)") + a.near(e.wrath, 0.0, 1e-9, "超时不吞冤气") diff --git a/tests/unit/test_runner.gd b/tests/unit/test_runner.gd index 6efd7ad..0bc9469 100644 --- a/tests/unit/test_runner.gd +++ b/tests/unit/test_runner.gd @@ -39,6 +39,10 @@ const CASE_FILES: Array[String] = [ "res://tests/unit/cases/test_economy_formulas.gd", "res://tests/unit/cases/test_balance_data.gd", "res://tests/unit/cases/test_economy_state.gd", + # W3 新增:相机 rig 数据行与纯数学 / 事件系统运行时 / 殿王战状态机 + "res://tests/unit/cases/test_camera_data.gd", + "res://tests/unit/cases/test_event_director.gd", + "res://tests/unit/cases/test_boss_battle.gd", ]