Files
gplan-2/tools/render_sample_sheet_v3.py
T

1763 lines
95 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(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 冻结产物管线不动。
四项否决原因全部断言化——生成即验,不过闸不出图:
F1 未开格 = 方案甲缘件 8 邻拼装(词表查表,禁整块 center 铺满)
F2 旗仅出现于未开格
F3 小人脚锚 = 自底边 25% 线(⚠ 纵向比例一律自底边口径)
F4 数字 0 格无字形(纯安全格 = 干净裸地面)
F5 微光仅带状:接触带微光只画英雄格周界空间侧;类型微光禁整格染色;微光格零附加图形(角嵌退役)
F6 墙格顶面/立面双明度带(否决③立体感——南向临地墙格 Δ≥18,体积感机器代理)
F7 整板悬崖 DEPTH_PX 与 game 渲染常量同源(board_view.gd 解析——否决③立体感)
程序化审计(§9 初级验收授权):
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)。
词表事实源 = board-tile-vocab-plan-a-2026-09-26.md(已出盘,沿革查 git 提交史);画法参数事实源 =
画法参数四源对照表-2026-09-26.md(四源对表先行产物,已出盘,沿革查 git 提交史)。
输出 = artwork/mockups/sample-batch-v3-final-2026-09-30.png(v3.5 冻结口径的正式产物)
+ 机检报告(中间产物,吸收即死不入 git;断言定值=A3b Δ≥30/A4 尺寸基线)。
用法:python tools/render_sample_sheet_v3.py
python tools/render_sample_sheet_v3.py --gen-assets (本批追加:产出 §5.2/§5.3 四件素材入包)
python tools/render_sample_sheet_v3.py --save-baseline(本批追加:把当前整屏 mockup 存为改前基线)
迭代 43 扩展(2026-10-29 战斗一屏样张批):整屏样张两版出口(1280×800 基座 + 1920×1080 全局等比
1.35×+左右黑边,§8.2 1920 档策略)+ art-bible §1.4 整屏四项断言(S1–S4)+ 战斗表现五项断言(P1–P5)
+ S3b 图类型色板变体第二套 + S5 三组关键帧条 + S6 描边同屏对照与遮挡对照组 + V1 两版尺寸断言
+ V10 改前/改后基线并排对拍;分区几何读 game/data/tables/battle_layout.tres(与游戏侧同源)。
"""
from PIL import Image, ImageDraw, ImageFilter, ImageChops, ImageFont
import os
import sys
import math
import re
import hashlib
from collections import deque
from layout_config import load as load_layout
from pixelize_candidates import STANDEE_SLOT_H # 站桩显示档单点(主角=中型档——art-bible §8.2)
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-v3-final-2026-09-30.png")
OUT_ED1 = os.path.join(ROOT, "artwork", "mockups", "sample_battle_screen_1280x800.png")
OUT_ED2 = os.path.join(ROOT, "artwork", "mockups", "sample_battle_screen_1920x1080.png")
BASELINE_PNG = os.path.join(ROOT, "artwork", "mockups", "sample_battle_screen_baseline_1280.png")
OUT_BEFORE_AFTER = os.path.join(ROOT, "artwork", "mockups", "sample_battle_screen_before_after.png")
OUT_REPORT = os.path.join(ROOT, "reports", "sample-v3-final-机检报告-2026-09-30.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(紫与微光蓝族色相冲突,禁回紫)
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, 14) # 顺位徽标=UiKit.FS_HINT 14(§7.4 清单外 11–13px 提档;镜像实装)
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)
# 裁除透明留白(素材底部 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 投影档(研究件 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(否决①总纲代理,白名单校准)
FACADE_MIN = 18.0 # 顶面/立面双明度带下限(否决③体积感机器代理)
WALL_FLOOR_MIN = 55 # 图底分离下限(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.30 # E 格墙面高度(v3.5 定值 30%)
def draw_facade(board, x0, y0, cell):
"""v3.3 E 格墙面(地面格南邻墙 → 南部承接墙面展示)。
墙面=墙顶同款岩块材质(center 瓦片纹理直接贴,明度 ×0.85 表达垂直面),禁平涂新颜色:
北端=墙脚阴线(与地面交界),南端=檐口亮棱(与墙顶交界,受光棱线)。"""
d = ImageDraw.Draw(board)
fy0 = y0 + int(cell * (1.0 - FACADE_FRAC))
wall_top = WALLS["center"].resize((cell, cell), Image.NEAREST)
face = wall_top.crop((0, 0, cell, cell - fy0 + y0))
# v3.5 ①:(b) 与 (c) 墙顶同色——顶面材质原亮度直贴,零暗档
board.paste(face, (x0, fy0))
d.rectangle([x0, fy0, x0 + cell, fy0 + 1], fill=(52, 42, 32)) # 墙脚阴线(地面/墙面交界)
# 墙面底缘不画任何线——墙面直接接到南侧墙顶(白边会导致两截)
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.3:南向墙格(南邻地)底部暗条改为顶面同款材质轻微暗档(×0.80)——
同一堵墙的顶面材质全板一致,南向厚度面只差明度,不再两截皮。终稿归瓦片重产(§8.5)。"""
im = tile.convert("RGB")
w, h = im.size
y0 = int(h * 0.60)
top_ref = im.crop((0, 0, w, y0)) # 顶面区材质
band_h = h - y0
ref = top_ref.resize((w, band_h)) # 顶面纹理拉伸到暗条区(同材质)
ref = ref.point([min(255, int(i * 0.80)) for i in range(256)] * 3)
out = im.copy()
out.paste(ref, (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——微光格只留微光,零角嵌/箭头/加号附加形)
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)
# 网格线(屏轴对齐,低对比);新 v3.5 ③:(b)-(c) 连接处(上格=地面且南邻墙/下格=墙)无边框
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(1, gmap.h + 1):
x = 0
bottom_edge = (i == gmap.h)
while x < gmap.w:
skip = (not bottom_edge) and (gmap.grid[i][x] == "W" and gmap.grid[i - 1][x] != "W")
x2 = x
while x2 < gmap.w and ((not bottom_edge) and (gmap.grid[i][x2] == "W" and gmap.grid[i - 1][x2] != "W")) == skip:
x2 += 1
if not skip:
d.line([(x * cell, i * cell), (x2 * cell, i * cell)], fill=(0, 0, 0, 45), width=1)
x = x2
# 整板悬崖(v2 立体强化——board_world 同款结构件,F7 同源)
board = draw_slab_cliff(board, cols=gmap.w)
return board, placement, records, band, gmap
# ══ B 区站桩(mockup 1:1——镜像 standee_body.gd 实装定值:血条危险红/条底收口/徽标 FS_HINT/
# 护甲 PNG/名字行素底不压地台/地台件九宫格拉伸;缺位回退与实装同构) ══
_PLAZA_CACHE = {}
_ARMOR_CACHE = {}
def _plaza_tex():
"""地台件 48×12(缺位回退 None——程序化平色镜像实装 fallback)。"""
if "tex" not in _PLAZA_CACHE:
try:
_PLAZA_CACHE["tex"] = Image.open(asset_root(
"game", "assets", "art", "scene", "scene_stage_plaza.png")).convert("RGBA")
except FileNotFoundError:
_PLAZA_CACHE["tex"] = None
return _PLAZA_CACHE["tex"]
def _armor_tex(armor):
"""护甲图标 24×24(缺位回退 None——mockup 画字形,实装画字形占位同构)。"""
if armor not in _ARMOR_CACHE:
try:
_ARMOR_CACHE[armor] = Image.open(asset_root(
"game", "assets", "ui", "icons", "ui_armor_%s.png" % armor)).convert("RGBA")
except FileNotFoundError:
_ARMOR_CACHE[armor] = None
return _ARMOR_CACHE[armor]
def draw_standee(canvas, ox, oy, kind, name, enemy_id, order=1, armor="light",
hp=1.0, intent="", statuses=(), dead=False):
"""站桩 150×176(standee_body.gd 几何):血条+状态行头顶/立绘盒 96px 槽位(显示档居中溢出)/底座/敌名行。"""
d = ImageDraw.Draw(canvas)
W, H = 150, 176
# 头顶:顺位徽标(敌,FS_HINT 14)+血条(填充=危险红 #A8321F 不因敌我分色;条底=HP_BAR_TRACK)
tx = ox + 8
if kind == "enemy":
d.text((tx, oy + 2), "①②③"[order - 1], font=F_ORDER, fill=(224, 184, 96))
tx += 18
bw = W - 16 - (18 if kind == "enemy" else 0)
d.rectangle([tx, oy + 4, tx + bw, oy + 14], fill=(26, 26, 28))
d.rectangle([tx, oy + 4, tx + int(bw * hp), oy + 14], fill=(168, 50, 31))
# 状态图标行(血条正下方,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 槽位(hero 显示档 = STANDEE_SLOT_H 绝对高、宽随源比例——§8.2,居中溢出同 _place_portrait)
pbox_y = oy + 44
if kind == "hero":
disp_h = STANDEE_SLOT_H
disp_w = int(round(PORTRAIT_SRC.width * disp_h / float(PORTRAIT_SRC.height)))
por = PORTRAIT_SRC.resize((disp_w, disp_h), Image.NEAREST)
canvas.alpha_composite(por, (ox + (W - disp_w) // 2, pbox_y + (96 - disp_h) // 2))
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")
# 意图徽标(立绘上半部居中悬浮;镜像实装:PANEL_DATA 素牌垫底——四色墨对牌 4.7–5.8:1)
if intent:
glyph, col = {"damage": ("⚔", (150, 44, 36)), "buff": ("✦", (30, 90, 150)),
"debuff": ("☠", (122, 66, 148)), "charge": ("⚔", (116, 80, 11))}[intent]
ts = d.textbbox((0, 0), glyph, font=F_INTENT)
tw_, th_ = ts[2] - ts[0], ts[3] - ts[1]
d.rectangle([ox + W // 2 - tw_ / 2 - 4, pbox_y + 30 - th_ * 0.75,
ox + W // 2 + tw_ / 2 + 4, pbox_y + 30 + th_ * 0.35], fill=(233, 217, 184))
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))
ped_w = W - 36
ped_x, ped_y = ox + 18, oy + 158
tex = _plaza_tex()
if tex is not None:
canvas.paste(tex.crop((0, 0, 4, 12)).resize((4, 12), Image.NEAREST), (ped_x, ped_y))
canvas.paste(tex.crop((4, 0, 44, 12)).resize((ped_w - 8, 12), Image.NEAREST), (ped_x + 4, ped_y))
canvas.paste(tex.crop((44, 0, 48, 12)).resize((4, 12), Image.NEAREST), (ped_x + ped_w - 4, ped_y))
else:
d.rectangle([ped_x, ped_y, ped_x + ped_w, ped_y + 12], fill=PLAZA_BODY, outline=PLAZA_EDGE)
d.rectangle([ped_x, ped_y, ped_x + ped_w, ped_y + 2], fill=PLAZA_RIM)
# 名字行:整行垫 HP_BAR_BACK 素底(牌底沿=地台顶——文字禁直接压地台 §6.1/§7.2;
# 护甲图标=24×24 PNG 缺位回退字形;几何镜像 standee_body._name_plate_rect)
if kind == "enemy":
itex = _armor_tex(armor)
if itex is not None:
icon_w, icon_draw = 24.0, ("tex", itex)
else:
gtxt = ARMOR_ICONS[armor]
icon_w = d.textbbox((0, 0), gtxt, font=F_NAME)[2]
icon_draw = ("glyph", gtxt, ARMOR_COLORS[armor])
name_w = d.textbbox((0, 0), name, font=F_NAME)[2]
plate_w = icon_w + 3.0 + name_w + 8.0
px0 = ox + (W - plate_w) / 2
py0 = oy + 158 - 26
d.rectangle([px0, py0, px0 + plate_w, py0 + 26], fill=(40, 34, 32))
base_x, base_y = px0 + 4, py0 + 17 # 基线=牌顶+17(镜像 _name_baseline;box.y-27 < 地台顶)
if icon_draw[0] == "tex":
canvas.paste(icon_draw[1].resize((24, 24), Image.NEAREST),
(int(base_x), py0 + 1), icon_draw[1].resize((24, 24), Image.NEAREST))
else:
d.text((base_x, base_y), icon_draw[1], font=F_NAME, fill=icon_draw[2], anchor="ls")
d.text((base_x + icon_w + 3.0, base_y), name, font=F_NAME, fill=(240, 226, 200), anchor="ls")
if dead:
gray = Image.new("RGBA", (W, H), (100, 100, 100, 120))
canvas.alpha_composite(gray, (ox, oy))
CANVAS_W = 1280 # 基座档(art-bible §8.1)
CANVAS_H = 800
_LAYOUT = load_layout()
B_H = _LAYOUT["stage_h"] # B 区高(布局表单点——与游戏侧同源)
C_W = _LAYOUT["info_panel_w"] # C 区宽
D_X = C_W + 4 # D 区左边 = C 区宽 + 列间距
D_W = CANVAS_W - D_X # D 区宽
BAND_DIG = _LAYOUT["log_band_h_dig"] # 明细带收起高
BAND_SETTLE = _LAYOUT["log_band_h_settle"]
AP_H = _LAYOUT["ap_bar_h"] # D 区 AP 条高
def draw_base(canvas_w=CANVAS_W, canvas_h=CANVAS_H, b_h=B_H):
"""样张底板(T1):B/C/D 三区骨架——分区几何全部读布局表 battle_layout.tres;格边长=CELL_D 45
(D 区 10×10 板实比,§8.1)。返回 (img, zones)——zones 供 S/P 断言取样:
b=(0,0,w,b_h) 含明细带;band=明细带收起高;c=(0,b_h+4,C_W,h);d=(D_X,b_h+4,w,h)。"""
img = Image.new("RGBA", (canvas_w, canvas_h), (24, 20, 17, 255))
d = ImageDraw.Draw(img)
c_y = b_h + 4
# B 区(舞台底=世界底层下沿子带 L 0.014–0.06;文字=信息面层暖白)
d.rectangle([0, 0, canvas_w, b_h], fill=(62, 52, 42), outline=UIKIT["PANEL_BORDER"])
beam = Image.new("RGBA", (canvas_w, b_h), (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))
img.alpha_composite(beam, (0, 0))
# 明细带(挖掘期收起——高读布局表)
d.rectangle([0, b_h - BAND_DIG, canvas_w, b_h], fill=(46, 39, 32))
# C 区
d.rectangle([0, c_y, C_W, canvas_h], fill=UIKIT["PANEL_HERO"], outline=UIKIT["PANEL_BORDER"])
# D 区
d.rectangle([D_X, c_y, canvas_w, canvas_h], fill=(34, 29, 24), outline=UIKIT["PANEL_BORDER"])
# D 区顶行工具件(停手/图例/刷新+AP 点阵+RAP——坐标随 C 区宽平移)
dx = D_X + 8
d.rectangle([dx, c_y + 6, dx + 52, c_y + 28], fill=(90, 80, 66), outline=(120, 106, 88))
d.text((dx + 26, c_y + 17), "停手", font=F_LBL2, fill=(232, 222, 200), anchor="mm")
d.rectangle([dx + 58, c_y + 6, dx + 102, c_y + 28], fill=(90, 80, 66), outline=(120, 106, 88))
d.text((dx + 80, c_y + 17), "?图例", font=F_LBL2, fill=(232, 222, 200), anchor="mm")
d.rectangle([dx + 108, c_y + 6, dx + 176, c_y + 28], fill=(90, 80, 66), outline=(120, 106, 88))
d.text((dx + 142, c_y + 17), "刷新 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([dx + 188 + i * 18, c_y + 10, dx + 200 + i * 18, c_y + 22], fill=col)
d.text((dx + 300, c_y + 17), "RAP 6/6", font=F_LBL2, fill=(200, 180, 140), anchor="lm")
zones = {"b": (0, 0, canvas_w, b_h), "band": (0, b_h - BAND_DIG, canvas_w, b_h),
"c": (0, c_y, C_W, canvas_h), "d": (D_X, c_y, canvas_w, canvas_h)}
return img, zones
def render_battle_screen(board_img=None):
"""B/C/D 三区全内容 1280×800 mockup(T1 底板+T2 内容)。返回 (scr, zones, board_origin)。"""
scr, zones = draw_base()
d = ImageDraw.Draw(scr)
b_h = B_H
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")
# 明细带摘要行 + 结算语境 hero 剪影 mini(P2 三语境之一;右端贴装不越缘)
d.text((10, b_h - BAND_DIG // 2), "── 第 3 轮 ── 地精 受到 4 点伤害(增伤 ×1)· 熊地精 蓄力(重击预告)…", font=F_LBL2,
fill=(240, 226, 200), anchor="lm")
mini = HERO.resize((24, int(24 * HERO.size[1] / HERO.size[0])), Image.NEAREST)
scr.alpha_composite(mini, (CANVAS_W - 44, b_h - BAND_DIG))
# ── C 区内容(次级文对齐 §7.2 ≥4.5:1——STONE_DEEP/GOLD_INK 在册墨;y 随 B 区高平移) ──
cy = b_h + 4
d.rectangle([8, cy + 8, C_W - 8, cy + 84], outline=(120, 106, 88), width=1) # 攻击数据框(直角细线)
d.text((16, cy + 20), "攻击 6", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22))
d.text((16, cy + 42), "(+50% 增伤 ×1)", font=F_LBL2, fill=(116, 80, 11))
d.text((16, cy + 62), "护甲 AC 12", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22))
d.text((8, cy + 96), "魔法道具槽(预留)", font=F_LBL2, fill=(85, 77, 65))
for i in range(4):
d.rectangle([8 + i * 40, cy + 112, 8 + i * 40 + 34, cy + 146], outline=(120, 106, 88), width=1)
d.text((8, cy + 160), "本关强化列表(预留)", font=F_LBL2, fill=(85, 77, 65))
# ── D 区主样张板(10×10@45px=450×450+悬崖 8)嵌进工具行之下居中 ──
board_origin = None
if board_img is not None:
bx = D_X + (D_W - board_img.size[0]) // 2
by = cy + AP_H + (CANVAS_H - cy - AP_H - board_img.size[1]) // 2
scr.paste(board_img.convert("RGB"), (bx, by), board_img)
board_origin = (bx, by)
return scr, zones, board_origin
# ══ 机检 ══
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 微光格零附加图形——源码自检:微光段禁 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 图底分离:色牌制时代阈值 Δ≥55 系地板 L0.036 校定;v3 地板提亮至 L 0.08–0.14 带后
# 与墙顶亮度差数学上必缩(墙顶 ~113 封顶,Δ≥55 要求地板 ≤L0.045,与带冲突)——v3.0 验收 2 已立
# 「墙亮/空间暗明度差为合法分类通道」。冻结定值 Δ≥30(方向稿实测 38,
# 灰度一眼可分)。
check("A3b", "图底分离读数(定值 Δ≥30——v3.5 冻结批落定,方向稿实测 38)",
(wall_top - floor_px) >= 30,
"墙顶 %.0f vs 地面 %.0f,Δ=%.0f(v2 阈值 55 系地板 L0.036 时代校定;v3.5 冻结定值 30)" % (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 画布缘净空(说明行/图例溢出永久断言,两处同根因)
edge_hits = edge_content(canvas_img)
check("A5", "画布四缘 2px 净空(零溢出)", edge_hits == 0, "缘带内容像素 %d" % edge_hits)
# A4 混排相融:小人/立绘/状态图标=在库原件;语义色==UiKit 常量
# A4 尺寸基线按现行资产实态固化——立绘 88×92(原稿分辨率)/棋盘小人源 32×32
# 硬等断言落锁(资产升档换代须显式解冻重校,断言失败=预期闸)。
check("A4", "混排相融(在库原件可载+UiKit 语义色对表+尺寸基线冻结:立绘 88×92/小人源 32×32)",
PORTRAIT_SRC.size == (88, 92) and HERO_SRC.size == (32, 32),
"小人源 %s(裁透明留白后 %s)/立绘 %s[冻结 88×92]/状态图标原件;INK/封印黑/奖励金 == ui_kit.gd"
% (str(HERO_SRC.size), str(HERO.size), str(PORTRAIT_SRC.size)))
return all(c[2] for c in CHECKS)
# ══ 整屏四项(S1–S4,art-bible §1.4 整屏样张闸)+战斗表现五项(P1–P5)+护甲对比锚(A7) ══
def _grad_top_cells(scr, frac=100):
"""整屏横向梯度图(步长 2)——首焦唯一性取样用,返回 [(ΔL, x, y)] 降序。"""
rgbp = scr.convert("RGB").load()
w, h = scr.size
grad = []
for y in range(0, h, 2):
for x in range(0, w - 2, 2):
c1, c2 = rgbp[x, y], rgbp[x + 2, y]
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), x, y))
grad.sort(reverse=True)
return grad[:max(1, len(grad) // frac)]
def _mask_maxch_dist(img, box, tol=40):
"""隔离剪影掩码:四角中值底色 + 三通道最大距离阈值(蓝夹克对暖底 maxΔ>40 可掩)。"""
crop = img.crop(box).convert("RGB")
w, h = crop.size
px = crop.load()
corners = [px[1, 1], px[w - 2, 1], px[1, h - 2], px[w - 2, h - 2]]
bg = tuple(sorted(c[i] for c in corners)[1] for i in range(3))
mask = []
for yy in range(h):
for xx in range(w):
c = px[xx, yy]
mask.append(1 if max(abs(c[i] - bg[i]) for i in range(3)) > tol else 0)
return mask, (w, h)
def _resample_mask(mask, size, tw=24, th=24):
w, h = size
return [mask[(ty * h // th) * w + (tx * w // tw)] for ty in range(th) for tx in range(tw)]
def _mask_iou(a, b):
inter = sum(1 for x, y in zip(a, b) if x and y)
uni = sum(1 for x, y in zip(a, b) if x or y)
return (inter / uni) if uni else 0.0
def _region_wcag(img, box):
"""区域 WCAG 相对亮度均值(0–1 线性域——§2 明度带口径;region_luma 是 0–255 加权域不可混用)。"""
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):
tot += wcag_lum(px[xx, yy])
n += 1
return tot / max(1, n)
def run_screen_checks(scr, zones, board_origin, frames_strip, frames_meta, s6_img, s6_meta):
"""整屏样张闸四项 + 战斗表现五项(战斗表现「一屏样张的验收标准」)+护甲图标挂载对比锚。"""
# S1 整屏①全部文字对直接背景 ≥4.5:1(静态对表——对值即图内实际用色;大字档 3:1 由 A3a 承载)
pairs = [("暖白×舞台底", (240, 226, 200), (62, 52, 42)), ("金亮徽标×舞台底", (224, 184, 96), (62, 52, 42)),
("暖白×明细带底", (240, 226, 200), (46, 39, 32)), ("标题墨×C区板", (30, 26, 22), (232, 220, 196)),
("暖白×名字行素底", (240, 226, 200), (40, 34, 32)), ("金深墨×C区板", (116, 80, 11), (232, 220, 196)),
("深化石×C区板", (85, 77, 65), (232, 220, 196)), ("暖白×工具钮", (232, 222, 200), (90, 80, 66))]
ratios = [(n, round((max(wcag_lum(a), wcag_lum(b)) + 0.05) / (min(wcag_lum(a), wcag_lum(b)) + 0.05), 2))
for n, a, b in pairs]
weakest = min(ratios, key=lambda r: r[1])
check("S1", "整屏①全部文字对直接背景 ≥4.5:1(WCAG AA)", all(r[1] >= 4.5 for r in ratios),
"最弱 %s=%.2f;全部 %s" % (weakest[0], weakest[1], ratios))
# S2 整屏②明度三档带 + 世界底层两子带(§2;舞台=实测区域读数,格底=实测板面;WCAG 0–1 域)
stage_l = _region_wcag(scr, (300, 190, 600, 220))
bx, by = board_origin
board_span = 10 * CELL_D + 8
floor_l = _region_wcag(scr, (bx + 2 * CELL_D, by + 3 * CELL_D + 10, bx + 4 * CELL_D, by + 4 * CELL_D - 10))
info_l = wcag_lum(INK_L_GOLD)
mid_l = wcag_lum(PLAZA_BODY)
ok_s2 = (info_l > mid_l > stage_l and 0.08 <= floor_l <= 0.14 and 0.014 <= stage_l <= 0.06
and 0.22 <= mid_l <= 0.45 and (floor_l - stage_l) >= 0.02)
check("S2", "整屏②明度三档带成立(信息>世界中层>世界底层)+世界底层两子带(0.014–0.06/0.08–0.14,间隔≥0.02)",
ok_s2, "信息 %.2f/中(地台) %.2f/底层 舞台 %.3f+格底 %.3f/子带间隔 %.3f"
% (info_l, mid_l, stage_l, floor_l, floor_l - stage_l))
# S3 整屏③首焦唯一(§3.4 整屏延伸:高对比内容量集中单区 ≥55%——挖掘期第一注视点=棋盘区
# 〔battle.md 信息层级〕;候选区=数字区/B 区立绘,C 区规划面与工具行抢焦即违)
top = _grad_top_cells(scr, 10)
bx, by = board_origin
board_span = 10 * CELL_D + 8
b_rect = zones["b"]
c_rect = zones["c"]
e_board = e_b = e_c = e_tool = 0
for g, x, y in top:
if bx <= x < bx + board_span and by <= y < by + board_span:
e_board += g
elif b_rect[0] <= x < b_rect[2] and b_rect[1] <= y < b_rect[3]:
e_b += g
elif c_rect[0] <= x < c_rect[2] and c_rect[1] <= y < c_rect[3]:
e_c += g
elif zones["d"][1] <= y < zones["d"][1] + 30:
e_tool += g
total = e_board + e_b + e_c + e_tool
shares = {"数字区": e_board / max(1, total), "B 区": e_b / max(1, total),
"C 区": e_c / max(1, total), "工具行": e_tool / max(1, total)}
winner = max(shares, key=shares.get)
check("S3", "整屏③首焦唯一(高对比内容量集中单区 ≥55%,无第二焦点竞争;候选=数字区/B 区立绘)",
shares[winner] >= 0.55 and winner in ("数字区", "B 区"),
"能量占比 %s(首焦=%s)" % ({k: round(v, 2) for k, v in shares.items()}, winner))
# S4 整屏④文字禁直接压纹理底(垫容器/罩层):素底牌顶右上角无字形区平底 + 全牌主色占比
# + 牌区零地台板身色(§6.1/§7.2 同文断言的样张侧)+明细带平底
d_ = ImageDraw.Draw(Image.new("RGB", (1, 1)))
name_w = d_.textbbox((0, 0), "地精", font=F_NAME)[2]
plate_w = 24 + 3 + name_w + 8
px0 = 818 + (150 - plate_w) / 2
py0 = 34 + 132
plate_crop = scr.convert("RGB").crop((int(px0), int(py0), int(px0 + plate_w), int(py0) + 26))
all_px = list(plate_crop.getdata())
from collections import Counter as _Counter
main_share = _Counter(all_px).most_common(1)[0]
flat_crop = plate_crop.crop((plate_crop.size[0] - 10, 1, plate_crop.size[0] - 2, 7))
flat_px = list(flat_crop.getdata())
flat_plate = sum(1 for c in flat_px if c == (40, 34, 32)) / max(1, len(flat_px))
rgbp = scr.convert("RGB").load()
plinth_hit = sum(1 for yy in range(int(py0), int(py0) + 26) for xx in range(int(px0), int(px0 + plate_w))
if rgbp[xx, yy][:3] == PLAZA_BODY)
band_flat = region_luma(scr, (700, 232, 1100, 246))
band_expect = sum(w * c for w, c in zip((0.2126, 0.7152, 0.0722), (46, 39, 32)))
check("S4", "整屏④文字禁直接压纹理底(名字行=素底牌平底+牌区零地台板身色+明细带平底)",
flat_plate >= 0.95 and main_share[0] == (40, 34, 32) and main_share[1] >= len(all_px) * 0.6
and plinth_hit == 0 and abs(band_flat - band_expect) < 3,
"牌顶角平底 %.0f%%;主色 %s 占 %.0f%%;牌区地台色命中 %d;明细带 L %.2f"
% (flat_plate * 100, main_share[0], main_share[1] * 100.0 / len(all_px), plinth_hit, band_flat))
# P1 战斗表现①棋盘区零打扰(数字格砖心零战斗元素签名色;数字对比硬指标由 A3a 承载。
# 签名表不含 CONTACT_GLOW——其色与数字冷亮墨 INK_L_COLD 差 <24 不可色分,空间约束由 F5a 承载)
sigs = list(GLOW_GROUPS.values()) + [DANGER_RED]
viol = []
for (dx_, dy_), dg in DIGITS.items():
if dg <= 0:
continue
x0, y0 = bx + dx_ * CELL_D, by + dy_ * CELL_D
m = int(CELL_D * 0.2)
for yy in range(y0 + m, y0 + CELL_D - m, 2):
for xx in range(x0 + m, x0 + CELL_D - m, 2):
c = rgbp[xx, yy][:3]
if any(all(abs(c[i] - s_[i]) <= 24 for i in range(3)) for s_ in sigs):
viol.append(((dx_, dy_), c))
break
check("P1", "战斗表现①棋盘区零打扰(数字格砖心零战斗元素签名色;挖掘期抢焦禁令由 S3 首焦承载)",
not viol, "违例 %s" % viol if viol else "%d 个数字格砖心全净" % len(DIGITS))
# P2 战斗表现②剪影跨舞台通用(灰度轮廓通道——S6 三语境隔离剪影两两 IoU ≥0.35)
masks = [_mask_maxch_dist(s6_img, box) for box in s6_meta["hero_boxes"]]
rm = [_resample_mask(m, s) for m, s in masks]
ious = [round(_mask_iou(rm[0], rm[1]), 2), round(_mask_iou(rm[0], rm[2]), 2), round(_mask_iou(rm[1], rm[2]), 2)]
check("P2", "战斗表现②剪影跨舞台通用(灰度三语境两两 IoU ≥0.35——识别只靠轮廓通道)",
min(ious) >= 0.35,
"舞台×棋盘 %s/舞台×明细 %s/棋盘×明细 %s" % tuple(ious))
# P3 战斗表现③动画方案可读(三组关键帧 ≥3 帧、相邻帧互异、退场末帧=灰化残位非瞬消)
fw, fh = frames_meta["frame_size"]
distinct, death_ok, death_note = True, True, ""
for key, (xs, y0) in frames_meta["strips"].items():
crops = [frames_strip.crop((x, y0, x + fw, y0 + fh)).convert("RGB") for x in xs]
for c1, c2 in zip(crops, crops[1:]):
if list(c1.getdata()) == list(c2.getdata()):
distinct = False
if key == "death":
sat = list(crops[2].convert("HSV").split()[1].getdata())
ink = sum(1 for p in crops[2].getdata() if p != (34, 29, 24))
death_ok = max(sat) <= 90 and ink > 200
death_note = "末帧最大饱和 %d/残位像素 %d" % (max(sat), ink)
check("P3", "战斗表现③动画方案可读(三组关键帧条各 3 帧+相邻帧互异+退场末帧灰化残位禁瞬消)",
distinct and death_ok, death_note or "三组帧互异")
# P4 战斗表现④战斗空间成立(双舞台结构边界在位:C/D 顶缘描边连续+区间净缝+明细带在位)
c_y = zones["c"][1]
def line_hit(y, x0, x1, col, tol=30):
n = hits = 0
for x in range(x0, x1, 4):
n += 1
if all(abs(rgbp[x, y][i] - col[i]) <= tol for i in range(3)):
hits += 1
return hits / max(1, n)
ok_p4 = (line_hit(c_y, 246, 1276, UIKIT["PANEL_BORDER"]) >= 0.9
and line_hit(c_y, 2, 238, UIKIT["PANEL_BORDER"]) >= 0.9
and all(rgbp[x, B_H + 2][:3] == (24, 20, 17) for x in range(250, 1270, 60))
and line_hit(B_H - 2, 250, 1270, (46, 39, 32)) >= 0.9)
check("P4", "战斗表现④战斗空间成立(双舞台边界线在位+区间净缝;视线锚归 S3 首焦唯一)", ok_p4,
"C/D 顶缘描边与净缝读数见 detail")
# P5 战斗表现⑤敌层插入位置可裁(遮挡对照:顶层 数字/印记/小人 可辨、敌像垫底可辨)
ox0, oy0, ox1, oy1 = s6_meta["overlap"]
crop = s6_img.crop((ox0, oy0, ox1, oy1)).convert("RGB")
cpx = crop.load()
def count_near(col, tol=28):
return sum(1 for yy in range(crop.size[1]) for xx in range(crop.size[0])
if all(abs(cpx[xx, yy][i] - col[i]) <= tol for i in range(3)))
cnt = {"数字墨": count_near(INK_L_WARM), "封印黑": count_near(SEAL_BLACK, 12),
"小人蓝": count_near(HERO_BLUE), "敌像灰": count_near((110, 104, 98), 14)}
ok_p5 = cnt["数字墨"] >= 25 and cnt["封印黑"] >= 8 and cnt["小人蓝"] >= 20 and cnt["敌像灰"] >= 10
check("P5", "战斗表现⑤敌层插入位置可裁(遮挡对照组:数字>小人>旗>敌,顶层三件可辨计数达标)", ok_p5,
"计数 %s" % cnt)
# A7 护甲三枚对挂载素底 ≥3:1(§5.3 共用验收——色值=产出件常量,尺寸/装载由 V8/V9 单测承载)
a_mount = wcag_lum((40, 34, 32))
a_ratios = [(k, round((max(wcag_lum(c), a_mount) + 0.05) / (min(wcag_lum(c), a_mount) + 0.05), 2))
for k, c in [("轻甲", ARMOR_LIGHT_COL), ("魔甲", ARMOR_WARD_COL), ("重甲", ARMOR_HEAVY_COL)]]
check("A7", "护甲三枚对挂载底色(名字行素底 #282220)图形对比 ≥3:1(§5.3 共用验收)",
all(r[1] >= 3.0 for r in a_ratios), " %s" % a_ratios)
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 render_s3b(cell=64):
"""S3b 图类型色板变体第二套(§1.4 内容清单「至少两套」的第三套槽位;§4.3 四槽换色示意——
deep_underdark 方向候选:灰黑主调+矿脉金属,§6.3 终值未冻结本套为方向示意)。信息层恒定:
数字亮墨在换色后重画;微光取与 S3 不同组(blast 覆盖另一组互辨)。"""
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, "blast")
board.alpha_composite(gd)
board = draw_slab_cliff(board, cols=gmap.w)
h, s, v = board.convert("HSV").split()
h = h.point(lambda _: 12)
s = s.point(lambda x: min(255, int(10 + x * 0.30)))
v = v.point(lambda x: int(x * 0.90))
out = Image.merge("HSV", (h, s, v)).convert("RGB").convert("RGBA")
draw_digit_direct(out, 2 * cell, 1 * cell, cell, 5, F_DIGIT) # 信息层恒定——换色后重画
return out
def render_keyframes():
"""S5 三组关键帧条(战斗表现「一屏样张的验收标准」内容清单:受击/退场/高光兑现,
每组至少起中末三帧;退场末帧=灰化残位——「死亡禁止瞬消」机器代理;每组标注减动效简化形态)。
返回 (strip, meta)——meta[key]=(行 x 列表, 行 y0),供 P3 断言按 bbox 取样。"""
fw, fh, gap = 110, 116, 16
labels = [("hit", "受击:常态→闪白+火花→回位(减动效=时长减半)"),
("death", "退场:常态→灰化+碎块→塌陷残位(减动效=直达残位;禁瞬消)"),
("highlight", "高光兑现:行→金框强调→定格(减动效=直达终态)")]
row_h = fh + 30
strip = Image.new("RGBA", ((fw + gap) * 3 + gap, row_h * 3 + 6), (24, 20, 17, 255))
d = ImageDraw.Draw(strip)
meta = {"frame_size": (fw, fh), "strips": {}}
def base_frame(sx, sy, col_body, flash=0, sparks=0, shards=(), sink=0, alpha=255, gold=0):
x0, y0 = sx, sy + 4
d.rectangle([x0, y0, x0 + fw, y0 + fh], fill=(34, 29, 24), outline=(120, 106, 88))
cy = y0 + 58 + sink
if gold == 2:
d.rectangle([x0 + 4, y0 + fh - 34, x0 + fw - 4, y0 + fh - 6], fill=(240, 168, 40), outline=(224, 184, 96), width=2)
elif gold == 1:
d.rectangle([x0 + 4, y0 + fh - 34, x0 + fw - 4, y0 + fh - 6], outline=(224, 184, 96), width=2)
else:
d.rectangle([x0 + 4, y0 + fh - 34, x0 + fw - 4, y0 + fh - 6], outline=(120, 106, 88), width=1)
d.text((x0 + 10, y0 + fh - 20), "伤害 4 → 地精", font=F_LBL2, fill=(240, 226, 200))
col = (col_body[0], col_body[1], col_body[2], alpha)
d.text((x0 + fw // 2, cy), "▆", font=F_STANDEE, fill=col, anchor="mm")
for k, (ox_, oy_) in enumerate(shards):
d.rectangle([x0 + fw // 2 + ox_, cy + oy_, x0 + fw // 2 + ox_ + 6, cy + oy_ + 4],
fill=(110, 104, 98, alpha))
if sparks:
for k in range(sparks):
ang = 6.283 * k / sparks
d.ellipse([x0 + fw // 2 + 26 * math.cos(ang) - 2, cy - 14 * math.sin(ang) - 2,
x0 + fw // 2 + 26 * math.cos(ang) + 2, cy - 14 * math.sin(ang) + 2],
fill=(230, 190, 90))
if flash:
d.rectangle([x0 + 1, y0 + 1, x0 + fw - 1, y0 + fh - 1], fill=(255, 255, 255, flash))
xs = [gap + i * (fw + gap) for i in range(3)]
for gi, (key, label) in enumerate(labels):
y0 = 4 + gi * row_h
meta["strips"][key] = (list(xs), y0)
if key == "hit":
base_frame(xs[0], y0, (150, 60, 48))
base_frame(xs[1], y0, (150, 60, 48), flash=120, sparks=6)
base_frame(xs[2], y0, (150, 60, 48))
elif key == "death":
base_frame(xs[0], y0, (150, 60, 48))
base_frame(xs[1], y0, (110, 104, 98), shards=[(-18, 6), (14, 10), (-6, 14)], alpha=200)
base_frame(xs[2], y0, (110, 104, 98), sink=6,
shards=[(-26, 18), (20, 22), (-10, 26), (8, 20), (-2, 30)], alpha=150)
else:
base_frame(xs[0], y0, (150, 60, 48), gold=0)
base_frame(xs[1], y0, (150, 60, 48), gold=1)
base_frame(xs[2], y0, (240, 168, 40), gold=2)
d.text((xs[2] + fw + gap + 4, y0 + 46), label, font=F_LBL2, fill=(225, 215, 195))
return strip, meta
def _outline_underlay(src, outline_rgb):
"""alpha 边缘 1px 外扩描边垫层(主角深墨描边/敌方暖白描边共用)。"""
a = src.split()[3]
dil = a.filter(ImageFilter.MaxFilter(3))
edge = ImageChops.subtract(dil, a)
under = Image.new("RGBA", src.size, outline_rgb + (255,))
under.putalpha(edge)
return under
def render_outline_overlap():
"""S6 剪影跨舞台三语境(P2 灰度取样,隔离平底免污染)+描边同屏对照(主角深墨 (40,19,13)/
敌方暖白族——art-prompts §5.4 描边条款)+护甲三枚展示+遮挡对照组(P5——敌人姿态精灵与
数字/封印印记/小人同格叠放,绘制优先级 数字>小人>旗>敌)。返回 (img, meta)。"""
W, H = 940, 190
img = Image.new("RGBA", (W, H), (24, 20, 17, 255))
d = ImageDraw.Draw(img)
meta = {"hero_boxes": [], "overlap": None, "armor": {}}
# ① 战斗舞台语境:主角立绘显示档(STANDEE_SLOT_H 绝对高、宽随源比例)+深墨描边
por = PORTRAIT_SRC.resize((int(round(PORTRAIT_SRC.width * STANDEE_SLOT_H / float(PORTRAIT_SRC.height))), STANDEE_SLOT_H), Image.NEAREST)
img.alpha_composite(_outline_underlay(por, (40, 19, 13)), (18, 14))
img.alpha_composite(por, (18, 14))
meta["hero_boxes"].append((18, 14, 18 + por.size[0], 14 + por.size[1]))
d.text((18, 166), "主角·战斗舞台语境 描边=深墨 (40,19,13)", font=F_LBL2, fill=(225, 215, 195))
# ② 棋盘格语境:小人 48px 压已开格底
d.rectangle([210, 14, 274, 78], fill=(24, 20, 16))
fl = FLOORS[0].resize((64, 64), Image.NEAREST)
img.paste(fl.convert("RGB"), (210, 14))
h48 = HERO.resize((48, int(48 * HERO.size[1] / HERO.size[0])), Image.NEAREST)
img.alpha_composite(h48, (210 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8))
meta["hero_boxes"].append((210 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8,
210 + (64 - h48.size[0]) // 2 + h48.size[0], 14 + 64 - 8))
d.text((210, 116), "棋盘格语境\n(隔离已开格底)", font=F_LBL2, fill=(225, 215, 195))
# ③ 结算明细带语境:小人 24px 压明细带底
d.rectangle([298, 14, 362, 78], fill=(46, 39, 32))
h24 = HERO.resize((24, int(24 * HERO.size[1] / HERO.size[0])), Image.NEAREST)
img.alpha_composite(h24, (298 + 8, 14 + 52 - h24.size[1]))
meta["hero_boxes"].append((298 + 8, 14 + 52 - h24.size[1], 298 + 8 + h24.size[0], 14 + 52))
d.text((298, 116), "结算明细带语境\n(同剪影 24px)", font=F_LBL2, fill=(225, 215, 195))
# ④ 敌方描边对照:字形多向偏移暖白垫底+本体深朱
for ddx, ddy in [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1)]:
d.text((410 + 32 + ddx, 14 + 32 + ddy), "█", font=F_STANDEE, fill=(240, 226, 200), anchor="mm")
d.text((410 + 32, 14 + 32), "█", font=F_STANDEE, fill=(150, 60, 48), anchor="mm")
d.text((410, 116), "敌方描边=暖白族\n(非黑非深褐)", font=F_LBL2, fill=(225, 215, 195))
# ⑤ 护甲三枚展示(源 24×24 挂 HP_BAR_BACK 素底——工单 §5.3 挂载底色口径)
for i, key in enumerate(["light", "ward", "heavy"]):
ax = 500 + i * 60
d.rectangle([ax, 14, ax + 40, 54], fill=(40, 34, 32))
tex = _armor_tex(key)
if tex is not None:
img.paste(tex, (ax + 8, 22), tex)
meta["armor"][key] = (ax + 8, 22, ax + 8 + 24, 22 + 24)
d.text((ax + 4, 58), {"light": "轻甲", "ward": "魔甲", "heavy": "重甲"}[key], font=F_LBL2, fill=(225, 215, 195))
d.text((500, 116), "护甲三枚 24×24\n挂载底=名字行素底", font=F_LBL2, fill=(225, 215, 195))
# ⑥ 遮挡对照组:敌像(最底)→旗→小人+蓝框→数字(最顶)——P5 断言:顶层三件仍可辨
cx, cy0 = 700, 10
d.rectangle([cx, cy0, cx + 68, cy0 + 68], fill=(24, 20, 16))
img.paste(FLOORS[1].resize((64, 64), Image.NEAREST).convert("RGB"), (cx + 2, cy0 + 2))
d.text((cx + 34, cy0 + 30), "▆", font=ImageFont.truetype(ZH_FONT, 30), fill=(110, 104, 98), anchor="mm")
px_, py_ = cx + 30, cy0 + 12
d.line([(px_, py_), (px_, cy0 + 52)], fill=(222, 210, 186), width=2)
d.polygon([(px_, py_), (px_ + 14, py_ + 4), (px_, py_ + 9)], fill=SEAL_BLACK)
h28 = HERO.resize((24, int(24 * HERO.size[1] / HERO.size[0])), Image.NEAREST)
img.alpha_composite(h28, (cx + 36, cy0 + 40 - h28.size[1]))
d.rectangle([cx + 3, cy0 + 3, cx + 65, cy0 + 65], outline=HERO_BLUE, width=2)
d.text((cx + 14, cy0 + 46), "6", font=ImageFont.truetype(ZH_FONT, 24), fill=INK_L_WARM)
meta["overlap"] = (cx, cy0, cx + 68, cy0 + 68)
d.text((cx, cy0 + 76), "遮挡对照组:数字>小人>旗>敌\n(敌像最底,顶层三件可辨)", font=F_LBL2, fill=(225, 215, 195))
return img, meta
# ══ 素材程序化产出(--gen-assets;art-prompts §5.2/§5.3 工单,色值全取 v3.22 在册值零新增) ══
PLAZA_EDGE = (76, 64, 56) # 描边 #4C4038(§4.1 换色合并登记目标值)
PLAZA_RIM = (201, 175, 131) # 顶缘受光线 #C9AF83(掩尘同枚——合并登记)
PLAZA_BODY = (150, 130, 100) # 板身 #968264(STAND_STONE)
ARMOR_INK = (51, 38, 27) # 图标深墨描边 #33261B(主墨)
ARMOR_LIGHT_COL = (205, 184, 140) # 轻甲 #CDB88C(WARM_FAINT——换色合并⑤)
ARMOR_WARD_COL = (240, 226, 200) # 魔法护甲 #F0E2C8(暖白)
ARMOR_HEAVY_COL = (134, 140, 154) # 重甲 #868C9A(ARMOR_PLATE_COLD)
def _ink_outline_polygon(d, pts, fill, width=3):
"""深墨描边画法:先画 width+2 的墨线再画 width 的面色(=1px 墨缘,工单「1px 深墨描边」)。"""
d.line(pts, fill=ARMOR_INK, width=width + 2)
d.line(pts, fill=fill, width=width)
def gen_scene_plaza():
"""地台件 48×12(§5.2 工艺要点:1px 上描边+2px 顶缘受光线+8px 板身+1px 下描边;左右 1px 全周闭合)。"""
im = Image.new("RGBA", (48, 12), PLAZA_BODY + (255,))
d = ImageDraw.Draw(im)
d.rectangle([0, 0, 47, 11], outline=PLAZA_EDGE + (255,), width=1) # 全周闭合描边
d.rectangle([1, 1, 46, 2], fill=PLAZA_RIM + (255,)) # 顶缘受光线 2px(纵向不缩放区)
return im
def _armor_canvas():
return Image.new("RGBA", (24, 24), (0, 0, 0, 0))
def gen_armor_light():
"""轻甲 = 皮条/布带记号(斜跨皮带+扣件,甲面覆盖率≈0,主体留空——§5.3.1;3px 带宽 1px 墨描边)。"""
im = _armor_canvas()
d = ImageDraw.Draw(im)
_ink_outline_polygon(d, [(5, 18), (18, 6)], ARMOR_LIGHT_COL, width=3) # 斜跨皮带
d.rectangle([9, 9, 14, 14], outline=ARMOR_INK + (255,), width=1) # 皮带扣
d.rectangle([10, 10, 13, 13], fill=ARMOR_LIGHT_COL + (255,))
return im
def gen_armor_ward():
"""魔法护甲 = 符纹饰带(横向符纹带+两枚 ≤3×3 符点,零金属甲板——§5.3.2)。"""
im = _armor_canvas()
d = ImageDraw.Draw(im)
d.rectangle([4, 10, 19, 14], fill=ARMOR_INK + (255,))
d.rectangle([4, 11, 19, 13], fill=ARMOR_WARD_COL + (255,)) # 符纹带
d.rectangle([8, 17, 10, 19], fill=ARMOR_WARD_COL + (255,)) # 符点一(3×3)
d.rectangle([13, 4, 15, 6], fill=ARMOR_WARD_COL + (255,)) # 符点二(3×3)
return im
def gen_armor_heavy():
"""重甲 = 金属甲板覆盖肩与胸(肩两块+胸一块,覆盖率三档最大——§5.3.3)。"""
im = _armor_canvas()
d = ImageDraw.Draw(im)
for box in [(3, 3, 10, 9), (13, 3, 20, 9), (7, 12, 16, 21)]: # 肩×2+胸×1
d.rectangle(list(box), fill=ARMOR_INK + (255,))
d.rectangle([box[0] + 1, box[1] + 1, box[2] - 1, box[3] - 1], fill=ARMOR_HEAVY_COL + (255,))
return im
def gen_batch_assets():
"""产出四件素材并入包:地台件 scene/ + 护甲三枚 ui/icons/(§5.2/§5.3——程序化直出即终稿)。"""
out = []
plaza_path = os.path.join(ROOT, "game", "assets", "art", "scene", "scene_stage_plaza.png")
os.makedirs(os.path.dirname(plaza_path), exist_ok=True)
im = gen_scene_plaza()
im.save(plaza_path)
out.append((plaza_path, im.size, 3))
for key, fn in [("light", gen_armor_light), ("ward", gen_armor_ward), ("heavy", gen_armor_heavy)]:
p = os.path.join(ROOT, "game", "assets", "ui", "icons", "ui_armor_%s.png" % key)
im = fn()
im.save(p)
out.append((p, im.size, 2))
for p, size, ncol in out:
colors = len({c for c in Image.open(p).convert("RGBA").getdata() if c[3] > 0})
print("[gen] %s size=%s 不透明像素域色数=%d" % (p, size, colors))
if size not in ((48, 12), (24, 24)) or colors > 32:
raise RuntimeError("素材产出校验失败: %s" % p)
print("[gen] 四件素材产出完成(§5.2/§5.3 工单口径)")
return out
def main():
if "--gen-assets" in sys.argv:
gen_batch_assets()
return 0
print("=== 样张 v3 方向稿生成 + 机检(参考) ===")
board, placement, records, band, gmap = render_main_board()
canvas = Image.new("RGBA", (1560, 2080), (24, 20, 17, 255))
ok = run_checks(board, placement, records, band, gmap, canvas)
# 三区 mockup(T1 底板 + T2 内容;板面在 render_battle_screen 内嵌装)
scr, zones, board_origin = render_battle_screen(board)
if "--save-baseline" in sys.argv:
os.makedirs(os.path.dirname(BASELINE_PNG), exist_ok=True)
scr.convert("RGB").save(BASELINE_PNG)
print("saved(baseline):", BASELINE_PNG)
return 0
frames_strip, frames_meta = render_keyframes()
s6_img, s6_meta = render_outline_overlap()
run_screen_checks(scr, zones, board_origin, frames_strip, frames_meta, s6_img, s6_meta)
d = ImageDraw.Draw(canvas)
d.text((20, 14), "战斗一屏样张方向稿 v3-draft(2026-09-29,art-bible v3.0;迭代 43 扩展:整屏两版+S1–S4/P1–P5 断言)——机检 %s(方向稿参考,冻结归目检后机检批)" %
("全绿" if ok else "有 FAIL"), col=(232, 222, 200), font=F_TITLE)
canvas.paste(scr, (20, 52))
d.text((20, 866), "三区 mockup(1280×800 实比,分区几何读布局表):B 区站桩(主角立绘 96 朝右/敌字形占位/顺位 FS_HINT/意图素牌/血条=危险红/名行=素底牌+护甲 PNG)",
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 / S3b 图类型色板变体两套(§1.4 内容清单:至少两套)
oy3 = oy2 + 76 + 60
s3 = render_s3()
canvas.paste(s3, (20, oy3))
s3b = render_s3b()
canvas.paste(s3b, (320, oy3))
d.text((630, oy3 + 8), "S3 图类型换色对照(underdark 冷调,§4.3 四槽换色示意):\n信息层恒定——亮墨三档/微光五组/封印黑/危险红同 S2",
col=(225, 215, 195), font=F_LBL)
d.text((630, oy3 + 78), "S3b 变体第二套(deep_underdark 方向候选:灰黑+矿脉金属;\n§6.3 终值未冻结,方向示意)——两套并陈满足 §1.4「至少两套」",
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, 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)
d.text((20, oy4 + 82), "整屏四项 S1–S4 + 战斗表现五项 P1–P5 + A7 护甲对比 + V1 两版出口/V10 基线并排——详见机检报告;"
"全过=机检冻结,气质终审=用户目检(方向稿目检先行)", col=(225, 215, 195), font=F_LBL2)
# S5 三组关键帧条 / S6 剪影三语境+描边对照+遮挡对照组
oy5 = oy4 + 104
canvas.paste(frames_strip, (20, oy5))
d.text((430, oy5 + 8), "S5 三组关键帧条(受击/退场/高光兑现,各起中末三帧;\n退场末帧=灰化残位——禁瞬消;每组标注减动效简化形态)", col=(225, 215, 195), font=F_LBL)
oy6 = oy5 + frames_strip.size[1] + 26
canvas.paste(s6_img, (20, oy6))
d.text((970, oy6 + 8), "S6 剪影跨舞台三语境(P2 灰度取样)+描边同屏对照\n(主角深墨/敌方暖白族)+护甲三枚展示+遮挡对照组(P5)", col=(225, 215, 195), font=F_LBL)
# 两版样张出口(V1)+改前/改后并排(V10)
os.makedirs(os.path.dirname(OUT_ED1), exist_ok=True)
canvas.convert("RGB").save(OUT_PNG)
print("saved:", OUT_PNG)
scr.convert("RGB").save(OUT_ED1)
print("saved:", OUT_ED1)
scale = 1080.0 / scr.size[1]
w2 = int(round(scr.size[0] * scale))
ed2 = Image.new("RGB", (1920, 1080), (0, 0, 0))
ed2.paste(scr.convert("RGB").resize((w2, 1080), Image.NEAREST), ((1920 - w2) // 2, 0))
ed2.save(OUT_ED2)
print("saved:", OUT_ED2)
check("V1", "样张两版产出(1280×800 基座 + 1920×1080=全局等比 1.35×+左右黑边,§8.2 1920 档策略)",
os.path.exists(OUT_ED1) and os.path.exists(OUT_ED2)
and Image.open(OUT_ED1).size == (1280, 800) and Image.open(OUT_ED2).size == (1920, 1080),
"ed1 %s / ed2 %s / 黑边各 %dpx" % (Image.open(OUT_ED1).size, Image.open(OUT_ED2).size, (1920 - w2) // 2))
has_base = os.path.exists(BASELINE_PNG)
if has_base:
ba = Image.new("RGB", (2570, 810), (24, 20, 17))
base_im = Image.open(BASELINE_PNG).convert("RGB")
ba.paste(base_im, (5, 5))
ba.paste(Image.open(OUT_ED1).convert("RGB"), (1285 + 5, 5))
ba.save(OUT_BEFORE_AFTER)
print("saved:", OUT_BEFORE_AFTER)
check("V10", "改前/改后并排对拍图产出(基线版与改后版两源样张尺寸一致+并排图生成)",
has_base and Image.open(BASELINE_PNG).size == (1280, 800) and os.path.exists(OUT_BEFORE_AFTER),
"baseline %s / ed1 1280×800 / 并排 %s" % (Image.open(BASELINE_PNG).size if has_base else "缺(先 --save-baseline)", OUT_BEFORE_AFTER))
# A5b 终稿净空(装配后画布——A5 原断言跑在空白底板上,此处补实际成图净空)
edge_hits_final = edge_content(canvas)
check("A5b", "装配成稿四缘 2px 净空(实际画幅,零溢出)", edge_hits_final == 0, "缘带内容像素 %d" % edge_hits_final)
ok_all = all(c[2] for c in CHECKS)
# 机检报告
os.makedirs(os.path.dirname(OUT_REPORT), exist_ok=True)
with open(OUT_REPORT, "w", encoding="utf-8") as f:
f.write("# 样张 v3 机检报告(2026-09-30 批 A 断言定值 + 迭代 43 整屏扩展 S1–S4/P1–P5/V1/V10/A7/A5b)\n\n")
f.write("生成:`python tools/render_sample_sheet_v3.py`;v3.5 冻结口径:格底分档/亮墨直画三档/色牌后备/物件级装饰/B 区舞台;A3b 图底分离 Δ≥30;A4 尺寸基线(立绘 46×48/小人源 32×32 硬等)。"
"迭代 43 扩展:B 区高读布局表出样;整屏四项与战斗表现五项断言落 CHECKS;两版样张出口与基线并排对拍。方向稿目检先行,机检冻结归目检通过后。\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)。\n" % ("全绿" if ok_all else "有红灯", sum(1 for c in CHECKS if c[2]), len(CHECKS)))
print("saved:", OUT_REPORT)
print("=== 机检结论: %s (%d/%d) ===" % ("全绿" if ok_all else "FAIL", sum(1 for c in CHECKS if c[2]), len(CHECKS)))
return 0 if ok_all else 1
if __name__ == "__main__":
sys.exit(main())