Files
game-code-4/tests/unit/cases/test_event_director.gd
T
程基岩(engineering-lead) 4f650aeb39 W3:相机 rig + band 组装器 + 事件系统 + 殿王战(灰盒);ADR-5 落定
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 范畴)
2026-10-20 01:39:14 +08:00

153 lines
6.3 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "超时不吞冤气")