nodes/save_load.pyΒΆ

Part of PirateMaker.

  1"""Level JSON save/load: persists editor canvas state.
  2
  3The save format mirrors `EditorMode.canvas_data` + objects. Object positions are
  4canvas-space, i.e. relative to grid cell (0, 0), so a level reloads where it was
  5built regardless of how the view was panned when it was saved:
  6
  7```json
  8{
  9  "version": 2,
 10  "origin": [512.0, 360.0],
 11  "tiles": [{"col": 1, "row": 2, "ids": [2]}, ...],
 12  "objects": [{"tile_id": 0, "position": [200, 0], "locked": true}, ...]
 13}
 14```
 15
 16Saving writes `levels/user_level.json`, which is not part of the checkout, so
 17pressing S never dirties version control. Loading prefers that file and falls
 18back to the bundled `levels/level.json` example level.
 19"""
 20
 21from __future__ import annotations
 22
 23import json
 24from pathlib import Path
 25from typing import TYPE_CHECKING
 26
 27from simvx.core import Vec2
 28
 29if TYPE_CHECKING:
 30    from .editor import EditorMode
 31
 32FORMAT_VERSION = 2
 33LEVELS_DIR = Path(__file__).resolve().parent.parent / "levels"
 34#: Where S writes. Untracked, so a saved level stays a local scratch file.
 35USER_SLOT = LEVELS_DIR / "user_level.json"
 36#: The level that ships with the example; read-only as far as the editor cares.
 37BUNDLED_SLOT = LEVELS_DIR / "level.json"
 38
 39
 40def load_slot() -> Path:
 41    """The file L reads: the player's own level if they have saved one."""
 42    return USER_SLOT if USER_SLOT.exists() else BUNDLED_SLOT
 43
 44
 45def save_level(editor: EditorMode, path: Path = USER_SLOT) -> None:
 46    """Serialise the editor's canvas state to JSON."""
 47    data = {
 48        "version": FORMAT_VERSION,
 49        "origin": [editor.origin.x, editor.origin.y],
 50        "tiles": [],
 51        "objects": [],
 52    }
 53
 54    for (col, row), tile in editor.canvas_data.items():
 55        ids: list[int] = []
 56        if tile.has_terrain:
 57            ids.append(2)
 58        if tile.has_water:
 59            ids.append(3)
 60        if tile.coin is not None:
 61            ids.append(tile.coin)
 62        if tile.enemy is not None:
 63            ids.append(tile.enemy)
 64        if ids:
 65            data["tiles"].append({"col": col, "row": row, "ids": ids})
 66
 67    for obj in editor.objects:
 68        data["objects"].append(
 69            {
 70                "tile_id": obj.tile_id,
 71                "position": [obj.position.x, obj.position.y],
 72                "locked": obj.locked,
 73            }
 74        )
 75
 76    path.parent.mkdir(parents=True, exist_ok=True)
 77    path.write_text(json.dumps(data, indent=2))
 78
 79
 80def load_level(editor: EditorMode, path: Path | None = None) -> bool:
 81    """Replace the editor's canvas state with whatever's in `path`.
 82
 83    Returns False if the file is missing or malformed.
 84    """
 85    from .editor import _ANIMATIONS, EditorObject  # local import to avoid cycles
 86
 87    if path is None:
 88        path = load_slot()
 89    if not path.exists():
 90        return False
 91    try:
 92        data = json.loads(path.read_text())
 93    except json.JSONDecodeError:
 94        return False
 95    if data.get("version") != FORMAT_VERSION:
 96        return False
 97
 98    # Wipe current state: destroy sprite children
 99    for tile in editor.canvas_data.values():
100        for sp in tile._sprites:
101            sp.destroy()
102    editor.canvas_data.clear()
103
104    # Drop free objects (player + sky handle stay)
105    persistent_ids = {0, 1}  # player marker, sky handle
106    for obj in list(editor.objects):
107        if obj.tile_id not in persistent_ids:
108            editor.objects.remove(obj)
109            obj.destroy()
110
111    # Restore origin
112    ox, oy = data.get("origin", [editor.origin.x, editor.origin.y])
113    editor.origin = Vec2(ox, oy)
114
115    # Restore tiles
116    for entry in data.get("tiles", []):
117        col, row = entry["col"], entry["row"]
118        for tile_id in entry["ids"]:
119            editor.place_tile_at(col, row, tile_id)
120    editor.recheck_all_neighbours()
121
122    # Restore objects (skip persistent; they're already there)
123    for obj_data in data.get("objects", []):
124        tid = obj_data["tile_id"]
125        position = Vec2(*obj_data["position"])
126        if tid in persistent_ids:
127            # Move the existing marker rather than spawning a second one
128            for o in editor.objects:
129                if o.tile_id == tid:
130                    o.position = position
131                    break
132            continue
133        frames = _ANIMATIONS.get(tid, [])
134        if not frames:
135            continue
136        editor.add_object(
137            EditorObject(
138                frames=frames,
139                tile_id=tid,
140                position=position,
141                locked=obj_data.get("locked", False),
142            )
143        )
144
145    return True