202 lines
8.2 KiB
Python
202 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""T5 节点图骨架 15 张一次性目检预览(M-3 迭代 5 U2 人闸第三件,2026-09-29)。
|
||
|
||
数据源 = game/data/tables/level.tres(游戏内实际落表,非生成器重算)。
|
||
产物 = 本目录 15 张单图 + 00-总览拼图.png + 0-目检清单.md。一次性工具,随产物留档可删。
|
||
用法:python design/qa/t5-skeleton-preview-20260929/render_preview.py(仓库根执行)
|
||
"""
|
||
import json
|
||
import math
|
||
import os
|
||
import re
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
ROOT = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", ".."))
|
||
TRES = os.path.join(ROOT, "game", "data", "tables", "level.tres")
|
||
FONT = os.path.join(ROOT, "game", "assets", "fonts", "ui_font_fusion_pixel_zh_hans.ttf")
|
||
OUT = os.path.dirname(os.path.abspath(__file__))
|
||
|
||
BG = (30, 27, 34)
|
||
INK = (240, 234, 220)
|
||
DIM = (150, 142, 132)
|
||
EDGE = (168, 158, 142)
|
||
TYPE_STYLE = {
|
||
"battle": {"label": "战斗", "fill": (62, 86, 113), "border": (120, 148, 180)},
|
||
"reward": {"label": "奖励", "fill": (176, 127, 44), "border": (222, 178, 96)},
|
||
"rest": {"label": "休整", "fill": (74, 124, 89), "border": (128, 186, 148)},
|
||
"shop": {"label": "商店", "fill": (58, 110, 140), "border": (118, 172, 200)},
|
||
"boss": {"label": "Boss", "fill": (122, 44, 72), "border": (226, 120, 150)},
|
||
}
|
||
|
||
|
||
def parse_rows(path):
|
||
text = open(path, encoding="utf-8").read()
|
||
body = text.split("rows = [", 1)[1].rsplit("\n]", 1)[0]
|
||
rows = []
|
||
depth, start = 0, None
|
||
for i, ch in enumerate(body):
|
||
if ch == "{":
|
||
if depth == 0:
|
||
start = i
|
||
depth += 1
|
||
elif ch == "}":
|
||
depth -= 1
|
||
if depth == 0 and start is not None:
|
||
rows.append(json.loads(body[start:i + 1]))
|
||
start = None
|
||
return rows
|
||
|
||
|
||
def f(size):
|
||
return ImageFont.truetype(FONT, size)
|
||
|
||
|
||
def arrow(d, p0, p1, color, w=3):
|
||
d.line([p0, p1], fill=color, width=w)
|
||
ang = math.atan2(p1[1] - p0[1], p1[0] - p0[0])
|
||
L, W = 14, 5
|
||
a1 = (p1[0] - L * math.cos(ang) + W * math.sin(ang), p1[1] - L * math.sin(ang) - W * math.cos(ang))
|
||
a2 = (p1[0] - L * math.cos(ang) - W * math.sin(ang), p1[1] - L * math.sin(ang) + W * math.cos(ang))
|
||
d.polygon([p1, a1, a2], fill=color)
|
||
|
||
|
||
def polyline_arrow(d, pts, color, w=3):
|
||
d.line(pts, fill=color, width=w)
|
||
ang = math.atan2(pts[-1][1] - pts[-2][1], pts[-1][0] - pts[-2][0])
|
||
L, W = 14, 5
|
||
a1 = (pts[-1][0] - L * math.cos(ang) + W * math.sin(ang), pts[-1][1] - L * math.sin(ang) - W * math.cos(ang))
|
||
a2 = (pts[-1][0] - L * math.cos(ang) - W * math.sin(ang), pts[-1][1] - L * math.sin(ang) + W * math.cos(ang))
|
||
d.polygon([pts[-1], a1, a2], fill=color)
|
||
|
||
|
||
def node_box(d, cx, cy, ntype, depth, big=False):
|
||
st = TYPE_STYLE[ntype]
|
||
w, h = (132, 72) if big else (112, 60)
|
||
x0, y0, x1, y1 = cx - w // 2, cy - h // 2, cx + w // 2, cy + h // 2
|
||
d.rounded_rectangle([x0, y0, x1, y1], radius=10, fill=st["fill"],
|
||
outline=st["border"], width=4 if big else 2)
|
||
label = st["label"]
|
||
fnt = f(24 if big else 21)
|
||
bb = d.textbbox((0, 0), label, font=fnt)
|
||
d.text((cx - (bb[2] - bb[0]) // 2, cy - (bb[3] - bb[1]) // 2 - 4), label, font=fnt, fill=INK)
|
||
if depth is not None:
|
||
tag = "#%d" % depth
|
||
fnt2 = f(15)
|
||
bb2 = d.textbbox((0, 0), tag, font=fnt2)
|
||
tw, th = bb2[2] - bb2[0], bb2[3] - bb2[1]
|
||
bx, by = x0 + 2, y0 - 12
|
||
d.rounded_rectangle([bx, by, bx + tw + 12, by + th + 8], radius=7, fill=BG,
|
||
outline=st["border"], width=1)
|
||
d.text((bx + 6, by + 4), tag, font=fnt2, fill=INK)
|
||
return (x0, y0, x1, y1)
|
||
|
||
|
||
def render_chain(name, level_name, nodes, path):
|
||
W, H = 1280, 320
|
||
img = Image.new("RGB", (W, H), BG)
|
||
d = ImageDraw.Draw(img)
|
||
d.text((24, 18), "%s — %s" % (level_name, name), font=f(30), fill=INK)
|
||
d.text((24, 64), "单链:自左向右依次推进|#N = 遭遇表行(战斗深度序)|入口 = 最左", font=f(17), fill=DIM)
|
||
n = len(nodes)
|
||
xs = [140 + i * ((W - 280) // (n - 1)) for i in range(n)]
|
||
cy = 190
|
||
boxes = [node_box(d, xs[i], cy, nd["type"], nd.get("node_depth"),
|
||
big=nd["type"] == "boss") for i, nd in enumerate(nodes)]
|
||
for i in range(n - 1):
|
||
arrow(d, (boxes[i][2] + 2, cy), (boxes[i + 1][0] - 3, cy), EDGE)
|
||
d.text((xs[0] - 62, cy + 46), "▶ 入口", font=f(17), fill=DIM)
|
||
img.save(path)
|
||
|
||
|
||
def render_graph(name, level_name, nodes, path):
|
||
ids = [nd["node_depth"] for nd in nodes]
|
||
inbound = {}
|
||
for nd in nodes:
|
||
for t in nd.get("next", []):
|
||
inbound.setdefault(t, []).append(nd["node_depth"])
|
||
layer = {}
|
||
|
||
def lay(i):
|
||
if i in layer:
|
||
return layer[i]
|
||
layer[i] = 0 if i not in inbound else max(lay(p) for p in inbound[i]) + 1
|
||
return layer[i]
|
||
|
||
for i in ids:
|
||
lay(i)
|
||
maxl = max(layer.values())
|
||
W, H = 1280, 480
|
||
img = Image.new("RGB", (W, H), BG)
|
||
d = ImageDraw.Draw(img)
|
||
d.text((24, 18), "%s — %s" % (level_name, name), font=f(30), fill=INK)
|
||
d.text((24, 64), "网状:箭头=可走的下一节点|#N = 遭遇表行(战斗深度)|入口 = 最左|商店→Boss 必经",
|
||
font=f(17), fill=DIM)
|
||
cols = {}
|
||
for i in sorted(ids, key=lambda x: (layer[x], x)):
|
||
cols.setdefault(layer[i], []).append(i)
|
||
xs = {l: 150 + l * ((W - 300) / max(maxl, 1)) for l in cols}
|
||
pos = {}
|
||
for l, members in cols.items():
|
||
k = len(members)
|
||
for j, i in enumerate(members):
|
||
y = 280 + (j - (k - 1) / 2.0) * (120 if k > 1 else 0)
|
||
pos[i] = (int(xs[l]), int(y))
|
||
for nd in nodes:
|
||
i = nd["node_depth"]
|
||
for t in nd.get("next", []):
|
||
(x0, y0), (x1, y1) = pos[i], pos[t]
|
||
if x1 - x0 >= 120:
|
||
arrow(d, (x0 + 60, y0), (x1 - 62, y1), EDGE, w=3)
|
||
else:
|
||
bow = 40 if y1 >= y0 else -40
|
||
pts = [(x0 + (x1 - x0) * u / 20.0,
|
||
y0 + (y1 - y0) * u / 20.0 + bow * math.sin(math.pi * u / 20.0))
|
||
for u in range(21)]
|
||
polyline_arrow(d, pts, EDGE)
|
||
entry = [i for i in ids if i not in inbound][0]
|
||
for nd in nodes:
|
||
i = nd["node_depth"]
|
||
node_box(d, pos[i][0], pos[i][1], nd["type"], i, big=nd["type"] == "boss")
|
||
if i == entry:
|
||
d.text((pos[i][0] - 32, pos[i][1] + 42), "▶ 入口", font=f(17), fill=DIM)
|
||
img.save(path)
|
||
|
||
|
||
def main():
|
||
rows = parse_rows(TRES)
|
||
assert len(rows) == 3, "rows=%d" % len(rows)
|
||
files = []
|
||
for row in rows:
|
||
li = row["level_index"]
|
||
for ti, tpl in enumerate(row["templates"]):
|
||
nodes = tpl["nodes"]
|
||
fn = "L%d-模板%d-%s.png" % (li, ti, re.sub(r"[\\/:*?\"<>|()·]", "", tpl["display_name"]))
|
||
path = os.path.join(OUT, fn)
|
||
is_graph = any("next" in n for n in nodes)
|
||
if is_graph:
|
||
render_graph(tpl["display_name"], row["display_name"], nodes, path)
|
||
else:
|
||
render_chain(tpl["display_name"], row["display_name"], nodes, path)
|
||
files.append((li, ti, tpl["display_name"], fn))
|
||
assert len(files) == 15, len(files)
|
||
CW, CH = 640, 190
|
||
sheet = Image.new("RGB", (CW, 40 + CH * 3), BG)
|
||
sd = ImageDraw.Draw(sheet)
|
||
sd.text((16, 8), "T5 骨架池总览(15 张 = 3 关 × 5;逐张单图见同目录)", font=f(22), fill=INK)
|
||
for li, ti, name, fn in files:
|
||
im = Image.open(os.path.join(OUT, fn)).resize((CW - 8, CH - 8))
|
||
sheet.paste(im, (4, 36 + (li - 1) * CH))
|
||
sheet.save(os.path.join(OUT, "00-总览拼图.png"))
|
||
body = "\n".join("- [ ] %s(%s)" % (fn, name) for li, ti, name, fn in files)
|
||
open(os.path.join(OUT, "0-目检清单.md"), "w", encoding="utf-8").write(
|
||
"# T5 骨架 15 张目检清单(U2 人闸第三件,2026-09-29)\n\n"
|
||
"数据源=game/data/tables/level.tres;逐张给「过/不过+原因」,回填记台账。\n\n" + body + "\n")
|
||
print("rendered %d files + sheet" % len(files))
|
||
for li, ti, name, fn in files:
|
||
print(" L%d-模板%d %s -> %s" % (li, ti, name, fn))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|