#!/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/seal_mark.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__)"), (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 → /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())