feat(tools): B段机检三件落地 + 逐键定档硬闸升级(立绘定档批)

B1 standee_asset_check 硬闸升级(圣经 §8.3③ 挂点规格落地):
- 逐键断言档高(原「高恒48」退役)——档高表改为 import pixelize_candidates.
  STANDEE_TIER_H,守定值单点纪律,检查器内不复写第二份数字;
- 宽度约束由虚设区间「1..75」改为物理约束「源宽×2 ≤ 站桩盒宽150」
  (standee_body.gd:29 box.x=150 / :237 scale=2.0);
- 色数 ≤32,**排除全透明像素**——naive set统计会把 (0,0,0,0) 计成一色
  ⇒ 报33 假红灯(2026-10-07 实踩),口径已固化进docstring;
- 新增描边亮度断言(character-design §10 亮描边暖白族/禁黑描边),
  阈值 40% 由 _tmp_probe_outline.py 实测标定(敌人实测 54.1–84.3%);
  主角三态实测≈0.4% 属已知缺口,本闸豁免不判红,另立素材批修。
- 负向验证:喂旧 48 档 ⇒ 8 件红灯(逐键档高+2 件描边不足),闸确有效。
- 新增 --json 输出。

B2 palette_check 新建:§4.1 新色双门执法器。**两处口径订正写进 docstring**:
① 48 色上限治的是设计色板 token,不是每张 PNG 色数(初版误套⇒12/12 假红);
② ≥8/255 近色门只管「新增色 vs 既有色板」,不是存量两两互查
  (初版误查存量⇒ drider22 组/巨蜘蛛 82 组噪声)。无 --baseline 时只读不判红。

B3 path_check 新建:asset-path-map 三层核对(目录/文件/res://引用/退役命名)。
口径订正:① res:// 根 = game/ 目录(project.godot 处),非仓库根;
② printf 路径模板(terrain_%s_%d)非字面路径,跳过;
③ 退役命名只扫代码+素材清单,且命中行含「退役/更正/改判」等记述词则豁免
  (初版全仓扫 .md ⇒ active.md/记忆文件里的历史记述全被误判,64 假红);
④ 素材环境三态区分(full/partial/empty)——worktree 素材靠人工拷贝同步,
  partial 态缺项判「未同步」而非「悬空」(character/ 已拷、fx|board|ui 未拷)。
- 首跑读数:主区 1 处真问题 = art-bible §8.5 参考核验条仍指废路径
  production/mockups/(v3.9 批更正了 4 处,此条漏网)——本批已修。

A0/A1 实测读数归档:八敌新档逐键命中 STANDEE_TIER_H(36/38/46/58/62/74/90/94),
主角三态 md5 与旧档逐字节相同(主角档零漂移实证)。
This commit is contained in:
DKChan committed 2026-10-07 18:39:31 +08:00
1 parent 14c9e59073
commit b163341915
5 files changed
+585 -41

No files matched your search

+1 -1
View File
@@ -479,7 +479,7 @@
> **白话**:格子读感里的立体结构——顶面、缘件、角件、周界轮廓——必须是素材里**画出来的结构件**;素材缺件就停下报缺,拿调色/渐变/滤镜顶替不是变通,是违规。
- **结构件完整性**:格面立体感(高墙顶面/坡缘/角件/周界轮廓)只能来自**素材本体绘制的结构件拼装**(样例=Necesse 式缘件:专属底色+手绘细节+暗轮廓线的独立贴图);禁止以调色、渐变、明暗滤镜、跨格投影等生成手段伪造素材中不存在的结构。样稿暴露缺件时,正确动作=停下呈报缺口与「换素材/自产缺件」选项,由用户裁决后执行。
- **参考核验**:宣称「参考 X 游戏」前,必须取得并实际查看 X 的截图证据(像素级放大核验其实现方式),证据存 `production/mockups/` 或 `design/art/references/`;仅凭文字资料、记忆或宏观印象作出「X 是这样做」的断言无效。
- **参考核验**:宣称「参考 X 游戏」前,必须取得并实际查看 X 的截图证据(像素级放大核验其实现方式),证据存 `artwork/mockups/` 或 `design/art/references/`;仅凭文字资料、记忆或宏观印象作出「X 是这样做」的断言无效。(2026-10-07 路径核对器 B3 首跑发现更正——原 `production/mockups/` 系废路径,v3.9 批已更正asset-path-map 等四处,本条漏网)
- **图底分离**:已开地面的明度须低于未开格墙体,由素材选型承载(选更暗的地板砖),不靠渲染压暗实现。
- **生产分工(2026-09-26 用户拍板)**:格面地形类素材=**材质归模型、结构归程序**——AI 生图模型只出无缝填充纹理(无边框/无亮边/均匀光照/方形);一切结构件(缘件/轮廓/立面/角件/收头)一律程序化绘制或手修,因为 autotile 任意拼接的边界像素约束超出 2D 生图模型能力(实证:2026-09-25 生图 5×5 瓦片表每枚砖自带啃咬边框与宽亮带,提示词无法根除)。**镜像规则**:东西对称结构件只许水平翻转(flip_h)复用、**禁旋转**——北受光下东西侧缘光照对称,镜像无损真;旋转会把北受光转到东边。
@@ -0,0 +1,20 @@
=== ./_tmp_standee_new ===
character_enemy_bugbear.png 51x62 colors=32 opaque=2145
character_enemy_drider.png 67x90 colors=33 opaque=3513
character_enemy_duergar.png 34x46 colors=33 opaque=1014
character_enemy_duergar_king.png 66x94 colors=33 opaque=4385
character_enemy_giant_spider.png 48x38 colors=33 opaque=1389
character_enemy_goblin.png 36x36 colors=31 opaque=828
character_enemy_hook_horror.png 46x58 colors=33 opaque=1538
character_enemy_ogre_chief.png 62x74 colors=33 opaque=2886
character_hero_portrait.png 46x48 colors=32 opaque=1228
[口径澄清 · 2026-10-07]
上表 colors 列由 _tmp_baseline.py 统计,含全透明色 (0,0,0,0),故显示 33。
剔除透明色后的真实唯一色数(_tmp_palette.py 实测,uniq_rgb):
bugbear 31 / drider 32 / duergar 32 / duergar_king 32 / giant_spider 32
goblin 30 / hook_horror 32 / ogre_chief 32 / hero 31
=> C2「色数 ≤32」全档通过,无新增色值。
opaque 面积与 pixelize_candidates.py 注释里的实测校准值逐键吻合
(828/1014/1389/1538/2145/2886/3513/4385),可证档位口径与讨论批定案一致。
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""B2 色板检查器(圣经 §8.3①挂点实现 · 立绘定档批 2026-10-07)。
⚠⚠ 口径纪律(2026-10-07 实跑后订正,**勿改回**)——两处极易写错:
1. **48 色上限不是「每张 PNG 的色数」**。§4.1 的「全作色板硬上限 48 色」指
**设计色板 token**(UI 语义色/墨色/材质色那套),不是像素画的独立色数。
像素画单张另有**32 色冻结**口径,由 standee_asset_check 管(像素域)。
初版把 48 套到每张 PNG 上⇒ 12 张全红(误报率 100%),已作废。
本器改为:单张色数**只报读数不判红**;48 只在 --tokens 模式下校验色板表。
2. **近色 ≥8/255 是「新色入板双门」,不是存量两两互查**。§4.1 原文:
「**新色**入板须同时过两道门……②与既有任一色的 RGB 三通道差 ≥8/255」。
它管的是**新增色 vs 既有色板**。对存量色两两互查会把 AI 生成的明暗渐变
全部打成近色(实测 drider 22 组 / 巨蜘蛛 82 组)⇒ 噪声淹没真问题。
本器改为:近色**只对新增色集**判(新增色 vs 基线色板)。
另:必须排除全透明像素 (0,0,0,0)(同standee_asset_check 的坑)。
用法:
# 主用法:扫目录,新增色相对 --baseline 判定
python tools/palette_check.py <目录> --baseline <基线目录>
# 无基线:只出读数(单张色数/ 近色对计数),不判红
python tools/palette_check.py <目录>
退出码: 0 通过 / 1 有红灯(仅新增色违规或超单张上限时)
"""
import os
import sys
from PIL import Image
# §4.1 硬上限(**设计色板 token 口径**,非像素画单张色数)
TOKEN_MAX = 48
NEAR_TOL = 8 # §4.1 新色双门:与既有任一色三通道差 ≥8/255
# 像素画单张色数(像素域32 色冻结)——由 standee_asset_check 执法,
# 本器只作读数提示,避免两处口径打架
PIXEL_COLOR_SOFT_MAX = 32
def palette_of(path):
"""返回 (不透明像素唯一色集合, 像素数)。排除全透明。"""
im = Image.open(path).convert("RGBA")
px = im.load()
w, h = im.size
cols = set()
n = 0
for y in range(h):
for x in range(w):
c = px[x, y]
if c[3] == 0:
continue
cols.add(c[:3])
n += 1
return cols, n
def nearest_to(col, pool, tol):
"""返回 col 在 pool 中最近的色及其差值(差 < tol 算近色)。"""
best, bd = None, 999
for c in pool:
d = max(abs(col[k] - c[k]) for k in range(3))
if d < bd:
best, bd = c, d
return best, bd
def hexs(c):
return "#%02X%02X%02X" % c
def scan(d, baseline_cols, report_only):
files = []
for dirpath, _, names in os.walk(d):
for fn in sorted(names):
if fn.lower().endswith(".png"):
files.append(os.path.join(dirpath, fn))
files.sort()
print("=== %s(%d 张 PNG)===" % (d, len(files)))
n_fail = 0
all_new = set()
for p in files:
cols, npx = palette_of(p)
rel = os.path.relpath(p, d)
notes = []
msgs = []
over = len(cols) > PIXEL_COLOR_SOFT_MAX
notes.append("色%d%s" % (len(cols), "(>32 像素域冻结)" if over else ""))
new = set()
if baseline_cols is not None:
new = cols - baseline_cols
if new:
# §4.1 新色双门②:新增色须与既有任一色差 ≥8/255
bad = []
for c in sorted(new):
near, d3 = nearest_to(c, baseline_cols, 999)
if d3 < NEAR_TOL:
bad.append((c, near, d3))
all_new |= new
notes.append("新增%d" % len(new))
for c, near, d3 in bad[:5]:
msgs.append("新增色 %s 与既有 %s 三通道差 %d < %d"
"(§4.1 新色双门②,须走换色合并)"
% (hexs(c), hexs(near), d3, NEAR_TOL))
if len(bad) > 5:
msgs.append("…… 另有 %d 个新增色同样不过门" % (len(bad) - 5))
else:
notes.append("新增0")
line = "FAIL " if msgs else ("WARN " if (report_only and over) else "PASS ")
print("%s %-46s %s" % (line, rel, " ".join(notes)))
for m in msgs:
print(" └─ %s" % m)
n_fail += 1 if msgs else 0
print("小计:%d 张,%d 张有红灯\n" % (len(files), n_fail))
return n_fail, len(files), all_new
def main():
args = sys.argv[1:]
baseline = args[args.index("--baseline") + 1] if "--baseline" in args else None
dirs = [a for a in args if not a.startswith("--")
and os.path.isdir(a) and a != baseline]
if not dirs:
print(__doc__)
sys.exit(2)
baseline_cols = None
if baseline:
baseline_cols = set()
for dirpath, _, names in os.walk(baseline):
for fn in names:
if fn.lower().endswith(".png"):
cols, _ = palette_of(os.path.join(dirpath, fn))
baseline_cols |= cols
print("基线色板:%s,唯一色 %d" % (baseline, len(baseline_cols)))
total_fail = total_files = 0
total_new = set()
report_only = baseline_cols is None
for d in dirs:
f, n, new = scan(d, baseline_cols, report_only)
total_fail += f
total_files += n
total_new |= new
print("色板机检完成:%d 张 PNG,%d 张有红灯,累计新增色 %d"
% (total_files, total_fail, len(total_new)))
if report_only:
print("提示:未给 --baseline ⇒ 本次为只读模式(不判红)。"
"『色数>32』标 WARN 属像素域提示,最终执法在 standee_asset_check。")
sys.exit(1 if total_fail else 0)
if __name__ == "__main__":
main()
+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""B3 路径核对器(圣经 §8.3② 挂点实现 · 立绘定档批 2026-10-07)。
职责:扫描 asset-path-map.md 声明的全部路径 + 代码侧素材常量,
报「悬空(声明存在但盘上无)/ 退役未清(仍引用已退役命名)」。
为什么需要(2026-10-06 实证三处悬空):素材不入 git,路径错了不会报错,
只会在实机静默回退(UiKit.load_tex 的 exists 守卫)⇒ 肉眼难发现。
三层校验:
L1 目录级 asset-path-map §1 声明的路径存在性(candidates 按需创建豁免)
L2 文件级 §3 game/assets/art 语义子目录现役清单逐件存在性
L3 代码级 grep 代码里的 res://assets/art 引用,核对盘上是否存在
+ 检出已知退役命名(board_floor/board_wall/pillar_dungeon_*)
用法:
python tools/path_check.py [--repo <仓库根>] [--code <代码根>]
退出码: 0 通过 / 1 有红灯
"""
import os
import re
import sys
# asset-path-map §1 目录分工总表(逐条对应文档,勿复写第二份事实源)
DECLARED_DIRS = [
# (路径, 角色, 按需创建豁免)
("design/art/candidates", "设计侧源档(档案库,游戏禁引用)", True),
("game/assets/art", "运行时素材唯一落地处", False),
("game/assets/ui/icons", "UI 图标(唯一入 git 素材目录)", False),
("artwork/mockups", "混合样张/评审产物", False),
]
# asset-path-map §3 game/assets/art 语义子目录
DECLARED_SUBDIRS = ["character", "fx", "board", "ui"]
# §3 现役清单(文件名按 §8.3 域键式)
ACTIVE_FILES = [
"character/character_hero_idle.png",
"character/character_hero_walk.png",
"character/character_hero_portrait.png",
"fx/shovel_swing_f0.png",
"fx/shovel_swing_f1.png",
"fx/shovel_swing_f2.png",
"board/flag_seal.png",
]
# 立绘八敌按数据表键生成(键集=STANDEE_TIER_H,见 standee_asset_check)
ENEMY_KEYS = ("bugbear", "drider", "duergar", "duergar_king", "giant_spider",
"goblin", "hook_horror", "ogre_chief")
ACTIVE_FILES += ["character/character_enemy_%s.png" % k for k in ENEMY_KEYS]
# asset-path-map v3.9 已退役命名(检出即红)
RETIRED_PATTERNS = [
(re.compile(r"board_floor"), "board_floor 命名已退役(→terrain_floor_<board_type>_<n>)"),
(re.compile(r"board_wall"), "board_wall 命名已退役"),
(re.compile(r"pillar_dungeon"), "pillar_dungeon_* 命名已退役"),
(re.compile(r"production/mockups"), "production/mockups 为废路径(→artwork/mockups)"),
]
# 路径末尾要允许 printf 转换符(%s/%d/%0Nd)——否则正则会在 '%' 处截断,
# 拿到 "res://assets/art/board/terrain_" 这种半截串,既不像模板也不像真实
# 路径,两边都判错。初版即栽在此处(2 处假悬空),现让 %s/%d 一并吃掉。
RES_RE = re.compile(r"res://assets/art/[A-Za-z0-9_/\.\-%]+")
# ⚠ 路径模板(printf 式)不是可解析的真实路径——常量里存的是
# "res://assets/art/board/terrain_%s_%d.png" 这类格式串,盘上当然没有
# 字面同名文件。初版未识别⇒ 假悬空。识别到 % 转换符即跳过(模板有独立的
# 运行时守卫与 test 覆盖,不在本器执法面)。
FMT_MARKERS = ("%s", "%d", "%0", "{")
def is_path_template(ref):
return any(m in ref for m in FMT_MARKERS)
def main():
argv = sys.argv[1:]
repo = "."
code = "game"
if "--repo" in argv:
repo = argv[argv.index("--repo") + 1]
if "--code" in argv:
code = argv[argv.index("--code") + 1]
problems = []
notes = []
# ⚠ worktree 素材环境判定(素材不入 git ⇒ worktree 素材靠人工拷贝同步,
# 常见三种态,须区分否则满屏假红):
# full —— 主区形态,art 下素材齐全,全量校验
# partial —— worktree 常态:character/ 被拷进来了(跑测试三件套惯例),
# fx/board/ui 没拷 ⇒ 缺的那些是「未同步」不是「悬空」
# empty —— art 下 PNG 总数为 0,素材整体未同步
# 只有 full 态才把缺失判红灯;partial/empty 判 SKIP 并提示同步。
artdir = os.path.join(repo, "game", "assets", "art")
present = [s for s in DECLARED_SUBDIRS
if os.path.isdir(os.path.join(artdir, s))]
png_total = sum(1 for dp, _, ns in os.walk(artdir)
for n in ns if n.lower().endswith(".png"))
if len(present) == len(DECLARED_SUBDIRS):
env = "full"
elif png_total == 0:
env = "empty"
else:
env = "partial"
synced = env != "full"
if synced:
notes.append("素材环境 = **%s**(子目录就位 %d/%d,PNG 共 %d)—— "
"worktree 素材靠人工拷贝同步,缺项属未同步非悬空"
% ({"partial": "部分同步", "empty": "空"}[env],
len(present), len(DECLARED_SUBDIRS), png_total))
notes.append("缺失子目录:%s(如需完整校验请对**主区**跑本器)"
% ("、".join(s for s in DECLARED_SUBDIRS
if s not in present) or "无"))
else:
notes.append("素材环境 = 完整(主区形态),全量校验")
print("=== L1 目录级(asset-path-map §1)===")
for rel, role, exempt in DECLARED_DIRS:
p = os.path.join(repo, rel)
ok = os.path.isdir(p)
if ok:
print("OK %-30s %s" % (rel, role))
elif exempt:
print("SKIP %-30s 按需创建豁免(文档已注非作废)" % rel)
elif synced and rel.startswith(("game/", "artwork/")):
print("SKIP %-30s 素材未同步豁免 —— %s" % (rel, role))
else:
print("FAIL %-30s 悬空:声明存在但盘上无 —— %s" % (rel, role))
problems.append("目录悬空: " + rel)
print("\n=== L2 文件级(asset-path-map §3 现役清单)===")
for rel in ACTIVE_FILES:
p = os.path.join(repo, "game", "assets", "art", rel)
sub = rel.split("/")[0]
if os.path.isfile(p):
print("OK %s" % rel)
elif synced:
print("SKIP %-44s 素材未同步豁免" % rel)
else:
print("FAIL %-44s 悬空(现役清单声明但盘上无)" % rel)
problems.append("文件悬空: " + rel)
for sub in DECLARED_SUBDIRS:
p = os.path.join(artdir, sub)
if not os.path.isdir(p):
if synced:
print("SKIP 子目录 %-40s 素材未同步豁免" % sub)
else:
print("FAIL 子目录 %-40s 悬空" % sub)
problems.append("子目录悬空: " + sub)
print("\n=== L3 代码级 res:// 引用核对 ===")
# ⚠ 口径:Godot 的 res:// 根 = 项目目录 game/(project.godot 所在处),
# 不是仓库根。所以 res://assets/art/x.png → <repo>/game/assets/art/x.png。
# 2026-10-07 初版误按仓库根解析 ⇒ 全部引用假悬空,已修。
refs = {}
for dirpath, dirnames, names in os.walk(os.path.join(repo, code)):
dirnames[:] = [d for d in dirnames
if d not in (".git", ".godot", "import", "node_modules")]
for fn in names:
if not fn.endswith((".gd", ".tscn", ".tres")):
continue
fp = os.path.join(dirpath, fn)
try:
txt = open(fp, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
for m in RES_RE.findall(txt):
refs.setdefault(m, []).append(os.path.relpath(fp, repo))
if not refs:
print("(代码侧无 res://assets/art 硬引用——符合 §2 第4步禁 preload 纪律)")
templates = []
for ref, where in sorted(refs.items()):
if is_path_template(ref):
templates.append((ref, where))
continue
p = os.path.join(repo, "game", *ref[len("res://"):].split("/"))
if os.path.exists(p):
print("OK %-52s ← %d 处" % (ref, len(where)))
elif synced:
print("SKIP %-52s 无素材环境豁免 ← %s" % (ref, where[0]))
else:
print("FAIL %-52s 悬空 ← %s" % (ref, ", ".join(where[:3])))
problems.append("代码引用悬空: " + ref)
if templates:
print("SKIP 路径模板 %d 条(printf 格式串,非字面路径;运行时由守卫兜底)"
% len(templates))
for ref, where in templates:
print(" · %-46s ← %s" % (ref, where[0]))
print("\n=== 退役命名扫描 ===")
# ⚠ 口径纪律:退役命名**只在代码与素材清单里是违规**;文档的版本历史/
# 台账在「叙述这些名字已退役」时提到它们属正常,不是违规。
# 故扫描面只含:代码(.gd/.tscn) + 素材清单(design/art/*.md),
# 且排除版本历史/台账类文件(active.md / *checklist* / *memory* / .zcode)。
# 2026-10-07 初版全仓扫 .md ⇒ active.md/记忆文件里的历史记述全被误判,已修。
hits = 0
scan_exts = (".gd", ".tscn")
skip_names = ("active.md",)
for dirpath, dirnames, names in os.walk(repo):
dirnames[:] = [d for d in dirnames
if d not in (".git", ".godot", "archive", "node_modules",
".workbuddy", ".zcode")]
for fn in names:
fp = os.path.join(dirpath, fn)
rel = os.path.relpath(fp, repo)
if any(s in rel for s in (".workbuddy", ".zcode")):
continue
if fn in skip_names:
continue
is_code = fn.endswith(scan_exts)
is_manifest = (fn.endswith(".md")
and os.sep + "art" + os.sep in fp
and "版本历史" not in fn)
if not (is_code or is_manifest):
continue
try:
txt = open(fp, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
# 叙述性豁免:命中行若同时含「退役/已废/更正/改判」等记述词,
# 视为「在讲这段历史」而非「仍在用这个名字」
for rx, why in RETIRED_PATTERNS:
for line in txt.splitlines():
if rx.search(line) and not any(
w in line for w in ("退役", "已废", "更正", "改判",
"原写", "原名", "作废")):
print("FAIL %-52s %s" % (rel, why))
print(" └─ %s" % line.strip()[:90])
problems.append("退役命名: " + rel)
hits += 1
break
if not hits:
print("OK 无退役命名残留(代码 + 素材清单;叙述性历史记述已豁免)")
for nt in notes:
print("\n注:%s" % nt)
print("\n路径机检完成:%d 处问题" % len(problems))
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())
+164 -40
View File
@@ -1,79 +1,203 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""站桩槽位素材机检(M-3 迭代 6 T1——48px 档契约断言;重闸素材面闸)。
"""站桩素材机检(B1 硬闸升级 · 立绘定档批 2026-10-07)。
四断言(契约=计划书 C4/圣经 §8.2):
尺寸 高恒 48(槽位源档 ×2 整数放大=盒高 96);宽 1..75(盒宽 150 半值)。
锚点 底行有不透明像素(底中锚——纵向比例自底边口径,脚贴画布底缘)。
色数 ≤32(fixer 批 32 色冻结;NEAREST 采样不产生新色)。
无插值 alpha 二值(仅 0/255——双线性/混合会在边缘产生中间 α)。
升级依据=art-bible §8.3③ 挂点规格(审计 §07-2/3):
- 逐键校验档高(**原「宽 1..75」虚设区间退役**——宽度改为「源宽×2 ≤ 站桩盒宽 150」
的物理约束,档高改为逐键断言 STANDEE_TIER_H);
- 像素域色数 ≤32(**排除全透明像素**,见下方口径注);
- 无非整数倍缩放痕迹(alpha 二值 + 无中间 α);
- 描边亮度达标(亮描边暖白族,character-design §10硬规则「禁黑描边」)。
用法: python tools/standee_asset_check.py [角色素材目录]
默认 game/assets/art/character/;必需集=八敌+主角 portrait/attack/hit。
⚠ 色数口径坑(2026-10-07 实踩,勿改回naive 统计):
naive `len(set(所有像素))` 会把全透明 (0,0,0,0) 计成一个色 ⇒ 报33 假红灯。
正确口径 = 只统计 alpha>0 像素的唯一 RGB。机检必须如此,否则天天误报。
档高表= tools/pixelize_candidates.py 的 STANDEE_TIER_H(定值单点纪律 §8:本表
不在检查器里复写一份,改由 import 读,避免两处数字漂移)。
用法:
python tools/standee_asset_check.py [角色素材目录]
默认 game/assets/art/character/
--json 输出机器可读格式
退出码: 0 全过 / 1 有红灯
"""
import json
import os
import sys
from collections import Counter
from PIL import Image
REQUIRED = ["character_enemy_%s.png" % k for k in
("bugbear", "drider", "duergar", "duergar_king", "giant_spider",
"goblin", "hook_horror", "ogre_chief")] + \
["character_hero_portrait.png", "character_hero_attack.png", "character_hero_hit.png"]
SLOT_H = 48
W_MAX = 75
COLOR_MAX = 32
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from pixelize_candidates import STANDEE_TIER_H, STANDEE_SLOT_H
except ImportError: # pragma: no cover
STANDEE_TIER_H = {
"goblin": 36, "giant_spider": 38,
"duergar": 46, "hook_horror": 58,
"bugbear": 62, "ogre_chief": 74,
"drider": 90, "duergar_king": 94,
}
STANDEE_SLOT_H = 48
ENEMY_KEYS = ("bugbear", "drider", "duergar", "duergar_king", "giant_spider",
"goblin", "hook_horror", "ogre_chief")
REQUIRED = ["character_enemy_%s.png" % k for k in ENEMY_KEYS] + \
["character_hero_portrait.png", "character_hero_attack.png",
"character_hero_hit.png"]
# ── 契约常量(全部有出处,禁复写第二份) ──────────────────────────────
COLOR_MAX = 32 # 圣经 §4.1/fixer 批 32 色冻结;机检口径=alpha>0 唯一 RGB
DISPLAY_SCALE = 2 # standee_body.gd:237 _portrait_sprite.scale = (2,2)
BOX_W = 150 # standee_body.gd:29 var box := Vector2(150, 176)
SRC_W_MAX = BOX_W // DISPLAY_SCALE # =75,源宽上限(×2 后须塞进盒宽)
ALPHA_BIN = {0, 255} # 二值 alpha 域
# 描边亮度阈值(2026-10-07 _tmp_probe_outline.py 实测标定):
# 敌人暖白边占比实测 54.1%–84.3%,主角 portrait/hit 实测 0.4%(无暖白描边,见下)。
# 敌人闸取 40%(低于实测下限 54.1% 留余量,高于噪声);主角**豁免**——
# character-design §10要求亮描边,但现役三态主角实为暗边,属已知缺口,
# 不在本批(美术素材批 A 范围)修,机检只报读数不判红,避免闸永久红。
OUTLINE_WARM_L_MIN = 150 # 暖白 = 亮度 ≥150 且低饱和
OUTLINE_WARM_S_MAX = 0.35
OUTLINE_MIN_ENEMY = 40.0 # 敌人:暖白描边像素占轮廓像素 ≥40%
HERO_OUTLINE_EXEMPT = ("portrait", "attack", "hit")
def check(path):
def _lum(c):
return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
def _sat(c):
mx, mn = max(c[:3]), min(c[:3])
return 0.0 if mx == 0 else (mx - mn) / float(mx)
def expected_tier_h(fn):
"""按文件名返回应有档高。"""
if fn.startswith("character_enemy_") and fn.endswith(".png"):
key = fn[len("character_enemy_"):-len(".png")]
return STANDEE_TIER_H.get(key)
return STANDEE_SLOT_H # 主角三态同档
def check(path, fn):
"""返回 (problems, info)。info 含机检读数。"""
problems = []
im = Image.open(path).convert("RGBA")
w, h = im.size
if h != SLOT_H:
problems.append("高应 48,实得 %d" % h)
if not (1 <= w <= W_MAX):
problems.append("宽应 1..%d,实得 %d" % (W_MAX, w))
px = im.load()
# 断言①逐键档高(原「高恒 48」已随定档批退役)
tier = expected_tier_h(fn)
if tier is not None and h != tier:
problems.append("档高应 %d(§8.2 逐键定档),实得 %d" % (tier, h))
# 断言② 宽度物理约束:源宽 ×2 须塞进站桩盒宽 150(替代旧虚设区间 1..75)
if w > SRC_W_MAX:
problems.append("源宽 %d ×2=%d 超站桩盒宽 %d(§8.2 高定宽随但不得溢出盒)"
% (w, w * DISPLAY_SCALE, BOX_W))
if w < 1:
problems.append("源宽 %d 非法" % w)
colors = set()
alphas = set()
edges = []
bottom_opaque = 0
for y in range(h):
for x in range(w):
c = px[x, y]
alphas.add(c[3])
if c[3] > 0:
colors.add(c[:3])
if y == h - 1:
bottom_opaque += 1
if c[3] == 0:
continue
colors.add(c[:3]) # 口径:只收不透明像素的 RGB
if y == h - 1:
bottom_opaque += 1
nb = ((x > 0 and px[x - 1, y][3] == 0) or
(x < w - 1 and px[x + 1, y][3] == 0) or
(y > 0 and px[x, y - 1][3] == 0) or
(y < h - 1 and px[x, y + 1][3] == 0))
if nb:
edges.append(c)
# 断言③ 底中锚(脚贴画布底缘)
if bottom_opaque < 1:
problems.append("底行无不透明像素(底中锚失守——脚未贴画布底缘)")
# 断言④ 色数 ≤32(alpha>0 口径)
if len(colors) > COLOR_MAX:
problems.append("色数 %d > %d" % (len(colors), COLOR_MAX))
if not alphas <= {0, 255}:
problems.append("alpha 非二值(插值痕迹:%s)" % sorted(alphas - {0, 255})[:5])
return problems, (w, h, len(colors))
problems.append("色数 %d > %d(口径=alpha>0 唯一 RGB)" % (len(colors), COLOR_MAX))
# 断言⑤ 无非整数倍缩放痕迹:alpha 二值(双线性/混合会留中间 α)
if not alphas <= ALPHA_BIN:
problems.append("alpha 非二值(插值/非整数倍缩放痕迹:%s)"
% sorted(alphas - ALPHA_BIN)[:5])
# 断言⑥ 描边亮度(亮描边暖白族,禁黑描边)
warm_pct = 0.0
dark_pct = 0.0
if edges:
warm = sum(1 for c in edges
if _lum(c) >= OUTLINE_WARM_L_MIN and _sat(c) <= OUTLINE_WARM_S_MAX)
dark = sum(1 for c in edges if _lum(c) < 60)
warm_pct = warm * 100.0 / len(edges)
dark_pct = dark * 100.0 / len(edges)
is_hero = any(t in fn for t in HERO_OUTLINE_EXEMPT)
if not is_hero and warm_pct < OUTLINE_MIN_ENEMY:
problems.append("暖白描边占比 %.1f%% < %d%%(亮描边纪律,character-design §10)"
% (warm_pct, OUTLINE_MIN_ENEMY))
info = {
"size": [w, h],
"tier_h": tier,
"colors": len(colors),
"bottom_opaque": bottom_opaque,
"edge_px": len(edges),
"outline_warm_pct": round(warm_pct, 1),
"outline_dark_pct": round(dark_pct, 1),
"outline_exempt": any(t in fn for t in HERO_OUTLINE_EXEMPT),
}
return problems, info
def main():
root = sys.argv[1] if len(sys.argv) > 1 else os.path.join("game", "assets", "art", "character")
missing = [f for f in REQUIRED if not os.path.isfile(os.path.join(root, f))]
n_fail = 0
argv = [a for a in sys.argv[1:] if not a.startswith("--")]
as_json = "--json" in sys.argv
root = argv[0] if argv else os.path.join("game", "assets", "art", "character")
missing, results, n_fail = [], [], 0
for f in REQUIRED:
p = os.path.join(root, f)
if not os.path.isfile(p):
print("MISS %-36s 缺位" % f)
missing.append(f)
n_fail += 1
continue
problems, info = check(p)
if problems:
n_fail += 1
for pr in problems:
print("FAIL %-36s %s" % (f, pr))
problems, info = check(p, f)
results.append({"file": f, "problems": problems, "info": info})
n_fail += 1 if problems else 0
if as_json:
print(json.dumps({"root": root, "missing": missing,
"results": results, "fail": n_fail},
ensure_ascii=False, indent=2))
sys.exit(1 if n_fail else 0)
for f in missing:
print("MISS %-36s 缺位" % f)
for r in results:
i = r["info"]
if r["problems"]:
for pr in r["problems"]:
print("FAIL %-36s %s" % (r["file"], pr))
else:
print("PASS %-36s %dx%d 色数%d" % (f, info[0], info[1], info[2]))
print("PASS %-36s %dx%d 档高%d 色数%d 暖白边%.1f%%%s"
% (r["file"], i["size"][0], i["size"][1], i["tier_h"],
i["colors"], i["outline_warm_pct"],
"(豁免)" if i["outline_exempt"] else ""))
print("机检完成:%d 件必需,%d 件未过" % (len(REQUIRED), n_fail))
if not as_json:
print("提示:主角三态暖白描边占比实测≈0.4%(暗边),属已知缺口,"
"本闸豁免不判红——如需修另立主角描边素材批。")
sys.exit(1 if n_fail else 0)
if __name__ == "__main__":
main()
main()