feat: W2 经济核心+数据管线+灰盒 — Balance Bible v1.0 八表逐值落地,EconomyState/sim 14 门/灰盒主场景/单测(GDScript 待本机引擎验证)

This commit is contained in:
程基岩(engineering-lead) committed 2026-10-19 17:44:53 +08:00
1 parent 856326a766
commit 24e5b0c4cc
58 files changed
+7099 -22

No files matched your search

+336
View File
@@ -0,0 +1,336 @@
class_name BalanceData
extends RefCounted
# ============================================================================
# BalanceData —— data/build/*.json 载入器(架构 §3.1 管线第三段)
#
# 单向数据管线的运行时入口:
# data/sheets/*.csv ──SheetImporter──► data/build/*.json ──本类──► def 对象
# sim(core/)与游戏(game/DataRegistry)读同一份文件 = 零漂移(架构 §3.1 铁律②)
#
# 纪律:
# - core 层:无 Node/无场景树依赖;sim 与单测可无头构造(架构 §2.2)
# - 只读:游戏永不写 data/(铁律①)
# - 载入即做跨表引用完整性检查(check_references),违规进 load_errors
#
# 知识缺口:4.7.x 超出训练数据——只用 4.x 稳定 API(FileAccess/JSON/DirAccess)。
# ============================================================================
const SHEET_FILES: Array[String] = [
"generators", "upgrades", "halls", "hall_palettes",
"boss_battles", "events", "meta_upgrades", "balance_tuning",
]
var generators: Array[GeneratorDef] = []
var upgrades: Array[UpgradeDef] = [] # 108 条非 meta 升级
var meta_upgrades: Array[UpgradeDef] = [] # 12 条宿慧商店(type=meta)
var halls: Array[HallDef] = []
var palettes: Array[PaletteDef] = []
var bosses: Array[BossDef] = []
var events: Array[EventDef] = []
var load_errors: Array[String] = []
var _gen_by_id := {}
var _up_by_id := {}
var _meta_by_id := {}
var _hall_by_id := {}
var _hall_by_no := {}
var _boss_by_hall := {}
var _palette_by_hall := {}
var _event_by_id := {}
var _tune := {} # id -> {"kind": String, "num": float, "text": String}
## 从目录载入全部表(缺表 = load_errors,不静默)
static func load_from_dir(dir: String) -> BalanceData:
var bd := BalanceData.new()
for sheet in SHEET_FILES:
var path := "%s/%s.json" % [dir, sheet]
if not FileAccess.file_exists(path):
bd.load_errors.append("缺少 build 产物:%s(先运行 SheetImporter import_all)" % path)
continue
var text := FileAccess.get_file_as_string(path)
var doc: Variant = JSON.parse_string(text)
if not (doc is Dictionary):
bd.load_errors.append("JSON 解析失败:%s" % path)
continue
var rows: Variant = doc.get("rows", [])
if not (rows is Array):
bd.load_errors.append("%s:缺少 rows 数组" % path)
continue
match sheet:
"generators": bd._load_generators(rows)
"upgrades": bd._load_upgrades(rows)
"halls": bd._load_halls(rows)
"hall_palettes": bd._load_palettes(rows)
"boss_battles": bd._load_bosses(rows)
"events": bd._load_events(rows)
"meta_upgrades": bd._load_meta(rows)
"balance_tuning": bd._load_tuning(rows)
bd._build_indexes()
bd.load_errors.append_array(bd.check_references())
return bd
# ----------------------------------------------------------------------------
# 各表载入(字段名与 CSV 列同名;JSON 数字按 int/float 到达,统一 float() 收敛)
# ----------------------------------------------------------------------------
func _load_generators(rows: Array) -> void:
for r in rows:
var d := GeneratorDef.new()
d.id = str(r.get("id", ""))
d.display_cn = str(r.get("display_cn", ""))
d.display_en = str(r.get("display_en", ""))
d.base_cost = float(r.get("base_cost", 0))
d.cost_ratio = float(r.get("cost_ratio", 1))
d.base_rate = float(r.get("base_rate", 0))
d.unlock_hall = str(r.get("unlock_hall", "none"))
d.unlock_gate = str(r.get("unlock_gate", "none"))
d.sprite_row = str(r.get("sprite_row", ""))
generators.append(d)
func _load_upgrades(rows: Array) -> void:
for r in rows:
var d := UpgradeDef.new()
d.id = str(r.get("id", ""))
d.name_cn = str(r.get("name_cn", ""))
d.name_en = str(r.get("name_en", ""))
d.type = str(r.get("type", ""))
d.target_gen = str(r.get("target_gen", "none"))
d.unlock_hall = str(r.get("unlock_hall", "none"))
d.effect_type = str(r.get("effect_type", ""))
d.effect_param = float(r.get("effect_param", 0))
d.requires = str(r.get("requires", "none"))
d.cost = int(r.get("cost", 0))
d.desc_cn = str(r.get("desc_cn", ""))
upgrades.append(d)
func _load_halls(rows: Array) -> void:
for r in rows:
var d := HallDef.new()
d.id = str(r.get("id", ""))
d.no = int(r.get("no", 0))
d.king_cn = str(r.get("king_cn", ""))
d.king_en = str(r.get("king_en", ""))
d.motif_cn = str(r.get("motif_cn", ""))
d.motif_en = str(r.get("motif_en", ""))
d.target_min = float(r.get("target_min", 0))
d.gate_merit = float(r.get("gate_merit", 0))
d.unlock_gen = str(r.get("unlock_gen", "none"))
d.unlock_gate = str(r.get("unlock_gate", "none"))
d.palette_id = str(r.get("palette_id", ""))
d.is_prestige_gate = bool(r.get("is_prestige_gate", false))
halls.append(d)
func _load_palettes(rows: Array) -> void:
for r in rows:
var d := PaletteDef.new()
d.id = str(r.get("id", ""))
d.hall_id = str(r.get("hall_id", ""))
d.name_cn = str(r.get("name_cn", ""))
d.ink_hex = str(r.get("ink_hex", "#1A1A1A"))
d.bone_hex = str(r.get("bone_hex", "#F2EDE4"))
d.cinnabar_hex = str(r.get("cinnabar_hex", "#B4402C"))
d.indigo_hex = str(r.get("indigo_hex", "#2C4A6E"))
d.accent_hex = str(r.get("accent_hex", "#1A1A1A"))
d.accent_name_cn = str(r.get("accent_name_cn", ""))
d.flow = str(r.get("flow", "seep_down"))
d.lut_index = int(r.get("lut_index", 0))
d.ink_pct = int(r.get("ink_pct", 65))
d.bone_pct = int(r.get("bone_pct", 25))
d.cinnabar_max_pct = int(r.get("cinnabar_max_pct", 8))
d.indigo_max_pct = int(r.get("indigo_max_pct", 7))
palettes.append(d)
func _load_bosses(rows: Array) -> void:
for r in rows:
var d := BossDef.new()
d.id = str(r.get("id", ""))
d.hall_id = str(r.get("hall_id", ""))
d.hall_factor = float(r.get("hall_factor", 1))
d.hp_factor = float(r.get("hp_factor", 15))
d.army_dps_factor = float(r.get("army_dps_factor", 1))
d.intro_sec = float(r.get("intro_sec", 3))
d.intro_dmg_souls_factor = float(r.get("intro_dmg_souls_factor", 0.5))
d.intro_dmg_cap_ratio = float(r.get("intro_dmg_cap_ratio", 0.2))
d.active_sec = float(r.get("active_sec", 30))
d.combo_window_sec = float(r.get("combo_window_sec", 0.6))
d.combo_step = float(r.get("combo_step", 0.02))
d.combo_cap = float(r.get("combo_cap", 0.6))
d.retry_cooldown_sec = float(r.get("retry_cooldown_sec", 60))
d.reward_hp_ratio = float(r.get("reward_hp_ratio", 0.2))
bosses.append(d)
func _load_events(rows: Array) -> void:
for r in rows:
var d := EventDef.new()
d.id = str(r.get("id", ""))
d.name_cn = str(r.get("name_cn", ""))
d.name_en = str(r.get("name_en", ""))
d.trigger_min_min = float(r.get("trigger_min_min", 1))
d.trigger_max_min = float(r.get("trigger_max_min", 1))
d.duration_sec = float(r.get("duration_sec", 0))
d.type = str(r.get("type", ""))
d.p_mult = float(r.get("p_mult", 0))
d.p_clicks = float(r.get("p_clicks", 0))
d.p_window_sec = float(r.get("p_window_sec", 0))
d.p_penalty = float(r.get("p_penalty", 0))
d.p_penalty_dur_sec = float(r.get("p_penalty_dur_sec", 0))
d.opt_a_type = str(r.get("opt_a_type", "none"))
d.opt_a_param = float(r.get("opt_a_param", 0))
d.opt_b_type = str(r.get("opt_b_type", "none"))
d.opt_b_param = float(r.get("opt_b_param", 0))
d.flavor_cn = str(r.get("flavor_cn", ""))
events.append(d)
func _load_meta(rows: Array) -> void:
for r in rows:
var d := UpgradeDef.new()
d.id = str(r.get("id", ""))
d.name_cn = str(r.get("name_cn", ""))
d.name_en = str(r.get("name_en", ""))
d.type = "meta"
d.target_gen = str(r.get("target_id", ""))
d.unlock_hall = "none"
d.effect_type = str(r.get("effect_type", ""))
d.effect_param = float(r.get("effect_param", 0))
d.requires = str(r.get("requires", "none"))
d.cost = int(r.get("cost_suihui", 0))
d.cost_is_suihui = true
d.max_rank = int(r.get("max_rank", 1))
d.condition = str(r.get("condition", "none"))
d.desc_cn = str(r.get("desc_cn", ""))
meta_upgrades.append(d)
func _load_tuning(rows: Array) -> void:
for r in rows:
var id := str(r.get("id", ""))
if id == "":
continue
_tune[id] = {
"kind": str(r.get("kind", "number")),
"num": float(r.get("num", 0)),
"text": str(r.get("text", "")),
}
# ----------------------------------------------------------------------------
# 索引与查询
# ----------------------------------------------------------------------------
func _build_indexes() -> void:
for d in generators:
_gen_by_id[d.id] = d
for d in upgrades:
_up_by_id[d.id] = d
for d in meta_upgrades:
_meta_by_id[d.id] = d
for d in halls:
_hall_by_id[d.id] = d
_hall_by_no[d.no] = d
for d in bosses:
_boss_by_hall[d.hall_id] = d
for d in palettes:
_palette_by_hall[d.hall_id] = d
for d in events:
_event_by_id[d.id] = d
func generator(id: String) -> GeneratorDef:
return _gen_by_id.get(id)
func upgrade(id: String) -> UpgradeDef:
return _up_by_id.get(id)
func meta(id: String) -> UpgradeDef:
return _meta_by_id.get(id)
func hall(id_or_no: Variant) -> HallDef:
if id_or_no is int:
return _hall_by_no.get(id_or_no)
return _hall_by_id.get(str(id_or_no))
func hall_by_no(no: int) -> HallDef:
return _hall_by_no.get(no)
func boss_for_hall(hall_id: String) -> BossDef:
return _boss_by_hall.get(hall_id)
func palette_for_hall(hall_id: String) -> PaletteDef:
return _palette_by_hall.get(hall_id)
func event(id: String) -> EventDef:
return _event_by_id.get(id)
## 全局调参数(balance_tuning.csv);缺失返回 default 并记 load_errors
func tune_num(key: String, default_value: float) -> float:
var row: Variant = _tune.get(key)
if row == null:
load_errors.append("tune 缺失:%s(用默认 %s)" % [key, str(default_value)])
return default_value
return float(row.num)
func tune_text(key: String, default_value: String) -> String:
var row: Variant = _tune.get(key)
if row == null:
load_errors.append("tune 缺失:%s(用默认 %s)" % [key, default_value])
return default_value
return str(row.text)
func has_tune(key: String) -> bool:
return _tune.has(key)
# ----------------------------------------------------------------------------
# 跨表引用完整性(schema 管单表;跨表在这里查)
# ----------------------------------------------------------------------------
func check_references() -> Array[String]:
var errs: Array[String] = []
for g in generators:
if g.unlock_hall != "none" and _hall_by_id.get(g.unlock_hall) == null:
errs.append("generators.%s.unlock_hall 引用了不存在的殿:%s" % [g.id, g.unlock_hall])
if g.unlock_gate != "none" and _up_by_id.get(g.unlock_gate) == null:
errs.append("generators.%s.unlock_gate 引用了不存在的路引:%s" % [g.id, g.unlock_gate])
for u in upgrades:
if u.target_gen != "none" and _gen_by_id.get(u.target_gen) == null:
errs.append("upgrades.%s.target_gen 引用了不存在的班底:%s" % [u.id, u.target_gen])
if u.unlock_hall != "none" and _hall_by_id.get(u.unlock_hall) == null:
errs.append("upgrades.%s.unlock_hall 引用了不存在的殿:%s" % [u.id, u.unlock_hall])
for h in halls:
if h.unlock_gen != "none" and _gen_by_id.get(h.unlock_gen) == null:
errs.append("halls.%s.unlock_gen 引用了不存在的班底:%s" % [h.id, h.unlock_gen])
if h.unlock_gate != "none" and _up_by_id.get(h.unlock_gate) == null:
errs.append("halls.%s.unlock_gate 引用了不存在的路引:%s" % [h.id, h.unlock_gate])
if _palette_by_hall.get(h.id) == null:
errs.append("halls.%s 缺少色板行(hall_palettes.csv)" % h.id)
for b in bosses:
if _hall_by_id.get(b.hall_id) == null:
errs.append("boss_battles.%s.hall_id 引用了不存在的殿:%s" % [b.id, b.hall_id])
for m in meta_upgrades:
if m.target_gen != "" and _gen_by_id.get(m.target_gen) == null:
errs.append("meta_upgrades.%s.target_id 引用了不存在的班底:%s" % [m.id, m.target_gen])
# 每殿恰一场殿王战
for h in halls:
if _boss_by_hall.get(h.id) == null:
errs.append("殿 %s 缺少 boss_battles 行" % h.id)
return errs
+39
View File
@@ -0,0 +1,39 @@
class_name BossDef
extends RefCounted
# ============================================================================
# BossDef —— 殿王战参数数据行(架构 §4.3;[B§5])
#
# 公式([B§5],参数全部来自本行,代码零硬编码):
# HP = hp_factor × 进入时产出/s × hall_factor (15×产出/s×殿系数 → ~30s 的仗)
# 军列 DPS = 进入时产出/s × army_dps_factor (阴兵在打,不是你一个人)
# 点击伤害 = 点击价值 × 连击(combo_step/层,combo_cap 封顶)
# INTRO = intro_sec 秒冲锋过场;开场伤害 = min(超度×intro_dmg_souls_factor, HP×intro_dmg_cap_ratio)
# ACTIVE = active_sec 秒主动阶段
# 通过奖励 = 阴德爆发 = HP × reward_hp_ratio
# 失败 = retry_cooldown_sec 冷却可再战(H7 前败即等推 → up_gate_boss_retry)
#
# 注:hp_factor=15 是 [B§11 差异#4] 对 GDD 50× 的修复值(50× 按 DPS=产出/s
# 推演 30s 打不完)。架构文档 §1.2/§4.3 中残留的 "50×" 为陈旧引用,以本表与
# Balance Bible v1.0 §5 为准。
# ============================================================================
var id: String # "boss_hall_01"
var hall_id: String # halls.csv 行 ID
var hall_factor: float # 殿系数 1.0→2.2
var hp_factor: float # HP 系数(15)
var army_dps_factor: float # 军列 DPS 系数(1.0)
var intro_sec: float # INTRO 秒数(3)
var intro_dmg_souls_factor: float # 开场伤害 = 超度 × 本值(0.5)
var intro_dmg_cap_ratio: float # 开场伤害封顶 = HP × 本值(0.2)
var active_sec: float # ACTIVE 秒数(30)
var combo_window_sec: float # 连击窗口(0.6s)
var combo_step: float # 连击每层 +2%
var combo_cap: float # 连击封顶 +60%
var retry_cooldown_sec: float # 失败冷却(60s)
var reward_hp_ratio: float # 通过阴德爆发 = HP × 本值(0.2)
## 进入时 HP(架构 §4.3 start() 口径)
func hp_at(output_per_sec: float) -> float:
return hp_factor * output_per_sec * hall_factor
+37
View File
@@ -0,0 +1,37 @@
class_name EventDef
extends RefCounted
# ============================================================================
# EventDef —— 事件数据行(架构 §4.4;[B§7])
#
# 事件倍率走独立瞬时区,不进全局乘区([B§0.3/§7])。出现前提 =
# up_gate_events(过 H2)。参数列按 type 取用:
# output_mult p_mult = 全产出倍率(百鬼夜行 ×5)
# click_challenge p_clicks = 窗口内需点击次数;p_window_sec = 窗口;
# p_penalty = 失败惩罚倍率(-50%);p_penalty_dur_sec = 持续
# (失败目标 = 随机已解锁班底,工程裁定 P-9)
# choice opt_a = 超度(merit_burst_sec:阴德 = 产出/s × 秒数);
# opt_b = 吞噬(wrath_gain:冤气 = lerp(p_clicks, p_window_sec,
# 殿序进度));超时默认 opt_a([B§7])
# click_output_mult p_clicks = 点击倍率;p_mult = 产出倍率(天狗食月 ×10/×2)
#
# EventDirector 运行时为 W3 交付;本文件 + events.csv 先行落定数据。
# ============================================================================
var id: String # "ev_01_baigui"
var name_cn: String
var name_en: String # 临时译名,待文策渊 glossary
var trigger_min_min: float # 触发间隔下限(分钟)
var trigger_max_min: float # 触发间隔上限(分钟,含)
var duration_sec: float # 持续秒数(choice 为非阻塞决策窗)
var type: String # output_mult/click_challenge/choice/click_output_mult
var p_mult: float
var p_clicks: float
var p_window_sec: float
var p_penalty: float
var p_penalty_dur_sec: float
var opt_a_type: String # none/merit_burst_sec/wrath_gain
var opt_a_param: float
var opt_b_type: String
var opt_b_param: float
var flavor_cn: String
+23
View File
@@ -0,0 +1,23 @@
class_name HallDef
extends RefCounted
# ============================================================================
# HallDef —— 十殿 band 数据行(架构 §2.1/§4.3;[B§6])
#
# 殿门 = 单计数器闸门:本轮累计阴德 merit_run ≥ gate_merit([B§0.1],
# 挂机也能过;与殿王战解耦——[B§5] 注)。is_prestige_gate=true 的行
# (仅 H10 转轮王)是轮回转世触发门([B§4.1])。
# ============================================================================
var id: String # "hall_01_qinguang"(W1 已分配,永不重编号)
var no: int # 1-10
var king_cn: String # 殿王名(秦广王…)
var king_en: String # 临时译名,待文策渊 glossary
var motif_cn: String # 刑典母题(剪影+文字暗示路线)
var motif_en: String
var target_min: float # 到达目标时长(分钟,[B§6] pacing 表 25→180)
var gate_merit: float # 殿门条件:本轮累计阴德(2K→1.5B)
var unlock_gen: String # 过殿解锁班底 ID 或 "none"
var unlock_gate: String # 过殿出现路引 ID 或 "none"
var palette_id: String # hall_palettes.csv 行 ID
var is_prestige_gate: bool # true = 轮回触发门(仅 H10)
+32
View File
@@ -0,0 +1,32 @@
class_name PaletteDef
extends RefCounted
# ============================================================================
# PaletteDef —— per-hall 色板数据行(架构 §5.3;[B§6 色板列])
#
# 水墨四色面积纪律([B]):墨 ~65% / 骨白 ~25% / 朱砂 ≤8% / 靛 ≤7%,
# 由 W5 look-dev 冻结评审的截图直方图检查执行(架构 §10.1 评审 #1)。
# hex 为工程起始值(provenance P-10),lut_generator 消费本表生成
# assets/luts/hall_XX.png;换殿 = 换一行数据 + shader 换一张 LUT(ADR-3)。
# ============================================================================
var id: String # "pal_hall_01"
var hall_id: String # halls.csv 行 ID(每殿恰一行)
var name_cn: String # 色板行名(冷靛/冰青/…/骨白+金)
var ink_hex: String # 墨(#1A1A1A)
var bone_hex: String # 骨白(纸底)
var cinnabar_hex: String # 朱砂(强调)
var indigo_hex: String # 靛(次强调;阴德方印编码)
var accent_hex: String # 该殿色温
var accent_name_cn: String
var flow: String # seep_down(自上而下渗)/ bleed_left(向左横溢洶)
var lut_index: int
var ink_pct: int # 面积目标%
var bone_pct: int
var cinnabar_max_pct: int # 面积硬顶
var indigo_max_pct: int
## "#RRGGBB" → Color(供 shader/LUT 生成用)
func to_color(hex: String) -> Color:
return Color.html(hex)