From 06d915d6cc774872dc970352bf3dc0eda755fba1 Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Wed, 4 Nov 2026 01:57:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E7=A8=8B(=E5=9B=BE=E9=89=B4=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=C2=B7=E9=87=87=E9=9B=86=E7=AB=AF=E4=B8=8E=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E7=AB=AF=C2=B7=E7=94=A8=E6=88=B7=E4=BB=A4):=20compend?= =?UTF-8?q?ium=5Fdump=E7=BB=8FConfigDB=E5=AF=BC=E5=87=BAenemy+skills?= =?UTF-8?q?=E5=85=A8=E8=A1=8CJSON(out/tables=5Froot=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8C=96)+compendium=5Fgen=E7=94=9F=E6=88=90=E8=87=AA=E5=8C=85?= =?UTF-8?q?=E5=90=ABHTML=E5=9B=BE=E9=89=B4(=E6=8C=89=E5=85=B3=E6=8C=89?= =?UTF-8?q?=E5=B8=A6=E5=88=86=E7=BB=84=C2=B7=E6=B1=A0=E4=BB=BD=E9=A2=9D?= =?UTF-8?q?=C2=B7=E6=8A=80=E8=83=BD=E8=AF=AD=E4=B9=89);=20=E5=AF=B9?= =?UTF-8?q?=E8=B4=A6=E4=B8=89=E4=BB=B6=E8=BF=87+=E6=82=AC=E7=A9=BA?= =?UTF-8?q?=E5=BC=95=E7=94=A8fail-fast=E9=9D=9E=E9=9B=B6=E9=80=80=E5=87=BA?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E8=BF=87+=E7=BB=93=E6=9E=84=E5=AE=A1?= =?UTF-8?q?=E8=AE=A1PASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- game/sim/compendium_dump.gd | 108 ++++++++++++++ game/sim/compendium_dump.gd.uid | 1 + tools/compendium_gen.py | 248 ++++++++++++++++++++++++++++++++ 3 files changed, 357 insertions(+) create mode 100644 game/sim/compendium_dump.gd create mode 100644 game/sim/compendium_dump.gd.uid create mode 100644 tools/compendium_gen.py diff --git a/game/sim/compendium_dump.gd b/game/sim/compendium_dump.gd new file mode 100644 index 00000000..c30c0206 --- /dev/null +++ b/game/sim/compendium_dump.gd @@ -0,0 +1,108 @@ +## 怪物图鉴外置工具·采集端:经 ConfigDB 单点读 enemy + skills 两表全行,行数据原样导出 JSON +## 到 sim-data/compendium/dump.json(渲染端 tools/compendium_gen.py 消费,生成自包含 HTML 图鉴)。 +## 运行:Godot_console.exe --headless --path . --script res://sim/compendium_dump.gd -- [out=<绝对路径>] [tables_root=] +## out 缺省 = 仓库根 sim-data/compendium/dump.json(res:// 的上一级);目录不存在则创建。 +## tables_root 缺省 = res://data/tables;fail-fast 路径验证时可指向临时表目录。 +## 导出形态:顶层 counts 带两表行数计数 + enemy_rows/skill_rows 行字典原样(零新增字段、零改名; +## 表内可省略字段缺席即缺席)。本件零随机、零数值硬编码、零判定逻辑。 +## fail-fast:表加载失败(缺表/行域校验/跨表悬空引用)、行缺关键字段、pool.skill_id 在技能表无对应行 +## → 逐条报错并以退出码 1 中止,禁软回退。 +extends SceneTree + +const DEFAULT_OUT := "../sim-data/compendium/dump.json" # 相对 res://(game/ 上一级 = 仓库根) +const DEFAULT_TABLES_ROOT := "res://data/tables" + +# 行关键字段(缺一即 fail-fast):取两表 schema 加载期必在的键—— +# enemy 的 unit_count_max/atk_min/atk_max/attack_type/armor_type/armor_class 与技能行的 +# trigger/buff_ref/buff_duration/damage_mult/rider_mult 均为 schema 可省略字段,不列入。 +const ENEMY_REQUIRED_KEYS := ["enemy_id", "display_name", "row_kind", "level_index", "depth_band", "unit_count", "hp", "atk", "pool"] +const SKILL_REQUIRED_KEYS := ["skill_id", "display_name", "type", "cooldown_beats"] + + +func _initialize() -> void: + var opt := _parse_args(OS.get_cmdline_user_args()) + var db = load("res://core/config_db.gd").new() + if not db.load_tables(String(opt.tables_root), false): + _print_diags(db) + push_error("compendium_dump: 表加载失败(fail-fast)") + quit(1) + return + var enemy_rows: Array = db.combat.enemy.rows + var skill_rows: Array = db.combat.skill.rows + var errs := _validate_rows(enemy_rows, skill_rows) + if not errs.is_empty(): + for e in errs: + push_error("compendium_dump: %s" % e) + quit(1) + return + var payload := { + "counts": {"enemy_rows": enemy_rows.size(), "skill_rows": skill_rows.size()}, + "enemy_rows": enemy_rows, + "skill_rows": skill_rows, + } + var out_path := String(opt.out) + var dir := out_path.get_base_dir() + if not DirAccess.dir_exists_absolute(dir): + var mk := DirAccess.make_dir_recursive_absolute(dir) + if mk != OK: + push_error("compendium_dump: 目录创建失败 %s(err=%d)" % [dir, mk]) + quit(1) + return + var f := FileAccess.open(out_path, FileAccess.WRITE) + if f == null: + push_error("compendium_dump: 导出文件打开失败 %s(err=%d)" % [out_path, FileAccess.get_open_error()]) + quit(1) + return + f.store_string(JSON.stringify(payload)) + f.close() + print("图鉴导出完成: enemy_rows=%d skill_rows=%d → %s" % [enemy_rows.size(), skill_rows.size(), out_path]) + quit(0) + + +func _parse_args(args: PackedStringArray) -> Dictionary: + var opt := {"out": "", "tables_root": DEFAULT_TABLES_ROOT} + for a in args: + var kv := String(a).split("=", true, 1) + if kv.size() != 2: + continue + if kv[0] == "out": + opt["out"] = String(kv[1]) + elif kv[0] == "tables_root": + opt["tables_root"] = String(kv[1]) + if String(opt.out).is_empty(): + opt["out"] = ProjectSettings.globalize_path("res://").path_join(DEFAULT_OUT) + return opt + + +# 行面 fail-fast 校验:关键字段在位 + pool.skill_id 引用闭合(技能表行集先建索引再对账) +func _validate_rows(enemy_rows: Array, skill_rows: Array) -> Array: + var errs: Array = [] + var skill_ids := {} + for row_v in skill_rows: + var row: Dictionary = row_v + var sid := String(row.get("skill_id", "")) + if sid.is_empty(): + errs.append("技能行缺 skill_id") + for k in SKILL_REQUIRED_KEYS: + if not row.has(k): + errs.append("技能行 %s 缺关键字段: %s" % [sid, k]) + skill_ids[sid] = true + for row_v in enemy_rows: + var row: Dictionary = row_v + var eid := String(row.get("enemy_id", "")) + if eid.is_empty(): + errs.append("敌行缺 enemy_id") + for k in ENEMY_REQUIRED_KEYS: + if not row.has(k): + errs.append("敌行 %s 缺关键字段: %s" % [eid, k]) + for entry_v in row.get("pool", []): + var entry: Dictionary = entry_v + var sid := String(entry.get("skill_id", "")) + if not skill_ids.has(sid): + errs.append("敌行 %s pool 引用不存在的 skill_id: %s(悬空致命)" % [eid, sid]) + return errs + + +func _print_diags(db) -> void: + for d in db.diagnostics(): + print("[ConfigDB/%s] %s: %s" % [d.category, d.severity, d.message]) diff --git a/game/sim/compendium_dump.gd.uid b/game/sim/compendium_dump.gd.uid new file mode 100644 index 00000000..2300a34a --- /dev/null +++ b/game/sim/compendium_dump.gd.uid @@ -0,0 +1 @@ +uid://bnbug3wg477av diff --git a/tools/compendium_gen.py b/tools/compendium_gen.py new file mode 100644 index 00000000..c81b918e --- /dev/null +++ b/tools/compendium_gen.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""怪物图鉴外置工具·渲染端——读 dump.json,生成自包含 index.html(零判定逻辑,只做组织与呈现)。 + +数据源 = game/sim/compendium_dump.gd 的导出件(enemy + skills 两表行数据原样 + 两表行数计数)。 +组织:按 level_index 分关、关内按 depth_band 分组、组内保持表内行序;每行一张敌种卡—— +显示名 / enemy_id / row_kind / 全部属性行值 / 池构成(技能显示名 + 权重 + 份额)+ 每技能语义 +(类型 / 倍率 / 必中附加分量 / 冷却 / 触发条件 / 增益引用与持续)。 +自包含:纯 python 标准库、内容全部内嵌标记、无外链无网络请求无构建工具链,双击可开; +同路径重复生成直接覆盖。字段名保留英文原文,值尽量用表内中文 display_name 与枚举中文名。 +用法: + python tools/compendium_gen.py # 缺省 dump = sim-data/compendium/dump.json,输出同目录 index.html + python tools/compendium_gen.py --dump <路径> --out <路径> +fail-fast:dump 缺失 / JSON 损坏 / counts 与实际行数不符 / 行缺展示必需字段 / 池引用悬空 +→ 报错退出码 1,禁软回退。 +""" +import argparse +import json +import sys +from html import escape +from pathlib import Path +from typing import NoReturn + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_DUMP = ROOT / "sim-data" / "compendium" / "dump.json" +DEFAULT_OUT = ROOT / "sim-data" / "compendium" / "index.html" + +# 展示必需字段(dump 采集端 fail-fast 契约的对应面;缺一即死) +ENEMY_REQUIRED = ("enemy_id", "display_name", "row_kind", "level_index", "depth_band") +SKILL_REQUIRED = ("skill_id", "display_name", "type", "cooldown_beats") + +# 以下中文名均为表 schema 封闭枚举的展示词(enemy_table.gd / skill_table.gd),非玩法数值 +LEVEL_CN = {1: "第一关", 2: "第二关", 3: "第三关"} +ROW_KIND_CN = {"battle": "战斗行", "boss": "Boss行"} +ATTACK_TYPE_CN = {"normal": "普通", "pierce": "穿刺", "magic": "魔法"} +ARMOR_TYPE_CN = {"light": "轻甲", "ward": "魔法护甲", "heavy": "重甲"} +SKILL_TYPE_CN = {"basic": "普攻", "damage": "伤害", "buff": "增益", "debuff": "妨害"} +TRIGGER_FACT_CN = { + "self_hp_pct": "自身血量%", "hero_hp_pct": "主角血量%", "round_index": "回合序号", + "last_round_end": "上轮结束", "mines_hit_last": "上轮踩雷数", "mines_hit_total": "累计踩雷数", +} +TRIGGER_OP_CN = {"eq": "==", "neq": "≠", "gt": ">", "gte": "≥", "lt": "<", "lte": "≤"} + +FIELD_CN = { + "level_index": "关卡", "row_kind": "行类", "depth_band": "深度带", "enemy_id": "敌种ID", + "display_name": "显示名", "unit_count": "单位数", "unit_count_max": "单位数上限", + "hp": "生命", "atk": "攻击基线", "atk_min": "攻击下限", "atk_max": "攻击上限", + "attack_type": "攻击类型", "armor_type": "护甲类型", "armor_class": "护甲等级", + "skill_id": "技能ID", "type": "类型", "damage_mult": "伤害倍率", "rider_mult": "必中附加分量", + "cooldown_beats": "冷却拍数", "trigger": "触发条件", "buff_ref": "增益引用", "buff_duration": "增益持续", +} + + +def die(msg: str) -> NoReturn: + print(f"[compendium_gen] FAIL:{msg}", file=sys.stderr) + sys.exit(1) + + +def esc(v) -> str: + return escape(str(v), quote=True) + + +def enum_cn(table: dict, key: str, cn_map: dict) -> str: + """枚举值 → 「中文名 (raw)」;未知枚举原样展示(fail-fast 只拦必需字段,展示层不发明)""" + raw = table.get(key, "") + cn = cn_map.get(raw) + return f"{cn} ({raw})" if cn is not None else str(raw) + + +def level_cn(level_index) -> str: + try: + n = int(level_index) + except (TypeError, ValueError): + return esc(level_index) + return LEVEL_CN.get(n, f"第{n}关") + + +def trigger_desc(trig) -> str: + if not isinstance(trig, dict): + die(f"trigger 字段须为字典,实际: {trig!r}") + fact = trig.get("fact", "") + op = trig.get("op", "") + parts = [f"{TRIGGER_FACT_CN.get(fact, fact)} {TRIGGER_OP_CN.get(op, op)} {trig.get('value', '')}".strip()] + if trig.get("latch"): + parts.append("一次性(latch)") + return " · ".join(parts) + + +def skill_semantics(srow: dict) -> str: + bits = [enum_cn(srow, "type", SKILL_TYPE_CN)] + if "damage_mult" in srow: + bits.append(f"倍率 ×{srow['damage_mult']}") + if "rider_mult" in srow: + bits.append(f"必中附加 ×{srow['rider_mult']}") + bits.append(f"冷却 {srow.get('cooldown_beats', '?')} 拍") + if "trigger" in srow: + bits.append(f"触发: {trigger_desc(srow['trigger'])}") + if "buff_ref" in srow: + dur = f" · 持续 {srow['buff_duration']}" if "buff_duration" in srow else "" + bits.append(f"增益引用 {srow['buff_ref']}{dur}") + return " · ".join(bits) + + +def attr_value(row: dict, key: str) -> str: + if key == "row_kind": + return enum_cn(row, key, ROW_KIND_CN) + if key == "attack_type": + return enum_cn(row, key, ATTACK_TYPE_CN) + if key == "armor_type": + return enum_cn(row, key, ARMOR_TYPE_CN) + return esc(row[key]) + + +def render_enemy_card(row: dict, skill_by_id: dict) -> str: + eid = row["enemy_id"] + badge = ROW_KIND_CN.get(row["row_kind"], row["row_kind"]) + out = [f'
'] + out.append(f'

{esc(row["display_name"])} {esc(eid)}' + f'{esc(badge)}

') + out.append('') + for key in row: + if key == "pool": + continue + out.append(f'' + f'') + out.append('
{esc(FIELD_CN.get(key, key))} {esc(key)}{attr_value(row, key)}
') + pool = row.get("pool") + if not isinstance(pool, list) or not pool: + die(f"敌行 {eid} pool 缺失或非数组(dump 采集端契约破坏)") + weights = [] + for entry in pool: + if not isinstance(entry, dict) or "skill_id" not in entry or "weight" not in entry: + die(f"敌行 {eid} pool 条目缺 skill_id/weight: {entry!r}") + weights.append(int(entry["weight"])) + total = sum(weights) + if total <= 0: + die(f"敌行 {eid} pool 权重合计非正: {total}") + out.append('' + '') + for entry, w in zip(pool, weights): + sid = str(entry["skill_id"]) + if sid not in skill_by_id: + die(f"敌行 {eid} pool 引用不存在的 skill_id: {sid}(悬空致命)") + srow = skill_by_id[sid] + share = f"{w / total * 100:.1f}%" + out.append(f'' + f'' + f'') + out.append('
技能权重份额语义
{esc(srow["display_name"])} {esc(sid)}{w}{share}{skill_semantics(srow)}
') + return "\n".join(out) + + +CSS = """\ +body{font-family:"Microsoft YaHei",system-ui,sans-serif;margin:2em auto;max-width:60em; + padding:0 1em;color:#222;background:#faf8f4;line-height:1.5} +h1{border-bottom:3px solid #6b5a3e;padding-bottom:.2em} +h2{margin-top:1.6em;color:#4a3b26} +h3{margin:1.2em 0 .4em;color:#5a4a30} +section.enemy{border:1px solid #c9bda5;border-radius:6px;padding:.6em 1em;margin:.8em 0;background:#fff} +section.enemy h4{margin:.2em 0 .5em} +code{background:#efe9dc;border-radius:3px;padding:0 .3em;font-size:.9em} +.badge{float:right;background:#6b5a3e;color:#fff;border-radius:3px;padding:.05em .5em;font-size:.8em} +table{border-collapse:collapse;width:100%;margin:.3em 0} +th,td{border:1px solid #d8cdb8;padding:.25em .6em;text-align:left;vertical-align:top} +th{background:#f1ebdd;white-space:nowrap;font-weight:600} +table.pool th{background:#e7dfd0} +td.sem{font-size:.92em} +p.note{color:#6a5c44;font-size:.92em} +.counts{background:#efe9dc;border-radius:4px;padding:.3em .8em;display:inline-block} +""" + + +def build_html(dump: dict) -> str: + counts = dump.get("counts") + enemy_rows = dump.get("enemy_rows") + skill_rows = dump.get("skill_rows") + if not isinstance(counts, dict) or not isinstance(enemy_rows, list) or not isinstance(skill_rows, list): + die("dump.json 顶层缺 counts/enemy_rows/skill_rows(结构不符)") + if counts.get("enemy_rows") != len(enemy_rows) or counts.get("skill_rows") != len(skill_rows): + die(f"counts 与行数不符: counts={counts} 实际 enemy={len(enemy_rows)} skill={len(skill_rows)}") + + skill_by_id = {} + for r in skill_rows: + if not isinstance(r, dict): + die(f"技能行非字典: {r!r}") + for k in SKILL_REQUIRED: + if k not in r: + die(f"技能行缺必需字段 {k}: {r!r}") + skill_by_id[str(r["skill_id"])] = r + + for r in enemy_rows: + if not isinstance(r, dict): + die(f"敌行非字典: {r!r}") + for k in ENEMY_REQUIRED: + if k not in r: + die(f"敌行缺必需字段 {k}: {r!r}") + + levels = {} + for r in enemy_rows: + levels.setdefault(int(r["level_index"]), []).append(r) + + out = ['', '', '', + '', '怪物图鉴', + f'', '', '', + '

怪物图鉴

', + '

本页由外置图鉴工具生成(tools/compendium_gen.py),数据来自 ' + 'sim-data/compendium/dump.json(game/data/tables/enemy.tres 与 skills.tres 经 ConfigDB ' + '单点读取导出)。只读对账面,非游戏内界面;重复生成直接覆盖。

', + f'

敌行 {len(enemy_rows)} 行 · 技行 ' + f'{len(skill_rows)} 行(counts 计数核对一致)

'] + + for lv in sorted(levels): + rows = levels[lv] + out.append(f'

{level_cn(lv)} level_index={lv}({len(rows)} 行)

') + bands = {} + for r in rows: + bands.setdefault(int(r["depth_band"]), []).append(r) + for band in sorted(bands): + out.append(f'

深度带 {band} depth_band={band}

') + for r in bands[band]: + out.append(render_enemy_card(r, skill_by_id)) + out.extend(['', '']) + return "\n".join(out) + + +def main() -> int: + ap = argparse.ArgumentParser(description="怪物图鉴渲染端:dump.json → 自包含 index.html") + ap.add_argument("--dump", default=str(DEFAULT_DUMP), help="采集端导出 JSON 路径") + ap.add_argument("--out", default=str(DEFAULT_OUT), help="输出 HTML 路径") + args = ap.parse_args() + dump_path, out_path = Path(args.dump), Path(args.out) + if not dump_path.is_file(): + die(f"dump.json 不存在: {dump_path}") + try: + dump = json.loads(dump_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + die(f"dump.json 读取/解析失败: {e}") + html = build_html(dump) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(html, encoding="utf-8") + n_enemy = len(dump["enemy_rows"]) + n_skill = len(dump["skill_rows"]) + print(f"[compendium_gen] 完成: 敌行 {n_enemy} · 技行 {n_skill} → {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())