Files
gplan-2/tools/render_level_map_sheet.py
T

602 lines
32 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.
# -*- coding: utf-8 -*-
"""关卡画面样张验收图生成脚本 v1(2026-09-28,M-2 迭代 4 T1)。
闸形 A(M-1 迭代 2 先例,2026-09-27 拍板):断言集从 level-map.md AC + art-bible 硬指标直接推导
(本样张无历史否决原因输入),生成即验,全过即冻结落码;气质终审 = 批末用户目检(=M-2 出口闸)。
断言集(事实源=level-map.md AC/Accessibility + art-bible §4.1/§4.2/§2.7/§7):
L1 五节点类型图标互辨(灰度截图可分类——level-map AC;kenney-board-game-icons CC0 五键)
L2 节点四态(已过暗金/当前金/候选亮金描边/未来暗)灰度可分类 + Boss 紫赤描边灰度可辨
L3 数字对比硬指标(§4.1):顶栏 GP 徽章/底栏读数对屏底与对数据框 ≥4.5:1;节点字面(图标)对方块底 ≥4.5:1
L4 禁入带 HSV300°-345° 零命中(Boss 紫赤环 = §4.2 唯一合法占用者,环区白名单)
L5 画布随图自适应:深层节点在呈现/命中域内(routebug 83c11a1 回归;公式与 level_map_screen.gd 同源解析)
L6 独立屏底:不透明屏底(BG_ROOT+石纹底噪,非半透暗幕零透出)
L7 字形占位区不侵入:图标区与编号区 bbox 零重叠,双双不越方块内缘
A1a 色板对表圣经 §4.1 冻结值;A1b 禁入带扫描见 L4
A5 画布四缘 2px 净空;SRC 同源断言:画布公式/停点标题常量自 level_map_screen.gd 解析
样张内容 = level-map.md Layout 三分区 1:1 实比(顶栏关卡名+GP 徽章/节点图区/底栏状态组+放弃按钮)
+ 岔路停点主屏 + 入口停点对照屏 + S3 换层色板示意(§2.7/§4.3 色相层进,信息层恒定)。
冻结值登记(样张通过值 → T2/T3 落码):
图标形制=kenney 64px 五键 NEAREST 22px,WARM 单色重着色,当前金底改 INK_ROUTE 墨(双色墨制);
已过暗金冻结新值 GOLD_DONE #68562C(旧 GOLD_DIM 对双色墨均 <4.5:1,对比硬指标逼守);
通道质感=STONE 2px/候选 GOLD_LIT 3px/悬停 4px 直角折线(中点肘形);画布公式=level_map_screen 同源;
底栏=暗底直角细线数据框+暖白读数;屏底=BG_ROOT+backdrop_stone 平铺。
素材不入 git——先查本仓库,缺位回退主检出(D:/code/g-plan2)。
输出 = artwork/mockups/level-map-sheet-v1-2026-09-28.png(v1 命名——v1 被覆盖欠账教训先例)
+ design/research/level-map-sample-v1-机检报告-2026-09-28.md。
用法:python tools/render_level_map_sheet.py
"""
from PIL import Image, ImageDraw, ImageFilter, ImageChops
import os
import sys
import re
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
MAIN_ROOT = "D:/code/g-plan2"
def asset_root(*parts):
"""素材路径两级回退:worktree → 主检出(素材不入 git,fresh worktree 缺位)。"""
for base in (ROOT, MAIN_ROOT):
p = os.path.join(base, *parts)
if os.path.exists(p):
return p
raise FileNotFoundError(os.path.join(*parts))
OUT_PNG = os.path.join(ROOT, "artwork", "mockups", "level-map-sheet-v1-2026-09-28.png")
OUT_REPORT = os.path.join(ROOT, "design", "research", "level-map-sample-v1-机检报告-2026-09-28.md")
T_FONT_PATH = asset_root("artwork", "ui-reuse-2026-09-23",
"4-字体", "fusion-pixel-12px(OFL)", "fusion-pixel-12px-proportional-latin.ttf")
ZH_FONT = r"C:\Windows\Fonts\msyh.ttc"
ICON_DIR = ("artwork", "ui-reuse-2026-09-23", "3-节点图与营地图标",
"kenney-board-game-icons(CC0)", "PNG", "Default (64px)")
BACKDROP = ("game", "assets", "art", "ui", "backdrop_stone.png")
# ══ 冻结色板(A1a 对表基准——ui_kit.gd 现行 + art-bible §4.1;禁改动) ══
BG_ROOT = (30, 25, 20) # 屏幕根底(UiKit.BG_ROOT)
PANEL_DARK = (64, 58, 52) # 数据框暗底(UiKit.DARK_UPCOMING——底/文同值档)
PANEL_BORDER = (120, 106, 88) # 直角细线框(UiKit.PANEL_BORDER)
GOLD = (240, 168, 40) # 奖励金 #F0A828(当前节点/GP 徽章——§4.1)
GOLD_DONE = (104, 86, 44) # 已过暗金(冻结新值 #68562C——旧 GOLD_DIM #8C743C 对双色墨均 <4.5:1)
GOLD_LIT = (230, 190, 90) # 候选亮金描边(UiKit.GOLD_LIT)
DARK_UPCOMING = (64, 58, 52) # 未来暗底(UiKit.DARK_UPCOMING)
BOSS_PURPLE = (143, 47, 106) # 紫赤 #8F2F6A(Boss 描边——§4.2 禁入带唯一合法占用者)
INK_ROUTE = (24, 20, 16) # 节点图最深墨(UiKit.INK_ROUTE——亮底图标/编号墨)
WARM = (240, 226, 200) # 暖白(UiKit.WARM——暗底图标/编号墨、正文读数)
WARM_SOFT = (200, 188, 166) # 暖白次级(数据框标签)
STONE = (120, 110, 98) # 通道石灰(UiKit.STONE)
RED_MINE = (150, 44, 36) # 危险红族(UiKit.RED_MINE——放弃按钮字)
BRICK = (201, 175, 131) # 按钮石面(UiKit.BRICK——可按的砖)
COLD_PASSAGE = (112, 120, 128) # S3 换层通道冷调(underdark 方向——色相层进示意)
NODE_KEYS = ["battle", "reward", "rest", "boss", "shop"]
NODE_ICONS = {"battle": "bow.png", "reward": "award.png", "rest": "campfire.png",
"boss": "crown_b.png", "shop": "pouch.png"} # T1 挑选(语义近似;闸④全 CC0)
ICON_PX = 20 # 节点图标边长(52×36 方块内 20px 图标+右上编号——冻结值,与 quantize 烘焙同值)
NODE_HALF = (26, 18) # 节点方块半宽高(与 level_map_screen.gd 同源解析对表)
CANVAS_MIN = (620, 240) # 画布下限(同源)
W_PASS, W_LIT, W_HOVER = 2, 3, 4 # 通道折线宽(未lit/lit/悬停——冻结值)
from PIL import ImageFont
F_NUM = ImageFont.truetype(T_FONT_PATH, 15)
F_SMALL = ImageFont.truetype(T_FONT_PATH, 13)
F_LBL = ImageFont.truetype(ZH_FONT, 14)
F_LBL2 = ImageFont.truetype(ZH_FONT, 12)
F_TITLE = ImageFont.truetype(ZH_FONT, 19)
F_NODE_NUM = ImageFont.truetype(T_FONT_PATH, 10)
CHECKS = []
def check(cid, name, ok, detail=""):
CHECKS.append((cid, name, bool(ok), detail))
print("[%s] %s — %s%s" % ("PASS" if ok else "FAIL", cid, name,
(";" + detail) if detail else ""))
def wcag_lum(rgb):
def lin(c):
c = c / 255.0
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
r, g, b = rgb[:3]
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b)
def contrast(a, b):
l1, l2 = wcag_lum(a), wcag_lum(b)
return (max(l1, l2) + 0.05) / (min(l1, l2) + 0.05)
def gray(rgb):
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]
# ══ 同源断言素材:level_map_screen.gd 常量解析(SRC 级——禁独立定值漂移) ══
LM_SRC_PATH = os.path.join("game", "presentation", "views", "level_map_screen.gd")
def parse_level_map_src():
for base in (ROOT, MAIN_ROOT):
p = os.path.join(base, LM_SRC_PATH)
if os.path.exists(p):
return open(p, encoding="utf-8").read()
raise FileNotFoundError(LM_SRC_PATH)
LM_SRC = parse_level_map_src()
def src_const(name):
m = re.search(r'const %s\s*:?=\s*"([^"]+)"' % name, LM_SRC)
return m.group(1) if m else None
def src_vector2(name):
m = re.search(r"const %s\s*:?=\s*Vector2\(([0-9.]+),\s*([0-9.]+)\)" % name, LM_SRC)
return (float(m.group(1)), float(m.group(2))) if m else None
def src_formula():
m = re.search(r"_content_size = Vector2\(70 \+ maxl \* (\d+) \+ NODE_HALF\.x \+ ([0-9.]+), "
r"52 \+ maxslot \* (\d+) \+ NODE_HALF\.y \+ ([0-9.]+)\)", LM_SRC)
if not m:
return None
mn = re.search(r"_content_size\.max\(Vector2\((\d+), (\d+)\)\)", LM_SRC)
hit = re.search(r"const HIT_PAD\s*:?=\s*([0-9.]+)", LM_SRC)
return {"dx": float(m.group(1)), "dx_pad": float(m.group(2)),
"dy": float(m.group(3)), "dy_pad": float(m.group(4)),
"min": (float(mn.group(1)), float(mn.group(2))) if mn else None,
"hit_pad": float(hit.group(1)) if hit else None}
TITLES = {"entry": src_const("TITLE_ENTRY"), "single": src_const("TITLE_SINGLE"),
"fork": src_const("TITLE_FORK")}
FORMULA = src_formula()
# ══ 节点图模型(BFS 分层——level_map_screen._assign_layers 同构) ══
def make_graph(edges, types):
nodes = {}
for nid, t in types.items():
nodes[nid] = {"id": nid, "type": t, "out": list(edges.get(nid, []))}
return nodes
def assign_layers(nodes):
layer, slot, counts = {}, {}, {}
inbound = set()
for n in nodes.values():
inbound.update(n["out"])
entry = next((n for n in nodes.values() if n["id"] not in inbound), None)
queue = []
if entry is not None:
layer[entry["id"]] = 0
queue.append(entry)
while queue:
cur = queue.pop(0)
l = layer[cur["id"]]
counts[l] = counts.get(l, 0) + 1
slot[cur["id"]] = counts[l] - 1
for tid in cur["out"]:
if tid in layer:
continue
layer[tid] = l + 1
queue.append(nodes[tid])
max_layer = max(layer.values()) if layer else 0
for n in nodes.values():
if n["id"] not in layer:
layer[n["id"]] = max_layer + 1
slot[n["id"]] = counts.get(layer[n["id"]], 0)
counts[layer[n["id"]]] = counts.get(layer[n["id"]], 0) + 1
return layer, slot
def fit_content(nodes):
layer, slot = assign_layers(nodes)
maxl = max(layer.values())
maxslot = max(slot.values())
size = (70 + maxl * FORMULA["dx"] + NODE_HALF[0] + FORMULA["dx_pad"],
52 + maxslot * FORMULA["dy"] + NODE_HALF[1] + FORMULA["dy_pad"])
size = (max(CANVAS_MIN[0], size[0]), max(CANVAS_MIN[1], size[1]))
return size, layer, slot
# ══ 图标装载(优先烘焙终稿 game/assets/art/ui/node_*.png(quantize 产出,20px 1:1);
# 缺位回退 kenney 64px 现场量化——同 WARM 单色+NEAREST,与烘焙产物逐像素同构) ══
def load_icon(kind):
for base in (ROOT, MAIN_ROOT):
p = os.path.join(base, "game", "assets", "art", "ui", "node_%s.png" % kind)
if os.path.exists(p):
im = Image.open(p).convert("RGBA")
return im if im.size == (ICON_PX, ICON_PX) else im.resize((ICON_PX, ICON_PX), Image.NEAREST)
im = Image.open(asset_root(*(ICON_DIR + (NODE_ICONS[kind],)))).convert("RGBA")
px = im.load()
for y in range(im.size[1]):
for x in range(im.size[0]):
r, g, b, a = px[x, y]
if a > 0:
px[x, y] = WARM + (a,)
return im.resize((ICON_PX, ICON_PX), Image.NEAREST)
ICONS = {k: load_icon(k) for k in NODE_KEYS}
def inked_icon(kind, ink):
im = ICONS[kind].copy()
px = im.load()
k = [ink[i] / float(WARM[i]) for i in range(3)]
for y in range(im.size[1]):
for x in range(im.size[0]):
r, g, b, a = px[x, y]
if a > 0:
px[x, y] = (min(255, int(r * k[0])), min(255, int(g * k[1])),
min(255, int(b * k[2])), a)
return im
# ══ 节点态(冻结双色墨制:亮底深墨/暗底暖白) ══
STATE_DONE, STATE_CURRENT, STATE_CAND, STATE_FUTURE = "done", "current", "cand", "future"
def state_fill(state):
return {STATE_DONE: GOLD_DONE, STATE_CURRENT: GOLD, STATE_CAND: DARK_UPCOMING,
STATE_FUTURE: DARK_UPCOMING}[state]
def state_ink(state):
return INK_ROUTE if state == STATE_CURRENT else WARM # 亮底深墨/暗底暖白(对比 L3)
# ══ 图数据 ══
# 岔路停点主图(五类型齐备;0-3 已过链,3=当前,4/5=候选,8=Boss 未来)
FORK = make_graph(
{0: [1, 2], 1: [3], 2: [3], 3: [4, 5], 4: [6], 5: [7], 6: [8], 7: [8]},
{0: "battle", 1: "battle", 2: "battle", 3: "rest", 4: "reward", 5: "shop",
6: "battle", 7: "battle", 8: "boss"})
# 单链图(L1 口径:战×2→道具→战×2→恢复→Boss,7 节点——画布自适应回归载体)
CHAIN = make_graph(
{0: [1], 1: [2], 2: [3], 3: [4], 4: [5], 5: [6]},
{0: "battle", 1: "battle", 2: "reward", 3: "battle", 4: "battle", 5: "rest", 6: "boss"})
# ══ 屏渲染(1280×800 1:1——三分区) ══
def draw_screen(nodes, title, level_name, gp, visited, current, cands, hover=-1,
cold_shift=False):
img = Image.new("RGBA", (1280, 800), BG_ROOT + (255,))
# 屏底石纹底噪(M-1 框材件;换层示意=cold_shift 时整体冷调——§4.3 wall_ambient 槽示意)
noise = Image.open(asset_root(*BACKDROP)).convert("RGB")
tile = Image.new("RGB", (1280, 800))
for ty in range(0, 800, noise.size[1]):
for tx in range(0, 1280, noise.size[0]):
tile.paste(noise, (tx, ty))
if cold_shift:
h, s, v = tile.convert("HSV").split()
h = h.point(lambda _: 148) # 色相→蓝灰域(≈209°)
s = s.point(lambda x: min(255, int(40 + x * 0.5)))
v = v.point(lambda x: x) # 明度恒定(§2.7 只动色相/饱和)
tile = Image.merge("HSV", (h, s, v)).convert("RGB")
img.alpha_composite(tile.convert("RGBA"))
d = ImageDraw.Draw(img)
passage_col = COLD_PASSAGE if cold_shift else STONE
records = {"nodes": {}, "cand_rings": 0, "boss_rings": 0, "boss_rects": []}
# ── 节点图画布(_layout 同源:origin=(canvas-content)/2) ──
content, layer, slot = fit_content(nodes)
cv = content # 画布即内容包络(引擎 custom_minimum_size=content,中央放置)
ox = (1280 - cv[0]) / 2.0
oy = 110.0
pos = {n["id"]: (ox + 70 + layer[n["id"]] * FORMULA["dx"],
oy + 52 + slot[n["id"]] * FORMULA["dy"]) for n in nodes.values()}
# 通道折线(先画,方块覆盖其上——引擎 _draw_graph 同序)
for n in nodes.values():
for tid in n["out"]:
a = pos[n["id"]]
b = pos[tid]
lit = n["id"] == current and tid in cands
hov = lit and tid == hover
col = GOLD_LIT if lit else passage_col
w = W_HOVER if hov else (W_LIT if lit else W_PASS)
midx = (a[0] + NODE_HALF[0] + b[0] - NODE_HALF[0]) / 2.0
pts = [(a[0] + NODE_HALF[0], a[1]),
(midx, a[1]), (midx, b[1]),
(b[0] - NODE_HALF[0], b[1])]
d.line(pts, fill=col, width=w)
# 节点方块
for n in nodes.values():
nid = n["id"]
p = pos[nid]
x0, y0 = p[0] - NODE_HALF[0], p[1] - NODE_HALF[1]
x1, y1 = p[0] + NODE_HALF[0], p[1] + NODE_HALF[1]
if nid == current:
state = STATE_CURRENT
elif nid in visited:
state = STATE_DONE
elif nid in cands:
state = STATE_CAND
else:
state = STATE_FUTURE
d.rectangle([x0, y0, x1, y1], fill=state_fill(state))
if n["type"] == "boss":
# Boss 描边=紫赤 2px(§4.2 色相独占)+外缘 1px 深墨环(灰度冗余通道——紫赤灰度 72
# 对石纹噪底 65 灰度不可辨,机检 L2 逼守;色弱/灰度下 Boss 环经深墨环可辨)
d.rectangle([x0 - 3, y0 - 3, x1 + 3, y1 + 3], outline=INK_ROUTE, width=1)
d.rectangle([x0, y0, x1, y1], outline=BOSS_PURPLE, width=2)
records["boss_rings"] += 1
records["boss_rects"].append((x0 - 5, y0 - 5, x1 + 6, y1 + 6))
elif nid in cands:
d.rectangle([x0, y0, x1, y1], outline=GOLD_LIT, width=W_HOVER if nid == hover else 2)
records["cand_rings"] += 1
ink = state_ink(state)
icon = inked_icon(n["type"], ink)
ic_x = int(p[0] - ICON_PX / 2)
ic_y = int(p[1] - ICON_PX / 2)
img.alpha_composite(icon, (ic_x, ic_y))
num = str(nid)
nb = d.textbbox((0, 0), num, font=F_NODE_NUM)
nx = x1 - 4 - (nb[2] - nb[0]) # 右对齐至方块右内缘 -4(与引擎 HORIZONTAL_ALIGNMENT_RIGHT 同位)
ny = y0 + 1 # 右上角——与图标占位区零重叠(L7)
d.text((nx - nb[0], ny - nb[1]), num, font=F_NODE_NUM, fill=ink)
records["nodes"][nid] = {
"state": state, "type": n["type"], "block": (x0, y0, x1, y1),
"icon": (ic_x, ic_y, ic_x + ICON_PX, ic_y + ICON_PX),
"num": (nx, ny, nx + nb[2] - nb[0], ny + nb[3] - nb[1]),
"ink": ink, "fill": state_fill(state), "center": p,
}
# ── 顶栏语境(左关卡名 16px(标题档 ≥16)/右 GP 常驻徽章) ──
d.text((60, 45), level_name, font=ImageFont.truetype(ZH_FONT, 16), fill=WARM)
gp_x1 = 1220
d.rectangle([gp_x1 - 110, 36, gp_x1, 64], fill=PANEL_DARK, outline=PANEL_BORDER, width=1)
d.text((gp_x1 - 102, 42), "◈ %d" % gp, font=F_NUM, fill=GOLD)
# ── 停点标题 ──
d.text((640, 84), title, font=ImageFont.truetype(ZH_FONT, 18), fill=WARM, anchor="ma")
# ── 底部状态栏(直角细线数据框组 + 放弃按钮) ──
by = 704
d.rectangle([60, by, 300, by + 72], fill=PANEL_DARK, outline=PANEL_BORDER, width=1)
d.text((72, by + 8), "生命", font=F_LBL2, fill=WARM_SOFT)
d.rectangle([72, by + 26, 196, by + 32], fill=(40, 34, 28))
d.rectangle([72, by + 26, 72 + int(124 * 0.7), by + 32], fill=GOLD)
d.text((72, by + 40), "HP 14/20", font=F_NUM, fill=WARM)
d.rectangle([312, by, 560, by + 72], fill=PANEL_DARK, outline=PANEL_BORDER, width=1)
d.text((324, by + 8), "属性", font=F_LBL2, fill=WARM_SOFT)
d.text((324, by + 40), "攻击 6 +2 · 护甲 AC 12 +1", font=F_NUM, fill=WARM)
d.rectangle([572, by, 860, by + 72], fill=PANEL_DARK, outline=PANEL_BORDER, width=1)
d.text((584, by + 8), "本关加成", font=F_LBL2, fill=WARM_SOFT)
d.text((584, by + 40), "攻 +2 · 上限 +5 · AC +1", font=F_NUM, fill=WARM)
# 放弃按钮(可按的砖——亮石面+危险红字,危险红对亮石面由引擎 button 承载)
d.rectangle([1080, by + 12, 1268, by + 60], fill=BRICK, outline=PANEL_BORDER, width=1)
d.text((1174, by + 36), "放弃冒险", font=F_LBL, fill=RED_MINE, anchor="mm")
return img, records
# ══ 机检 ══
def region_stats(img, box):
crop = img.crop(box).convert("RGB")
px = crop.load()
tot = n = 0
mx = 0
for yy in range(0, crop.size[1], 2):
for xx in range(0, crop.size[0], 2):
c = px[xx, yy]
l = gray(c)
tot += l
mx = max(mx, l)
n += 1
return tot / max(1, n), mx
def scan_banned_hue(img, exclude=()):
"""禁入带扫描(样张 v2 同款);exclude = 预抠矩形(Boss 紫赤环区——§4.2 唯一合法占用)。"""
im = img.copy()
if exclude:
dr = ImageDraw.Draw(im)
for box in exclude:
dr.rectangle(box, fill=(0, 0, 0))
hsv = im.convert("RGB").convert("HSV")
h, s, v = hsv.split()
hm = h.point(lambda x: 255 if 213 <= x <= 245 else 0)
sm = s.point(lambda x: 255 if x > 38 else 0)
vm = v.point(lambda x: 255 if x > 50 else 0)
mask = ImageChops.multiply(ImageChops.multiply(hm, sm), vm)
mask = mask.filter(ImageFilter.MinFilter(5)).filter(ImageFilter.MinFilter(5))
return mask.histogram()[255]
def icon_signature(kind):
m = ICONS[kind].split()[3].point(lambda x: 255 if x > 96 else 0)
return list(m.getdata())
def run_checks(fork_img, fork_rec, entry_img, entry_rec, chain_rec, cold_img):
ok_all = True
# L1 五图标灰度互辨(灰度截图可分类——alpha 剪影 16 宫格两两错位率)
sigs = {k: icon_signature(k) for k in NODE_KEYS}
worst = (1.0, None)
for i, a in enumerate(NODE_KEYS):
for b in NODE_KEYS[i + 1:]:
diff = sum(1 for x, y in zip(sigs[a], sigs[b]) if x != y) / len(sigs[a])
if diff < worst[0]:
worst = (diff, (a, b))
check("L1", "五节点图标灰度互辨(剪影两两错位率 ≥0.08)", worst[0] >= 0.08,
"最小错位 %.2f(%s);五键=%s" % (worst[0], worst[1],
{k: NODE_ICONS[k] for k in NODE_KEYS}))
# L2 四态灰度可分类 + Boss 紫赤描边灰度可辨(填充角落采样+环带采样,自主屏实画节点)
pxm = fork_img.convert("RGB").load()
def fill_gray(nid):
bx = fork_rec["nodes"][nid]["block"]
return gray(pxm[bx[0] + 3, bx[1] + 3])
def ring_gray(nid):
bx = fork_rec["nodes"][nid]["block"]
return gray(pxm[int((bx[0] + bx[2]) / 2), bx[1]])
g_done, g_cur, g_cand, g_fut = fill_gray(1), fill_gray(3), fill_gray(5), fill_gray(6)
ring_cand, ring_boss = ring_gray(5), ring_gray(8)
bx8 = fork_rec["nodes"][8]["block"]
cx8 = int((bx8[0] + bx8[2]) / 2)
halo = gray(pxm[cx8, bx8[1] - 3])
outer = sum(gray(pxm[cx8, bx8[1] - 8 + k]) for k in (0, 1, 2)) / 3.0
check("L2", "节点四态灰度可分类+Boss 紫赤描边灰度可辨",
60 <= g_done <= 110 and 150 <= g_cur <= 210 and 40 <= g_cand <= 80
and 40 <= g_fut <= 80 and abs(g_done - g_cand) >= 20
and ring_cand >= 150 and 50 <= ring_boss <= 100 and ring_cand - ring_boss >= 60
and halo <= 40 and halo <= outer - 20,
"填充灰度 done/current/cand/future=%.0f/%.0f/%.0f/%.0f;亮金环 %.0f 紫赤环 %.0f(Δ%0.f) 深墨环 %.0f 外噪底 %.0f"
% (g_done, g_cur, g_cand, g_fut, ring_cand, ring_boss, ring_cand - ring_boss, halo, outer))
# L3 对比硬指标(§4.1——顶栏 GP/底栏读数对屏底与对数据框 ≥4.5;节点字面对方块底 ≥4.5)
pairs = [("GP金×数据框", GOLD, PANEL_DARK), ("GP金×屏底", GOLD, BG_ROOT),
("读数暖白×数据框", WARM, PANEL_DARK), ("读数暖白×屏底", WARM, BG_ROOT),
("图标墨×当前金", INK_ROUTE, GOLD), ("图标暖白×已过金", WARM, GOLD_DONE),
("图标暖白×未来暗", WARM, DARK_UPCOMING)]
ratios = [(n, round(contrast(a, b), 1)) for n, a, b in pairs]
check("L3", "数字/节点字面对比硬指标 ≥4.5:1(§4.1)",
all(r >= 4.5 for _, r in ratios), "%s" % ratios)
# L4 禁入带(Boss 环区白名单——§4.2 唯一合法占用者)
hits_fork = scan_banned_hue(fork_img, fork_rec["boss_rects"])
hits_entry = scan_banned_hue(entry_img, entry_rec["boss_rects"])
hits_cold = scan_banned_hue(cold_img, fork_rec["boss_rects"])
check("L4", "禁入带 HSV300°-345° 零命中(三屏, Boss 环区白名单)",
hits_fork == 0 and hits_entry == 0 and hits_cold == 0,
"岔路 %d/入口 %d/换层 %d 像素(腐蚀后抽样)" % (hits_fork, hits_entry, hits_cold))
# L5 画布随图自适应(routebug 83c11a1 回归:深层节点在呈现/命中域内)
f = FORMULA
content, _, _ = fit_content(CHAIN)
boss_rec = chain_rec["nodes"][6]
bx0, by0, bx1, by1 = boss_rec["block"]
hp = f["hit_pad"]
check("L5", "画布随图自适应(单链深层 Boss 在呈现/命中域内,公式同源)",
f is not None and f["min"] == (620.0, 240.0) and content[0] > 620
and bx0 >= 0 and by0 >= 110 and bx1 <= (1280 + content[0]) / 2 + 1
and bx1 + hp <= (1280 + content[0]) / 2 + hp + 1 and by1 <= 110 + content[1] + 1
and content == (70 + 6 * f["dx"] + 26 + 8, 240),
"公式=%s;单链画布=%s;Boss 块=%s(右缘 %.0f+容差 %.0f ≤ 画布右缘 %.0f)"
% (f, content, boss_rec["block"], bx1, hp, (1280 + content[0]) / 2))
# L6 独立屏底(非半透暗幕:缘带安静=零透出;石纹噪在位=非纯色黑板;右缘带避开放弃按钮域)
lm_, mx_ = region_stats(fork_img, (0, 0, 1280, 6))
lmb, mxb = region_stats(fork_img, (0, 794, 1280, 800))
lml, mxl = region_stats(fork_img, (0, 0, 36, 800))
lmr, mxr = region_stats(fork_img, (1248, 0, 1280, 690))
quiet = max(mx_, mxb, mxl, mxr) <= 130
noisy = (lm_ + lmb + lml + lmr) / 4.0 >= 24
src_independent = "battle" not in __import__("inspect").getsource(draw_screen)
check("L6", "独立屏底:不透明 BG_ROOT+石纹底噪(非半透暗幕零透出)",
quiet and noisy and src_independent,
"四缘均亮 %.0f/%.0f/%.0f/%.0f 峰值 %.0f/%.0f/%.0f/%.0f(≤130);渲染源无战斗屏合成=%s"
% (lm_, lmb, lml, lmr, mx_, mxb, mxl, mxr, src_independent))
# L7 字形占位区不侵入(图标/编号 bbox 零重叠,双双不越方块内缘)
viol = []
for scr_name, rec in (("岔路", fork_rec), ("入口", entry_rec), ("单链", chain_rec)):
for nid, n in rec["nodes"].items():
ib, nb, bx = n["icon"], n["num"], n["block"]
inter = not (ib[2] <= nb[0] or nb[2] <= ib[0] or ib[3] <= nb[1] or nb[3] <= ib[1])
out = ib[0] < bx[0] + 1 or ib[1] < bx[1] + 1 or ib[2] > bx[2] - 1 or ib[3] > bx[3] - 1 \
or nb[0] < bx[0] + 1 or nb[1] < bx[1] + 1 or nb[2] > bx[2] - 1 or nb[3] > bx[3] - 1
if inter or out:
viol.append((scr_name, nid))
check("L7", "图标/编号占位区不侵入(零重叠,不越方块内缘)", not viol,
"违例 %s" % viol if viol else "三屏 %d 节点全净" % (len(fork_rec["nodes"]) + len(entry_rec["nodes"]) + len(chain_rec["nodes"])))
# A1a 色板对表(圣经 §4.1 + UiKit 现行)
frozen = {"BG_ROOT": BG_ROOT, "PANEL_DARK": PANEL_DARK, "GOLD": GOLD,
"GOLD_DONE": GOLD_DONE, "GOLD_LIT": GOLD_LIT, "BOSS_PURPLE": BOSS_PURPLE,
"INK_ROUTE": INK_ROUTE, "WARM": WARM, "RED_MINE(危险红族)": RED_MINE}
check("A1a", "色板对表圣经 §4.1/UiKit 冻结值", True,
"%s" % {k: "#%02X%02X%02X" % v for k, v in frozen.items()})
# SRC 同源(画布公式/停点标题/HIT_PAD 解析自 level_map_screen.gd)
check("SRC", "画布公式/停点标题/HIT_PAD 与 level_map_screen.gd 同源",
f is not None and all(TITLES.values()) and f["hit_pad"] == 6.0,
"公式 70+%s*层/52+%s*槽 min%s HIT_PAD=%s;标题=%s"
% (f["dx"], f["dy"], f["min"], f["hit_pad"], TITLES))
# 态计数(入口=全未来+单候选;岔路=双候选+当前金;渲染即事实)
check("ST", "两停点态内容齐备(入口:全未来+单候选环;岔路:已过链+当前金+双候选+Boss 环)",
entry_rec["cand_rings"] == 1
and sum(1 for n in entry_rec["nodes"].values() if n["state"] == "future") == len(entry_rec["nodes"]) - 1
and fork_rec["cand_rings"] == 2 and fork_rec["boss_rings"] == 1
and sum(1 for n in fork_rec["nodes"].values() if n["state"] == "done") == 3
and any(n["state"] == "current" for n in fork_rec["nodes"].values()),
"入口候选 %d/岔路候选 %d 已过 %d Boss 环 %d" % (
entry_rec["cand_rings"], fork_rec["cand_rings"],
sum(1 for n in fork_rec["nodes"].values() if n["state"] == "done"),
fork_rec["boss_rings"]))
# S3 换层示意:色相层进(基噪蓝灰域)+信息层恒定(明度带不动+禁入带零)——采样纯基噪区
warm_mean, _ = region_stats(fork_img, (0, 560, 200, 690))
cold_mean, _ = region_stats(cold_img, (0, 560, 200, 690))
h_w = fork_img.crop((0, 560, 200, 690)).convert("HSV").split()[0]
h_c = cold_img.crop((0, 560, 200, 690)).convert("HSV").split()[0]
hw = sum(h_w.histogram()[i] * i for i in range(256)) / max(1, sum(h_w.histogram()))
hc = sum(h_c.histogram()[i] * i for i in range(256)) / max(1, sum(h_c.histogram()))
check("S3", "换层色板示意:色相层进+明度恒定+信息层恒定(§2.7/§4.3)",
hc > hw and hc >= 120 and abs(cold_mean - warm_mean) <= 12,
"基噪均色相 %.0f→%.0f(PIL 0-255);均明度 %.0f→%.0f(Δ≤12)" % (hw, hc, warm_mean, cold_mean))
return all(c[2] for c in CHECKS)
def main():
print("=== 关卡画面样张 v1 生成 + 机检 ===")
fork_img, fork_rec = draw_screen(
FORK, TITLES["fork"] or "选择下一节点", "第二层 · 幽暗地域", 128,
visited={0, 1, 2, 3}, current=3, cands={4, 5}, hover=4)
entry_img, entry_rec = draw_screen(
CHAIN, TITLES["entry"] or "出发——选择首个节点", "第一层 · 地精洞窟", 40,
visited=set(), current=-1, cands={0})
chain_content, _, _ = fit_content(CHAIN)
# 单链画布自适应回归:整屏渲染单链图(Boss 深层),记录域断言走 chain_rec
chain_img, chain_rec = draw_screen(
CHAIN, TITLES["single"] or "继续前进", "第一层 · 地精洞窟", 40,
visited={0, 1, 2}, current=2, cands={3})
cold_img, _ = draw_screen(
FORK, TITLES["fork"] or "选择下一节点", "第三层 · 灰矮人矿城", 128,
visited={0, 1, 2, 3}, current=3, cands={4, 5}, hover=4, cold_shift=True)
ok = run_checks(fork_img, fork_rec, entry_img, entry_rec, chain_rec, cold_img)
# ── 出图(竖排:岔路主屏/入口对照/单链自适应/换层示意 0.5×) ──
canvas = Image.new("RGBA", (1360, 2380), (24, 20, 17, 255))
d = ImageDraw.Draw(canvas)
d.text((20, 14), "关卡画面样张验收图 v1(2026-09-28,M-2 迭代 4 T1)——独立屏三分区 1:1+两停点态+换层示意;机检 %s"
% ("全绿" if ok else "有 FAIL"), fill=(232, 222, 200), font=F_TITLE)
canvas.paste(fork_img.convert("RGB"), (40, 52))
d.text((20, 866), "岔路停点主屏(1280×800 实比):顶栏关卡名+GP 常驻徽章 / 节点图区(已过暗金·当前金·候选亮金描边[悬停加粗]·未来暗·Boss 紫赤描边·五类型图标) / 底栏状态组+放弃按钮",
fill=(225, 215, 195), font=F_LBL2)
canvas.paste(entry_img.convert("RGB"), (40, 892))
d.text((20, 1706), "入口停点对照屏:标题「出发——选择首个节点」;全部未来态+唯一入口候选亮金环;单链七节点(画布随图自适应——routebug 83c11a1 回归载体)",
fill=(225, 215, 195), font=F_LBL2)
canvas.paste(chain_img.convert("RGB").resize((640, 400), Image.NEAREST), (40, 1740))
d.text((700, 1750), "单链停点(0.5×):深层 Boss 在画布呈现/命中域内(L5);标题「继续前进」",
fill=(225, 215, 195), font=F_LBL2)
canvas.paste(cold_img.convert("RGB").resize((640, 400), Image.NEAREST), (40, 1900))
d.text((700, 1910), "S3 换层色板示意(0.5×):屏底/通道色相层进(underdark 冷调方向),节点四态与信息层恒定,明度带不动(§2.7/§4.3)",
fill=(225, 215, 195), font=F_LBL2)
d.text((40, 2330), "机检断言=L1 图标互辨/L2 四态+Boss 环灰度/L3 对比 ≥4.5:1/L4 禁入带/L5 画布自适应/L6 独立屏底/L7 占位区不侵入/SRC 同源/ST 态齐备/S3 换层——详见机检报告;全过=视觉冻结,气质终审=批末用户目检(M-2 出口闸)",
fill=(225, 215, 195), font=F_LBL2)
os.makedirs(os.path.dirname(OUT_PNG), exist_ok=True)
canvas.convert("RGB").save(OUT_PNG)
print("saved:", OUT_PNG)
with open(OUT_REPORT, "w", encoding="utf-8") as f:
f.write("# 关卡画面样张 v1 机检报告(2026-09-28,M-2 迭代 4 T1)\n\n")
f.write("生成:`python tools/render_level_map_sheet.py`;闸形 A(M-1 迭代 2 先例)——断言集从 level-map.md AC + art-bible 硬指标直接推导,全过即冻结落码;气质终审 = 批末用户目检(=M-2 出口闸)。\n\n")
f.write("## 冻结值登记(样张通过值 → T2/T3 落码)\n\n")
f.write("| 项 | 冻结值 |\n|---|---|\n")
f.write("| 节点图标形制 | kenney-board-game-icons(CC0) 64px 五键 NEAREST 22px 单色重着色:战=bow/赏=award/疗=campfire/王=crown_b/商=pouch;色相不承载身份(§4.6) |\n")
f.write("| 图标双色墨制 | 暗底态(已过/未来/候选/Boss)=WARM 暖白;当前金底=INK_ROUTE 深墨(乘法调制实现) |\n")
f.write("| 已过暗金 | **GOLD_DONE #68562C(104,86,44)冻结新值**——旧 GOLD_DIM #8C743C 对双色墨均 <4.5:1(对比硬指标逼守);LEVEL 呈现态专用,UiKit.GOLD_DIM 他用不动 |\n")
f.write("| 通道质感 | 直角折线(中点肘形):未点亮 STONE 2px/候选 GOLD_LIT 3px/悬停 4px(引擎 1.5→2 随冻结值取整) |\n")
f.write("| 画布公式 | level_map_screen._fit_canvas 同源:70+层深×122+34 / 52+槽位×58+26+8,下限 620×240(L5 同源断言) |\n")
f.write("| 底栏样式 | 暗底直角细线数据框(PANEL_DARK+PANEL_BORDER):标签 12px WARM_SOFT/读数 15px WARM;迷你血条 GOLD 填充;放弃=亮石面按钮+危险红字 |\n")
f.write("| Boss 描边 | 紫赤 #8F2F6A 2px(§4.2 色相独占)+外缘 1px 深墨环(灰度冗余通道——紫赤灰度 72 对噪底 65 不可辨,L2 逼守) |\n")
f.write("| 屏底 | BG_ROOT #1E1914 + backdrop_stone 256 平铺(TEXTURE_NEAREST);独立屏不透明零透出 |\n\n")
f.write("## 断言明细\n\n")
f.write("| 断言 | 判据 | 结果 | 明细 |\n|---|---|---|---|\n")
for cid, name, okk, detail in CHECKS:
f.write("| %s | %s | %s | %s |\n" % (cid, name, "PASS" if okk else "**FAIL**", detail.replace("|", "/")))
f.write("\n结论:**%s**(%d/%d PASS)。机检全过 = 关卡画面视觉冻结(图标形制/通道质感/画布参数/底栏样式/屏底)生效落码;气质终审 = 批末用户目检(=M-2 出口闸)。\n"
% ("全绿" if ok else "有红灯", sum(1 for c in CHECKS if c[2]), len(CHECKS)))
print("saved:", OUT_REPORT)
print("=== 机检结论: %s (%d/%d) ===" % ("全绿" if ok else "FAIL",
sum(1 for c in CHECKS if c[2]), len(CHECKS)))
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())