feat(ux): T1 关卡画面样张 v1 机检全绿——11 断言+冻结值登记(M-2 迭代 4 承重卡)
This commit is contained in:
1 parent
74c8ebe430
commit
9a79927b05
2 files changed
+629
No files matched your search
@@ -0,0 +1,34 @@
|
|||||||
|
# 关卡画面样张 v1 机检报告(2026-09-28,M-2 迭代 4 T1)
|
||||||
|
|
||||||
|
生成:`python tools/render_level_map_sheet.py`;闸形 A(M-1 迭代 2 先例)——断言集从 level-map.md AC + art-bible 硬指标直接推导,全过即冻结落码;气质终审 = 批末用户目检(=M-2 出口闸)。
|
||||||
|
|
||||||
|
## 冻结值登记(样张通过值 → T2/T3 落码)
|
||||||
|
|
||||||
|
| 项 | 冻结值 |
|
||||||
|
|---|---|
|
||||||
|
| 节点图标形制 | kenney-board-game-icons(CC0) 64px 五键 NEAREST 22px 单色重着色:战=bow/赏=award/疗=campfire/王=crown_b/商=pouch;色相不承载身份(§4.6) |
|
||||||
|
| 图标双色墨制 | 暗底态(已过/未来/候选/Boss)=WARM 暖白;当前金底=INK_ROUTE 深墨(乘法调制实现) |
|
||||||
|
| 已过暗金 | **GOLD_DONE #68562C(104,86,44)冻结新值**——旧 GOLD_DIM #8C743C 对双色墨均 <4.5:1(对比硬指标逼守);LEVEL 呈现态专用,UiKit.GOLD_DIM 他用不动 |
|
||||||
|
| 通道质感 | 直角折线(中点肘形):未点亮 STONE 2px/候选 GOLD_LIT 3px/悬停 4px(引擎 1.5→2 随冻结值取整) |
|
||||||
|
| 画布公式 | level_map_screen._fit_canvas 同源:70+层深×122+34 / 52+槽位×58+26+8,下限 620×240(L5 同源断言) |
|
||||||
|
| 底栏样式 | 暗底直角细线数据框(PANEL_DARK+PANEL_BORDER):标签 12px WARM_SOFT/读数 15px WARM;迷你血条 GOLD 填充;放弃=亮石面按钮+危险红字 |
|
||||||
|
| Boss 描边 | 紫赤 #8F2F6A 2px(§4.2 色相独占)+外缘 1px 深墨环(灰度冗余通道——紫赤灰度 72 对噪底 65 不可辨,L2 逼守) |
|
||||||
|
| 屏底 | BG_ROOT #1E1914 + backdrop_stone 256 平铺(TEXTURE_NEAREST);独立屏不透明零透出 |
|
||||||
|
|
||||||
|
## 断言明细
|
||||||
|
|
||||||
|
| 断言 | 判据 | 结果 | 明细 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| L1 | 五节点图标灰度互辨(剪影两两错位率 ≥0.08) | PASS | 最小错位 0.19(('boss', 'shop'));五键={'battle': 'bow.png', 'reward': 'award.png', 'rest': 'campfire.png', 'boss': 'crown_b.png', 'shop': 'pouch.png'} |
|
||||||
|
| L2 | 节点四态灰度可分类+Boss 紫赤描边灰度可辨 | PASS | 填充灰度 done/current/cand/future=87/174/59/59;亮金环 191 紫赤环 72(Δ120) 深墨环 21 外噪底 65 |
|
||||||
|
| L3 | 数字/节点字面对比硬指标 ≥4.5:1(§4.1) | PASS | [('GP金×数据框', 5.5), ('GP金×屏底', 8.6), ('读数暖白×数据框', 8.8), ('读数暖白×屏底', 13.6), ('图标墨×当前金', 9.0), ('图标暖白×已过金', 5.6), ('图标暖白×未来暗', 8.8)] |
|
||||||
|
| L4 | 禁入带 HSV300°-345° 零命中(三屏, Boss 环区白名单) | PASS | 岔路 0/入口 0/换层 0 像素(腐蚀后抽样) |
|
||||||
|
| L5 | 画布随图自适应(单链深层 Boss 在呈现/命中域内,公式同源) | PASS | 公式={'dx': 122.0, 'dx_pad': 8.0, 'dy': 58.0, 'dy_pad': 8.0, 'min': (620.0, 240.0), 'hit_pad': 6.0};单链画布=(836.0, 240);Boss 块=(998.0, 144.0, 1050.0, 180.0)(右缘 1050+容差 6 ≤ 画布右缘 1058) |
|
||||||
|
| L6 | 独立屏底:不透明 BG_ROOT+石纹底噪(非半透暗幕零透出) | PASS | 四缘均亮 65/64/65/66 峰值 80/88/97/98(≤130);渲染源无战斗屏合成=True |
|
||||||
|
| L7 | 图标/编号占位区不侵入(零重叠,不越方块内缘) | PASS | 三屏 23 节点全净 |
|
||||||
|
| A1a | 色板对表圣经 §4.1/UiKit 冻结值 | PASS | {'BG_ROOT': '#1E1914', 'PANEL_DARK': '#403A34', 'GOLD': '#F0A828', 'GOLD_DONE': '#68562C', 'GOLD_LIT': '#E6BE5A', 'BOSS_PURPLE': '#8F2F6A', 'INK_ROUTE': '#181410', 'WARM': '#F0E2C8', 'RED_MINE(危险红族)': '#962C24'} |
|
||||||
|
| SRC | 画布公式/停点标题/HIT_PAD 与 level_map_screen.gd 同源 | PASS | 公式 70+122.0*层/52+58.0*槽 min(620.0, 240.0) HIT_PAD=6.0;标题={'entry': '出发——选择首个节点', 'single': '继续前进', 'fork': '选择下一节点'} |
|
||||||
|
| ST | 两停点态内容齐备(入口:全未来+单候选环;岔路:已过链+当前金+双候选+Boss 环) | PASS | 入口候选 1/岔路候选 2 已过 3 Boss 环 1 |
|
||||||
|
| S3 | 换层色板示意:色相层进+明度恒定+信息层恒定(§2.7/§4.3) | PASS | 基噪均色相 23→147(PIL 0-255);均明度 65→61(Δ≤12) |
|
||||||
|
|
||||||
|
结论:**全绿**(11/11 PASS)。机检全过 = 关卡画面视觉冻结(图标形制/通道质感/画布参数/底栏样式/屏底)生效落码;气质终审 = 批末用户目检(=M-2 出口闸)。
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
# -*- 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 = 22 # 节点图标边长(52×36 方块内 22px 图标+右下 10px 编号)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ══ 图标装载(kenney CC0 64px → WARM 单色重着色 → NEAREST 缩放) ══
|
||||||
|
def load_icon(kind):
|
||||||
|
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])
|
||||||
|
ny = y1 - 3 - (nb[3] - nb[1])
|
||||||
|
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())
|
||||||
Reference in new issue
Block a user