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
@@ -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
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user