239 lines
11 KiB
Python
239 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""B3 路径核对器(圣经 §8.3② 挂点实现)。
|
||
|
||
职责:扫描 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/seal_mark.png",
|
||
]
|
||
# 立绘八敌按数据表键生成(键集=STANDEE_DISPLAY_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),其余一律不扫;
|
||
# .git/.workbuddy/.zcode/archive 在目录层就排除。
|
||
# 2026-10-07 初版全仓扫 .md 曾把历史记述全判成违规,故收窄到上述两面(当日还额外跳过
|
||
# 根目录会话状态文件,该文件已于 2026-10-18 用户裁废除,跳过项一并删除;
|
||
# 「版本历史」文件名跳过项亦于 2026-10-19 变更记录节禁设批删除——此类节改由
|
||
# gdd_lint.py --changelog-only 全 design/ 扫,本工具不再分派豁免)。
|
||
hits = 0
|
||
scan_exts = (".gd", ".tscn")
|
||
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
|
||
is_code = fn.endswith(scan_exts)
|
||
is_manifest = (fn.endswith(".md") and os.sep + "art" + os.sep in fp)
|
||
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()) |