Files
game-code-4/src/core/data/balance_data.gd
T

417 lines
15 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.
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