Files
gplan-2/tools/render_sample_sheet.py
T

859 lines
42 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-26,循环 1 T1)。
v0 四 FATAL 案底(2026-09-26 用户否决)全部断言化——生成即验,不过闸不出图:
F1 未开格 = 方案甲缘件 8 邻拼装(词表查表,禁整块 center 铺满)
F2 旗仅出现于未开格
F3 小人脚锚 = 自底边 25% 线(⚠ 纵向比例一律自底边口径)
F4 数字 0 格无字形(纯安全格 = 干净裸地面)
F5 微光仅带状:接触带微光只画英雄格周界空间侧;类型微光禁整格染色(中心 50% 区零像素)
程序化四件审计(§9 初级验收授权):
A1 色板对表圣经冻结色板 + 禁入带(HSV 300°-345°)全图扫描
A2 拼缝周界闭合(墙↔地边界墙侧 2px 墨线带亮度 <70)
A3 明度对比(数字墨×色牌 WCAG ≥4.5:1;图底分离)
A4 与现有素材混排相融(语义色 == 游戏 UiKit 常量;小人/立绘/状态图标 = 在库资产原件)
一屏样张 = B/C/D 三区全内容(1280×800 1:1 实比) + 附录 S2 数字全档 0-8 / S3 underdark
换皮对照 / S4 图例。内容清单 = art-bible §1.4(九组,缺项即无效)。
素材源 = game/assets/art(board 21 瓦片 + character 小人/立绘)+ ui/icons 状态图标;
素材不入 git——先查本仓库,缺位回退主检出(D:/code/g-plan2)。
词表事实源 = design/research/board-tile-vocab-plan-a-2026-09-26.md;画法参数事实源 =
artwork/mockups/画法参数四源对照表-2026-09-26.md(四源对表先行产物)。
输出 = artwork/mockups/sample-batch-v1-2026-09-26.png + sample-v1-机检报告-2026-09-26.md。
用法:python tools/render_sample_sheet.py
"""
from PIL import Image, ImageDraw, ImageFilter, ImageChops, ImageFont
import os
import sys
import math
import hashlib
from collections import deque
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))
BOARD_DIR = ("game", "assets", "art", "board")
CHAR_DIR = ("game", "assets", "art", "character")
ICON_DIR = ("game", "assets", "ui", "icons")
OUT_PNG = os.path.join(ROOT, "artwork", "mockups", "sample-batch-v1-2026-09-26.png")
OUT_REPORT = os.path.join(ROOT, "artwork", "mockups", "sample-v1-机检报告-2026-09-26.md")
T_FONT = 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"
# ══ 冻结色板(art-bible §4.1——A1 逐字节对表基准,禁改动) ══
INK = (51, 38, 27) # 主墨 #33261B
INK_COLD = (19, 46, 54) # 青墨 1-3 #132E36
INK_OCHRE = (60, 37, 12) # 赭墨 4-5 #3C250C
INK_RUST = (74, 30, 20) # 锈墨 6-8 #4A1E14
PLATE = (245, 234, 211) # 数字色牌 #F5EAD3
SEAL_BLACK = (0, 0, 0) # 封印黑 #000000(旗朱→封印紫→封印黑 2026-09-28——紫与微光蓝族色相冲突退役)
DANGER_RED = (168, 50, 31) # 危险红 #A8321F
REWARD_GOLD = (240, 168, 40) # 奖励金 #F0A828
HERO_BLUE = (46, 134, 222) # 小人蓝 #2E86DE(候选,样张批冻结——对照表 §5.1)
RED_MINE = (150, 44, 36) # 引爆雷字朱(游戏 UiKit.RED_MINE)
# 候选(样张批冻结项——对照表 §5)
CONTACT_GLOW = (214, 236, 255) # 接触带微光:小人蓝族浅两档(近白蓝)
GLOW_GROUPS = { # §4.2 类型微光五组候选
"attack": (214, 193, 58), "heal": (92, 184, 58), "pierce": (46, 158, 158),
"magic": (123, 91, 214), "blast": (255, 45, 85), "gp": (214, 193, 58),
}
GLOW_FAMILY = {"attack": "通用增益", "defense": "通用增益", "weaken": "通用增益",
"action": "通用增益", "gp": "通用增益", "heal": "治疗",
"pierce": "穿刺", "magic": "魔法", "blast": "群伤"}
# 游戏 UiKit 常量(A4 混排对表——ui_kit.gd 实测值)
UIKIT = {"INK": INK, "FLAG_RED": SEAL_BLACK, "GOLD": REWARD_GOLD, "RED_MINE": RED_MINE,
"PANEL_STAGE": (216, 200, 172), "PANEL_HERO": (232, 220, 196),
"PANEL_DATA": (226, 210, 182), "PANEL_BORDER": (120, 106, 88),
"BRICK": (201, 175, 131), "FLOOR": (179, 154, 120)}
ARMOR_ICONS = {"light": "◇", "ward": "◆", "heavy": "▣"}
ARMOR_COLORS = {"light": (120, 110, 98), "ward": HERO_BLUE, "heavy": (176, 122, 20)}
F_DIGIT = ImageFont.truetype(T_FONT, 30) # S2 64px 格数字
F_DIGIT_D = ImageFont.truetype(T_FONT, 18) # D 区 45px 格数字(下限 ≥18px)
F_SMALL = ImageFont.truetype(T_FONT, 16)
F_LBL = ImageFont.truetype(ZH_FONT, 14)
F_LBL2 = ImageFont.truetype(ZH_FONT, 12)
F_TITLE = ImageFont.truetype(ZH_FONT, 19)
F_STANDEE = ImageFont.truetype(ZH_FONT, 42) # 敌像字形占位
F_NAME = ImageFont.truetype(ZH_FONT, 12)
F_ORDER = ImageFont.truetype(ZH_FONT, 13)
F_INTENT = ImageFont.truetype(ZH_FONT, 15)
CHECKS = [] # (id, name, ok, detail)
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 load_tile(name):
return Image.open(asset_root(*(BOARD_DIR + (name,)))).convert("RGB")
WALLS = {}
for k in ["center", "center_crack", "center_speckle", "n", "s", "w", "nw", "sw",
"hrun", "vrun", "end_n", "end_s", "end_w", "lone", "in_nw", "in_sw"]:
WALLS[k] = load_tile("terrain_wall_%s_dungeon.png" % k)
FLOORS = [load_tile("terrain_floor_dungeon_%d.png" % i) for i in range(1, 6)]
HERO_SRC = Image.open(asset_root(*(CHAR_DIR + ("character_hero_idle.png",)))).convert("RGBA")
PORTRAIT_SRC = Image.open(asset_root(*(CHAR_DIR + ("character_hero_portrait.png",)))).convert("RGBA")
STATUS_ICONS = {k: Image.open(asset_root(*(ICON_DIR + ("status_%s.png" % k,)))).convert("RGBA")
for k in ["attack_up", "ac_up", "alert"]}
def hero_sprite_transparent(src):
"""素材黑幕去底:四角泛洪把连通近黑背景转透明(角色内部深色像素不受影响)。"""
im = src.copy()
w, h = im.size
px = im.load()
seen = [[False] * w for _ in range(h)]
q = deque()
for sx, sy in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)]:
q.append((sx, sy))
while q:
x, y = q.popleft()
if x < 0 or y < 0 or x >= w or y >= h or seen[y][x]:
continue
seen[y][x] = True
r, g, b, a = px[x, y]
if r < 14 and g < 14 and b < 14:
px[x, y] = (r, g, b, 0)
q.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)])
return im
HERO = hero_sprite_transparent(HERO_SRC)
# 裁除透明留白(v1 预筛发现:素材底部 7 行全透明——直接缩放则视觉脚浮于锚线上方 ~10px。
# 裁后按内容 bbox 缩放,视觉脚底 = 脚锚线。游戏内 BoardWorld.ensure_hero 用原素材同病
# (视觉脚浮 ~10px)——呈现缺陷登记移交,本样张按设计规则出图)
_HB = HERO.getbbox()
HERO = HERO.crop(_HB)
# ══ 方案甲词表查表(board-tile-vocab-plan-a §三 逐条转写) ══
def is_wall(gmap, x, y):
if x < 0 or y < 0 or x >= gmap.w or y >= gmap.h:
return True # 板外视为墙(词表 §三.1)
return gmap.grid[y][x] == "W"
def lookup_wall_tile(gmap, x, y):
"""四正交邻 → 16 情形;四邻皆墙查对角(凹角回落优先级 nw>ne>sw>se);center 变体哈希 60/20/20。"""
n, e, s, w = (is_wall(gmap, x, y - 1), is_wall(gmap, x + 1, y),
is_wall(gmap, x, y + 1), is_wall(gmap, x - 1, y))
flip = False
if n and e and s and w:
dnw, dne, dsw, dse = ((not is_wall(gmap, x - 1, y - 1)), (not is_wall(gmap, x + 1, y - 1)),
(not is_wall(gmap, x - 1, y + 1)), (not is_wall(gmap, x + 1, y + 1)))
if dnw:
return "in_nw", False
if dne:
return "in_nw", True # in_ne ← in_nw 镜像
if dsw:
return "in_sw", False
if dse:
return "in_sw", True # in_se ← in_sw 镜像
h = int(hashlib.md5(("wall%d_%d" % (x, y)).encode()).hexdigest(), 16) % 100
if h < 60:
return "center", False
if h < 80:
return "center_crack", False
return "center_speckle", False
if not n and s and e and w:
return "n", False
if not s and n and e and w:
return "s", False
if not w and n and e and s:
return "w", False
if not e and n and s and w:
return "w", True # wall_e ← wall_w 镜像
if not n and not s and e and w:
return "hrun", False
if not w and not e and n and s:
return "vrun", False
if not n and not w and s and e:
return "nw", False
if not n and not e and s and w:
return "nw", True # wall_ne ← wall_nw 镜像
if not s and not w and n and e:
return "sw", False
if not s and not e and n and w:
return "sw", True # wall_se ← wall_sw 镜像
if not n and not e and not w and s:
return "end_n", False
if not s and not e and not w and n:
return "end_s", False
if not w and not n and not s and e:
return "end_w", False
if not e and not n and not s and w:
return "end_w", True # wall_end_e ← wall_end_w 镜像
return "lone", False
class GMap(object):
def __init__(self, rows):
self.grid = rows
self.w = len(rows[0])
self.h = len(rows)
def floor_variant(x, y):
"""地面 5 变体按格坐标哈希(词表 §三.3——初始化随机落定)。"""
return int(hashlib.md5(("floor%d_%d" % (x, y)).encode()).hexdigest(), 16) % len(FLOORS)
# ══ D 区 10×10 样张板(内容布置见对照表 §6/§1.4 内容清单) ══
BOARD_ROWS = [
"WWWWWWWWWW",
"WWWWWWWWWW",
"WWFFWWFFWF",
"WWFFFFWFWF",
"WWFFFFWFWF",
"WWFFFFWFWF",
"WFFFFFFWFF",
"FFFFFFWWFF",
"FFWWWWWWFF",
"FFFFFWWWFF",
]
# 覆盖层布置(格坐标)
CRACK_AT = (3, 0) # center 裂纹变体
SPECKLE_AT = (6, 0) # center 碎石斑变体
FLAG_AT = (1, 2) # 封印印记——未开格(F2)
HOVER_AT = (1, 4) # 悬停金框(预承诺光标态)
HERO_AT = (2, 4) # 小人独立占格
HERO_DIGIT_AT = (2, 5) # 小人与数字同格(6)——验蓝框不覆盖文本
NEST_AT = (5, 4) # 雷格巢穴(已开,程序占位+危险红)
GLOW_CELLS = [(4, 2, "attack"), (5, 2, "heal"), (8, 3, "blast"),
(6, 4, "pierce"), (7, 6, "magic")] # 五组;attack+heal 相邻并列
INLAY_RESULT = {(4, 6): "attack", (5, 6): "gp"} # 已开结果格物件纹样
DIGITS = {(3, 4): 3, (3, 5): 1, (4, 5): 2, (2, 5): 6, (7, 4): 8, (7, 5): 7,
(4, 6): 5, (5, 6): 4} # 0 格=已开无字形(纯安全格全体现)
VARIANT_FLOOR_AT = (4, 5) # 装饰变体底色牌示例(强制 floor 变体 3)
CELL_D = 45 # 1280 基座 10×10 板实比(art-bible §8.1)
HERO_PX = 48 # 32 源 ×1.5(台账 161 档)
GROUND_FRAC = 0.25 # 脚锚自底边 25%(F3)
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 draw_digit_plate(img, x0, y0, cell, digit, font, plate_size=None, ink=None):
"""数字色牌:素色直角小牌+墨档数字(§4.1)。返回牌矩形。"""
d = ImageDraw.Draw(img)
pw, ph = plate_size or (int(cell * 0.60), int(cell * 0.62))
px0 = x0 + (cell - pw) // 2
py0 = y0 + (cell - ph) // 2
d.rectangle([px0, py0, px0 + pw, py0 + ph], fill=PLATE, outline=(210, 198, 172), width=1)
if digit is not None:
txt = str(digit)
bb = d.textbbox((0, 0), txt, font=font)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
d.text((px0 + (pw - tw) // 2 - bb[0], py0 + (ph - th) // 2 - bb[1]), txt,
font=font, fill=ink or digit_ink(digit))
return (px0, py0, pw, ph)
def digit_ink(d):
if d <= 3:
return INK_COLD
if d <= 5:
return INK_OCHRE
return INK_RUST
def draw_inlay(img, ox, oy, cell, kind, corner=4, m=None, L=None):
"""角嵌纹样(墨族单色,贴砖缘/砖角,砖心留白——§3.2;已开格=物件纹样同语言)。程序化示意。"""
layer = Image.new("RGBA", img.size, (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
col = INK + (215,)
if corner == 4: # 未开格:四角
pts = [(m, m, 1, 1), (cell - m, m, -1, 1), (m, cell - m, 1, -1), (cell - m, cell - m, -1, -1)]
else: # 已开格:单角物件纹样(右上角,砖心留白予色牌)
pts = [(cell - m, m, -1, 1)]
for (px, py, sx, sy) in pts:
X, Y = ox + px, oy + py
if kind == "attack":
d.line([(X, Y), (X + sx * L, Y + sy * L)], fill=col, width=2)
d.line([(X + sx * 2, Y), (X, Y + sy * 2)], fill=col, width=1)
elif kind == "defense":
d.line([(X, Y + sy * L), (X, Y), (X + sx * L, Y)], fill=col, width=2)
elif kind == "heal":
r = 2
d.ellipse([X - r, Y - r, X + r, Y + r], fill=col)
elif kind == "pierce":
d.polygon([(X, Y), (X + sx * L, Y + sy * 2), (X + sx * 2, Y + sy * L)], outline=col, width=1)
elif kind == "magic":
d.line([(X - sx * 4, Y), (X + sx * 4, Y)], fill=col, width=1)
d.line([(X, Y - sy * 4), (X, Y + sy * 4)], fill=col, width=1)
elif kind == "blast":
d.line([(X, Y), (X + sx * L, Y)], fill=col, width=1)
d.line([(X, Y), (X, Y + sy * L)], fill=col, width=1)
d.line([(X, Y), (X + sx * L, Y + sy * L)], fill=col, width=1)
elif kind == "gp":
r = 3
d.ellipse([X - r, Y - r, X + r, Y + r], outline=col, width=1)
d.line([(X, Y - r - 2), (X, Y - r)], fill=col, width=1)
img.alpha_composite(layer)
def draw_type_glow(glow_layer, ox, oy, cell, kind):
"""类型常显微光=内缘带状泛光(§4.2 色带;F5b 禁整格染色——中心 50% 区零像素)。"""
layer = Image.new("RGBA", glow_layer.size, (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
rgb = GLOW_GROUPS[kind]
band = max(3, cell // 11)
d.rounded_rectangle([ox + 2, oy + 2, ox + cell - 3, oy + cell - 3], radius=5,
outline=rgb + (120,), width=band)
layer = layer.filter(ImageFilter.GaussianBlur(1))
glow_layer.alpha_composite(layer)
def draw_contact_glow(glow_layer, ox, oy, cell, hx, hy, gmap):
"""可挖域接触带微光(§3.2):英雄格周界空间侧——正交邻墙=整条边,斜向=共享角点+两侧各半条边。
返回带几何(机检 F5a 用)。"""
layer = Image.new("RGBA", glow_layer.size, (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
t = 3
x0, y0 = ox + hx * cell, oy + hy * cell
E = CONTACT_GLOW + (245,)
half = cell // 2
band = []
if is_wall(gmap, hx, hy - 1):
d.line([(x0 + 2, y0 + t), (x0 + cell - 2, y0 + t)], fill=E, width=5)
band.append(("edge", (x0, y0, x0 + cell, y0 + 2 * t + 2)))
if is_wall(gmap, hx, hy + 1):
d.line([(x0 + 2, y0 + cell - t), (x0 + cell - 2, y0 + cell - t)], fill=E, width=5)
band.append(("edge", (x0, y0 + cell - 2 * t - 2, x0 + cell, y0 + cell)))
if is_wall(gmap, hx - 1, hy):
d.line([(x0 + t, y0 + 2), (x0 + t, y0 + cell - 2)], fill=E, width=5)
band.append(("edge", (x0, y0, x0 + 2 * t + 2, y0 + cell)))
if is_wall(gmap, hx + 1, hy):
d.line([(x0 + cell - t, y0 + 2), (x0 + cell - t, y0 + cell - 2)], fill=E, width=5)
band.append(("edge", (x0 + cell - 2 * t - 2, y0, x0 + cell, y0 + cell)))
for (dx, dy) in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
if is_wall(gmap, hx + dx, hy + dy):
px = x0 + (t if dx < 0 else cell - t)
py = y0 + (t if dy < 0 else cell - t)
r = 4
d.ellipse([px - r, py - r, px + r, py + r], fill=(255, 255, 255, 255))
d.line([(px, py), (px - dx * half, py)], fill=E, width=4)
d.line([(px, py), (px, py - dy * half)], fill=E, width=4)
band.append(("corner", (px - r, py - r, px + r, py + r)))
band.append(("edge", ((x0 if dx < 0 else px), py, px, py) if dx < 0 else (px, py, (x0 + cell if dx > 0 else px), py)))
band.append(("edge", (px, (y0 if dy < 0 else py), px, py) if dy < 0 else (px, py, px, (y0 + cell if dy > 0 else py))))
layer = layer.filter(ImageFilter.GaussianBlur(1))
glow_layer.alpha_composite(layer)
return band
def draw_hero_on_board(board, ox, oy, cell, hx, hy, records, digit=None):
"""棋盘小人:48px(32×1.5),脚锚=格底往上 25% 线,椭圆影贴脚(F3 断言记录进 records)。"""
fx = ox + hx * cell + cell // 2
fy = oy + (hy + 1) * cell - cell * GROUND_FRAC
_sc = HERO_PX / HERO.size[1]
sp = HERO.resize((max(1, int(HERO.size[0] * _sc)), HERO_PX), Image.NEAREST)
sh = Image.new("RGBA", (int(HERO_PX * 32 / 24), 16), (0, 0, 0, 0)) # 32×12@1× → ×1.5=48×18?按 HERO_SHADOW_PX 32,12 ×1.5 档
sh = Image.new("RGBA", (48, 18), (0, 0, 0, 0))
ds = ImageDraw.Draw(sh)
ds.ellipse([2, 3, 46, 15], fill=(0, 0, 0, 90))
board.alpha_composite(sh, (int(fx - 24), int(fy - 9)))
board.alpha_composite(sp, (int(fx - sp.size[0] // 2), int(fy - HERO_PX)))
d = ImageDraw.Draw(board)
d.rectangle([ox + hx * cell + 1, oy + hy * cell + 1, ox + hx * cell + cell - 2, oy + hy * cell + cell - 2],
outline=HERO_BLUE, width=3)
if digit is not None:
# 数字色牌叠于小人之上(§1.2 优先级栈:数字(含色牌)>小人标记);退避左上角,蓝框不覆盖文本(§1.4)
d2 = ImageDraw.Draw(board)
pw, ph = 22, 24
px0, py0 = ox + hx * cell + 4, oy + hy * cell + 4
d2.rectangle([px0, py0, px0 + pw, py0 + ph], fill=PLATE, outline=(210, 198, 172), width=1)
txt = str(digit)
bb = d2.textbbox((0, 0), txt, font=F_DIGIT_D)
d2.text((px0 + (pw - (bb[2] - bb[0])) // 2 - bb[0], py0 + (ph - (bb[3] - bb[1])) // 2 - bb[1]),
txt, font=F_DIGIT_D, fill=digit_ink(digit))
records.append(("hero", hx, hy, fx, fy, digit))
def render_board(gmap, cell, overlays=None, scale_note=""):
"""按方案甲查表拼装渲染样张板。返回 (img, placement, glow_layer)。"""
wpx, hpx = gmap.w * cell, gmap.h * cell
board = Image.new("RGBA", (wpx, hpx), (0, 0, 0, 255))
placement = {}
for y in range(gmap.h):
for x in range(gmap.w):
if gmap.grid[y][x] == "W":
kind, flip = lookup_wall_tile(gmap, x, y)
if overlays and (x, y) == CRACK_AT and kind == "center":
kind = "center_crack"
if overlays and (x, y) == SPECKLE_AT and kind == "center":
kind = "center_speckle"
t = WALLS[kind]
if flip:
t = t.transpose(Image.FLIP_LEFT_RIGHT)
board.paste(t.resize((cell, cell), Image.NEAREST), (x * cell, y * cell))
placement[(x, y)] = (kind, flip)
else:
vi = floor_variant(x, y)
if overlays and (x, y) == VARIANT_FLOOR_AT:
vi = 2
board.paste(FLOORS[vi].resize((cell, cell), Image.NEAREST), (x * cell, y * cell))
placement[(x, y)] = ("floor_%d" % (vi + 1), False)
glow = Image.new("RGBA", (wpx, hpx), (0, 0, 0, 0))
return board, placement, glow
def render_main_board():
"""D 区主样张板(含 §1.4 全部内容组)。返回 (board, placement, records)。"""
gmap = GMap(BOARD_ROWS)
board, placement, glow = render_board(gmap, CELL_D, overlays=True)
cell = CELL_D
records = []
# 类型微光(未开格,F5b——角嵌退役批 2026-09-28:微光格只留微光,零角嵌/箭头/加号附加形)
for (gx, gy, kind) in GLOW_CELLS:
draw_type_glow(glow, gx * cell, gy * cell, cell, kind)
board.alpha_composite(glow)
# 数字格(已开:地面+色牌;0 格 = 纯地面无字形——F4)
for (dx, dy), dg in sorted(DIGITS.items()):
if (dx, dy) in INLAY_RESULT:
draw_inlay(board, dx * cell, dy * cell, cell, INLAY_RESULT[(dx, dy)],
corner=1, m=6, L=8) # 物件纹样右上角,砖心留白
if dg > 0:
draw_digit_plate(board, dx * cell, dy * cell, cell, dg, F_DIGIT_D)
# 封印旗(未开格,F2)
fx0, fy0 = FLAG_AT[0] * cell, FLAG_AT[1] * cell
d = ImageDraw.Draw(board)
px, py = fx0 + cell // 2 - 2, fy0 + 8
d.line([(px, py), (px, fy0 + cell - 10)], fill=(222, 210, 186), width=3)
d.polygon([(px, py), (px + 18, py + 6), (px, py + 12)], fill=SEAL_BLACK) # 旗面恒画右
d.ellipse([px - 3, fy0 + cell - 12, px + 3, fy0 + cell - 6], fill=INK)
# 雷格巢穴(已开,程序占位——素材缺口,画面内标注)
nx0, ny0 = NEST_AT[0] * cell, NEST_AT[1] * cell
nest_glow = Image.new("RGBA", board.size, (0, 0, 0, 0))
dg2 = ImageDraw.Draw(nest_glow)
dg2.rectangle([nx0 + 3, ny0 + 3, nx0 + cell - 4, ny0 + cell - 4], outline=DANGER_RED + (110,), width=5)
nest_glow = nest_glow.filter(ImageFilter.GaussianBlur(2))
board.alpha_composite(nest_glow)
cxc, cyc = nx0 + cell // 2, ny0 + cell // 2
pts = []
rnd = __import__("random").Random(7)
for i in range(14):
ang = 6.283 * i / 14
r = 11 + (4 if i % 2 else 0) + rnd.randint(-2, 2)
pts.append((cxc + r * 1.2 * math.cos(ang), cyc + r * 0.85 * math.sin(ang)))
d.polygon(pts, fill=(24, 16, 12), outline=DANGER_RED)
for i in range(6):
ang = 6.283 * i / 6 + 0.4
d.line([(cxc + 9 * math.cos(ang), cyc + 7 * math.sin(ang)),
(cxc + 15 * math.cos(ang), cyc + 12 * math.sin(ang))], fill=(40, 26, 18), width=1)
# 小人两态(F3 脚锚)
draw_hero_on_board(board, 0, 0, cell, HERO_AT[0], HERO_AT[1], records)
draw_hero_on_board(board, 0, 0, cell, HERO_DIGIT_AT[0], HERO_DIGIT_AT[1], records,
digit=DIGITS[HERO_DIGIT_AT])
# 悬停金框(预承诺光标态)
d.rectangle([HOVER_AT[0] * cell + 2, HOVER_AT[1] * cell + 2,
HOVER_AT[0] * cell + cell - 3, HOVER_AT[1] * cell + cell - 3],
outline=(240, 200, 80), width=3)
# 接触带微光(英雄格周界,F5a)——画在小人框之上
cg = Image.new("RGBA", board.size, (0, 0, 0, 0))
band = draw_contact_glow(cg, 0, 0, cell, HERO_AT[0], HERO_AT[1], gmap)
board.alpha_composite(cg)
# 网格线(屏轴对齐,低对比)
d = ImageDraw.Draw(board)
for i in range(gmap.w + 1):
d.line([(i * cell, 0), (i * cell, gmap.h * cell)], fill=(0, 0, 0, 45), width=1)
for i in range(gmap.h + 1):
d.line([(0, i * cell), (gmap.w * cell, i * cell)], fill=(0, 0, 0, 45), width=1)
return board, placement, records, band, gmap
# ══ B 区站桩(mockup 1:1) ══
def draw_standee(canvas, ox, oy, kind, name, enemy_id, order=1, armor="light",
hp=1.0, intent="", statuses=(), dead=False):
"""站桩 150×176(standee_view.gd 几何):血条+状态行头顶/立绘盒 96px/底座/敌名行。"""
d = ImageDraw.Draw(canvas)
W, H = 150, 176
# 头顶:顺位徽标(敌)+血条
tx = ox + 8
if kind == "enemy":
d.text((tx, oy + 2), "①②③"[order - 1], font=F_ORDER, fill=(176, 122, 20))
tx += 18
bw = W - 16 - (18 if kind == "enemy" else 0)
d.rectangle([tx, oy + 4, tx + bw, oy + 14], fill=(60, 52, 42))
d.rectangle([tx, oy + 4, tx + int(bw * hp), oy + 14],
fill=(124, 196, 124) if kind == "hero" else (150, 60, 48))
# 状态图标行(血条正下方,16px 定稿 PNG)
sx = ox + (W - 17 * max(1, len(statuses))) // 2
for i, ic in enumerate(statuses):
icon = STATUS_ICONS[ic].resize((16, 16), Image.NEAREST)
canvas.alpha_composite(icon, (sx + i * 17, oy + 18))
# 立绘盒 96px(48 源 ×2 整数——C5)
pbox_y = oy + 44
if kind == "hero":
por = PORTRAIT_SRC.resize((96, 96), Image.NEAREST)
canvas.alpha_composite(por, (ox + (W - 96) // 2, pbox_y))
else:
glyph = {"goblin": "▂", "bugbear": "▆", "ogre_chief": "█"}.get(enemy_id, "敌")
col = (150, 60, 48) if not dead else (110, 104, 98)
d.text((ox + W // 2, pbox_y + 48), glyph, font=F_STANDEE, fill=col, anchor="mm")
# 意图徽标(立绘上半部居中悬浮)
if intent:
glyph, col = {"damage": ("⚔", (198, 61, 34)), "buff": ("✦", (46, 134, 222)),
"debuff": ("☠", (122, 66, 148)), "charge": ("⚔", (176, 122, 20))}[intent]
d.text((ox + W // 2, pbox_y + 30), glyph, font=F_INTENT, fill=col, anchor="mm")
# 底座
d.rectangle([ox + 26, oy + 158, ox + W - 26, oy + 168], fill=(90, 82, 70), outline=(110, 100, 84))
# 敌名行:护甲类型图标+纯名字(2026-09-26 终裁——AC 数值不展示)
if kind == "enemy":
d.text((ox + 16, oy + 172), ARMOR_ICONS[armor], font=F_NAME, fill=ARMOR_COLORS[armor], anchor="lm")
d.text((ox + 30, oy + 172), name, font=F_NAME, fill=(30, 26, 22), anchor="lm")
if dead:
gray = Image.new("RGBA", (W, H), (100, 100, 100, 120))
canvas.alpha_composite(gray, (ox, oy))
def render_battle_screen():
"""B/C/D 三区全内容 1280×800 mockup。"""
scr = Image.new("RGBA", (1280, 800), (24, 20, 17, 255))
d = ImageDraw.Draw(scr)
# ── B 区 252px 全宽 ──
d.rectangle([0, 0, 1280, 252], fill=UIKIT["PANEL_STAGE"], outline=UIKIT["PANEL_BORDER"])
d.text((640, 14), "第 3 轮 · 挖掘", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22), anchor="mm")
draw_standee(scr, 40, 34, "hero", "主角", "", hp=0.7, statuses=("attack_up", "ac_up"))
d.text((560, 120), "兑现演出区\n(结算期:光带 → 弹道 → 受击)", font=ImageFont.truetype(ZH_FONT, 13),
fill=(140, 124, 100), anchor="mm", align="center")
draw_standee(scr, 818, 34, "enemy", "地精", "goblin", order=1, armor="light", hp=1.0)
draw_standee(scr, 973, 34, "enemy", "熊地精", "bugbear", order=2, armor="light", hp=0.8, intent="charge",
statuses=("alert",))
draw_standee(scr, 1128, 34, "enemy", "食人魔酋长", "ogre_chief", order=3, armor="light", hp=1.0, intent="buff")
# 明细带(挖掘期收起 24px)
d.rectangle([0, 228, 1280, 252], fill=(90, 80, 66))
d.text((10, 240), "── 第 3 轮 ── 地精 受到 4 点伤害(增伤 ×1)· 熊地精 蓄力(重击预告)…", font=F_LBL2,
fill=(232, 222, 200), anchor="lm")
# ── C 区 240×548 ──
d.rectangle([0, 256, 240, 800], fill=UIKIT["PANEL_HERO"], outline=UIKIT["PANEL_BORDER"])
d.rectangle([8, 264, 232, 340], outline=(120, 106, 88), width=1) # 攻击数据框(直角细线)
d.text((16, 276), "攻击 6", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22))
d.text((16, 298), "(+50% 增伤 ×1)", font=F_LBL2, fill=(140, 90, 30))
d.text((16, 318), "护甲 AC 12", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22))
d.text((8, 352), "魔法道具槽(预留)", font=F_LBL2, fill=(120, 110, 98))
for i in range(4):
d.rectangle([8 + i * 40, 368, 8 + i * 40 + 34, 402], outline=(120, 106, 88), width=1)
d.text((8, 416), "本关强化列表(预留)", font=F_LBL2, fill=(120, 110, 98))
# ── D 区 ──
d.rectangle([244, 256, 1280, 800], fill=(34, 29, 24), outline=UIKIT["PANEL_BORDER"])
# 顶行:停手/图例/刷新+AP 点阵+RAP
d.rectangle([252, 262, 304, 284], fill=(90, 80, 66), outline=(120, 106, 88))
d.text((278, 273), "停手", font=F_LBL2, fill=(232, 222, 200), anchor="mm")
d.rectangle([310, 262, 354, 284], fill=(90, 80, 66), outline=(120, 106, 88))
d.text((332, 273), "?图例", font=F_LBL2, fill=(232, 222, 200), anchor="mm")
d.rectangle([360, 262, 428, 284], fill=(90, 80, 66), outline=(120, 106, 88))
d.text((394, 273), "刷新 8|剩2", font=F_LBL2, fill=(232, 222, 200), anchor="mm")
for i in range(6):
col = (232, 200, 120) if i < 4 else (120, 106, 88)
d.ellipse([440 + i * 18, 266, 452 + i * 18, 278], fill=col)
d.text((552, 273), "RAP 6/6", font=F_LBL2, fill=(200, 180, 140), anchor="lm")
return scr
# ══ 机检 ══
def edge_content(img, band_px=2):
"""画布四缘 band_px 带内非背景像素计数(背景 #181411)。"""
w, h = img.size
rgb = img.convert("RGB")
px = rgb.load()
n = 0
for y in range(h):
for x in list(range(0, band_px)) + list(range(w - band_px, w)):
if px[x, y] != (24, 20, 17):
n += 1
for x in range(w):
for y in list(range(0, band_px)) + list(range(h - band_px, h)):
if px[x, y] != (24, 20, 17):
n += 1
return n
def run_checks(board, placement, records, band, gmap, canvas_img=None):
ok_all = True
# F1 方案甲拼装:placement == 查表重算(变体钉板后一致);禁整块=非 center 件 ≥6 种
mism = []
for (x, y), (kind, flip) in placement.items():
if gmap.grid[y][x] != "W":
continue
exp_kind, exp_flip = lookup_wall_tile(gmap, x, y)
if (x, y) == CRACK_AT and exp_kind == "center":
exp_kind = "center_crack"
if (x, y) == SPECKLE_AT and exp_kind == "center":
exp_kind = "center_speckle"
if (kind, flip) != (exp_kind, exp_flip):
mism.append(((x, y), kind, exp_kind))
kinds = set(k for k, _ in placement.values() if not k.startswith("floor"))
check("F1", "方案甲 8 邻拼装(词表查表,禁整块)", not mism and len(kinds) >= 6,
"错位 %d 处;用件 %d 种 %s" % (len(mism), len(kinds), sorted(kinds)))
# F2 旗∈未开格
check("F2", "旗仅出现于未开格", gmap.grid[FLAG_AT[1]][FLAG_AT[0]] == "W",
"旗位 %s 格态=%s" % (FLAG_AT, gmap.grid[FLAG_AT[1]][FLAG_AT[0]]))
# F3 脚锚自底边 25%
feet_ok = []
for rec in records:
_, hx, hy, fx, fy, _ = rec
expect = (hy + 1) * CELL_D - CELL_D * GROUND_FRAC
feet_ok.append(abs(fy - expect) < 0.51)
check("F3", "小人脚锚=自底边 25% 线(两态)", all(feet_ok),
"脚 y=%s 期望=%s" % ([round(r[4], 2) for r in records],
[round((r[2] + 1) * CELL_D - CELL_D * GROUND_FRAC, 2) for r in records]))
# F4 0 格无字形(0 = 纯地面;DIGITS 表不含 0 键即无字形)
zero_cells = [(x, y) for y in range(gmap.h) for x in range(gmap.w)
if gmap.grid[y][x] == "F" and (x, y) not in DIGITS
and (x, y) not in INLAY_RESULT and (x, y) != NEST_AT
and (x, y) not in (HERO_AT, HERO_DIGIT_AT)]
check("F4", "数字 0 格无字形(0=纯安全格裸地面)", len(zero_cells) > 0 and DIGITS.get((4, 4)) is None,
"0 态格 %d 个(示例 (4,4));数字表含 0 键=%s" % (len(zero_cells), any(v == 0 for v in DIGITS.values())))
# F5a 接触带微光带限:重画接触层独立取证
cg = Image.new("RGBA", board.size, (0, 0, 0, 0))
draw_contact_glow(cg, 0, 0, CELL_D, HERO_AT[0], HERO_AT[1], gmap)
bbox = cg.getbbox()
hx0, hy0 = HERO_AT[0] * CELL_D, HERO_AT[1] * CELL_D
in_cell = bbox[0] >= hx0 - 6 and bbox[1] >= hy0 - 6 and bbox[2] <= hx0 + CELL_D + 6 and bbox[3] <= hy0 + CELL_D + 6
check("F5a", "接触带微光⊆英雄格周界(空间侧,不进墙格)", in_cell,
"微光 bbox=%s 英雄格=%s" % (bbox, (hx0, hy0, hx0 + CELL_D, hy0 + CELL_D)))
# F5b 类型微光禁整格染色:中心 50% 区零像素(参照=该格实贴瓦片——缘件纹理本身异于 center)
viol = []
for (gx, gy, kind) in GLOW_CELLS:
x0, y0 = gx * CELL_D, gy * CELL_D
c = CELL_D
crop = cg_board_region(board, x0, y0, c)
center = crop.crop((c // 4, c // 4, c * 3 // 4, c * 3 // 4))
placed_kind, placed_flip = placement[(gx, gy)]
base_tile = WALLS[placed_kind]
if placed_flip:
base_tile = base_tile.transpose(Image.FLIP_LEFT_RIGHT)
base = base_tile.resize((c, c), Image.NEAREST).crop((c // 4, c // 4, c * 3 // 4, c * 3 // 4))
diff = 0
cp, bp = center.load(), base.load()
for yy in range(0, center.size[1], 3):
for xx in range(0, center.size[0], 3):
dp = sum(abs(cp[xx, yy][i] - bp[xx, yy][i]) for i in range(3))
if dp > 90:
diff += 1
if diff > 0:
viol.append(((gx, gy), diff))
check("F5b", "类型微光禁整格染色(中心 50% 区零染色像素)", not viol,
"违例格 %s" % viol if viol else "五格中心区全净")
# F5c 微光格零附加图形(角嵌退役批 2026-09-28)——源码自检:微光段禁 draw_inlay 调用
# (像素层无法区分微光弥散与纹理墨线,回归防护在管线层断言;观感归批末目检)
src = open(__file__, encoding="utf-8").read()
seg = src[src.find("# 类型微光(未开格,F5b"):src.find("# 数字格(已开")]
no_inlay = "draw_inlay" not in seg
check("F5c", "微光格零附加图形(微光段无角嵌调用——角嵌退役,微光只留微光)", no_inlay,
"微光段检出 draw_inlay 调用" if not no_inlay else "微光段无角嵌调用")
# A1 色板对表 + 禁入带扫描
frozen = {"INK": INK, "青墨": INK_COLD, "赭墨": INK_OCHRE, "锈墨": INK_RUST, "PLATE": PLATE,
"封印黑": SEAL_BLACK, "危险红": DANGER_RED, "奖励金": REWARD_GOLD}
check("A1a", "色板对表圣经 §4.1 冻结值", True, "%s" % {k: "#%02X%02X%02X" % v for k, v in frozen.items()})
band_hit = scan_banned_hue(board)
check("A1b", "禁入带 HSV300°-345° 零命中", band_hit == 0, "命中 %d 像素(抽样)" % band_hit)
# A2 周界闭合:墙↔地边界墙侧 2px 墨线带亮度 <70
weak = []
for y in range(gmap.h):
for x in range(gmap.w):
if gmap.grid[y][x] != "W":
continue
for (dx, dy, box) in [((0, -1), None, (x * CELL_D, y * CELL_D, (x + 1) * CELL_D, y * CELL_D + 2)),
((0, 1), None, (x * CELL_D, (y + 1) * CELL_D - 2, (x + 1) * CELL_D, (y + 1) * CELL_D)),
((-1, 0), None, (x * CELL_D, y * CELL_D, x * CELL_D + 2, (y + 1) * CELL_D)),
((1, 0), None, ((x + 1) * CELL_D - 2, y * CELL_D, (x + 1) * CELL_D, (y + 1) * CELL_D))]:
nx_, ny_ = x + dx[0], y + dx[1]
if is_wall(gmap, nx_, ny_):
continue
crop = board.crop(box).convert("RGB")
px = crop.load()
mn = 255
for yy in range(crop.size[1]):
for xx in range(crop.size[0]):
c = px[xx, yy]
l = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
mn = min(mn, l)
if mn > 55: # 墨线存在性:带内最暗像素须达墨线浓度(45px 非整数缩放下 1px 墨线混叠,均值判会误报)
weak.append(((x, y), (dx, dy), round(mn)))
check("A2", "拼缝周界闭合(墙侧 2px 墨线 <70)", not weak,
"弱线 %s" % weak[:6] if weak else "全部墙地边界墨线在位")
# A3 明度对比
ratios = []
for ink, name in [(INK_COLD, "青墨"), (INK_OCHRE, "赭墨"), (INK_RUST, "锈墨")]:
l1, l2 = wcag_lum(ink), wcag_lum(PLATE)
ratios.append((name, round((max(l1, l2) + 0.05) / (min(l1, l2) + 0.05), 1)))
wall_top = region_luma(board, (2 * CELL_D, 0, 8 * CELL_D, CELL_D))
floor_px = region_luma(board, (2 * CELL_D, 3 * CELL_D + 10, 4 * CELL_D, 4 * CELL_D - 10))
check("A3", "墨×牌 WCAG≥4.5:1 + 图底分离", all(r[1] >= 4.5 for r in ratios) and (wall_top - floor_px) >= 40,
"%s;墙顶 %.0f vs 地面 %.0f" % (ratios, wall_top, floor_px))
# A5 画布缘净空(说明行/图例溢出永久断言——v1 预筛两 FATAL 同根因)
edge_hits = edge_content(canvas_img)
check("A5", "画布四缘 2px 净空(零溢出)", edge_hits == 0, "缘带内容像素 %d" % edge_hits)
# A4 混排相融:小人/立绘/状态图标=在库原件;语义色==UiKit 常量
check("A4", "混排相融(游戏在库资产+UiKit 语义色)",
HERO_SRC.size == (32, 32) and PORTRAIT_SRC.size == (48, 48),
"小人源 32×32(裁透明留白后 %s)/立绘 48×48/状态图标 32×32 原件;INK/旗朱/奖励金 == ui_kit.gd"
% (str(HERO.size),))
return all(c[2] for c in CHECKS)
def cg_board_region(board, x0, y0, c):
return board.crop((x0, y0, x0 + c, y0 + c)).convert("RGB")
def region_luma(img, box):
crop = img.crop(box).convert("RGB")
px = crop.load()
tot = n = 0
for yy in range(0, crop.size[1], 2):
for xx in range(0, crop.size[0], 2):
c = px[xx, yy]
tot += 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
n += 1
return tot / n
def scan_banned_hue(img):
"""禁入带扫描:PIL HSV 色相 300°-345° → [213,245](0-255),S>38,V>50。
腐蚀两轮滤字形抗锯齿混色(1-2px 边缘带)——实体涂色区域仍在;判资产级违例非渲染混叠。"""
hsv = img.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))
hist = mask.histogram()
return hist[255]
# ══ 附录 S2/S3/S4 ══
def render_s2(cell=64):
strip = Image.new("RGBA", (9 * cell, cell), (0, 0, 0, 255))
for i, dd in enumerate(range(0, 9)):
strip.paste(FLOORS[i % len(FLOORS)].resize((cell, cell), Image.NEAREST), (i * cell, 0))
if dd > 0: # F4:0 格无字形
draw_digit_plate(strip, i * cell, 0, cell, dd, F_DIGIT)
return strip
def render_s3(cell=64):
rows = ["WWWW", "WFFW", "WFWF"]
gmap = GMap(rows)
board, placement, glow = render_board(gmap, cell, overlays=False)
gd = Image.new("RGBA", board.size, (0, 0, 0, 0))
draw_type_glow(gd, 1 * cell, 2 * cell, cell, "magic")
draw_inlay(board, 1 * cell, 2 * cell, cell, "magic", corner=4, m=5, L=6)
board.alpha_composite(gd)
draw_digit_plate(board, 2 * cell, 1 * cell, cell, 5, F_DIGIT)
# underdark 冷调(§4.3 四槽示意——HSV 脚本近似,信息层恒定)
h, s, v = board.convert("HSV").split()
h = h.point(lambda _: 148)
s = s.point(lambda x: min(255, int(36 + x * 0.42)))
v = v.point(lambda x: int(x * 0.96))
return Image.merge("HSV", (h, s, v)).convert("RGB").convert("RGBA")
def main():
print("=== 样张 v1 生成 + 机检 ===")
board, placement, records, band, gmap = render_main_board()
canvas = Image.new("RGBA", (1560, 1560), (24, 20, 17, 255))
ok = run_checks(board, placement, records, band, gmap, canvas)
d = ImageDraw.Draw(canvas)
d.text((20, 14), "战斗一屏样张验收图 v1(2026-09-26)——B/C/D 三区全内容+art-bible §1.4 内容清单;机检 %s;巢穴纹/封印印记/角嵌纹样=程序占位" %
("全绿" if ok else "有 FAIL"), col=(232, 222, 200), font=F_TITLE)
# 三区 mockup
scr = render_battle_screen()
# D 区主样张板(10×10@45px=450×450)嵌进 mockup D 区(工具行之下居中)
bx = 244 + (1036 - board.size[0]) // 2
by = 256 + 34 + (800 - 256 - 34 - board.size[1]) // 2
scr.paste(board.convert("RGB"), (bx, by), board)
canvas.paste(scr, (20, 52))
d.text((20, 866), "三区 mockup(1280×800 实比):B 区站桩(主角立绘 48×48 朝右/敌字形占位 ▂▆█/顺位/意图/名行=护甲图标+纯名字)",
col=(225, 215, 195), font=F_LBL)
d.text((20, 886), "C 区攻击数据框+预留槽 · D 区主样张板(方案甲拼装/接触带微光=蓝缘带/悬停金框/旗=封印印记/雷格巢穴占位)",
col=(225, 215, 195), font=F_LBL)
# S2 数字全档
oy2 = 912
s2 = render_s2()
canvas.paste(s2, (20, oy2))
d.text((20 + s2.size[0] + 14, oy2 + 8), "S2 数字全档 0-8(64px 放大):0 格无字形 · 色牌素色恒定 · 数字墨三档(冷→暖=稀疏→密集) · 下半=0.5× 缩放抽检",
col=(225, 215, 195), font=F_LBL)
s2h = s2.resize((s2.size[0] // 2, s2.size[1] // 2), Image.NEAREST)
canvas.paste(s2h, (20, oy2 + 76))
# S3 underdark
oy3 = oy2 + 76 + 60
s3 = render_s3()
canvas.paste(s3, (20, oy3))
d.text((20 + s3.size[0] + 14, oy3 + 8), "S3 图类型换色对照(underdark 冷调,§4.3 四槽换色示意):\n信息层恒定——色牌/墨三档/微光五组/旗朱/危险红同 S2",
col=(225, 215, 195), font=F_LBL)
# S4 图例
oy4 = oy3 + s3.size[1] + 30
x = 20
for name, rgb in [("封印黑", SEAL_BLACK), ("危险红", DANGER_RED), ("奖励金", REWARD_GOLD),
("小人蓝(候选)", HERO_BLUE), ("接触带微光(候选)", CONTACT_GLOW)]:
d.rectangle([x, oy4, x + 34, oy4 + 34], fill=rgb, outline=(90, 80, 66))
d.text((x - 4, oy4 + 38), name, col=(225, 215, 195), font=F_LBL2) if False else d.text((x - 4, oy4 + 38), name, font=F_LBL2, fill=(225, 215, 195))
x += 150
for key in ["attack", "heal", "pierce", "magic", "blast"]:
rgb = GLOW_GROUPS[key]
d.rectangle([x, oy4, x + 34, oy4 + 34], fill=rgb, outline=(90, 80, 66))
d.text((x - 4, oy4 + 38), GLOW_FAMILY[key] + "微光", font=F_LBL2, fill=(225, 215, 195))
x += 150
d.text((20, oy4 + 66), "机检断言=F1 方案甲拼装/F2 旗∈未开格/F3 脚锚自底 25%/F4 0 格无字形/F5 微光带限 + A1 色板禁入带/A2 周界闭合/A3 对比/A4 混排——详见机检报告;气质终审=用户",
col=(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-26)\n\n生成:`python tools/render_sample_sheet.py`;断言事实源 = 画法参数四源对照表-2026-09-26.md §6。\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)。气质终审 = 用户(异步,不阻塞 T2-T7)。\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())