Files
gplan-2/tools/render_level_map_sheet.py
T
DKChan f958a03f51 docs(research/退役批): design/research 全目录退役落地——中间文档唯一暂存地收敛为 reports/(用户 2026-10-20 令「design/ 只放持久化设计文档,过程件一律出 design/」的执行补完,2026-10-21 会话复述确认)
八件受版文件 git mv 进 reports/(一律先迁不删,删除归后续批或用户点名):提案三件(冒险前资源配比预设/效果引擎无状态化/武器锻造系统)、审计两件(代码术语审计/单测与验收门禁标准测试审计)、美术审计两件(cursor 会话存档 md + canvas 转 HTML 渲染件)、需求澄清-营地交互重做;未入库的 UI预览demo 三路对照件只搬盘不入库。

GDD 落文:锻造工坊.md「满级特效扩展位」节收下七条候选池(长剑破甲/长剑暴击面扩大/长枪先手连击/法杖开局魔法矢/轻甲灵巧/重甲反震/魔法护甲护盾),按现役 EFFECT_KINDS 与 settlement.tres:51/52 逐条复核实现代价分档,效果类型闭集口径取正文现行 31 种、不抄过程稿旧记 24 种;候选池定性为备用清单非承诺清单。

引用面换代 08(playnote 之外两处 ../research/ 改 ../../reports/)、待办清单四处、reports 八张卡片(跨目录前缀改同目录)、art-assets-checklist 与 sim-data/README 两处既存死指改「沿革查 git 提交史」、content-archive 出处句标注该目录已退役。迁入件内部相对链接随迁移重基准(../gdd/ → ../design/gdd/,playnote 与待办清单改回仓库根),并订正上一批遗留错误:两张迭代 30 卡片的 playnote 链接原写 ../../playnote.md(从 reports/ 出发指到仓库外),现改 ../playnote.md。

规则与机检:doc-conventions §11「暂存地非档案库」行改为单暂存地+禁在 design/ 下设过程件目录+图像载体与报告渲染件口径分列(禁图留 artwork/ 沿用 2026-09-27 用户裁;.html/.json 允许作为报告的一种=2026-10-21 用户裁,原「只放 .md」句系 10-17 建目录时顺带照搬、无单独点头记录);AGENTS Project Layout 两行同步;reports/README 补「设计讨论段过程稿」命名行。repo_structure_audit:DESIGN_SUBDIRS 去 research、RETIRED 加 design/research;doc_ref_audit 排除面清空(design 全树入扫描面);doc_term/doc_link 扫描域注释换代;三件样张脚本 OUT_REPORT 改落 reports/ 并补 os.makedirs(改前只对 OUT_PNG 建目录,目录缺失即 FileNotFoundError)。

变异验证:重建 design/research 并放文件 → 结构闸退出码 1、同时报「退役路径再现」与「子目录越界」两条;design/ 内放两条指向不存在文件的引用 → 引用可核机检报「路径不存在」红项 2(该闸暂未注册进闸壳,无基线记录必判变化);清场后结构闸 PASS。
2026-10-21 15:27:16 +08:00

603 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。
闸形 A:断言集从 level-map.md AC + art-bible 硬指标直接推导
(本样张无历史否决原因输入),生成即验,全过即冻结落码;气质终审 = 批末用户目检。
断言集(事实源=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 回归防护;公式与 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 色相层进,信息层恒定)。
冻结值登记(样张通过值,供后续落码):
图标形制=kenney 64px 五键 NEAREST 22px,WARM 单色重着色,当前金底改 INK_ROUTE 墨(双色墨制);
已过暗金冻结新值 GOLD_DONE #68562C(双色墨均须过 ≥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(文件名带版本与日期,防重渲染覆盖)
+ 机检报告(中间产物,吸收即死不入 git——doc-conventions §11)。
用法: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, "reports", "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,))
# 屏底石纹底噪(框材件;换层示意=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)
os.makedirs(os.path.dirname(OUT_REPORT), exist_ok=True)
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())