Files
gplan-2/tools/compendium_gen.py
T

250 lines
11 KiB
Python
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.
#!/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行", "elite": "精英行"}
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": "护甲等级",
"base_ref": "基础敌种", "pool_extra": "附加技",
"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'<section class="enemy" data-enemy-row="{esc(eid)}">']
out.append(f'<h4>{esc(row["display_name"])} <code>{esc(eid)}</code>'
f'<span class="badge">{esc(badge)}</span></h4>')
out.append('<table class="attrs"><tbody>')
for key in row:
if key in ("pool", "pool_extra"):
continue
out.append(f'<tr><th>{esc(FIELD_CN.get(key, key))} <code>{esc(key)}</code></th>'
f'<td>{attr_value(row, key)}</td></tr>')
out.append('</tbody></table>')
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('<table class="pool"><thead><tr>'
'<th>技能</th><th>权重</th><th>份额</th><th>语义</th></tr></thead><tbody>')
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'<tr><td>{esc(srow["display_name"])} <code>{esc(sid)}</code></td>'
f'<td>{w}</td><td>{share}</td>'
f'<td class="sem">{skill_semantics(srow)}</td></tr>')
out.append('</tbody></table></section>')
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 = ['<!DOCTYPE html>', '<html lang="zh-CN">', '<head>',
'<meta charset="utf-8">', '<title>怪物图鉴</title>',
f'<style>{CSS}</style>', '</head>', '<body>',
'<h1>怪物图鉴</h1>',
'<p class="note">本页由外置图鉴工具生成(tools/compendium_gen.py),数据来自 '
'sim-data/compendium/dump.json(game/data/tables/enemy.tres 与 skills.tres 经 ConfigDB '
'单点读取导出)。只读对账面,非游戏内界面;重复生成直接覆盖。</p>',
f'<p class="counts">敌行 <strong>{len(enemy_rows)}</strong> 行 · 技行 '
f'<strong>{len(skill_rows)}</strong> 行(counts 计数核对一致)</p>']
for lv in sorted(levels):
rows = levels[lv]
out.append(f'<h2>{level_cn(lv)} <code>level_index={lv}</code>({len(rows)} 行)</h2>')
bands = {}
for r in rows:
bands.setdefault(int(r["depth_band"]), []).append(r)
for band in sorted(bands):
out.append(f'<h3>深度带 {band} <code>depth_band={band}</code></h3>')
for r in bands[band]:
out.append(render_enemy_card(r, skill_by_id))
out.extend(['</body>', '</html>'])
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())