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", "camera", # W3:per-hall 相机 rig 行(camera.csv;每殿恰一行) "wrath_layers", # W5:冤煞层 22 行(wrath_layers.csv;[B§12] 漏登补登,G-1) ] 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 cameras: Array[CameraDef] = [] # W3:per-hall 相机 rig var wrath_layers: Array[WrathLayerDef] = [] # W5:冤煞层 22 行(一层一行) 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 _camera_by_hall := {} var _palette_by_hall := {} var _event_by_id := {} var _tune := {} # id -> {"kind": String, "num": float, "text": String} var _wrath_by_index := {} # W5:layer_index -> WrathLayerDef ## 从目录载入全部表(缺表 = 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) "camera": bd._load_cameras(rows) "events": bd._load_events(rows) "meta_upgrades": bd._load_meta(rows) "balance_tuning": bd._load_tuning(rows) "wrath_layers": bd._load_wrath_layers(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)) # W4 方案 B(systems-spec §1.2):HP = 产出/s × boss_army_seconds; # 旧 hp_factor / intro_dmg_souls_factor / intro_dmg_cap_ratio 列已删除。 d.boss_army_seconds = float(r.get("boss_army_seconds", 23.0)) d.souls_par = int(r.get("souls_par", 0)) d.army_dps_factor = float(r.get("army_dps_factor", 1)) d.intro_sec = float(r.get("intro_sec", 3)) 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_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() 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 _load_wrath_layers(rows: Array) -> void: for r in rows: var d := WrathLayerDef.new() d.id = str(r.get("id", "")) d.layer_index = int(r.get("layer_index", 0)) d.effect = float(r.get("effect", 0)) d.cost = int(r.get("cost", 0)) wrath_layers.append(d) # ---------------------------------------------------------------------------- # 索引与查询 # ---------------------------------------------------------------------------- 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 cameras: _camera_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 for d in wrath_layers: _wrath_by_index[d.layer_index] = 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 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) ## W5:冤煞层行(layer_index 1 起;越界返回 null——buy_wrath_layer 先查上限) func wrath_layer(layer_index: int) -> WrathLayerDef: return _wrath_by_index.get(layer_index) ## W5:前 layers 层的 effect 合计(全局乘区加法;recompute 非热路径,逐行求和) func wrath_layer_effect_sum(layers: int) -> float: var sum := 0.0 for d in wrath_layers: if d.layer_index <= layers: sum += d.effect return sum ## 全局调参数(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) # 每殿恰一行相机 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]) # W5:冤煞层表完整性(wrath_layers.csv;G-1 补登) # ① tune_wrath_layers 与表行数交叉校验(双源必须一致) # ② layer_index 从 1 连续递增(一行一层,无跳号/重号——x-unique 已挡重号) var wrath_total := 0 if _tune.has("tune_wrath_layers"): wrath_total = int(_tune["tune_wrath_layers"].num) if wrath_total != wrath_layers.size(): errs.append("wrath_layers:tune_wrath_layers=%d 与表行数 %d 不一致(改层总数 = 加行 + 同步调参,走 ADR)" % [ wrath_total, wrath_layers.size()]) for i in wrath_layers.size(): var wl := wrath_layers[i] if wl.layer_index != i + 1: errs.append("wrath_layers:第 %d 行 layer_index=%d(应从 1 连续递增)" % [i + 1, wl.layer_index]) if wl.effect < 0.0: errs.append("wrath_layers.%s:effect 为负(%f)" % [wl.id, wl.effect]) return errs