1748 lines
94 KiB
Python
1748 lines
94 KiB
Python
# -*- 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 改前/改后基线并排对拍;B 区高按代码现状 252 出样(art-bible §8.2 文 270 差异只登记不修)。
|
||
"""
|
||
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-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(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")
|
||
# 意图徽标(立绘上半部居中悬浮;镜像实装: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))
|
||
|
||
|
||
B_H = 252 # B 区高=代码现状(battle_stage_view custom_minimum_size 252;§8.2 文 270 差异只登记不修)
|
||
CANVAS_W = 1280 # 基座档(art-bible §8.1)
|
||
|
||
|
||
def draw_base(canvas_w=CANVAS_W, canvas_h=800, b_h=B_H):
|
||
"""样张底板(T1):B/C/D 三区骨架——分区数值按 battle.md 指路、B 区按代码 252;格边长=CELL_D 45
|
||
(D 区 10×10 板实比,§8.1)。返回 (img, zones)——zones 供 S/P 断言取样:
|
||
b=(0,0,w,b_h) 含明细带;band=明细带 24px;c=(0,b_h+4,240,h);d=(244,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))
|
||
# 明细带(挖掘期收起 24px)
|
||
d.rectangle([0, b_h - 24, canvas_w, b_h], fill=(46, 39, 32))
|
||
# C 区
|
||
d.rectangle([0, c_y, 240, canvas_h], fill=UIKIT["PANEL_HERO"], outline=UIKIT["PANEL_BORDER"])
|
||
# D 区
|
||
d.rectangle([244, c_y, canvas_w, canvas_h], fill=(34, 29, 24), outline=UIKIT["PANEL_BORDER"])
|
||
# D 区顶行工具件(停手/图例/刷新+AP 点阵+RAP)
|
||
d.rectangle([252, c_y + 6, 304, c_y + 28], fill=(90, 80, 66), outline=(120, 106, 88))
|
||
d.text((278, c_y + 17), "停手", font=F_LBL2, fill=(232, 222, 200), anchor="mm")
|
||
d.rectangle([310, c_y + 6, 354, c_y + 28], fill=(90, 80, 66), outline=(120, 106, 88))
|
||
d.text((332, c_y + 17), "?图例", font=F_LBL2, fill=(232, 222, 200), anchor="mm")
|
||
d.rectangle([360, c_y + 6, 428, c_y + 28], fill=(90, 80, 66), outline=(120, 106, 88))
|
||
d.text((394, 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([440 + i * 18, c_y + 10, 452 + i * 18, c_y + 22], fill=col)
|
||
d.text((552, 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 - 24, canvas_w, b_h),
|
||
"c": (0, c_y, 240, canvas_h), "d": (244, 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, 240), "── 第 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, (1236, b_h - 24))
|
||
# ── C 区内容(次级文对齐 §7.2 ≥4.5:1——STONE_DEEP/GOLD_INK 在册墨) ──
|
||
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=(116, 80, 11))
|
||
d.text((16, 318), "护甲 AC 12", font=ImageFont.truetype(ZH_FONT, 15), fill=(30, 26, 22))
|
||
d.text((8, 352), "魔法道具槽(预留)", font=F_LBL2, fill=(85, 77, 65))
|
||
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=(85, 77, 65))
|
||
# ── D 区主样张板(10×10@45px=450×450+悬崖 8)嵌进工具行之下居中 ──
|
||
board_origin = None
|
||
if board_img is not None:
|
||
bx = 244 + (1036 - board_img.size[0]) // 2
|
||
by = b_h + 4 + 34 + (800 - b_h - 4 - 34 - 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 尺寸基线按现行资产实态固化——立绘 46×48/棋盘小人源 32×32
|
||
# 硬等断言落锁(资产升档换代须显式解冻重校,断言失败=预期闸)。
|
||
check("A4", "混排相融(在库原件可载+UiKit 语义色对表+尺寸基线冻结:立绘 46×48/小人源 32×32)",
|
||
PORTRAIT_SRC.size == (46, 48) and HERO_SRC.size == (32, 32),
|
||
"小人源 %s(裁透明留白后 %s)/立绘 %s[冻结 46×48]/状态图标原件;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": {}}
|
||
# ① 战斗舞台语境:主角立绘 96px + 深墨描边
|
||
por = PORTRAIT_SRC.resize((96, 96), 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 + 96, 14 + 96))
|
||
d.text((18, 116), "主角·战斗舞台语境\n描边=深墨 (40,19,13)", font=F_LBL2, fill=(225, 215, 195))
|
||
# ② 棋盘格语境:小人 48px 压已开格底
|
||
d.rectangle([150, 14, 214, 78], fill=(24, 20, 16))
|
||
fl = FLOORS[0].resize((64, 64), Image.NEAREST)
|
||
img.paste(fl.convert("RGB"), (150, 14))
|
||
h48 = HERO.resize((48, int(48 * HERO.size[1] / HERO.size[0])), Image.NEAREST)
|
||
img.alpha_composite(h48, (150 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8))
|
||
meta["hero_boxes"].append((150 + (64 - h48.size[0]) // 2, 14 + 64 - h48.size[1] - 8,
|
||
150 + (64 - h48.size[0]) // 2 + h48.size[0], 14 + 64 - 8))
|
||
d.text((150, 116), "棋盘格语境\n(隔离已开格底)", font=F_LBL2, fill=(225, 215, 195))
|
||
# ③ 结算明细带语境:小人 24px 压明细带底
|
||
d.rectangle([238, 14, 302, 78], fill=(46, 39, 32))
|
||
h24 = HERO.resize((24, int(24 * HERO.size[1] / HERO.size[0])), Image.NEAREST)
|
||
img.alpha_composite(h24, (238 + 8, 14 + 52 - h24.size[1]))
|
||
meta["hero_boxes"].append((238 + 8, 14 + 52 - h24.size[1], 238 + 8 + h24.size[0], 14 + 52))
|
||
d.text((238, 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((350 + 32 + ddx, 14 + 32 + ddy), "█", font=F_STANDEE, fill=(240, 226, 200), anchor="mm")
|
||
d.text((350 + 32, 14 + 32), "█", font=F_STANDEE, fill=(150, 60, 48), anchor="mm")
|
||
d.text((350, 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 = 470 + 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((470, 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 区按代码 252):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 区按代码 252 出样;整屏四项与战斗表现五项断言落 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())
|