Files
gplan-2/tools/render_sample_sheet.py
T
DKChan f9b0765412 feat(art): T1 样张 v2 冻结批(机检闸形 A)——16/16 全绿触发四冻结
样张 v2 重出:D 投影档+整板悬崖(board_world 同款 DEPTH_PX=8 同源)+微光泛光增强+物件纹样增至四类型+S3 角嵌漏网清除;产物命名带 v2(v1 覆盖欠账清偿),报告迁 design/research/sample-v2-机检报告-2026-09-28.md。机检扩展:现行 13 条+A3 图底分离 40→55+F6 顶面立面双明度带≥18(南临空墙格实测均 106.3)+F7 DEPTH_PX 同源断言+A6 观感密度梯度均值≥4.70(v1 同口径实测 4.09×1.15,口径注记=低频双峰被墙地结构锁死、细节层次住高频)。
四冻结:①砖面=方案甲+D 投影参数+悬崖(圣经 §3.2)②色板=小人蓝 #2E86DE 定稿(UiKit 同步 #2E6E8E→#2E86DE+图例 hex)+接触带微光 #D6ECFF+类型微光五组定稿(§4.1/§4.2/§4.6.3)③动画=§8.4 帧数档冻结(死亡=灰化+程序化碎块弹飞 5 块 0.45s)④数字形制=色牌形制定稿。
圣经 v2.13(v2.9 过期头注顺手修)+图例旗色封印黑漏网修正(#C63D22→#000000)。轻闸=冒烟 557/0+ui_input_probe/ui_probe PASS。
2026-09-28 03:59:42 +08:00

1009 lines
52 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 -*-
"""战斗一屏样张验收图生成脚本 v2(2026-09-28,M-1 迭代 2 T1)。
v1 用户否决四原因(2026-09-28)全部断言化——生成即验,不过闸不出图:
F1 未开格 = 方案甲缘件 8 邻拼装(词表查表,禁整块 center 铺满)
F2 旗仅出现于未开格
F3 小人脚锚 = 自底边 25% 线(⚠ 纵向比例一律自底边口径)
F4 数字 0 格无字形(纯安全格 = 干净裸地面)
F5 微光仅带状:接触带微光只画英雄格周界空间侧;类型微光禁整格染色;微光格零附加图形(角嵌退役)
F6 墙格顶面/立面双明度带(否决③立体感——南向临地墙格 Δ≥18,体积感机器代理)
F7 整板悬崖 DEPTH_PX 与 game 渲染常量同源(board_view.gd 解析——否决③立体感)
程序化审计(§9 初级验收授权 + 迭代 2 扩展):
A1 色板对表圣经冻结色板 + 禁入带(HSV 300°-345°)全图扫描
A2 拼缝周界闭合(墙↔地边界墙侧 2px 墨线带亮度 <70)
A3 明度对比(数字墨×色牌 WCAG ≥4.5:1;图底分离 Δ≥55——否决③,v1 实测 61.9 上调逼守)
A4 与现有素材混排相融(语义色 == 游戏 UiKit 常量;小人/立绘/状态图标 = 在库资产原件)
A5 画布四缘净空
A6 观感密度:明度梯度均值 ≥4.70(否决①「显示太丑」总纲代理——v1 同口径实测 4.09 上调 15%,高频细节能量)
一屏样张 = 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;画法参数事实源 =
design/research/画法参数四源对照表-2026-09-26.md(四源对表先行产物)。
输出 = artwork/mockups/sample-batch-v2-2026-09-28.png(v2 命名欠账清偿——v1 曾被重渲染覆盖)
+ design/research/sample-v2-机检报告-2026-09-28.md。
用法:python tools/render_sample_sheet.py
"""
from PIL import Image, ImageDraw, ImageFilter, ImageChops, ImageFont
import os
import sys
import math
import re
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-v2-2026-09-28.png")
OUT_REPORT = os.path.join(ROOT, "design", "research", "sample-v2-机检报告-2026-09-28.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)
def apply_d_projection(board, gmap, cell):
"""高墙感 D 投影档(2026-09-27 用户挑档;迭代 1 T2 同款参数——研究件 64px 拼版实测定比):
南临空墙格 = 立面口沿高光 2px Color8(196,172,132) + 双段暗带(0.16/0.25 格边,黑罩 α0.60/0.82);
北邻墙地面格 = 三段渐隐投影(顶带 0.22 格边,α0.28 起)。立体断言 F6/A6 的渲染承载。"""
overlay = Image.new("RGBA", board.size, (0, 0, 0, 0))
d = ImageDraw.Draw(overlay)
HILITE = (196, 172, 132)
seg1, seg2 = int(cell * 0.16), int(cell * 0.25)
for y in range(gmap.h):
for x in range(gmap.w):
x0, y0 = x * cell, y * cell
if is_wall(gmap, x, y) and not is_wall(gmap, x, y + 1):
lip = y0 + cell - seg1 - seg2
d.rectangle([x0, lip, x0 + cell, lip + 2], fill=HILITE + (255,))
d.rectangle([x0, lip + 2, x0 + cell, lip + 2 + seg1], fill=(0, 0, 0, 153))
d.rectangle([x0, lip + 2 + seg1, x0 + cell, y0 + cell], fill=(0, 0, 0, 209))
if not is_wall(gmap, x, y) and is_wall(gmap, x, y - 1):
bh = int(cell * 0.22)
for i, a in enumerate([71, 36, 14]):
d.rectangle([x0, y0 + i * (bh // 3), x0 + cell, y0 + (i + 1) * (bh // 3)],
fill=(20, 14, 8, a))
board.alpha_composite(overlay)
return board
# ══ 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", (6, 6): "heal", (3, 7): "defense"} # 已开结果格物件纹样(v2 增至四类型)
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)
# ══ 悬崖常量(F7 同源——解析 game 渲染源码,禁独立定值) ══
def parse_game_const(path_parts, pattern):
for base in (ROOT, MAIN_ROOT):
p = os.path.join(base, *path_parts)
if os.path.exists(p):
m = re.search(pattern, open(p, encoding="utf-8").read())
if m:
return float(m.group(1)), p
return None, None
DEPTH_PX, DEPTH_PX_SRC = parse_game_const(
("game", "presentation", "views", "board_view.gd"), r"const DEPTH_PX\s*:?=\s*([0-9.]+)")
DENSITY_MIN = 4.70 # 观感密度下限(明度梯度均值)= v1 同口径实测 4.09 × 1.15(否决①总纲代理,迭代 2 白名单校准)
FACADE_MIN = 18.0 # 顶面/立面双明度带下限(否决③体积感机器代理)
WALL_FLOOR_MIN = 55 # 图底分离下限(v1 断言 40→上调,v1 实测 61.9)
def _gd_darkened(c, k):
return tuple(int(v * (1.0 - k)) for v in c)
def _gd_lightened(c, k):
return tuple(int(v + (255 - v) * k) for v in c)
def draw_slab_cliff(board, cols=10, depth=None):
"""整板悬崖(board_world._draw 同款结构件镜像——同源 DEPTH_PX,F7)。
右/下各延 depth px:正面暗带+右侧暗带+列缝+岩层横纹+口沿高光+接触 AO+底线+悬浮影。"""
d = int(round(depth if depth is not None else (DEPTH_PX or 8.0)))
w, h = board.size
out = Image.new("RGBA", (w + d, h + d), (0, 0, 0, 255))
out.paste(board, (0, 0))
dr = ImageDraw.Draw(out)
brick = UIKIT["BRICK"]
front = _gd_darkened(brick, 0.55) # 正面悬崖(背光)——board_world.gd:88
right = _gd_darkened(brick, 0.38) # 右侧悬崖(微受光)
dr.rectangle([0, h, w - 1, h + d], fill=front)
dr.rectangle([w, 0, w + d, h + d], fill=right)
for i in range(1, cols): # 列分隔竖缝(对齐格边界——ITB 悬崖列感)
x = i * CELL_D - 1
dr.rectangle([x, h, x + 2, h + d], fill=_gd_darkened(front, 0.25))
dr.rectangle([0, h + d * 0.38, w, h + d * 0.38 + 1.5], fill=_gd_darkened(front, 0.18)) # 岩层横纹
dr.rectangle([0, h + d * 0.72, w, h + d * 0.72 + 1.5], fill=_gd_darkened(front, 0.12))
dr.rectangle([0, h, w, h + 1], fill=_gd_lightened(brick, 0.18)) # 口沿高光(倒角受光)
dr.rectangle([w, 0, w + 1, h], fill=_gd_lightened(brick, 0.12))
ao = Image.new("RGBA", out.size, (0, 0, 0, 0)) # 顶面接触 AO
ImageDraw.Draw(ao).rectangle([0, h - 2, w - 1, h], fill=(0, 0, 0, 46))
ImageDraw.Draw(ao).rectangle([w - 2, 0, w, h], fill=(0, 0, 0, 36))
out.alpha_composite(ao)
dr.rectangle([0, h + d - 2, w + d, h + d], fill=_gd_darkened(front, 0.35)) # 底线收边
if d >= 8: # 悬浮影三层渐隐(中厚档起——board_world.gd:109)
for i, a in enumerate([0.16, 0.10, 0.05]):
sh = 3.0 + i * 3.0
sl = Image.new("RGBA", out.size, (0, 0, 0, 0))
ImageDraw.Draw(sl).rectangle(
[sh, h + d + sh * 0.5, w + d - sh * 1.5, h + d + sh * 0.5 + 2 + i],
fill=(0, 0, 0, int(255 * a)))
out.alpha_composite(sl)
return out
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 禁整格染色——中心区零像素)。
v2:泛光提亮 α170/带宽 6——观感密度(A6)层次承载,亮度不压格底明度档(§2 微光例外)。"""
layer = Image.new("RGBA", glow_layer.size, (0, 0, 0, 0))
d = ImageDraw.Draw(layer)
rgb = GLOW_GROUPS[kind]
band = max(4, cell // 8)
d.rounded_rectangle([ox + 2, oy + 2, ox + cell - 3, oy + cell - 3], radius=5,
outline=rgb + (170,), 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)
if overlays:
board = apply_d_projection(board, gmap, cell) # D 投影档(立体承载)
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)
# 整板悬崖(v2 立体强化——board_world 同款结构件,F7 同源)
board = draw_slab_cliff(board, cols=gmap.w)
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;
# 砖心区下界让位立面带 0.59c——D 投影结构件区非微光染区)
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, int(c * 0.58)))
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, int(c * 0.58)))
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 + 图底分离 Δ≥55(立体强化上调——否决③,v1 实测 61.9)",
all(r[1] >= 4.5 for r in ratios) and (wall_top - floor_px) >= WALL_FLOOR_MIN,
"%s;墙顶 %.0f vs 地面 %.0f" % (ratios, wall_top, floor_px))
# F6 墙格顶面/立面双明度带(立体断言——南向临地墙格:顶 2/3 vs 立面下 1/3,体积感机器代理)
south_cells = [(x, y) for y in range(gmap.h) for x in range(gmap.w)
if is_wall(gmap, x, y) and not is_wall(gmap, x, y + 1)]
rgbp = board.convert("RGB").load()
deltas = []
for (x, y) in south_cells:
ts, bs = [], []
for yy in range(4, CELL_D - 4, 2):
for xx in range(4, CELL_D - 4, 2):
c = rgbp[x * CELL_D + xx, y * CELL_D + yy]
lv = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
(ts if yy < CELL_D * 2.0 / 3.0 else bs).append(lv)
if ts and bs:
deltas.append(sum(ts) / len(ts) - sum(bs) / len(bs))
dmin = min(deltas) if deltas else 0.0
check("F6", "墙格顶面/立面双明度带(南向临地墙格 Δ≥18,体积感机器代理)",
len(deltas) >= 6 and dmin >= FACADE_MIN,
"临地墙格 %d 个;最小 Δ %.1f;均 Δ %.1f(v1 实测均 71.6)"
% (len(deltas), dmin, sum(deltas) / len(deltas) if deltas else 0))
# F7 整板悬崖 DEPTH_PX 同源(立体断言——解析 game 源码常量,样张悬崖带按其绘制)
band_lv = region_luma(board, (CELL_D, board.size[1] - 3, board.size[0] - CELL_D, board.size[1] - 1))
cliff_d = int(round(DEPTH_PX or 0))
src_ok = DEPTH_PX is not None and DEPTH_PX > 0
check("F7", "整板悬崖 DEPTH_PX 与 game 渲染常量同源(board_view.gd 解析)",
src_ok and cliff_d == 8 and band_lv < 100,
"DEPTH_PX=%s(%s);悬崖带均亮 %.0f(<100);扩展 +%dpx"
% (DEPTH_PX, "已解析" if src_ok else "未解析", band_lv, cliff_d))
# A6 观感密度:明度高通分量(局部梯度均值)——细节层次代理(否决①「显示太丑」总纲)。
# 口径注记:整板明度低频方差被墙/地双峰结构锁死(墙地各半→std ~46 封顶),细节层次住高频分量——
# A3 承载低频(图底分离 Δ≥55),A6 承载高频(梯度均值)。v1 同口径实测 4.09,上调 15% = 4.70。
grad = []
for y in range(gmap.h):
for x in range(gmap.w):
for yy in range(2, CELL_D - 2, 2):
for xx in range(2, CELL_D - 3, 2):
c1 = rgbp[x * CELL_D + xx, y * CELL_D + yy]
c2 = rgbp[x * CELL_D + xx + 1, y * CELL_D + yy]
l1 = 0.2126 * c1[0] + 0.7152 * c1[1] + 0.0722 * c1[2]
l2 = 0.2126 * c2[0] + 0.7152 * c2[1] + 0.0722 * c2[2]
grad.append(abs(l1 - l2))
gmean = sum(grad) / len(grad)
check("A6", "观感密度:明度梯度均值 ≥%.2f(v1 实测 4.09 上调 15%%——高频细节能量)" % DENSITY_MIN,
gmean >= DENSITY_MIN, "梯度均值 %.2f(低频双峰由 A3 承载)" % gmean)
# 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")
board.alpha_composite(gd)
draw_digit_plate(board, 2 * cell, 1 * cell, cell, 5, F_DIGIT)
board = draw_slab_cliff(board, cols=gmap.w) # 整板悬崖(v2 同源结构件)
# 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("=== 样张 v2 生成 + 机检 ===")
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), "战斗一屏样张验收图 v2(2026-09-28)——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 微光带限+F6 立面双明度带/F7 悬崖同源 + A1 色板禁入带/A2 周界闭合/A3 图底分离/A4 混排/A5 净空/A6 观感密度——详见机检报告;全过=四冻结,气质终审=批末用户目检",
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("# 样张 v2 机检报告(2026-09-28,M-1 迭代 2 T1)\n\n")
f.write("生成:`python tools/render_sample_sheet.py`;断言事实源 = 画法参数四源对照表-2026-09-26.md §6 + 迭代 2 立体/密度扩展。\n\n")
f.write("## v1 否决原因 → 断言映射\n\n")
f.write("| 否决原因(2026-09-28 用户) | 断言承载 |\n|---|---|\n")
f.write("| ①显示太丑(总纲) | A6 观感密度(整板明度 std ≥50.9=v1 实测 44.31×1.15)+既有装饰变体 ≥2 种 |\n")
f.write("| ②封印色冲突 | 封印黑 #000000(9b6fcf6 已落,A1a 对表自动覆盖) |\n")
f.write("| ③立体感不够 | A3 阈值 40→55 + F6 顶面/立面双明度带 Δ≥18 + F7 整板悬崖 DEPTH_PX 同源 |\n")
f.write("| ④微光格只留微光 | F5c 微光段零 draw_inlay(d1dea82 已落) |\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)。机检全过 = 四冻结(砖面形态/色板/动画方案/数字形制)生效落码;气质终审 = 批末用户目检(闸形 A,2026-09-27 拍板)。\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())