Files
gplan-2/tools/render_sample_sheet_v3.py
T

1173 lines
61 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 -*-
"""战斗一屏样张方向稿生成脚本 v3-draft(2026-09-29,art-bible v3.0 中修案 B 首批)。
v3.0 口径(方向稿=目检先行闸,冻结归机检批):
V1 格底明度分档:已开格底压暗至世界底层档(候选 L 0.08–0.14;§8.5 注记——压暗仅方向稿示意,终稿归地板素材选型/重产)
V2 数字亮墨直画:亮族三档(冷亮/暖白/金亮,L≥0.75),色牌降级=复杂格底(物件纹样/装饰变体)后备
V3 装饰物件级解禁演示:小石块堆(专属装饰形状家族,圆钝三角形,零信息纪律不变)
V4 B 区舞台正面目标:世界底层暗背景+顶光柱+地台落影(§6.1 地窟看台)
A3 断言改造:亮墨×实测已开格底 WCAG ≥4.5:1(色牌后备组维持深墨×牌 ≥4.5:1)
基于 v2 脚本定点补丁生成(_v3_patch.py),v2 冻结产物管线不动。
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-v31-draft-2026-09-29.png")
OUT_REPORT = os.path.join(ROOT, "design", "research", "sample-v31-draft-机检报告-2026-09-29.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(v3.0 后备件)
# ── v3 亮族数字墨(art-bible v3.0:直画已开格底世界底层档,候选 L≥0.75;金亮低饱和避奖励金语义) ──
INK_L_COLD = (216, 236, 240) # 冷亮青白 1-3 #D8ECF0
INK_L_WARM = (240, 226, 200) # 暖白 4-5 #F0E2C8(关卡屏 WARM 同族)
INK_L_GOLD = (248, 227, 168) # 金亮 6-8 #F8E3A8
FLOOR_DARK_GAMMA = 1.75 # 已开格底压暗曲线(方向稿示意):gamma 保纹理相对对比
FLOOR_DARK_GAIN = 0.92
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)]
def _v3_darken_floors(tiles, target_l=0.10):
"""v3 方向稿:已开格底归一到世界底层档中点 L≈0.11(候选带 0.08–0.14)——实测现行瓦片 L≈0.036
低于带下沿,故实为**提亮**;线性增益保纹理相对对比。终稿归素材重产(§8.5),本函数仅方向稿示意。"""
def _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)
out = []
for tl in tiles:
rgb = tl.convert("RGB")
probe = list(rgb.resize((8, 8)).getdata())
cur = sum(_lum(c) for c in probe) / len(probe)
k = (target_l / max(cur, 1e-3)) ** (1 / 2.2) # L ∝ c^2.2 近似
k = max(0.6, min(1.8, k))
lut = [min(255, int(i * k + 0.5)) for i in range(256)]
a = tl.split()[3] if tl.mode == "RGBA" else None
rgb = rgb.point(lut * 3)
if a is not None:
rgb.putalpha(a)
out.append(rgb.convert("RGBA"))
print("[v3] floor normalize L %.3f → %.3f (k=%.2f)" % (cur, target_l, k))
return out
FLOORS = _v3_darken_floors(FLOORS)
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_deep(digit))
return (px0, py0, pw, ph)
def draw_digit_direct(img, x0, y0, cell, digit, font):
"""v3 亮墨直画(无牌,数字禁描边——§4.1 字形唯一数值载体)。"""
d = ImageDraw.Draw(img)
txt = str(digit)
bb = d.textbbox((0, 0), txt, font=font)
tw, th = bb[2] - bb[0], bb[3] - bb[1]
d.text((x0 + (cell - tw) // 2 - bb[0], y0 + (cell - th) // 2 - bb[1]), txt,
font=font, fill=digit_ink(digit))
def draw_pebble(img, x0, y0, cell, seed):
"""v3 物件级装饰演示:小石块堆(专属装饰形状家族=圆钝三角堆叠,与信息元素零同形;
贴砖缘分布不构成完整凸形焦点,零信息纪律:与雷/安/类型零相关)。"""
rnd = __import__("random").Random(seed)
d = ImageDraw.Draw(img)
bx, by = x0 + int(cell * 0.72), y0 + int(cell * 0.70) # 右下角砖缘
base = (152, 128, 98)
for i, (ox, oy, r) in enumerate([(0, 6, 5), (7, 2, 4), (3, -2, 3)]):
shade = tuple(int(c * (0.9 + 0.08 * i)) for c in base)
d.polygon([(bx + ox - r, by + oy + r), (bx + ox, by + oy - r), (bx + ox + r, by + oy + r)],
fill=shade, outline=(40, 32, 24))
def digit_ink(d):
"""v3 亮族(直画格底=信息面层)。"""
if d <= 3:
return INK_L_COLD
if d <= 5:
return INK_L_WARM
return INK_L_GOLD
def digit_ink_deep(d):
"""深墨族(色牌后备件上专用——牌 L≥0.70 配深墨)。"""
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:
# v3 亮墨直画退避左上角(§1.2 优先级栈:数字>小人标记;蓝框不覆盖文本,§1.4)
d2 = ImageDraw.Draw(board)
txt = str(digit)
bb = d2.textbbox((0, 0), txt, font=F_DIGIT_D)
d2.text((ox + hx * cell + 5 - bb[0], oy + hy * cell + 4 - bb[1]),
txt, font=F_DIGIT_D, fill=digit_ink(digit))
records.append(("hero", hx, hy, fx, fy, digit))
FACADE_FRAC = 0.55 # E 格立面占比(v3.2 档位候选:0.55/0.80/1.00 三档对比,用户挑档)
def draw_facade(board, x0, y0, cell):
"""v3.1 高墙立面(用户方案:地面格南邻墙 → 南部承接墙面展示,E 格)。
南部画墙身:横层理暗砖+檐口阴线;程序化示意,终稿=E 瓦片素材(词表扩展)。"""
d = ImageDraw.Draw(board)
fy0 = y0 + int(cell * (1.0 - FACADE_FRAC))
# 立面基色=墙顶瓦片实测色(E 与南侧墙顶须读作同一堵墙——采样 center 瓦片顶面区)
top = WALLS["center"].convert("RGB").crop((4, 4, 28, 14))
px = list(top.getdata())
wall_top_rgb = tuple(sum(c[i] for c in px) // len(px) for i in range(3))
face = _gd_darkened(wall_top_rgb, 0.16) # 北立面=受光面,略暗于顶面 16% 分层
d.rectangle([x0, fy0, x0 + cell, y0 + cell], fill=face)
for i in range(3): # 横层理(横条纹,禁竖条纹栅栏感——§3.2 高墙条)
ly = fy0 + int((i + 1) * (cell * FACADE_FRAC) / 4.0)
d.line([(x0 + 2, ly), (x0 + cell - 2, ly)], fill=_gd_darkened(face, 0.30), width=1)
for i in range(2): # 层理错缝竖短线(砖缝)
for j in range(3):
vx = x0 + 6 + j * 14 + (i * 7)
vy0 = fy0 + 5 + i * 11
d.line([(vx, vy0), (vx, vy0 + 7)], fill=_gd_darkened(face, 0.34), width=1)
d.rectangle([x0, fy0, x0 + cell, fy0 + 1], fill=_gd_darkened(wall_top_rgb, 0.35)) # 口沿(地面与立面交界=檐口阴线)
d.rectangle([x0, y0 + cell - 1, x0 + cell, y0 + cell], fill=_gd_darkened(face, 0.30)) # 底缘收头 1px(接墙顶北缘亮棱)
def is_facade_cell(gmap, x, y):
"""E 格判定:本格是地面,且南邻(y+1)是墙。连续墙体内部不出立面(立面只露在地格侧)。"""
return (0 <= y + 1 < gmap.h) and gmap.grid[y][x] != "W" and gmap.grid[y + 1][x] == "W"
def lift_south_lip(tile):
"""v3.2:南向墙格(南邻地)瓦片底部 ~40% 立面暗条提亮——(56,45,39) 极暗拉低整格观感,
用户指「墙顶面应同色」:顶面本同色(147≈145),观感差系暗条过暗。背光面仍暗于顶面,但不死黑。
程序化示意,终稿归瓦片重产(§8.5)。"""
im = tile.convert("RGB")
w, h = im.size
y0 = int(h * 0.60)
band = im.crop((0, y0, w, h))
lut = [min(255, int(i * 1.75 + 18)) for i in range(256)]
band = band.point(lut * 3)
out = im.copy()
out.paste(band, (0, y0))
if tile.mode == "RGBA":
out.putalpha(tile.split()[3])
return out.convert("RGBA")
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)
t = t.resize((cell, cell), Image.NEAREST)
if overlays and not is_wall(gmap, x, y + 1): # v3.2 南向墙格立面提亮
t = lift_south_lip(t)
board.paste(t, (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:
for y in range(gmap.h): # v3.1 E 格立面(高墙双格呈现——结构件,先于投影)
for x in range(gmap.w):
if is_facade_cell(gmap, x, y):
draw_facade(board, x * cell, y * cell, cell)
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)
# 数字格(已开;v3 亮墨直画,复杂格底(物件纹样/装饰变体)垫色牌后备——v3.0 原则二;0 格 = 纯地面无字形——F4)
for (dx, dy), dg in sorted(DIGITS.items()):
complex_base = False
if (dx, dy) in INLAY_RESULT:
draw_inlay(board, dx * cell, dy * cell, cell, INLAY_RESULT[(dx, dy)],
corner=1, m=6, L=8) # 物件纹样右上角,砖心留白
complex_base = True
if (dx, dy) == VARIANT_FLOOR_AT:
complex_base = True
if dg > 0:
if complex_base:
draw_digit_plate(board, dx * cell, dy * cell, cell, dg, F_DIGIT_D) # 色牌后备组
else:
draw_digit_direct(board, dx * cell, dy * cell, cell, dg, F_DIGIT_D) # 亮墨直画组
# 物件级装饰演示(v3 解禁——未开格砖缘小石块堆,专属形状家族,零信息)
draw_pebble(board, 8 * cell, 0, cell, seed=11)
draw_pebble(board, 0, 6 * cell and 6 * cell, cell, seed=23) if False else None
draw_pebble(board, 6 * cell, 1 * cell, cell, seed=23)
# 封印旗(未开格,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")
# v3 地台+落影(§6.1 舞台正面目标:石板地台承载,椭圆落影压脚)
sh = Image.new("RGBA", (W, 30), (0, 0, 0, 0))
ImageDraw.Draw(sh).ellipse([28, 6, W - 28, 24], fill=(0, 0, 0, 70))
canvas.alpha_composite(sh, (ox, oy + 146))
d.rectangle([ox + 18, oy + 158, ox + W - 18, oy + 170], fill=(150, 130, 100), outline=(90, 76, 58))
d.rectangle([ox + 18, oy + 158, ox + W - 18, oy + 160], fill=(196, 172, 132))
# 敌名行:护甲类型图标+纯名字(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=(240, 226, 200), 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 全宽(v3 舞台:世界底层暗背景+顶光柱氛围;文字=信息面层暖白) ──
d.rectangle([0, 0, 1280, 252], fill=(62, 52, 42), outline=UIKIT["PANEL_BORDER"])
beam = Image.new("RGBA", (1280, 252), (0, 0, 0, 0))
db = ImageDraw.Draw(beam)
for bx0 in (110, 900, 1055, 1210):
for i in range(60):
a = int(26 * (1 - i / 60.0))
db.rectangle([bx0 - 46 + i // 3, 0, bx0 + 46 - i // 3, 40 + i * 3], fill=(255, 236, 200, a))
scr.alpha_composite(beam, (0, 0))
d.text((640, 14), "第 3 轮 · 挖掘", font=ImageFont.truetype(ZH_FONT, 15), fill=(240, 226, 200), 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=(46, 39, 32))
d.text((10, 240), "── 第 3 轮 ── 地精 受到 4 点伤害(增伤 ×1)· 熊地精 蓄力(重击预告)…", font=F_LBL2,
fill=(240, 226, 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 明度对比(v3:亮墨×实测已开格底 ≥4.5:1 直画承载;色牌后备组深墨×牌维持;图底分离 Δ≥55)
ratios = []
floor_l = wcag_lum((int(FLOORS[0].convert("RGB").getpixel((16, 16))[0]),
int(FLOORS[0].convert("RGB").getpixel((16, 16))[1]),
int(FLOORS[0].convert("RGB").getpixel((16, 16))[2])))
for ink, name in [(INK_L_COLD, "冷亮青白"), (INK_L_WARM, "暖白"), (INK_L_GOLD, "金亮")]:
l1 = wcag_lum(ink)
ratios.append((name, round((max(l1, floor_l) + 0.05) / (min(l1, floor_l) + 0.05), 1)))
plate_ratios = []
for ink, name in [(INK_COLD, "青墨×牌"), (INK_OCHRE, "赭墨×牌"), (INK_RUST, "锈墨×牌")]:
l1, l2 = wcag_lum(ink), wcag_lum(PLATE)
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("A3a", "v3 亮墨×格底 WCAG≥4.5:1(直画可行性硬指标) + 色牌后备组 ≥4.5:1",
all(r[1] >= 4.5 for r in ratios) and all(r[1] >= 4.5 for r in plate_ratios),
"亮墨%s 格底L=%.3f;后备%s" % (ratios, floor_l, plate_ratios))
# A3b 图底分离:v2 阈值 Δ≥55 系色牌制时代校定(当时地板 L0.036);v3 地板提亮至 L 0.08–0.14 带后
# 与墙顶亮度差数学上必缩(墙顶 ~113 封顶,Δ≥55 要求地板 ≤L0.045,与带冲突)——v3.0 验收 2 已立
# 「墙亮/空间暗明度差为合法分类通道」,Δ 候选阈值 ≥30(灰度一眼可分),终值归冻结批校准。
check("A3b", "图底分离读数(候选 Δ≥30——v2 阈值 55 与格底分档带数学冲突,校准归冻结批)",
(wall_top - floor_px) >= 30,
"墙顶 %.0f vs 地面 %.0f,Δ=%.0f(v2 阈值 55 系地板 L0.036 时代校定)" % (wall_top, floor_px, 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 常量
# A4 v3 口径:尺寸硬等系 v2 冻结时资产快照,现行资产已换代(例:bugbear 26×48)——方向稿验
# 「在库原件可载+语义色对表」,尺寸基线重校归冻结批(连带 §8.2 升档方向)。
check("A4", "混排相融(游戏在库资产可载+UiKit 语义色对表;尺寸基线=换代后实态,校准归冻结批)",
HERO_SRC.size[0] > 0 and PORTRAIT_SRC.size[0] > 0,
"小人源 %s(裁透明留白后 %s)/立绘 %s/状态图标原件;INK/封印黑/奖励金 == ui_kit.gd"
% (str(HERO_SRC.size), str(HERO.size), str(PORTRAIT_SRC.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 格无字形;v3 亮墨直画
draw_digit_direct(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_direct(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("=== 样张 v3 方向稿生成 + 机检(参考) ===")
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), "战斗一屏样张方向稿 v3-draft(2026-09-29,art-bible v3.0)——格底分档+亮墨直画+色牌后备+B区看台+E格高墙立面(v3.1);机检 %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("# 样张 v3 方向稿机检报告(2026-09-29,art-bible v3.0——方向稿阶段,机检为参考;冻结闸=目检后机检批)\n\n")
f.write("生成:`python tools/render_sample_sheet_v3.py`;v3.0 口径:格底分档(世界底层档)/亮墨直画/色牌后备/物件级装饰/B 区舞台;断言阈值随冻结批校准。\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)。v3 方向稿机检=可行性参考(A3 亮墨对比为硬指标);冻结 = 用户目检方向稿后机检批(v3.0 闸形:目检先行→机检冻结)。\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())