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