1298 lines
49 KiB
Python
1298 lines
49 KiB
Python
#!/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())
|