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 范畴)
This commit is contained in:
1 parent
422dda6394
commit
4f650aeb39
27 files changed
+2753
-53
No files matched your search
@@ -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 增长,上面这条会红 → 说明可胜性结构已变,需重评
|
||||
@@ -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 → 瞬时")
|
||||
@@ -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, "超时不吞冤气")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user