473 lines
19 KiB
Python
473 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Extract Stage 1B visible land evidence from the original Cocos source.
|
|
|
|
This pass intentionally uses farm_scene_v3, not farm_scene_v3_sprite. The
|
|
sprite prefab contains 36 static visual cells, while the live land config and
|
|
the mounted farm_scene_v3 prefab contain the 24 business lands used by runtime.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from PIL import Image
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SOURCE_ROOT = Path("/Users/hy/Documents/dev/qq_farm_godot_rebuild_20260616")
|
|
MAIN_ELEMENTS = SOURCE_ROOT / "source_first" / "out" / "main_scene_elements.json"
|
|
FARM_SCENE_PREFAB_RAW = SOURCE_ROOT / "downloads/raw/remote/extraRes/import/0e/0ec3b3100.19268.json"
|
|
LAND_CONFIG = SOURCE_ROOT / "downloads/raw/remote/delayRes/import/47/47eb608b-eae8-46d2-b8b3-b128522275d0.5c42d.json"
|
|
LEVEL_CONFIG = SOURCE_ROOT / "downloads/raw/remote/delayRes/import/d3/d3d0dbbe-6b4e-47de-8f1a-b73d8296662e.f3b53.json"
|
|
WEATHER_CONFIG = SOURCE_ROOT / "downloads/raw/remote/delayRes/import/02/02901df2-cd47-4d3a-bf6f-7570a5c27387.4de34.json"
|
|
CONVERTED_IMAGES = SOURCE_ROOT / "downloads" / "converted_png" / "images"
|
|
SPINE_REGION_IMAGES = SOURCE_ROOT / "downloads" / "spine_regions" / "images"
|
|
SCENE_LAND_ATLAS = SOURCE_ROOT / "downloads/raw/remote/extraRes/native/ef/ef013316-a58f-4146-aaaf-962f3b31e11b.592d9.atlas"
|
|
SCENE_LAND_ATLAS_IMAGE = (
|
|
SOURCE_ROOT
|
|
/ "downloads/converted_png/images/extraRes__spine__v2__scene__scene_land__scene_land__df2cacd7-f461-49e3-a50e-2e8a8bf76000.37b48.png"
|
|
)
|
|
EXTRARES_IMPORT = SOURCE_ROOT / "downloads/raw/remote/extraRes/import"
|
|
OUT_PATH = ROOT / "source_export" / "stage1_visible_lands_source.json"
|
|
REPORT_PATH = ROOT / "source_export" / "stage1_visible_lands_extract_report.json"
|
|
|
|
|
|
LAND_ASSET_TERMS = [
|
|
"land_valid1",
|
|
"land_valid2",
|
|
"land_valid3",
|
|
"land_valid4",
|
|
"land_dry1",
|
|
"land_dry2",
|
|
"land_dry3",
|
|
"land_dry4",
|
|
"land_extend",
|
|
"land_locked",
|
|
]
|
|
|
|
SPINE_REGION_ASSETS = {
|
|
"land_valid1",
|
|
"land_valid2",
|
|
"land_valid3",
|
|
"land_valid4",
|
|
"land_dry1",
|
|
"land_dry2",
|
|
"land_dry3",
|
|
"land_dry4",
|
|
"land_locked",
|
|
}
|
|
|
|
|
|
def load_json(path: Path) -> Any:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def compact_json_asset_records(path: Path) -> list[dict[str, Any]]:
|
|
data = load_json(path)
|
|
return data[5][0][2]
|
|
|
|
|
|
def vec3(value: dict[str, Any]) -> dict[str, float]:
|
|
return {"x": float(value.get("x", 0)), "y": float(value.get("y", 0)), "z": float(value.get("z", 0))}
|
|
|
|
|
|
def vec2_from_size(value: dict[str, Any] | None) -> dict[str, float]:
|
|
if not value:
|
|
return {"x": 0.0, "y": 0.0}
|
|
return {"x": float(value.get("width", value.get("x", 0))), "y": float(value.get("height", value.get("y", 0)))}
|
|
|
|
|
|
def vec2(value: dict[str, Any] | None, default_x: float = 0.5, default_y: float = 0.5) -> dict[str, float]:
|
|
if not value:
|
|
return {"x": default_x, "y": default_y}
|
|
return {"x": float(value.get("x", default_x)), "y": float(value.get("y", default_y))}
|
|
|
|
|
|
def find_metadata_dict(obj: Any, name: str) -> dict[str, Any] | None:
|
|
if isinstance(obj, dict):
|
|
if obj.get("name") == name and "rect" in obj and "originalSize" in obj:
|
|
return obj
|
|
for value in obj.values():
|
|
found = find_metadata_dict(value, name)
|
|
if found:
|
|
return found
|
|
elif isinstance(obj, list):
|
|
for value in obj:
|
|
found = find_metadata_dict(value, name)
|
|
if found:
|
|
return found
|
|
return None
|
|
|
|
|
|
def find_sprite_metadata(name: str) -> tuple[Path, dict[str, Any]]:
|
|
for path in sorted(EXTRARES_IMPORT.rglob("*.json")):
|
|
text = path.read_text(encoding="utf-8", errors="ignore")
|
|
if f'"name":"{name}"' not in text and f'"name": "{name}"' not in text:
|
|
continue
|
|
found = find_metadata_dict(json.loads(text), name)
|
|
if found:
|
|
return path, found
|
|
raise FileNotFoundError(f"missing spriteFrame metadata for {name}")
|
|
|
|
|
|
def find_converted_png(name: str) -> Path:
|
|
matches = sorted(CONVERTED_IMAGES.glob(f"extraRes__model__v3__{name}__*.png"))
|
|
if not matches:
|
|
raise FileNotFoundError(f"missing converted png for {name}")
|
|
return matches[0]
|
|
|
|
|
|
def find_spine_region_png(name: str) -> Path:
|
|
matches = sorted(SPINE_REGION_IMAGES.glob(f"spine__v2__scene__scene_land__scene_land__{name}.png"))
|
|
if not matches:
|
|
raise FileNotFoundError(f"missing scene_land spine region png for {name}")
|
|
return matches[0]
|
|
|
|
|
|
def read_atlas_region(name: str) -> dict[str, Any]:
|
|
lines = SCENE_LAND_ATLAS.read_text(encoding="utf-8").splitlines()
|
|
for index, line in enumerate(lines):
|
|
if line.strip() != name:
|
|
continue
|
|
region: dict[str, Any] = {"name": name}
|
|
for raw in lines[index + 1 : index + 8]:
|
|
if not raw.startswith(" "):
|
|
break
|
|
key, value = raw.strip().split(":", 1)
|
|
value = value.strip()
|
|
if key in {"rotate"}:
|
|
region[key] = value == "true"
|
|
elif key in {"xy", "size", "orig", "offset"}:
|
|
left, right = [int(item.strip()) for item in value.split(",")]
|
|
region[key] = {"x": left, "y": right} if key in {"xy", "offset"} else {"width": left, "height": right}
|
|
elif key == "index":
|
|
region[key] = int(value)
|
|
else:
|
|
region[key] = value
|
|
return region
|
|
raise KeyError(f"missing atlas region {name}")
|
|
|
|
|
|
def scene_land_skeleton_data(farm_prefab_raw: list[Any]) -> dict[str, Any]:
|
|
"""Extract the exact scene_land SkeletonData payload embedded in farm_scene_v3."""
|
|
|
|
record = farm_prefab_raw[5][0][0][0]
|
|
if record[1] != "scene_land":
|
|
raise ValueError(f"unexpected first SkeletonData record: {record[1]}")
|
|
skeleton_json = record[4]
|
|
animations = sorted(skeleton_json.get("animations", {}).keys())
|
|
return {
|
|
"source": str(FARM_SCENE_PREFAB_RAW),
|
|
"recordTemplateIndex": record[0],
|
|
"name": record[1],
|
|
"atlasText": record[2],
|
|
"textureNames": record[3],
|
|
"skeletonJson": skeleton_json,
|
|
"sourceTextureRefs": record[5],
|
|
"atlasPath": str(SCENE_LAND_ATLAS),
|
|
"atlasImagePath": str(SCENE_LAND_ATLAS_IMAGE),
|
|
"skeleton": skeleton_json.get("skeleton", {}),
|
|
"skins": [item.get("name") for item in skeleton_json.get("skins", [])],
|
|
"animations": animations,
|
|
}
|
|
|
|
|
|
def icon_skeleton_components(farm_prefab_raw: list[Any]) -> list[dict[str, Any]]:
|
|
icons: list[dict[str, Any]] = []
|
|
for row in farm_prefab_raw[5][1][0]:
|
|
if not isinstance(row, list) or len(row) < 4 or row[1] != "icon":
|
|
continue
|
|
skeleton = row[3][1]
|
|
if skeleton[0] == 10:
|
|
icons.append(
|
|
{
|
|
"sourceIconOrder": len(icons),
|
|
"compactTemplate": 10,
|
|
"defaultSkin": "",
|
|
"preCacheMode": skeleton[1],
|
|
"cacheMode": skeleton[2],
|
|
"enableBatch": skeleton[3],
|
|
"skeletonDataRef": skeleton[6],
|
|
}
|
|
)
|
|
elif skeleton[0] == 39:
|
|
icons.append(
|
|
{
|
|
"sourceIconOrder": len(icons),
|
|
"compactTemplate": 39,
|
|
"defaultSkin": skeleton[1],
|
|
"preCacheMode": skeleton[2],
|
|
"cacheMode": skeleton[3],
|
|
"enableBatch": skeleton[4],
|
|
"skeletonDataRef": skeleton[7],
|
|
}
|
|
)
|
|
else:
|
|
raise ValueError(f"unexpected icon skeleton compact template {skeleton[0]}")
|
|
return icons
|
|
|
|
|
|
def build_asset_records() -> list[dict[str, Any]]:
|
|
assets: list[dict[str, Any]] = []
|
|
for name in LAND_ASSET_TERMS:
|
|
if name in SPINE_REGION_ASSETS:
|
|
image_path = find_spine_region_png(name)
|
|
atlas_region = read_atlas_region(name)
|
|
metadata_path = SCENE_LAND_ATLAS
|
|
metadata = {
|
|
"rect": {"x": 0, "y": 0, "width": atlas_region["orig"]["width"], "height": atlas_region["orig"]["height"]},
|
|
"originalSize": atlas_region["orig"],
|
|
"offset": atlas_region["offset"],
|
|
"pivot": {"x": 0.5, "y": 0.5},
|
|
"rotated": atlas_region["rotate"],
|
|
"capInsets": [0, 0, 0, 0],
|
|
}
|
|
render_source = "spine_region"
|
|
source_resource = f"spine/v2/scene/scene_land/scene_land:{name}"
|
|
spine = {
|
|
"skeletonResource": "spine/v2/scene/scene_land/scene_land",
|
|
"regionName": name,
|
|
"atlasPath": str(SCENE_LAND_ATLAS),
|
|
"atlasRegion": atlas_region,
|
|
}
|
|
else:
|
|
image_path = find_converted_png(name)
|
|
metadata_path, metadata = find_sprite_metadata(name)
|
|
render_source = "sprite_frame"
|
|
source_resource = f"model/v3/{name}/spriteFrame"
|
|
spine = None
|
|
with Image.open(image_path) as image:
|
|
pixel_width, pixel_height = image.size
|
|
rect = metadata["rect"]
|
|
original = metadata["originalSize"]
|
|
pivot = metadata.get("pivot", {"x": 0.5, "y": 0.5})
|
|
assets.append(
|
|
{
|
|
"id": name,
|
|
"sourcePath": str(image_path),
|
|
"metadataPath": str(metadata_path),
|
|
"sourceResource": source_resource,
|
|
"renderSource": render_source,
|
|
"spine": spine,
|
|
"rect": {
|
|
"x": float(rect["x"]),
|
|
"y": float(rect["y"]),
|
|
"width": float(rect["width"]),
|
|
"height": float(rect["height"]),
|
|
},
|
|
"originalSize": {
|
|
"width": float(original["width"]),
|
|
"height": float(original["height"]),
|
|
},
|
|
"offset": {
|
|
"x": float(metadata.get("offset", {}).get("x", 0)),
|
|
"y": float(metadata.get("offset", {}).get("y", 0)),
|
|
},
|
|
"pivot": {"x": float(pivot.get("x", 0.5)), "y": float(pivot.get("y", 0.5))},
|
|
"rotated": bool(metadata.get("rotated", False)),
|
|
"capInsets": metadata.get("capInsets", [0, 0, 0, 0]),
|
|
"pixelSize": {"width": pixel_width, "height": pixel_height},
|
|
}
|
|
)
|
|
return assets
|
|
|
|
|
|
def element_by_path(elements: list[dict[str, Any]], path: str) -> dict[str, Any]:
|
|
for element in elements:
|
|
if element.get("full_path") == path:
|
|
return element
|
|
raise KeyError(path)
|
|
|
|
|
|
def compact_node(element: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"sourceIndex": element["index"],
|
|
"name": element["name"],
|
|
"path": element["full_path"],
|
|
"parentPath": element.get("parent_path"),
|
|
"category": element.get("category"),
|
|
"localPosition": vec3(element["local_position"]),
|
|
"localScale": vec3(element["local_scale"]),
|
|
"localEuler": vec3(element["local_euler"]),
|
|
"worldPosition": vec3(element["world_position_transformed"]),
|
|
"size": vec2_from_size(element.get("primary_size")),
|
|
"anchor": vec2(element.get("anchor"), 0.5, 0.5),
|
|
"componentCount": int(element.get("component_count", 0)),
|
|
"resolvedParentSource": element.get("resolved_parent_source"),
|
|
}
|
|
|
|
|
|
def mock_land_asset(record: dict[str, Any]) -> dict[str, Any]:
|
|
land_id = int(record["id"])
|
|
if land_id <= 6:
|
|
return {"state": "unlocked_normal", "landLevel": 1, "assetId": "land_valid1", "evidence": "Level.id=1.land_res"}
|
|
if land_id == 7:
|
|
return {
|
|
"state": "first_expandable",
|
|
"landLevel": 0,
|
|
"assetId": "land_extend",
|
|
"evidence": "Land.id=7.unlocked_res; first record with preceding_land_id and level_need=5",
|
|
}
|
|
return {"state": "locked_unplowed", "landLevel": 0, "assetId": "land_locked", "evidence": "Weather.id=1.lockLand"}
|
|
|
|
|
|
def main() -> int:
|
|
main_elements = load_json(MAIN_ELEMENTS)
|
|
farm_prefab_raw = load_json(FARM_SCENE_PREFAB_RAW)
|
|
prefab = main_elements["prefabs"]["farm_scene_v3"]
|
|
elements = prefab["elements"]
|
|
sprite_prefab = main_elements["prefabs"]["farm_scene_v3_sprite"]
|
|
|
|
cells = [compact_node(item) for item in elements if item.get("category") == "visual_land_cell"]
|
|
cells_by_grid = {item["name"]: item for item in cells}
|
|
icons = {
|
|
item["parent_path"].rsplit("/", 1)[-1]: compact_node(item)
|
|
for item in elements
|
|
if item.get("full_path", "").startswith("farm_scene_v3/Scaled/Rotate/GridOrigin/")
|
|
and item.get("full_path", "").endswith("/icon")
|
|
}
|
|
land_config = compact_json_asset_records(LAND_CONFIG)
|
|
level_config = compact_json_asset_records(LEVEL_CONFIG)
|
|
weather_config = compact_json_asset_records(WEATHER_CONFIG)
|
|
sunny_weather = next(item for item in weather_config if item["id"] == 1)
|
|
land_level_asset_rules = [
|
|
{
|
|
"id": item["id"],
|
|
"landLevel": item["land_level"],
|
|
"levelName": item["level_name"],
|
|
"landRes": item["land_res"],
|
|
"landDryRes": item["land_dryRes"],
|
|
"mergeAnim": item["merge_anim"],
|
|
"source": "Level JsonAsset",
|
|
}
|
|
for item in level_config
|
|
if item.get("id") in [1, 2, 3, 4, 5]
|
|
]
|
|
visible_lands: list[dict[str, Any]] = []
|
|
for record in land_config:
|
|
grid_name = f"grid_{record['grid_x']}_{record['grid_y']}"
|
|
cell = cells_by_grid[grid_name]
|
|
icon = icons[grid_name]
|
|
visible_lands.append(
|
|
{
|
|
"landId": int(record["id"]),
|
|
"gridName": grid_name,
|
|
"config": record,
|
|
"cell": cell,
|
|
"icon": icon,
|
|
"mockRender": mock_land_asset(record),
|
|
}
|
|
)
|
|
|
|
evidence = {
|
|
"metadata": {
|
|
"module": "stage1_visible_lands",
|
|
"sourceFiles": [
|
|
str(MAIN_ELEMENTS),
|
|
str(FARM_SCENE_PREFAB_RAW),
|
|
str(LAND_CONFIG),
|
|
str(LEVEL_CONFIG),
|
|
str(WEATHER_CONFIG),
|
|
str(SCENE_LAND_ATLAS),
|
|
str(SCENE_LAND_ATLAS_IMAGE),
|
|
],
|
|
"sourcePackage": str(SOURCE_ROOT),
|
|
"sourceRule": "Use farm_scene_v3 24 visual_land_cell nodes. farm_scene_v3_sprite has 36 static cells and is recorded only as a non-authoritative comparison source for this pass.",
|
|
},
|
|
"excludedStaticGrid": {
|
|
"prefab": "farm_scene_v3_sprite",
|
|
"visualLandCellCount": int(sprite_prefab["category_counts"].get("visual_land_cell", 0)),
|
|
"reason": "This prefab serializes 36 static grid cells under a rotated/scaled GridOrigin; Land config and live farm_scene_v3 only define 24 lands.",
|
|
},
|
|
"landRenderEvidence": {
|
|
"farmScenePrefabTypeNames": [
|
|
item[0] if isinstance(item, list) else item
|
|
for item in farm_prefab_raw[3]
|
|
if (isinstance(item, list) and item) or isinstance(item, str)
|
|
],
|
|
"farmSceneFieldNames": farm_prefab_raw[2],
|
|
"farmSceneSkeletonComponentFields": farm_prefab_raw[3][2][1],
|
|
"farmSceneSkeletonDataFields": farm_prefab_raw[3][7][1],
|
|
"iconSkeletonCompactTemplates": {
|
|
"regularIconTemplate": farm_prefab_raw[4][10],
|
|
"explicitDefaultSkinTemplate": farm_prefab_raw[4][39],
|
|
},
|
|
"rule": "farm_scene_v3 serializes each land icon with sp.Skeleton. Its embedded scene_land SkeletonData contains atlasText, textureNames, skeletonJson, and animations; runtime land state selects Level.merge_anim or Weather.lockLand animation. Stage 1B must generate sp.Skeleton components instead of replacing them with cc.Sprite tiles.",
|
|
},
|
|
"sceneLandSkeletonData": scene_land_skeleton_data(farm_prefab_raw),
|
|
"iconSkeletonComponents": icon_skeleton_components(farm_prefab_raw),
|
|
"gridRoot": {
|
|
"prefab": "farm_scene_v3",
|
|
"visualLandCellCount": len(cells),
|
|
"transformNodes": [
|
|
compact_node(element_by_path(elements, "farm_scene_v3/Scaled")),
|
|
compact_node(element_by_path(elements, "farm_scene_v3/Scaled/Rotate")),
|
|
compact_node(element_by_path(elements, "farm_scene_v3/Scaled/Rotate/GridOrigin")),
|
|
],
|
|
},
|
|
"landConfig": {
|
|
"path": str(LAND_CONFIG),
|
|
"count": len(land_config),
|
|
"records": land_config,
|
|
"gridMappingRule": "grid_x/grid_y maps directly to farm_scene_v3/Scaled/Rotate/GridOrigin/grid_{grid_x}_{grid_y}.",
|
|
},
|
|
"levelConfig": {
|
|
"path": str(LEVEL_CONFIG),
|
|
"landLevelAssetRules": land_level_asset_rules,
|
|
},
|
|
"weatherConfig": {
|
|
"path": str(WEATHER_CONFIG),
|
|
"sunnyLockLand": sunny_weather["lockLand"],
|
|
},
|
|
"assets": build_asset_records(),
|
|
"visibleLands": visible_lands,
|
|
"mockRuntimeState": {
|
|
"separationRule": "These values drive the local preview only. They do not create or modify source layout.",
|
|
"currentUnlockedLandIds": [1, 2, 3, 4, 5, 6],
|
|
"defaultUnlockedLandLevel": 1,
|
|
"landLevelSourceRule": "Actual land-level-to-sprite mapping is read from Level.land_res and Level.land_dryRes. No red/black/gold land is assigned without runtime land-level state evidence.",
|
|
"firstExpandableLandId": 7,
|
|
"lockedLandIds": list(range(8, 25)),
|
|
},
|
|
}
|
|
errors: list[str] = []
|
|
if len(cells) != 24:
|
|
errors.append(f"farm_scene_v3 visual_land_cell count expected 24, got {len(cells)}")
|
|
if len(icons) != 24:
|
|
errors.append(f"farm_scene_v3 icon count expected 24, got {len(icons)}")
|
|
if len(evidence["iconSkeletonComponents"]) != 24:
|
|
errors.append(f"farm_scene_v3 icon skeleton count expected 24, got {len(evidence['iconSkeletonComponents'])}")
|
|
if len(land_config) != 24:
|
|
errors.append(f"Land config record count expected 24, got {len(land_config)}")
|
|
if len(visible_lands) != 24:
|
|
errors.append(f"visible lands expected 24, got {len(visible_lands)}")
|
|
missing = [path for path in evidence["metadata"]["sourceFiles"] if not Path(path).exists()]
|
|
missing.extend(asset["sourcePath"] for asset in evidence["assets"] if not Path(asset["sourcePath"]).exists())
|
|
missing.extend(asset["metadataPath"] for asset in evidence["assets"] if not Path(asset["metadataPath"]).exists())
|
|
|
|
write_json(OUT_PATH, evidence)
|
|
report = {
|
|
"status": "pass" if not errors and not missing else "needs_solution",
|
|
"sourceEvidence": str(OUT_PATH),
|
|
"visibleLandCount": len(visible_lands),
|
|
"sourceGridCount": len(cells),
|
|
"excludedStaticGridCount": evidence["excludedStaticGrid"]["visualLandCellCount"],
|
|
"assetCount": len(evidence["assets"]),
|
|
"errors": errors,
|
|
"missing": missing,
|
|
}
|
|
write_json(REPORT_PATH, report)
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
return 0 if report["status"] == "pass" else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|