Files
gplan-2/design/qa/t5-skeleton-preview-20260929/render_preview.py
T

202 lines
8.2 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 -*-
"""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()