cocos-farm/tools/extract_land_interaction_evidence.py

595 lines
25 KiB
Python

#!/usr/bin/env python3
"""Extract source-backed evidence for empty-land click interaction.
This pass is intentionally narrow. It records only the original Cocos
`plant_interactive_v2` nodes and assets needed to show the selectable empty-land
state: land type detail bubble, selected-land image, and seed list. The selected
land visual must use the original `model/v3/land_valid_selected` SpriteFrame.
"""
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"
)
GAME_JS_PATH = SOURCE_ROOT / "source_raw" / "6A3BB7D3D4349E2050E7B701AAD51600_726750140f8084ebcbe5190fe9c198c9" / "game.js"
RAW_REMOTE = SOURCE_ROOT / "downloads" / "raw" / "remote"
CONVERTED_IMAGES = SOURCE_ROOT / "downloads" / "converted_png" / "images"
PREFAB_PATH = RAW_REMOTE / "extraRes" / "import" / "03" / "032791d54.4866b.json"
FARM_SCENE_PREFAB_PATH = RAW_REMOTE / "extraRes" / "import" / "0e" / "0ec3b3100.19268.json"
MAIN_SCENE_ELEMENTS_PATH = SOURCE_ROOT / "source_first" / "out" / "main_scene_elements.json"
LEVEL_PATH = RAW_REMOTE / "delayRes" / "import" / "d3" / "d3d0dbbe-6b4e-47de-8f1a-b73d8296662e.f3b53.json"
ITEM_PATH = RAW_REMOTE / "delayRes" / "import" / "54" / "54da07c3-8a3c-44fb-b50f-79af4a2ed78a.98c0b.json"
OUT_PATH = ROOT / "source_export" / "stage1_land_interaction_source.json"
REPORT_PATH = ROOT / "source_export" / "stage1_land_interaction_extract_report.json"
INTERACTION_ASSETS = {
"tuditanchuang2": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/tuditanchuang2/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__tuditanchuang2__a2f9575a-c951-4dc6-a0da-97069a896a25.bf2ea.png",
"spriteFrame": None,
},
"putongtudi": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/putongtudi/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__putongtudi__091dc5e6-0196-48a1-a392-44953ed50b8a.ad9dd.png",
"spriteFrame": None,
},
"daojudibu": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/daojudibu/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__daojudibu__bafc3fef-9b6d-4a0d-bcb2-05975ce113b7.c13a3.png",
"spriteFrame": None,
},
"BottomSeedList": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/BottomSeedList/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__BottomSeedList__9eca5499-3b17-4643-afce-67496298676c.84a85.png",
"spriteFrame": None,
},
"land_valid_selected": {
"bundle": "extraRes",
"resource": "model/v3/land_valid_selected/spriteFrame",
"image": "extraRes__model__v3__land_valid_selected__f5bde290-de64-4e6f-974f-81997d3386e0.7531f.png",
"spriteFrame": RAW_REMOTE / "extraRes" / "import" / "f5" / "f5bde290-de64-4e6f-974f-81997d3386e0@f9941.82d95.json",
},
"seed_bg_1": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/seed_bg_1/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__seed_bg_1__08156573-1e68-4c3e-a0e2-11cebc1f3e28.b735a.png",
"spriteFrame": None,
},
"zhongzishuliangdi": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/zhongzishuliangdi/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__zhongzishuliangdi__6d858cca-fd03-4fcd-8f47-c66525faf04a.828f5.png",
"spriteFrame": None,
},
"zhongzishuliangdi_line": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/zhongzishuliangdi_line/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__zhongzishuliangdi_line__1bdaf766-9b67-41f8-969b-81f72db6ab79.59eb5.png",
"spriteFrame": None,
},
"img_lock": {
"bundle": "extraRes",
"resource": "gui/texture/plantinteractive/img_lock/spriteFrame",
"image": "extraRes__gui__texture__plantinteractive__img_lock__25b722c9-e9c1-4177-b1d2-b88e1a7e99d6.b8790.png",
"spriteFrame": None,
},
"Crop_3_Seed": {
"bundle": "plant",
"resource": "model/v4/Crop_3_Seed/spriteFrame",
"image": "plant__model__v4__Crop_3_Seed__a823c190-2bd8-4b86-bbac-6c5cbf0c465d.20d67.png",
"spriteFrame": RAW_REMOTE / "plant" / "import" / "a8" / "a823c190-2bd8-4b86-bbac-6c5cbf0c465d@f9941.35a83.json",
},
"Crop_59_Seed": {
"bundle": "plant",
"resource": "model/v4/Crop_59_Seed/spriteFrame",
"image": "plant__model__v4__Crop_59_Seed__d4057a83-ecef-45fb-8988-905b56ae7ce3.0a012.png",
"spriteFrame": RAW_REMOTE / "plant" / "import" / "d4" / "d4057a83-ecef-45fb-8988-905b56ae7ce3@f9941.3a5e6.json",
},
}
def read_json(path: Path) -> Any:
return json.loads(path.read_text(encoding="utf-8"))
def write_json(path: Path, data: 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_sprite_frame(path: Path | None, image_path: Path) -> dict[str, Any]:
with Image.open(image_path) as image:
pixel_width, pixel_height = image.size
if path is None:
return {
"metadataPath": None,
"name": image_path.stem,
"rect": {"x": 0, "y": 0, "width": pixel_width, "height": pixel_height},
"offset": {"x": 0, "y": 0},
"originalSize": {"width": pixel_width, "height": pixel_height},
"rotated": False,
"capInsets": [0, 0, 0, 0],
"pivot": {"x": 0.5, "y": 0.5},
"pixelSize": {"width": pixel_width, "height": pixel_height},
}
raw = read_json(path)
frame = raw[5][0]
return {
"metadataPath": str(path),
"name": frame["name"],
"rect": frame["rect"],
"offset": frame["offset"],
"originalSize": frame["originalSize"],
"rotated": frame["rotated"],
"capInsets": frame.get("capInsets", [0, 0, 0, 0]),
"pivot": frame.get("pivot", {"x": 0.5, "y": 0.5}),
"pixelSize": {"width": pixel_width, "height": pixel_height},
}
def find_level_one(level_path: Path) -> dict[str, Any]:
raw = read_json(level_path)
level_rows = raw[5][0][2]
for row in level_rows:
if row.get("id") == 1:
return row
raise RuntimeError("Level config id=1 was not found.")
def assert_prefab_source(prefab: list[Any]) -> dict[str, Any]:
classes = prefab[3]
custom_class = classes[32]
expected_fields = [
"detailYOffset",
"stealAnimationNames",
"node",
"__prefab",
"seedInteractionNode",
"toolInteractionNode",
"followInteractionNode",
"plantPaginationNode",
"toolPaginationNode",
"btnGoShop",
"dragPreview",
"seed3BgNode",
"seed5BgNode",
"detailInteractionNode",
"interactNode",
"tipNode",
"tipLabel",
"guideNode",
"landTargetNode",
]
if custom_class[0] != "62225t3M0tNjKqXu9MAz7CW" or custom_class[1] != expected_fields:
raise RuntimeError("Unexpected plant_interactive_v2 custom component layout.")
prefab_text = json.dumps(prefab, ensure_ascii=False)
required_markers = [
"plant_interactive_v2",
"land_detail",
"followNode",
"seedGroup",
"group_5",
"bg_node3",
"bg_node5",
"plant_pagination",
"landTarget",
"普通土地",
"node1",
"node2",
"node3",
"node4",
"node5",
]
missing = [marker for marker in required_markers if marker not in prefab_text]
if missing:
raise RuntimeError(f"Prefab source is missing markers: {missing}")
return {
"componentClassIndex": 32,
"componentClassUuid": custom_class[0],
"componentFields": custom_class[1],
"packedPrefabInstanceIndex": 5,
"requiredMarkers": required_markers,
}
def decode_compact_instance(prefab: list[Any], value: Any) -> dict[str, Any] | None:
if not isinstance(value, list) or not value or not isinstance(value[0], int):
return None
template_id = value[0]
templates = prefab[4]
classes = prefab[3]
if template_id < 0 or template_id >= len(templates):
return None
template = templates[template_id]
if not template or not isinstance(template[0], int):
return None
class_index = template[0]
if class_index < 0 or class_index >= len(classes):
return None
class_def = classes[class_index]
fields = class_def[1] if len(class_def) > 1 and isinstance(class_def[1], list) else []
props: dict[str, Any] = {}
for prop_index, prop_value in zip(template[1:], value[1:]):
if isinstance(prop_index, int) and 0 <= prop_index < len(fields):
props[fields[prop_index]] = prop_value
return {"type": class_def[0], "template": template_id, "props": props}
def find_compact_node(prefab: list[Any], name: str, payload_index: int = 5) -> dict[str, Any]:
payload = prefab[5][payload_index]
def visit(value: Any) -> dict[str, Any] | None:
decoded = decode_compact_instance(prefab, value)
if decoded and decoded["type"] == "cc.Node" and decoded["props"].get("_name") == name:
return decoded
if isinstance(value, list):
for child in value:
match = visit(child)
if match:
return match
elif isinstance(value, dict):
for child in value.values():
match = visit(child)
if match:
return match
return None
node = visit(payload)
if not node:
raise RuntimeError(f"Source compact node was not found: {name}")
return node
def group_5_layout_evidence(prefab: list[Any]) -> dict[str, Any]:
group = find_compact_node(prefab, "group_5")
components = group["props"].get("_components", [])
decoded_components = [
item for item in (decode_compact_instance(prefab, component) for component in components) if item
]
layout = next((item for item in decoded_components if item["type"] == "cc.Layout"), None)
if not layout:
raise RuntimeError("group_5 cc.Layout component was not found in source prefab.")
props = layout["props"]
return {
"sourceNode": "plant_interactive_v2/followNode/seedGroup/group_5",
"componentType": "cc.Layout",
"template": layout["template"],
"layoutType": props.get("_layoutType", 0),
"resizeMode": props.get("_resizeMode", 0),
"spacingX": props.get("_spacingX", 0),
"paddingLeft": props.get("_paddingLeft", 0),
"paddingRight": props.get("_paddingRight", 0),
"paddingTop": props.get("_paddingTop", 0),
"paddingBottom": props.get("_paddingBottom", 0),
"horizontalDirection": props.get("_horizontalDirection", 0),
"affectedByScale": props.get("_affectedByScale", False),
"isAlign": props.get("_isAlign", False),
}
def element_by_path(elements: list[dict[str, Any]], path: str) -> dict[str, Any]:
for item in elements:
if item.get("full_path") == path:
return item
raise RuntimeError(f"Missing source element {path}")
def raw_color_rgba(value: int) -> dict[str, int]:
"""Cocos compact colors serialize as a uint32. Keep raw plus both byte orders."""
return {
"raw": value,
"littleEndianR": value & 0xFF,
"littleEndianG": (value >> 8) & 0xFF,
"littleEndianB": (value >> 16) & 0xFF,
"littleEndianA": (value >> 24) & 0xFF,
"argbA": (value >> 24) & 0xFF,
"argbR": (value >> 16) & 0xFF,
"argbG": (value >> 8) & 0xFF,
"argbB": value & 0xFF,
}
def farm_grid_graphics_evidence() -> dict[str, Any]:
prefab = read_json(FARM_SCENE_PREFAB_PATH)
classes = prefab[3]
custom_class = classes[12]
expected_fields = [
"gridX",
"gridY",
"gridMargin",
"parallelogramLength",
"parallelogramWidth",
"node",
"__prefab",
"gridOrigin",
"plantGridOrigin",
"prePlantNode",
"postPlantNode",
"gridBorderColor",
"gridBackgroundColor",
"bugLayer",
"grassLayer",
"goldBugLayer",
"strangeGrassLayer",
"extraPlantUnitLayer",
"landIcon",
"spineTemplate",
]
if custom_class[0] != "8db83TJTKhGzIl3ro4xvWky" or custom_class[1] != expected_fields:
raise RuntimeError("Unexpected farm_scene_v3 grid component layout.")
grid_component = prefab[5][1][0][1][3][1]
if grid_component[0] != 32:
raise RuntimeError("farm_scene_v3 grid component row was not found.")
main_elements = read_json(MAIN_SCENE_ELEMENTS_PATH)
elements = main_elements["prefabs"]["farm_scene_v3"]["elements"]
pre_plant = element_by_path(elements, "farm_scene_v3/PrePlant")
post_plant = element_by_path(elements, "farm_scene_v3/PostPlant")
return {
"sourcePrefab": str(FARM_SCENE_PREFAB_PATH),
"customComponentIndex": 12,
"customComponentUuid": custom_class[0],
"componentFields": custom_class[1],
"gridX": grid_component[1],
"gridY": grid_component[2],
"gridMargin": grid_component[3],
"parallelogramLength": grid_component[4],
"parallelogramWidth": grid_component[5],
"gridBorderColor": raw_color_rgba(grid_component[12][1]),
"gridBackgroundColor": raw_color_rgba(grid_component[13][1]),
"prePlantNode": {
"path": pre_plant["full_path"],
"localPosition": pre_plant["local_position"],
"componentCount": pre_plant["component_count"],
},
"postPlantNode": {
"path": post_plant["full_path"],
"localPosition": post_plant["local_position"],
"componentCount": post_plant["component_count"],
},
}
def node_record(
path: str,
name: str,
position: tuple[float, float, float],
size: tuple[float, float] | None = None,
active: bool = True,
sprite: str | None = None,
label: dict[str, Any] | None = None,
) -> dict[str, Any]:
return {
"path": path,
"name": name,
"active": active,
"localPosition": {"x": position[0], "y": position[1], "z": position[2]},
"size": None if size is None else {"x": size[0], "y": size[1]},
"anchor": {"x": 0.5, "y": 0.5},
"spriteAssetId": sprite,
"label": label,
}
def main() -> int:
prefab = read_json(PREFAB_PATH)
source_assertions = assert_prefab_source(prefab)
level_one = find_level_one(LEVEL_PATH)
assets: list[dict[str, Any]] = []
for asset_id, spec in INTERACTION_ASSETS.items():
image_path = CONVERTED_IMAGES / spec["image"]
if not image_path.exists():
raise RuntimeError(f"Missing converted interaction image: {image_path}")
sprite_frame = compact_sprite_frame(spec["spriteFrame"], image_path)
assets.append(
{
"id": asset_id,
"bundle": spec["bundle"],
"resource": spec["resource"],
"sourcePath": str(image_path),
"spriteFrame": sprite_frame,
}
)
source_nodes = [
node_record("plant_interactive_v2", "plant_interactive_v2", (0, 0, 0), active=False),
node_record("plant_interactive_v2/land_detail", "land_detail", (0, 0, 0), (500, 400)),
node_record("plant_interactive_v2/land_detail/land", "land", (-23.591, 59.798, 0), (214, 87), False, "tuditanchuang2"),
node_record(
"plant_interactive_v2/land_detail/land/title",
"title",
(29.507, 0.539, 0),
(79.9706, 27.72),
True,
None,
{
"string": "普通土地",
"fontSize": 20,
"actualFontSize": 20.4375,
"lineHeight": 30,
"bold": True,
"colorUint32": 4280960130,
},
),
node_record("plant_interactive_v2/land_detail/land/icon", "icon", (-67.839, 2.23, 0), (53, 29), True, "putongtudi"),
node_record("plant_interactive_v2/followNode", "followNode", (0, 0, 0), (500, 400)),
node_record("plant_interactive_v2/followNode/detailNode", "detailNode", (0, 38.652, 0)),
node_record("plant_interactive_v2/followNode/seedGroup", "seedGroup", (0, -124.664, 0), (685, 129)),
node_record("plant_interactive_v2/followNode/seedGroup/bg_node3", "bg_node3", (0, 0, 0), (416, 129), True, "daojudibu"),
node_record("plant_interactive_v2/followNode/seedGroup/bg_node5", "bg_node5", (0, 0, 0), (685, 129), False, "BottomSeedList"),
node_record("plant_interactive_v2/followNode/seedGroup/group_5", "group_5", (8.389, -0.108, 0), (575, 140)),
node_record("plant_interactive_v2/landTarget", "landTarget", (0, 0, 0)),
node_record("plant_interactive_v2/landTarget/land_valid_selected", "land_valid_selected", (0, 0, 0), (150, 89), True, "land_valid_selected"),
]
slot_nodes = []
for index, x in enumerate([-235, -120, -5, 110, 225], start=1):
path = f"plant_interactive_v2/followNode/seedGroup/group_5/node{index}"
slot_nodes.extend(
[
node_record(path, f"node{index}", (x, 0, 0), (105, 105), index <= 2),
node_record(f"{path}/seed_bg", "seed_bg", (0, 0, 0), (67, 67), index <= 2, "seed_bg_1"),
node_record(
f"{path}/icon",
"icon",
(0, 0, 0),
(100, 100),
index <= 2,
"Crop_3_Seed" if index == 1 else "Crop_59_Seed" if index == 2 else None,
),
node_record(f"{path}/bg_txt", "bg_txt", (-22.815, 32.233, 0), (55, 36), index <= 2, "zhongzishuliangdi"),
node_record(f"{path}/bg_txt/color_block", "color_block", (0, 1, 0), (55, 36), index <= 2, "zhongzishuliangdi_line"),
node_record(
f"{path}/count",
"count",
(-22.815, 32.233, 0),
(32, 25.2),
index <= 2,
None,
{
"string": "99",
"fontSize": 20,
"actualFontSize": 20,
"lineHeight": 25,
"bold": True,
"colorUint32": 4280494909,
},
),
]
)
source_nodes.extend(slot_nodes)
evidence = {
"meta": {
"module": "stage1_land_interaction",
"sourcePrefab": str(PREFAB_PATH),
"levelConfig": str(LEVEL_PATH),
"seedItemConfig": str(ITEM_PATH),
"extractionRule": "Record only empty-land interaction nodes and assets from original Cocos plant_interactive_v2 prefab and Level id=1 config.",
},
"sourceAssertions": source_assertions,
"componentEvidence": {
"customComponentUuid": source_assertions["componentClassUuid"],
"detailYOffset": 120,
"seedInteractionNode": "plant_interactive_v2/followNode/seedGroup",
"followInteractionNode": "plant_interactive_v2/followNode",
"plantPaginationNode": "plant_interactive_v2/followNode/seedGroup/plant_pagination",
"seed3BgNode": "plant_interactive_v2/followNode/seedGroup/bg_node3",
"seed5BgNode": "plant_interactive_v2/followNode/seedGroup/bg_node5",
"detailInteractionNode": "plant_interactive_v2/followNode/detailNode",
"interactNode": "plant_interactive_v2/InteractNodeParent",
"landTargetNode": "plant_interactive_v2/landTarget",
},
"runtimeClickEvidence": {
"sourceFile": str(GAME_JS_PATH),
"landIconEventOffset": 2517803,
"plantInteractiveReceiverOffset": 3251454,
"targetPositionMethodOffset": 3261813,
"landIconEventRule": "The source land icon touch handler emits a payload containing the land icon component, the source node position, the land id, and guide state. It clears the farm grid selection before the interaction event is consumed.",
"targetPositionRule": "The source plant interaction handler receives the clicked land position, converts it through worldToScreen, screenToWorld, and convertToNodeSpaceAR, then sets landTargetNode and followInteractionNode positions. The source root plant_interactive_v2 is not moved to the land.",
"wideSlotRule": "When the active seed/tool slot count is greater than three, the source keeps followInteractionNode.x at 0 so the seed list stays centered. Three or fewer slots follow the clicked land position.",
},
"farmGridGraphics": farm_grid_graphics_evidence(),
"selectedLandVisual": {
"targetNode": "plant_interactive_v2/landTarget",
"imageNode": "plant_interactive_v2/landTarget/land_valid_selected",
"assetId": "land_valid_selected",
"resource": "model/v3/land_valid_selected/spriteFrame",
"positionRule": "When an unlocked normal land is clicked, resolve the touched visual land by the source grid icon center, keep plant_interactive_v2 at its source root position, set landTargetNode to the converted land position, and mount the runtime selected frame at landTarget local (0,0). The source prefab serializes landTarget as a target node; land_valid_selected is a runtime visual recovered from model/v3/land_valid_selected/spriteFrame.",
"forbiddenAsset": "gui/texture/plantinteractive/gold is a 18x18 coin image and must not be used as the selected-land visual.",
},
"levelConfig": {
"level": int(level_one["id"]),
"levelName": level_one["level_name"],
"landRes": level_one["land_res"],
"landIconRes": level_one["land_icon_res"],
"landSpineRes": level_one["land_spine_res"],
"mergeAnim": level_one["merge_anim"],
},
"nodes": source_nodes,
"slotLayout": {
"slotSource": "plant_interactive_v2/followNode/seedGroup/group_5/node1..node5",
"slotPositions": [-235, -120, -5, 110, 225],
"slotSize": {"x": 105, "y": 105},
"layoutComponent": group_5_layout_evidence(prefab),
"backgroundRule": "bg_node3 is active in the source prefab and bg_node5 is inactive. Runtime should keep bg_node3 for three or fewer visible seed/tool slots and switch to bg_node5 only when more than three slots are visible.",
"twoSeedRuntimePositions": [-62.5, 52.5],
"twoSeedMockVisibleSlots": ["node1", "node2"],
},
"interactionStateRule": {
"selectableState": "unlocked_normal",
"noResponseStates": ["first_expandable", "locked_unplowed"],
"touchResolveRule": "Do not trust overlapping grid UITransform event targets. Resolve the intended land from the touch location against visible grid icon world centers, apply land state gating, then move only the source target nodes inside plant_interactive_v2.",
"runtimeMockOnly": True,
},
"mockSeeds": [
{
"seedItemId": 20003,
"cropConfigId": 1020003,
"name": "胡萝卜种子",
"assetId": "Crop_3_Seed",
"count": 99,
"bundle": "plant",
"resource": "model/v4/Crop_3_Seed/spriteFrame",
},
{
"seedItemId": 20059,
"cropConfigId": 1020059,
"name": "大白菜种子",
"assetId": "Crop_59_Seed",
"count": 99,
"bundle": "plant",
"resource": "model/v4/Crop_59_Seed/spriteFrame",
},
],
"assets": assets,
"verificationPlan": {
"creatorSceneNodes": [
"plant_interactive_v2",
"land_detail/land/title",
"followNode/detailNode",
"landTarget runtime selected frame",
"followNode/seedGroup/group_5/node1",
"followNode/seedGroup/group_5/node2",
],
"runtimeClick": "Click a land with state unlocked_normal and verify plant_interactive_v2.active=true; click locked_unplowed and verify selection remains unchanged.",
},
}
write_json(OUT_PATH, evidence)
write_json(
REPORT_PATH,
{
"status": "pass",
"module": evidence["meta"]["module"],
"sourceEvidence": str(OUT_PATH),
"assetCount": len(assets),
"nodeCount": len(source_nodes),
"mockSeedCount": len(evidence["mockSeeds"]),
},
)
print(json.dumps(read_json(REPORT_PATH), ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())