417 lines
15 KiB
GDScript
417 lines
15 KiB
GDScript
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
|