#!/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())