MatchMaster/tools/audit-gamescene-ui-images.py
COMPUTER\EDY 4a352390b8 更新
2026-08-27 19:31:20 +08:00

1298 lines
49 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
"""Audit image dependencies of selected GameScene UI subtrees.
This tool is intentionally read-only for assets. It resolves Cocos Creator 2.4
UUID references through scene data, prefabs, animation clips, sprite atlases,
Spine data and bitmap fonts, then writes human-reviewable CSV/Markdown reports.
"""
from __future__ import annotations
import argparse
import csv
import json
import re
import sys
from collections import defaultdict, deque
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
UUID_RE = re.compile(
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
)
UUID_IN_TEXT_RE = re.compile(
r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
)
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
TEXT_DEPENDENCY_EXTENSIONS = {
".prefab",
".fire",
".anim",
".json",
".material",
".mtl",
".effect",
".fnt",
}
SUPPORT_EXTENSIONS = {".plist", ".atlas", ".anim", ".fnt", ".prefab"}
MODULE_ROOT_NAMES = {
"win": "Win",
"lose": "Lose",
"propWindow": "propWindow",
"NewMode": "NewMode",
"pause": "Pause",
}
def posix(path: Path) -> str:
return path.as_posix()
def walk_uuid_values(value: Any, *, skip_uuid_fields: bool = False) -> set[str]:
found: set[str] = set()
if isinstance(value, dict):
for key, child in value.items():
if skip_uuid_fields and key == "uuid":
continue
found.update(walk_uuid_values(child, skip_uuid_fields=skip_uuid_fields))
elif isinstance(value, list):
for child in value:
found.update(walk_uuid_values(child, skip_uuid_fields=skip_uuid_fields))
elif isinstance(value, str) and UUID_RE.fullmatch(value):
found.add(value.lower())
return found
@dataclass
class UUIDEntry:
uuid: str
record: "AssetRecord"
subasset: str = ""
importer: str = ""
@dataclass
class AssetRecord:
source_abs: Path
source_rel: str
meta_abs: Path
meta_rel: str
meta: dict[str, Any]
importer: str
owned_entries: list[UUIDEntry] = field(default_factory=list)
uuid_dependencies: set[str] = field(default_factory=set)
path_dependencies: set[str] = field(default_factory=set)
@property
def extension(self) -> str:
return self.source_abs.suffix.lower()
@property
def is_image(self) -> bool:
return self.extension in IMAGE_EXTENSIONS
@dataclass(frozen=True)
class RefUsage:
uuid: str
node_path: str
object_type: str
property_path: str
def display(self) -> str:
return f"{self.node_path} | {self.object_type} | {self.property_path}"
class AssetIndex:
def __init__(self, project_root: Path) -> None:
self.project_root = project_root
self.assets_root = project_root / "assets"
self.records: list[AssetRecord] = []
self.records_by_source: dict[str, AssetRecord] = {}
self.uuid_entries: dict[str, UUIDEntry] = {}
self.duplicate_uuids: dict[str, list[str]] = defaultdict(list)
self._image_cache: dict[str, frozenset[str]] = {}
def build(self) -> None:
for meta_path in sorted(self.assets_root.rglob("*.meta")):
try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
continue
if not isinstance(meta, dict):
continue
source_path = Path(str(meta_path)[: -len(".meta")])
source_rel = posix(source_path.relative_to(self.project_root))
record = AssetRecord(
source_abs=source_path,
source_rel=source_rel,
meta_abs=meta_path,
meta_rel=posix(meta_path.relative_to(self.project_root)),
meta=meta,
importer=str(meta.get("importer", "")),
)
self.records.append(record)
self.records_by_source[source_rel] = record
self._collect_owned_entries(record, meta)
owned = {entry.uuid for entry in record.owned_entries}
record.uuid_dependencies = (
walk_uuid_values(meta, skip_uuid_fields=True) - owned
)
for record in self.records:
self._add_content_dependencies(record)
self._add_path_dependencies(record)
def _collect_owned_entries(self, record: AssetRecord, meta: dict[str, Any]) -> None:
def visit(value: Any, path: tuple[str, ...]) -> None:
if isinstance(value, dict):
raw_uuid = value.get("uuid")
if isinstance(raw_uuid, str) and UUID_RE.fullmatch(raw_uuid):
uuid = raw_uuid.lower()
subasset = ""
if "subMetas" in path:
subasset = path[-1]
entry = UUIDEntry(
uuid=uuid,
record=record,
subasset=subasset,
importer=str(value.get("importer", record.importer)),
)
record.owned_entries.append(entry)
previous = self.uuid_entries.get(uuid)
if previous is None:
self.uuid_entries[uuid] = entry
elif previous.record.source_rel != record.source_rel:
self.duplicate_uuids[uuid].extend(
[previous.record.source_rel, record.source_rel]
)
for key, child in value.items():
visit(child, path + (str(key),))
elif isinstance(value, list):
for index, child in enumerate(value):
visit(child, path + (str(index),))
visit(meta, ())
def _add_content_dependencies(self, record: AssetRecord) -> None:
if not record.source_abs.is_file():
return
if record.extension not in TEXT_DEPENDENCY_EXTENSIONS:
return
try:
text = record.source_abs.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return
record.uuid_dependencies.update(
match.lower() for match in UUID_IN_TEXT_RE.findall(text)
)
record.uuid_dependencies.difference_update(
entry.uuid for entry in record.owned_entries
)
def _add_path_dependencies(self, record: AssetRecord) -> None:
if not record.source_abs.is_file():
return
try:
text = record.source_abs.read_text(encoding="utf-8", errors="ignore")
except OSError:
return
candidates: set[str] = set()
if record.extension == ".atlas":
for line in text.splitlines():
stripped = line.strip()
if Path(stripped).suffix.lower() in IMAGE_EXTENSIONS:
candidates.add(stripped)
elif record.extension == ".fnt":
candidates.update(re.findall(r'file=["\']([^"\']+)["\']', text))
elif record.extension == ".plist":
candidates.update(
re.findall(
r"<key>textureFileName</key>\s*<string>([^<]+)</string>",
text,
flags=re.IGNORECASE,
)
)
for candidate in candidates:
dependency = (record.source_abs.parent / candidate).resolve()
try:
dependency_rel = posix(dependency.relative_to(self.project_root))
except ValueError:
continue
if dependency.exists():
record.path_dependencies.add(dependency_rel)
def resolve_uuid_images(self, uuid: str) -> frozenset[str]:
uuid = uuid.lower()
cached = self._image_cache.get(uuid)
if cached is not None:
return cached
result = frozenset(self._resolve_uuid_images(uuid, set()))
self._image_cache[uuid] = result
return result
def _resolve_uuid_images(self, uuid: str, visiting: set[str]) -> set[str]:
if uuid in visiting:
return set()
entry = self.uuid_entries.get(uuid)
if entry is None:
return set()
return self._resolve_record_images(entry.record, visiting | {uuid})
def _resolve_record_images(
self, record: AssetRecord, visiting: set[str]
) -> set[str]:
if record.is_image:
return {record.source_rel}
images: set[str] = set()
for dependency_uuid in record.uuid_dependencies:
images.update(self._resolve_uuid_images(dependency_uuid, visiting))
for dependency_path in record.path_dependencies:
dependency_record = self.records_by_source.get(dependency_path)
if dependency_record is None:
if Path(dependency_path).suffix.lower() in IMAGE_EXTENSIONS:
images.add(dependency_path)
else:
images.update(self._resolve_record_images(dependency_record, visiting))
return images
def reachable_uuids(self, start_uuids: Iterable[str]) -> set[str]:
reachable: set[str] = set()
queue = deque(uuid.lower() for uuid in start_uuids)
visited_records: set[str] = set()
while queue:
uuid = queue.popleft()
if uuid in reachable:
continue
reachable.add(uuid)
entry = self.uuid_entries.get(uuid)
if entry is None:
continue
record = entry.record
if record.source_rel in visited_records:
continue
visited_records.add(record.source_rel)
queue.extend(record.uuid_dependencies)
for dependency_path in record.path_dependencies:
dependency_record = self.records_by_source.get(dependency_path)
if dependency_record:
queue.extend(e.uuid for e in dependency_record.owned_entries[:1])
return reachable
def support_assets_for_images(
self, reachable_uuids: Iterable[str]
) -> dict[str, set[str]]:
support: dict[str, set[str]] = defaultdict(set)
visited_records: set[str] = set()
for uuid in reachable_uuids:
entry = self.uuid_entries.get(uuid)
if entry is None:
continue
record = entry.record
if record.source_rel in visited_records or record.is_image:
continue
visited_records.add(record.source_rel)
images = self.resolve_uuid_images(uuid)
if not images:
continue
if self._is_support_asset(record):
for image in images:
support[image].add(record.source_rel)
if record.importer == "spine" and record.extension == ".json":
atlas = record.source_abs.with_suffix(".atlas")
if atlas.exists():
atlas_rel = posix(atlas.relative_to(self.project_root))
for image in images:
support[image].add(atlas_rel)
return support
@staticmethod
def _is_support_asset(record: AssetRecord) -> bool:
if record.extension in SUPPORT_EXTENSIONS:
return True
return record.extension == ".json" and record.importer == "spine"
class SceneScope:
def __init__(
self,
name: str,
node_ids: set[int],
object_ids: set[int],
refs: list[RefUsage],
) -> None:
self.name = name
self.node_ids = node_ids
self.object_ids = object_ids
self.refs = refs
class SceneInspector:
def __init__(self, scene_path: Path) -> None:
self.scene_path = scene_path
self.objects: list[Any] = json.loads(scene_path.read_text(encoding="utf-8"))
self.nodes: dict[int, dict[str, Any]] = {
index: obj
for index, obj in enumerate(self.objects)
if isinstance(obj, dict) and obj.get("__type__") == "cc.Node"
}
self.children: dict[int, list[int]] = defaultdict(list)
for index, node in self.nodes.items():
parent = node.get("_parent")
if isinstance(parent, dict) and isinstance(parent.get("__id__"), int):
self.children[parent["__id__"]].append(index)
def node_path(self, node_id: int) -> str:
parts: list[str] = []
current = node_id
seen: set[int] = set()
while current in self.nodes and current not in seen:
seen.add(current)
node = self.nodes[current]
parts.append(str(node.get("_name", f"<node:{current}>")))
parent = node.get("_parent")
if not isinstance(parent, dict) or not isinstance(parent.get("__id__"), int):
break
current = parent["__id__"]
return "/".join(reversed(parts))
def find_unique_root(self, expected_name: str) -> int:
matches = [
index
for index, node in self.nodes.items()
if node.get("_name") == expected_name
]
if len(matches) != 1:
details = ", ".join(self.node_path(index) for index in matches) or "none"
raise RuntimeError(
f"Expected exactly one node named {expected_name!r}; found "
f"{len(matches)}: {details}"
)
return matches[0]
def descendant_nodes(self, root_id: int) -> set[int]:
descendants: set[int] = set()
queue = deque([root_id])
while queue:
node_id = queue.popleft()
if node_id in descendants:
continue
descendants.add(node_id)
queue.extend(self.children.get(node_id, ()))
return descendants
def make_scope(self, name: str, node_ids: set[int]) -> SceneScope:
object_ids, contexts = self._collect_object_ids(node_ids)
refs: list[RefUsage] = []
for object_id in sorted(object_ids):
value = self.objects[object_id]
if not isinstance(value, dict):
continue
context_node = contexts.get(object_id)
node_path = (
self.node_path(context_node)
if context_node in self.nodes
else f"<object:{object_id}>"
)
object_type = str(value.get("__type__", type(value).__name__))
refs.extend(
self._collect_asset_refs(value, node_path, object_type, "$")
)
unique_refs = sorted(
set(refs),
key=lambda ref: (ref.node_path, ref.object_type, ref.property_path, ref.uuid),
)
return SceneScope(name, node_ids, object_ids, unique_refs)
def _collect_object_ids(
self, allowed_nodes: set[int]
) -> tuple[set[int], dict[int, int]]:
object_ids: set[int] = set()
contexts: dict[int, int] = {}
queue: deque[tuple[int, int]] = deque((node_id, node_id) for node_id in allowed_nodes)
while queue:
object_id, context_node = queue.popleft()
if object_id in object_ids or not (0 <= object_id < len(self.objects)):
continue
value = self.objects[object_id]
if not isinstance(value, dict):
object_ids.add(object_id)
contexts[object_id] = context_node
continue
if value.get("__type__") == "cc.Node" and object_id not in allowed_nodes:
continue
owner = value.get("node")
if isinstance(owner, dict) and isinstance(owner.get("__id__"), int):
owner_id = owner["__id__"]
if owner_id not in allowed_nodes:
continue
context_node = owner_id
object_ids.add(object_id)
contexts[object_id] = context_node
for referenced_id in self._collect_id_refs(value):
target = self.objects[referenced_id] if 0 <= referenced_id < len(self.objects) else None
if (
isinstance(target, dict)
and target.get("__type__") == "cc.Node"
and referenced_id not in allowed_nodes
):
continue
queue.append((referenced_id, context_node))
return object_ids, contexts
@staticmethod
def _collect_id_refs(value: Any) -> set[int]:
refs: set[int] = set()
if isinstance(value, dict):
if set(value.keys()) == {"__id__"} and isinstance(value["__id__"], int):
refs.add(value["__id__"])
else:
for child in value.values():
refs.update(SceneInspector._collect_id_refs(child))
elif isinstance(value, list):
for child in value:
refs.update(SceneInspector._collect_id_refs(child))
return refs
@staticmethod
def _collect_asset_refs(
value: Any,
node_path: str,
object_type: str,
property_path: str,
) -> list[RefUsage]:
refs: list[RefUsage] = []
if isinstance(value, dict):
raw_uuid = value.get("__uuid__")
if isinstance(raw_uuid, str) and UUID_RE.fullmatch(raw_uuid):
refs.append(
RefUsage(
uuid=raw_uuid.lower(),
node_path=node_path,
object_type=object_type,
property_path=property_path,
)
)
return refs
for key, child in value.items():
if key == "__id__":
continue
refs.extend(
SceneInspector._collect_asset_refs(
child,
node_path,
object_type,
f"{property_path}.{key}",
)
)
elif isinstance(value, list):
for index, child in enumerate(value):
refs.extend(
SceneInspector._collect_asset_refs(
child,
node_path,
object_type,
f"{property_path}[{index}]",
)
)
return refs
@dataclass
class ScopeResult:
scope: SceneScope
images: set[str]
image_usages: dict[str, set[str]]
image_subassets: dict[str, set[str]]
image_support: dict[str, set[str]]
unresolved_refs: list[RefUsage]
reachable_uuids: set[str]
dynamic_candidate_images: set[str] = field(default_factory=set)
def analyze_scope(scope: SceneScope, index: AssetIndex) -> ScopeResult:
images: set[str] = set()
usages: dict[str, set[str]] = defaultdict(set)
unresolved: list[RefUsage] = []
start_uuids = {ref.uuid for ref in scope.refs}
for ref in scope.refs:
resolved = index.resolve_uuid_images(ref.uuid)
if not resolved and ref.uuid not in index.uuid_entries:
unresolved.append(ref)
for image in resolved:
images.add(image)
usages[image].add(ref.display())
reachable = index.reachable_uuids(start_uuids)
subassets: dict[str, set[str]] = defaultdict(set)
for uuid in reachable:
entry = index.uuid_entries.get(uuid)
if entry is None or not entry.subasset:
continue
for image in index.resolve_uuid_images(uuid):
subassets[image].add(entry.subasset)
return ScopeResult(
scope=scope,
images=images,
image_usages=usages,
image_subassets=subassets,
image_support=index.support_assets_for_images(reachable),
unresolved_refs=unresolved,
reachable_uuids=reachable,
)
def add_manual_image(
result: ScopeResult,
image: str,
usage: str,
index: AssetIndex,
*,
dynamic_candidate: bool = False,
) -> None:
"""Add a runtime dependency that cannot be discovered from serialized UUIDs."""
record = index.records_by_source.get(image)
if record is None or not record.is_image:
raise RuntimeError(f"Manual runtime image is missing from assets index: {image}")
result.images.add(image)
result.image_usages.setdefault(image, set()).add(usage)
for entry in record.owned_entries:
if entry.subasset:
result.image_subassets.setdefault(image, set()).add(entry.subasset)
if dynamic_candidate:
result.dynamic_candidate_images.add(image)
def classify_image(
image: str,
support_assets: Iterable[str],
index: AssetIndex,
) -> str:
support_records = [
index.records_by_source[path]
for path in support_assets
if path in index.records_by_source
]
if any(record.importer == "spine" for record in support_records):
return "Spine纹理"
if any(record.importer == "particle" for record in support_records):
return "粒子纹理"
if any(record.extension == ".fnt" for record in support_records):
return "位图字体纹理"
if any(
record.extension == ".plist" and record.meta.get("type") == "Texture Packer"
for record in support_records
):
return "图集纹理"
image_record = index.records_by_source.get(image)
if image_record:
reverse_candidates = []
for record in index.records:
if record.is_image:
continue
if image in index._resolve_record_images(record, set()):
reverse_candidates.append(record)
if any(record.importer == "spine" for record in reverse_candidates):
return "Spine纹理"
if any(
record.extension == ".plist" and record.meta.get("type") == "Texture Packer"
for record in reverse_candidates
):
return "图集纹理"
return "单图"
def image_metrics(image: str, index: AssetIndex) -> tuple[int, int, float, float]:
record = index.records_by_source.get(image)
width = int(record.meta.get("width", 0) or 0) if record else 0
height = int(record.meta.get("height", 0) or 0) if record else 0
rgba_mib = width * height * 4 / (1024 * 1024) if width and height else 0.0
try:
file_mib = record.source_abs.stat().st_size / (1024 * 1024) if record else 0.0
except OSError:
file_mib = 0.0
return width, height, rgba_mib, file_mib
def ownership_label(
modules: set[str],
used_outside: bool,
global_external_users: Iterable[str] = (),
) -> str:
labels: list[str] = []
if len(modules) > 1:
labels.append("五模块之间共享")
if used_outside:
labels.append("与GameScene其他区域共享")
if set(global_external_users):
labels.append("与其他场景或预制体共享")
return " + ".join(labels) if labels else "模块独占"
def handling_advice(label: str, category: str) -> str:
if label == "模块独占":
return "可随所属模块迁移;图片、meta及配套资源一起移动"
if category == "图集纹理":
return "不要整张归入某一分包;按各模块使用的SpriteFrame重打小图集,或放公共包"
if label == "五模块之间共享":
return "若要求完全隔离,可复制为各包独立UUID并重新绑定;否则放公共包"
return "仍有其他使用方;不可直接搬走,需复制/拆图集、放公共包或同步迁移全部使用方"
def build_global_external_users(
target_images: set[str],
index: AssetIndex,
audited_scene: Path,
) -> dict[str, set[str]]:
"""Find other serialized scenes/prefabs that load each target image."""
users: dict[str, set[str]] = defaultdict(set)
audited_scene_rel = posix(audited_scene.relative_to(index.project_root))
for record in index.records:
if record.source_rel == audited_scene_rel:
continue
if record.extension not in {".fire", ".prefab"}:
continue
for image in index._resolve_record_images(record, set()) & target_images:
users[image].add(record.source_rel)
return users
def recommended_target(
module: str,
image: str,
category: str,
exclusive: bool,
support_assets: Iterable[str],
index: AssetIndex,
) -> str:
if not exclusive:
return "待处理共享资源"
kind_dirs = {
"图集纹理": "atlas",
"Spine纹理": "spine",
"粒子纹理": "particle",
"位图字体纹理": "font",
"单图": "image",
}
image_path = Path(image)
group = image_path.stem if category in {"图集纹理", "Spine纹理"} else image_path.parent.name
if category == "Spine纹理":
for support_path in support_assets:
support_record = index.records_by_source.get(support_path)
if (
support_record
and support_record.importer == "spine"
and support_record.extension == ".json"
):
group = support_record.source_abs.stem
break
return posix(Path("assets") / module / "texture" / kind_dirs[category] / group / image_path.name)
def dependency_origin(usages: Iterable[str]) -> str:
values = set(usages)
origins: list[str] = []
if any(value.startswith("[动态路径]") for value in values):
origins.append("动态路径候选")
if any(value.startswith("[外部控制器]") for value in values):
origins.append("外部控制器字段")
if any(value.startswith("[持久公共组件]") for value in values):
origins.append("持久公共组件")
if any(not value.startswith("[") for value in values):
origins.append("场景静态UUID")
return " ; ".join(origins) or "场景静态UUID"
def csv_join(values: Iterable[str]) -> str:
return " ; ".join(sorted(set(values)))
def md_escape(value: str) -> str:
return value.replace("|", "\\|").replace("\n", " ")
def write_reports(
output_dir: Path,
scene_path: Path,
inspector: SceneInspector,
index: AssetIndex,
module_results: dict[str, ScopeResult],
outside_result: ScopeResult,
) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
image_modules: dict[str, set[str]] = defaultdict(set)
for module, result in module_results.items():
for image in result.images:
image_modules[image].add(module)
all_target_images = set(image_modules)
global_external_users = build_global_external_users(
all_target_images, index, scene_path
)
rows_by_module: dict[str, list[dict[str, str]]] = defaultdict(list)
all_rows: list[dict[str, str]] = []
for image in sorted(all_target_images):
modules = image_modules[image]
used_outside = image in outside_result.images
label = ownership_label(
modules, used_outside, global_external_users.get(image, set())
)
merged_support: set[str] = set()
for module in modules:
merged_support.update(module_results[module].image_support.get(image, set()))
merged_support.update(outside_result.image_support.get(image, set()))
category = classify_image(image, merged_support, index)
width, height, rgba_mib, file_mib = image_metrics(image, index)
for module in sorted(modules):
result = module_results[module]
outside_frames = outside_result.image_subassets.get(image, set())
other_frames: set[str] = set()
for other_module in modules - {module}:
other_frames.update(module_results[other_module].image_subassets.get(image, set()))
row = {
"module": module,
"ownership": label,
"dependency_origin": dependency_origin(result.image_usages.get(image, set())),
"category": category,
"source_image": image,
"width": str(width),
"height": str(height),
"rgba_mib_estimate": f"{rgba_mib:.2f}",
"source_file_mib": f"{file_mib:.2f}",
"used_sprite_frames": csv_join(result.image_subassets.get(image, set())),
"other_module_sprite_frames": csv_join(other_frames),
"outside_sprite_frames": csv_join(outside_frames),
"global_external_users": csv_join(global_external_users.get(image, set())),
"direct_usage_locations": csv_join(result.image_usages.get(image, set())),
"supporting_assets": csv_join(result.image_support.get(image, set())),
"recommended_target": recommended_target(
module,
image,
category,
label == "模块独占",
result.image_support.get(image, set()),
index,
),
"handling": handling_advice(label, category),
}
rows_by_module[module].append(row)
all_rows.append(row)
fieldnames = [
"module",
"ownership",
"dependency_origin",
"category",
"source_image",
"width",
"height",
"rgba_mib_estimate",
"source_file_mib",
"used_sprite_frames",
"other_module_sprite_frames",
"outside_sprite_frames",
"global_external_users",
"direct_usage_locations",
"supporting_assets",
"recommended_target",
"handling",
]
def write_csv(path: Path, rows: list[dict[str, str]], fields: list[str] = fieldnames) -> None:
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
for module, rows in rows_by_module.items():
rows.sort(
key=lambda row: (
row["ownership"] != "模块独占",
-float(row["rgba_mib_estimate"]),
row["source_image"],
)
)
write_csv(output_dir / f"{module}.csv", rows)
all_rows.sort(key=lambda row: (row["module"], -float(row["rgba_mib_estimate"])))
write_csv(output_dir / "all-images.csv", all_rows)
shared_fields = [
"ownership",
"category",
"source_image",
"modules",
"used_outside_target_nodes",
"global_external_users",
"width",
"height",
"rgba_mib_estimate",
"supporting_assets",
"scope_sprite_frames",
"handling",
]
shared_rows: list[dict[str, str]] = []
for image, modules in image_modules.items():
used_outside = image in outside_result.images
label = ownership_label(
modules, used_outside, global_external_users.get(image, set())
)
if label == "模块独占":
continue
support: set[str] = set()
scope_frames: list[str] = []
for module in sorted(modules):
support.update(module_results[module].image_support.get(image, set()))
frames = csv_join(module_results[module].image_subassets.get(image, set())) or "<无子图名>"
scope_frames.append(f"{module}: {frames}")
support.update(outside_result.image_support.get(image, set()))
if used_outside:
frames = csv_join(outside_result.image_subassets.get(image, set())) or "<无子图名>"
scope_frames.append(f"GameScene其他区域: {frames}")
category = classify_image(image, support, index)
width, height, rgba_mib, _ = image_metrics(image, index)
shared_rows.append(
{
"ownership": label,
"category": category,
"source_image": image,
"modules": csv_join(modules),
"used_outside_target_nodes": "是" if used_outside else "否",
"global_external_users": csv_join(global_external_users.get(image, set())),
"width": str(width),
"height": str(height),
"rgba_mib_estimate": f"{rgba_mib:.2f}",
"supporting_assets": csv_join(support),
"scope_sprite_frames": " || ".join(scope_frames),
"handling": handling_advice(label, category),
}
)
shared_rows.sort(key=lambda row: -float(row["rgba_mib_estimate"]))
write_csv(output_dir / "shared-images.csv", shared_rows, shared_fields)
unresolved_fields = ["scope", "uuid", "node_path", "object_type", "property_path"]
unresolved_rows: list[dict[str, str]] = []
for scope_name, result in {**module_results, "GameScene其他区域": outside_result}.items():
for ref in result.unresolved_refs:
unresolved_rows.append(
{
"scope": scope_name,
"uuid": ref.uuid,
"node_path": ref.node_path,
"object_type": ref.object_type,
"property_path": ref.property_path,
}
)
write_csv(output_dir / "unresolved-refs.csv", unresolved_rows, unresolved_fields)
unresolved_summary_fields = [
"uuid",
"count",
"scopes",
"object_types",
"property_paths",
"example_node_path",
"assessment",
]
grouped_unresolved: dict[str, list[dict[str, str]]] = defaultdict(list)
for row in unresolved_rows:
grouped_unresolved[row["uuid"]].append(row)
unresolved_summary_rows: list[dict[str, str]] = []
for uuid, rows in grouped_unresolved.items():
unresolved_summary_rows.append(
{
"uuid": uuid,
"count": str(len(rows)),
"scopes": csv_join(row["scope"] for row in rows),
"object_types": csv_join(row["object_type"] for row in rows),
"property_paths": csv_join(row["property_path"] for row in rows),
"example_node_path": rows[0]["node_path"],
"assessment": "assets中没有对应meta;从材质/按钮状态字段看属于引擎内置资源候选,迁移前人工确认",
}
)
unresolved_summary_rows.sort(key=lambda row: -int(row["count"]))
write_csv(
output_dir / "unresolved-summary.csv",
unresolved_summary_rows,
unresolved_summary_fields,
)
duplicate_fields = ["uuid", "asset_paths", "handling"]
duplicate_rows = [
{
"uuid": uuid,
"asset_paths": csv_join(paths),
"handling": "UUID冲突;不要继续复制meta,应在Cocos Creator内新建目录或删除副本meta后让编辑器重新生成",
}
for uuid, paths in sorted(index.duplicate_uuids.items())
]
write_csv(output_dir / "duplicate-uuids.csv", duplicate_rows, duplicate_fields)
non_image_fields = [
"scope",
"source_asset",
"importer",
"extension",
"resolved_image_count",
"note",
]
non_image_rows: list[dict[str, str]] = []
for scope_name, result in module_results.items():
seen_records: set[str] = set()
for uuid in result.reachable_uuids:
entry = index.uuid_entries.get(uuid)
if entry is None:
continue
record = entry.record
if record.is_image or record.source_rel in seen_records:
continue
seen_records.add(record.source_rel)
image_count = len(index.resolve_uuid_images(uuid))
note = ""
if record.importer == "particle" and image_count == 0:
note = "粒子贴图可能内嵌在plist中;预制体迁移时必须带上该plist"
elif record.importer == "spine":
note = "Spine JSON/ATLAS/PNG必须成组迁移"
elif record.extension == ".anim":
note = "动画会间接引用SpriteFrame"
non_image_rows.append(
{
"scope": scope_name,
"source_asset": record.source_rel,
"importer": record.importer,
"extension": record.extension,
"resolved_image_count": str(image_count),
"note": note,
}
)
non_image_rows.sort(key=lambda row: (row["scope"], row["source_asset"]))
write_csv(output_dir / "non-image-assets.csv", non_image_rows, non_image_fields)
readme: list[str] = []
readme.extend(
[
"# GameScene 五个 UI 模块图片依赖清单",
"",
f"> 来源场景:`{posix(scene_path.relative_to(index.project_root))}`",
"> 本报告只盘点依赖,没有移动、复制或修改任何资源与 UUID。",
"",
"## 先看结论",
"",
"- `active=false` 不会阻止场景加载节点引用的纹理。",
"- CSV 中的 `rgba_mib_estimate` 是按 `宽 × 高 × 4` 计算的单份解码纹理估算,不是文件体积,也不是进程实测。",
"- `模块独占` 才能直接迁入对应分包;共享资源不能直接归入其中一个包。",
"- `模块独占` 同时排除了 GameScene 其他节点、其他场景和其他 prefab 的静态引用。",
"- 图集必须看 `used_sprite_frames`:即使只使用一个小 SpriteFrame,运行时仍加载整张 PNG。",
"- 移动资源请优先通过 Cocos Creator 资源管理器;若使用文件系统,必须连同 `.meta` 一起移动。",
"",
"## 模块汇总",
"",
"| 模块 | 根节点 | 子树节点数 | 静态/控制器图片 | 动态候选图片 | 静态单份RGBA估算 | 动态候选全集 | 动态单张最大 | 静态+单张动态 | 独占图片 | 共享图片 |",
"|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|",
]
)
for module, result in module_results.items():
static_images = result.images - result.dynamic_candidate_images
static_mib = sum(image_metrics(image, index)[2] for image in static_images)
dynamic_mib = sum(
image_metrics(image, index)[2] for image in result.dynamic_candidate_images
)
dynamic_max_mib = max(
(image_metrics(image, index)[2] for image in result.dynamic_candidate_images),
default=0.0,
)
exclusive_count = sum(
1
for image in result.images
if ownership_label(
image_modules[image],
image in outside_result.images,
global_external_users.get(image, set()),
)
== "模块独占"
)
shared_count = len(result.images) - exclusive_count
root_id = min(result.scope.node_ids, key=lambda value: len(inspector.node_path(value)))
readme.append(
f"| {module} | `{inspector.node_path(root_id)}` | {len(result.scope.node_ids)} | "
f"{len(static_images)} | {len(result.dynamic_candidate_images)} | "
f"{static_mib:.2f} MiB | {dynamic_mib:.2f} MiB | "
f"{dynamic_max_mib:.2f} MiB | {static_mib + dynamic_max_mib:.2f} MiB | "
f"{exclusive_count} | {shared_count} |"
)
shared_total = sum(image_metrics(row["source_image"], index)[2] for row in shared_rows)
readme.extend(
[
"",
"## 共享资源处理原则",
"",
f"共有 **{len(shared_rows)} 张实际纹理页**存在共享,去重后的单份 RGBA 估算为 **{shared_total:.2f} MiB**。",
"",
"1. **共享大图集**:优先按各模块使用的 SpriteFrame 重新打小图集,避免把整张公共大图带进每个分包。",
"2. **少量小图共享**:可以放公共包;如果五个包必须完全独立,则复制成不同 UUID 并逐一重新绑定。",
"3. **仍被 GameScene、其他场景或其他 prefab 使用**:不能直接搬走,除非同步迁移所有使用方。",
"4. **Spine**:PNG、JSON、ATLAS 必须视为一组,不能只移动 PNG。",
"",
"## 每个模块的详细文件",
"",
]
)
for module in module_results:
readme.append(f"- `{module}.csv`:{module} 的图片、SpriteFrame、使用节点、配套文件和建议目标目录。")
readme.extend(
[
"- `shared-images.csv`:所有共享纹理、每个作用域使用的 SpriteFrame,以及其他场景/prefab 使用方。",
"- `all-images.csv`:五个模块的合并明细。",
"- `non-image-assets.csv`:动画、Spine、粒子、预制体等配套文件,防止只移动PNG。",
"- `unresolved-summary.csv`:无法在 assets 中解析的 UUID 汇总。",
"- `unresolved-refs.csv`:无法解析 UUID 的逐条位置。",
"- `duplicate-uuids.csv`:当前工作区重复 UUID,必须先处理。",
"",
"## 最大纹理页",
"",
"| 图片 | 模块 | 分类 | 尺寸 | 单份RGBA估算 | 归属 |",
"|---|---|---|---:|---:|---|",
]
)
unique_rows: dict[str, dict[str, str]] = {}
for row in all_rows:
unique_rows.setdefault(row["source_image"], row)
for row in sorted(
unique_rows.values(),
key=lambda item: -float(item["rgba_mib_estimate"]),
)[:25]:
modules = csv_join(image_modules[row["source_image"]])
readme.append(
f"| `{md_escape(row['source_image'])}` | {md_escape(modules)} | {row['category']} | "
f"{row['width']}×{row['height']} | {row['rgba_mib_estimate']} MiB | {row['ownership']} |"
)
readme.extend(["", "## 独占资源迁移清单", ""])
for module, rows in rows_by_module.items():
exclusive_rows = [row for row in rows if row["ownership"] == "模块独占"]
readme.extend([f"### {module}", ""])
if not exclusive_rows:
readme.append("没有可直接迁移的独占纹理;先处理共享图集。")
readme.append("")
continue
readme.extend(
[
"| 原图片 | 分类 | 配套资源 | 建议目标 |",
"|---|---|---|---|",
]
)
for row in exclusive_rows:
readme.append(
f"| `{md_escape(row['source_image'])}` | {row['category']} | "
f"{md_escape(row['supporting_assets']) or '-'} | "
f"`{md_escape(row['recommended_target'])}` |"
)
readme.append("")
readme.extend(
[
"## 运行时动态依赖",
"",
"- NewMode 会在 `NewMode.ts` 中拼接 `Window_Prop/<name>`,加载 `NEW_LEVEL.json` 配置的 `daoju0` 到 `daoju32`。它们已写入 `NewMode.csv`,标记为 `动态路径候选`;一次弹窗只加载其中一张,不能把候选全集当作同时驻留。",
"- Win 的飞金币预制体和兜底 SpriteFrame 原本绑定在 SceneManager 上,本报告已重新归入 Win;后续拆包时应把字段一并迁入 Win 加载逻辑。",
"- Win 的排行榜/入职数字会通过持久 `NumberToImage.font3` 使用 `assets/UI/font/font3.png`,该图已标为持久公共依赖。",
"- Win 还会按玩家头像动态加载 `resources/spine/action*/skeleton` 或远程头像。这属于全局头像系统,不建议复制进 Win 包,但 Win prefab 仍依赖相应加载接口。",
"",
"## 当前 UUID 冲突",
"",
]
)
if duplicate_rows:
readme.append("工作区存在以下重复 UUID。继续复制或移动前必须处理,否则 Cocos 可能重新生成 UUID 或绑定到错误目录:")
readme.append("")
readme.extend(["| UUID | 冲突路径 |", "|---|---|"])
for row in duplicate_rows:
readme.append(f"| `{row['uuid']}` | `{md_escape(row['asset_paths'])}` |")
readme.append("")
else:
readme.extend(["未发现重复 UUID。", ""])
readme.extend(
[
"## 审计边界",
"",
"本工具能解析场景和资源文件中的 UUID 静态引用。脚本通过字符串拼出的动态路径、远程 URL、运行时生成图片不会自动出现在结果中,需要结合模块脚本再做一次人工复核。",
"",
]
)
(output_dir / "README.md").write_text("\n".join(readme), encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--project-root",
type=Path,
default=Path(__file__).resolve().parents[1],
)
parser.add_argument(
"--scene",
type=Path,
default=Path("assets/Scene/GameScene.fire"),
)
parser.add_argument(
"--output",
type=Path,
default=Path("tools/game-ui-image-inventory"),
)
args = parser.parse_args()
project_root = args.project_root.resolve()
scene_path = args.scene if args.scene.is_absolute() else project_root / args.scene
output_dir = args.output if args.output.is_absolute() else project_root / args.output
index = AssetIndex(project_root)
index.build()
inspector = SceneInspector(scene_path)
module_scopes: dict[str, SceneScope] = {}
target_nodes: set[int] = set()
for module, root_name in MODULE_ROOT_NAMES.items():
root_id = inspector.find_unique_root(root_name)
nodes = inspector.descendant_nodes(root_id)
target_nodes.update(nodes)
module_scopes[module] = inspector.make_scope(module, nodes)
outside_nodes = set(inspector.nodes) - target_nodes
outside_scope = inspector.make_scope("GameScene其他区域", outside_nodes)
# These fields live on SceneManager, but their only runtime purpose is the
# Win settlement coin animation. Re-assign them to Win for bundle planning.
win_controller_properties = {"$.flyCoinPrefab", "$.flyCoinSpriteFrame"}
win_controller_refs = [
RefUsage(
uuid=ref.uuid,
node_path=f"[外部控制器] {ref.node_path}",
object_type=ref.object_type,
property_path=ref.property_path,
)
for ref in outside_scope.refs
if ref.property_path in win_controller_properties
]
module_scopes["win"].refs = sorted(
set(module_scopes["win"].refs + win_controller_refs),
key=lambda ref: (ref.node_path, ref.object_type, ref.property_path, ref.uuid),
)
outside_scope.refs = [
ref for ref in outside_scope.refs if ref.property_path not in win_controller_properties
]
module_results = {
module: analyze_scope(scope, index) for module, scope in module_scopes.items()
}
outside_result = analyze_scope(outside_scope, index)
# NewMode dynamically loads one configured icon via
# cc.resources.load('Window_Prop/' + propName). These candidates have no
# serialized UUID reference in GameScene and must be added explicitly.
new_level_path = project_root / "assets/resources/Json/NEW_LEVEL.json"
new_level_data = json.loads(new_level_path.read_text(encoding="utf-8"))
dynamic_icon_names = {
str(item.get("name", "")).strip()
for item in new_level_data.get("NEW_LEVEL", [])
if str(item.get("name", "")).strip()
}
for icon_name in sorted(dynamic_icon_names):
image = f"assets/resources/Window_Prop/{icon_name}.png"
add_manual_image(
module_results["NewMode"],
image,
f"[动态路径] assets/Script/NewMode.ts:45 -> Window_Prop/{icon_name}",
index,
dynamic_candidate=True,
)
# Win creates rank/career digits through the persistent NumberToImage
# provider. It is deliberately also marked outside because other screens
# use the same global atlas.
font3_image = "assets/UI/font/font3.png"
add_manual_image(
module_results["win"],
font3_image,
"[持久公共组件] NumberToImage.font3 -> Map.createCareer/createRank",
index,
)
add_manual_image(
outside_result,
font3_image,
"[持久公共组件] NumberToImage.font3 also used outside target modules",
index,
)
write_reports(
output_dir,
scene_path,
inspector,
index,
module_results,
outside_result,
)
summary = {
module: {
"root": inspector.node_path(inspector.find_unique_root(root_name)),
"nodes": len(module_results[module].scope.node_ids),
"static_or_controller_images": len(
module_results[module].images
- module_results[module].dynamic_candidate_images
),
"dynamic_candidate_images": len(
module_results[module].dynamic_candidate_images
),
"static_or_controller_rgba_mib": round(
sum(
image_metrics(image, index)[2]
for image in (
module_results[module].images
- module_results[module].dynamic_candidate_images
)
),
2,
),
"dynamic_candidate_pool_rgba_mib": round(
sum(
image_metrics(image, index)[2]
for image in module_results[module].dynamic_candidate_images
),
2,
),
}
for module, root_name in MODULE_ROOT_NAMES.items()
}
print(json.dumps(summary, ensure_ascii=False, indent=2))
print(f"Report: {output_dir}")
if index.duplicate_uuids:
print(f"Warning: {len(index.duplicate_uuids)} duplicate UUID(s) found", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())