Files
game-code-4/tests/unit/cases/test_camera_data.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

112 lines
5.0 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 相机 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 → 瞬时")