Files
gplan-2/tools/path_check.py
T
DKChan b163341915 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 与旧档逐字节相同(主角档零漂移实证)。
2026-10-07 18:39:31 +08:00

242 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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())