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 范畴)
212 lines
10 KiB
GDScript
212 lines
10 KiB
GDScript
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 增长,上面这条会红 → 说明可胜性结构已变,需重评
|