nodes/editor.py¶
Part of PirateMaker.
1"""PirateMaker editor mode.
2
3- Infinite scrollable grid (pan with a middle-mouse drag or the wheel).
4- Left button paints, right button erases tiles and free objects; the strip's
5 Erase and Pan buttons latch those onto the left button for touch.
6- Tile palette (terrain / coin / enemy / palm fg+bg) pinned bottom-right.
7- Animated water / coin / enemy tiles, live in the editor.
8- Auto-tiling for terrain: looks up the 8-neighbour key in the land-tiles dict,
9 falling back to the 'X' all-dirt sprite.
10- Drag-to-reposition for object-typed entries (player, palms, sky handle).
11- A translucent ghost of the current selection follows the cursor.
12- "Play level" builds the level dict and hands it to the play mode.
13
14Architecture: every placed tile and free object is a `Sprite2D` / `FolderSprite`
15node under one `Node2D` canvas container. Panning moves that single container,
16so the scene graph does the work instead of per-sprite position bookkeeping, and
17mutating a cell replaces only that cell's children.
18"""
19
20from __future__ import annotations
21
22import math
23from random import choice, randint
24
25from settings import (
26 ANIMATION_SPEED,
27 BUTTON_BG_COLOUR,
28 BUTTON_LINE_COLOUR,
29 EDITOR_DATA,
30 GFX,
31 HORIZON_COLOUR,
32 HORIZON_TOP_COLOUR,
33 LINE_COLOUR,
34 NEIGHBOUR_DIRECTIONS,
35 SEA_COLOUR,
36 SFX,
37 SKY_COLOUR,
38 TILE_SIZE,
39 WINDOW_HEIGHT,
40 WINDOW_WIDTH,
41)
42from support import folder_dict, folder_frames
43
44from simvx.core import (
45 AudioClip,
46 AudioPlayer,
47 CanvasLayer,
48 Input,
49 Node2D,
50 Property,
51 Signal,
52 Sprite2D,
53 Vec2,
54)
55
56from .folder_sprite import FolderSprite
57from .hud import STRIP_HEIGHT, editor_strip, view_size
58
59# ---------------------------------------------------------------------------
60# Asset cache (loaded lazily, shared across editor and play modes)
61# ---------------------------------------------------------------------------
62
63_LAND_TILES: dict[str, str] | None = None
64_MENU_SURFS_BY_GROUP: dict[str, list[tuple[int, str]]] | None = None
65_ANIMATIONS: dict[int, list[str]] | None = None
66_WATER_BOTTOM: str | None = None
67_CLOUD_FRAMES: list[str] | None = None
68_HANDLE_SURF: str | None = None
69
70
71def _load_assets() -> None:
72 global _LAND_TILES, _MENU_SURFS_BY_GROUP, _ANIMATIONS
73 global _WATER_BOTTOM, _CLOUD_FRAMES, _HANDLE_SURF
74
75 if _LAND_TILES is not None:
76 return
77 _LAND_TILES = folder_dict(GFX / "terrain/land")
78 _WATER_BOTTOM = str(GFX / "terrain/water/water_bottom.png")
79 _CLOUD_FRAMES = folder_frames(GFX / "clouds")
80 _HANDLE_SURF = str(GFX / "cursors/handle.png")
81
82 menu_groups: dict[str, list[tuple[int, str]]] = {}
83 anims: dict[int, list[str]] = {}
84 for tile_id, data in EDITOR_DATA.items():
85 if data["menu"]:
86 menu_groups.setdefault(data["menu"], []).append((tile_id, str(data["menu_surf"])))
87 if data["graphics"]:
88 anims[tile_id] = folder_frames(data["graphics"])
89 _MENU_SURFS_BY_GROUP = menu_groups
90 _ANIMATIONS = anims
91
92
93# ---------------------------------------------------------------------------
94# Canvas data: mirrors upstream CanvasTile
95# ---------------------------------------------------------------------------
96
97
98class CanvasTile:
99 """Mutable per-cell record of what's stamped at a grid cell."""
100
101 __slots__ = (
102 "has_terrain",
103 "has_water",
104 "water_on_top",
105 "coin",
106 "enemy",
107 "objects",
108 "terrain_neighbours",
109 "is_empty",
110 "_sprites",
111 )
112
113 def __init__(self, tile_id: int, offset: Vec2 | None = None):
114 self.has_terrain: bool = False
115 self.has_water: bool = False
116 self.water_on_top: bool = False
117 self.coin: int | None = None
118 self.enemy: int | None = None
119 self.objects: list[tuple[int, Vec2]] = []
120 self.terrain_neighbours: list[str] = []
121 self.is_empty: bool = False
122 # Per-cell sprite children (managed by EditorMode); cleared on rebuild.
123 self._sprites: list[Node2D] = []
124 self.add_id(tile_id, offset or Vec2(0, 0))
125
126 def add_id(self, tile_id: int, offset: Vec2 | None = None) -> None:
127 style = EDITOR_DATA[tile_id]["style"]
128 if style == "terrain":
129 self.has_terrain = True
130 elif style == "water":
131 self.has_water = True
132 elif style == "coin":
133 self.coin = tile_id
134 elif style == "enemy":
135 self.enemy = tile_id
136 else:
137 entry = (tile_id, offset or Vec2(0, 0))
138 if entry not in self.objects:
139 self.objects.append(entry)
140
141 def remove_id(self, tile_id: int) -> None:
142 style = EDITOR_DATA[tile_id]["style"]
143 if style == "terrain":
144 self.has_terrain = False
145 elif style == "water":
146 self.has_water = False
147 elif style == "coin":
148 self.coin = None
149 elif style == "enemy":
150 self.enemy = None
151 self._refresh_empty()
152
153 def _refresh_empty(self) -> None:
154 self.is_empty = (
155 not self.has_terrain
156 and not self.has_water
157 and self.coin is None
158 and self.enemy is None
159 and not self.objects
160 )
161
162 def get_terrain_key(self) -> str:
163 return "".join(self.terrain_neighbours)
164
165
166# ---------------------------------------------------------------------------
167# EditorObject: draggable free-position entity (player marker, palms, sky)
168# ---------------------------------------------------------------------------
169
170
171class EditorObject(Node2D):
172 """Draggable free-floating object, positioned in canvas space.
173
174 It lives under the editor's canvas container, so panning is inherited from
175 the parent transform and this node only ever stores where the object sits in
176 the level.
177 """
178
179 def __init__(self, frames: list[str], tile_id: int, position: Vec2, locked: bool = False, fps: float = 8.0):
180 super().__init__(position=position)
181 self.tile_id = tile_id
182 self.selected = False
183 self.locked = locked
184 self.mouse_offset = Vec2(0, 0)
185
186 # Sprite child so the engine handles the texture upload; it draws
187 # centred on this node.
188 self._sprite: FolderSprite | Sprite2D
189 if len(frames) > 1:
190 self._sprite = FolderSprite(frames=frames, fps=fps)
191 else:
192 self._sprite = Sprite2D(texture=frames[0], filter="nearest") if frames else Sprite2D()
193 self.add_child(self._sprite)
194
195 @property
196 def sprite_size(self) -> Vec2:
197 # draw_size is the sprite's on-screen pixel size whether it was given an
198 # explicit width/height or is drawing its texture at native size.
199 return self._sprite.draw_size
200
201 def hit(self, point: Vec2) -> bool:
202 """Whether ``point`` (canvas space) is inside this object's sprite."""
203 size = self.sprite_size
204 return (
205 self.position.x - size.x / 2 <= point.x <= self.position.x + size.x / 2
206 and self.position.y - size.y / 2 <= point.y <= self.position.y + size.y / 2
207 )
208
209
210class _PaletteLayer(CanvasLayer):
211 """Screen-space layer holding the palette panel and its thumbnail sprites."""
212
213 def __init__(self, editor: EditorMode):
214 super().__init__(layer=CanvasLayer.Band.UI)
215 self._editor = editor
216
217 def on_draw(self, renderer) -> None:
218 self._editor.draw_palette(renderer)
219
220
221# ---------------------------------------------------------------------------
222# Editor mode
223# ---------------------------------------------------------------------------
224
225
226class EditorMode(Node2D):
227 """The editor scene. Owns canvas data, palette, objects, sky."""
228
229 selection_index = Property(2, hint="Currently-selected tile id (2..18)", on_change="_on_selection_changed")
230
231 play_requested = Signal()
232 quit_requested = Signal()
233
234 def __init__(self):
235 super().__init__()
236 _load_assets()
237
238 self.canvas_data: dict[tuple[int, int], CanvasTile] = {}
239 self.objects: list[EditorObject] = []
240
241 # Clouds drift behind the level, so their container is added first.
242 self._cloud_layer = self.add_child(Node2D())
243 # Everything the level is made of lives under one container; its
244 # position is the pan offset, i.e. where canvas cell (0, 0) sits on
245 # screen. Panning moves this node and the whole level follows.
246 self._canvas = self.add_child(Node2D(position=Vec2(WINDOW_WIDTH * 0.4, WINDOW_HEIGHT * 0.5)))
247 # Tiles and objects go in here; the selection ghost is added to the
248 # canvas after it, so it draws over the level without needing z_index
249 # (which would cost a depth sort of every tile on each collect).
250 self._content = self._canvas.add_child(Node2D())
251
252 self.pan_active = False
253 self.pan_anchor = Vec2(0, 0)
254
255 # What the left button does. The right and middle buttons keep their
256 # own meanings; this is the one-button (touch) path.
257 self.tool = "paint"
258
259 self.last_painted_cell: tuple[int, int] | None = None
260 self._object_place_cooldown = 0.0
261
262 # Live window size; the palette and the painted backdrop follow it.
263 self._view = Vec2(WINDOW_WIDTH, WINDOW_HEIGHT)
264
265 # Palette layout (bottom-right, 180x180 with 4 quadrants)
266 self.palette_size = 180
267 self.palette_margin = 6
268 self.palette_buttons: list[dict] = []
269 self._build_palette_buttons()
270
271 # Cloud spawner
272 self._cloud_timer = 0.0
273 self._clouds: list[Cloud] = []
274
275 # Music
276 self._music_player: AudioPlayer | None = None
277
278 # Drag state
279 self._dragging: EditorObject | None = None
280
281 @property
282 def origin(self) -> Vec2:
283 """Screen position of canvas cell (0, 0): the pan offset."""
284 return self._canvas.position
285
286 @origin.setter
287 def origin(self, value: Vec2) -> None:
288 self._canvas.position = Vec2(value)
289 # on_draw paints the sky, horizon and grid from this (non-Property) pan
290 # offset, so it has to be marked stale or retained 2D freezes the grid.
291 self.queue_redraw()
292
293 # ------------------------------------------------------------------
294 # Lifecycle
295 # ------------------------------------------------------------------
296
297 def on_ready(self) -> None:
298 # Player marker
299 self.player_marker = self.add_object(
300 EditorObject(frames=_ANIMATIONS[0], tile_id=0, position=Vec2(200, 0), locked=True)
301 )
302
303 # Sky handle: its screen Y is the horizon the backdrop is painted from.
304 self.sky_handle = self.add_object(
305 EditorObject(frames=[_HANDLE_SURF], tile_id=1, position=Vec2(WINDOW_WIDTH * 0.1, 0), locked=True)
306 )
307
308 # Translucent ghost of the current selection, snapped like the real thing.
309 self._ghost = self._canvas.add_child(Sprite2D(filter="nearest", colour=(1.0, 1.0, 1.0, 0.55)))
310 self._on_selection_changed()
311
312 # Palette panel: screen-space, so tiles painted under it cannot cover it
313 self._palette_layer = self.add_child(_PaletteLayer(self))
314 self._build_palette_icons()
315
316 self._strip = self.add_child(editor_strip())
317 self._strip.button_pressed.connect(self._on_strip_button)
318
319 self._startup_clouds()
320 self.start_music()
321
322 def add_object(self, obj: EditorObject) -> EditorObject:
323 self._content.add_child(obj)
324 self.objects.append(obj)
325 return obj
326
327 def set_active(self, active: bool) -> None:
328 """Show or hide the whole editor, its controls strip included.
329
330 Hiding a node stops its subtree drawing but not updating, so the strip
331 is told separately: otherwise it would keep swallowing clicks behind a
332 level that is playing.
333 """
334 self.visible = active
335 self._strip.visible = active
336 if active:
337 self.start_music()
338 else:
339 self.stop_music()
340
341 def _on_strip_button(self, action: str) -> None:
342 if action == "play":
343 self.play_requested()
344 elif action == "quit":
345 self.quit_requested()
346 elif action == "save":
347 from .save_load import save_level
348
349 save_level(self)
350 elif action == "load":
351 from .save_load import load_level
352
353 load_level(self)
354 elif action == "palm_layer":
355 self._toggle_palm_layer()
356 elif action.startswith("tool_"):
357 self.set_tool("paint" if self.tool == action[5:] else action[5:])
358
359 def set_tool(self, tool: str) -> None:
360 """Choose what the left button does: ``paint``, ``erase`` or ``pan``.
361
362 The right and middle buttons always erase and pan, so this only matters
363 to a pointer that has one button (touch).
364 """
365 self.tool = tool
366 self._strip.set_active(None if tool == "paint" else f"tool_{tool}")
367
368 def _build_palette_icons(self) -> None:
369 """Create one Sprite2D per palette button, parented to the CanvasLayer.
370
371 The sprites draw at their texture's native size, which the engine reads
372 from the image header, so nothing here has to measure the PNGs.
373 """
374 for btn in self.palette_buttons:
375 items = self._palette_items(btn)
376 if not items:
377 continue
378 _, surf_path = items[btn["index"]]
379 sprite = self._palette_layer.add_child(Sprite2D(texture=surf_path, filter="nearest"))
380 btn["_sprite"] = sprite
381 self._layout_palette()
382
383 @staticmethod
384 def _palette_items(btn: dict) -> list[tuple[int, str]]:
385 """The item list a palette button is currently showing (main or alt)."""
386 return btn["alt"] if not btn["main_active"] and btn["alt"] else btn["items"]
387
388 def _refresh_palette_icons(self) -> None:
389 for btn in self.palette_buttons:
390 sprite = btn.get("_sprite")
391 items = self._palette_items(btn)
392 if sprite is None or not items:
393 continue
394 sprite.texture = items[btn["index"]][1]
395 self._on_selection_changed()
396
397 def start_music(self) -> None:
398 if self._music_player is not None:
399 return
400 path = SFX / "Explorer.ogg"
401 if path.exists():
402 try:
403 stream = AudioClip(str(path))
404 self._music_player = self.add_child(
405 AudioPlayer(stream=stream, autoplay=True, loop=True, volume_db=-6.0)
406 )
407 except Exception:
408 self._music_player = None # audio backend unavailable
409
410 def stop_music(self) -> None:
411 if self._music_player is not None:
412 self._music_player.destroy()
413 self._music_player = None
414
415 # ------------------------------------------------------------------
416 # Palette
417 # ------------------------------------------------------------------
418
419 def _build_palette_buttons(self) -> None:
420 """Create the four palette quadrants; `_layout_palette` places them."""
421 for group in ("terrain", "coin", "palm fg", "enemy"):
422 self.palette_buttons.append(
423 {
424 "x": 0.0,
425 "y": 0.0,
426 "w": 0.0,
427 "h": 0.0,
428 "group": group,
429 "items": _MENU_SURFS_BY_GROUP.get(group, []),
430 "alt": _MENU_SURFS_BY_GROUP.get("palm bg", []) if group == "palm fg" else [],
431 "index": 0,
432 "main_active": True,
433 }
434 )
435
436 def palette_origin(self) -> Vec2:
437 """Top-left of the palette panel: bottom-right, clear of the strip."""
438 return Vec2(
439 self._view.x - self.palette_size - self.palette_margin,
440 self._view.y - self.palette_size - self.palette_margin - STRIP_HEIGHT,
441 )
442
443 def _layout_palette(self) -> None:
444 """Re-place the palette quadrants and their icons for the current window."""
445 base = self.palette_origin()
446 half = self.palette_size // 2
447 inset = 5
448 offsets = {"terrain": (0, 0), "coin": (half, 0), "palm fg": (0, half), "enemy": (half, half)}
449 for btn in self.palette_buttons:
450 ox, oy = offsets[btn["group"]]
451 btn["x"] = base.x + ox + inset
452 btn["y"] = base.y + oy + inset
453 btn["w"] = half - 2 * inset
454 btn["h"] = half - 2 * inset
455 sprite = btn.get("_sprite")
456 if sprite is not None:
457 sprite.position = Vec2(btn["x"] + btn["w"] / 2, btn["y"] + btn["h"] / 2)
458 self._palette_layer.queue_redraw()
459
460 def palette_rect_contains(self, point: Vec2) -> bool:
461 base = self.palette_origin()
462 size = self.palette_size
463 return base.x <= point.x <= base.x + size and base.y <= point.y <= base.y + size
464
465 def palette_click(self, point: Vec2, *, cycle: bool, swap_layer: bool, pick: bool = False) -> int | None:
466 """Resolve a click on the palette to a tile id, mutating button state.
467
468 ``cycle`` steps to the next entry in the quadrant (right button);
469 ``swap_layer`` flips the palm quadrant between its foreground and
470 background sets (middle button). ``pick`` is the plain left button: it
471 selects the quadrant, and steps on when that entry is already selected,
472 so every tile is reachable by tapping alone.
473 """
474 for btn in self.palette_buttons:
475 if not (btn["x"] <= point.x <= btn["x"] + btn["w"] and btn["y"] <= point.y <= btn["y"] + btn["h"]):
476 continue
477 if swap_layer and btn["alt"]:
478 btn["main_active"] = not btn["main_active"]
479 items = self._palette_items(btn)
480 if not items:
481 return None
482 if cycle or (pick and items[btn["index"]][0] == self.selection_index):
483 btn["index"] = (btn["index"] + 1) % len(items)
484 return items[btn["index"]][0]
485 return None
486
487 def _toggle_palm_layer(self) -> None:
488 """Swap the palm quadrant between foreground and background palms."""
489 for btn in self.palette_buttons:
490 if not btn["alt"]:
491 continue
492 btn["main_active"] = not btn["main_active"]
493 items = self._palette_items(btn)
494 if items:
495 btn["index"] %= len(items)
496 self.selection_index = items[btn["index"]][0]
497 self._refresh_palette_icons()
498 return
499
500 # ------------------------------------------------------------------
501 # Public API used by harness / root
502 # ------------------------------------------------------------------
503
504 def place_tile_at(self, col: int, row: int, tile_id: int) -> None:
505 """Place a tile at grid cell (col,row). Equivalent to LMB-paint."""
506 cell = (col, row)
507 if cell in self.canvas_data:
508 self.canvas_data[cell].add_id(tile_id)
509 else:
510 self.canvas_data[cell] = CanvasTile(tile_id)
511 self._check_neighbours(cell)
512 # Rebuild only this cell + neighbours
513 for dc in (-1, 0, 1):
514 for dr in (-1, 0, 1):
515 self._rebuild_cell_sprites((col + dc, row + dr))
516
517 def recheck_all_neighbours(self) -> None:
518 for cell in list(self.canvas_data.keys()):
519 self._check_neighbours(cell)
520 self._rebuild_cell_sprites(cell)
521
522 # ------------------------------------------------------------------
523 # Per-cell sprite management
524 # ------------------------------------------------------------------
525
526 def _rebuild_cell_sprites(self, cell: tuple[int, int]) -> None:
527 """Recreate child sprites for one cell (terrain key may have changed)."""
528 tile = self.canvas_data.get(cell)
529 if tile is None:
530 return
531 # Remove existing sprites for this cell
532 for sp in tile._sprites:
533 sp.destroy()
534 tile._sprites = []
535
536 cx = cell[0] * TILE_SIZE + TILE_SIZE / 2
537 cy = cell[1] * TILE_SIZE + TILE_SIZE / 2
538
539 if tile.has_water:
540 if tile.water_on_top:
541 sp = Sprite2D(
542 texture=_WATER_BOTTOM, position=Vec2(cx, cy), width=TILE_SIZE, height=TILE_SIZE, filter="nearest"
543 )
544 else:
545 frames = _ANIMATIONS[3]
546 sp = FolderSprite(
547 frames=frames, fps=ANIMATION_SPEED, position=Vec2(cx, cy), width=TILE_SIZE, height=TILE_SIZE
548 )
549 self._content.add_child(sp)
550 tile._sprites.append(sp)
551
552 if tile.has_terrain:
553 key = tile.get_terrain_key()
554 path = _LAND_TILES.get(key) or _LAND_TILES.get("X")
555 if path:
556 sp = Sprite2D(texture=path, position=Vec2(cx, cy), width=TILE_SIZE, height=TILE_SIZE, filter="nearest")
557 self._content.add_child(sp)
558 tile._sprites.append(sp)
559
560 if tile.coin is not None:
561 frames = _ANIMATIONS[tile.coin]
562 if frames:
563 sp = FolderSprite(frames=frames, fps=ANIMATION_SPEED, position=Vec2(cx, cy))
564 self._content.add_child(sp)
565 tile._sprites.append(sp)
566
567 if tile.enemy is not None:
568 frames = _ANIMATIONS[tile.enemy]
569 if frames:
570 # Enemies stand on the cell floor; Sprite2D draws centred, so the
571 # anchor is pushed below the cell centre.
572 sp = FolderSprite(
573 frames=frames, fps=ANIMATION_SPEED, position=Vec2(cx, cell[1] * TILE_SIZE + TILE_SIZE * 0.7)
574 )
575 self._content.add_child(sp)
576 tile._sprites.append(sp)
577
578 # ------------------------------------------------------------------
579 # Input
580 # ------------------------------------------------------------------
581
582 def _canvas_mouse(self) -> Vec2:
583 """Pointer position in canvas space (i.e. relative to cell (0, 0))."""
584 return Input.mouse_position - self.origin
585
586 def _ui_blocks(self, screen_point: Vec2) -> bool:
587 """Whether the pointer is over the palette or the controls strip."""
588 return self.palette_rect_contains(screen_point) or self._strip.blocks(screen_point)
589
590 @staticmethod
591 def _cell_of(point: Vec2) -> tuple[int, int]:
592 """The grid cell containing a canvas-space point."""
593 return int(math.floor(point.x / TILE_SIZE)), int(math.floor(point.y / TILE_SIZE))
594
595 def on_update(self, dt: float) -> None:
596 if not self.visible:
597 return
598
599 view = view_size(self)
600 if (view.x, view.y) != (self._view.x, self._view.y):
601 self._view = view
602 self._layout_palette()
603 # The sky, sea and grid are painted from _view, a plain attribute.
604 self.queue_redraw()
605
606 self._object_place_cooldown = max(0.0, self._object_place_cooldown - dt)
607 self._update_clouds(dt)
608
609 # Selection cycling
610 if Input.is_action_just_pressed("select_prev"):
611 self.selection_index = max(2, self.selection_index - 1)
612 if Input.is_action_just_pressed("select_next"):
613 self.selection_index = min(18, self.selection_index + 1)
614
615 # Save / load
616 if Input.is_action_just_pressed("save_level"):
617 from .save_load import save_level
618
619 save_level(self)
620 if Input.is_action_just_pressed("load_level"):
621 from .save_load import load_level
622
623 load_level(self)
624
625 screen = Input.mouse_position
626 point = self._canvas_mouse()
627 on_ui = self._ui_blocks(screen)
628
629 lmb_just = Input.is_action_just_pressed("paint")
630 lmb_held = Input.is_action_pressed("paint")
631 mmb_held = Input.is_action_pressed("pan")
632 mmb_just = Input.is_action_just_pressed("pan")
633 rmb_just = Input.is_action_just_pressed("erase")
634 rmb_held = Input.is_action_pressed("erase")
635
636 # Pan: middle-button drag, or a left-button drag with the Pan tool on.
637 # A press that lands on the UI never starts one (over the palette the
638 # middle button is the foreground/background palm swap).
639 pan_held = mmb_held or (lmb_held and self.tool == "pan")
640 if pan_held and not self.pan_active and not on_ui:
641 self.pan_active = True
642 self.pan_anchor = screen - self.origin
643 elif not pan_held and self.pan_active:
644 self.pan_active = False
645 if self.pan_active:
646 self.origin = screen - self.pan_anchor
647
648 # Wheel pan (vertical scroll moves the canvas horizontally, as upstream)
649 _, scroll_y = Input.scroll_delta
650 if scroll_y != 0.0 and not on_ui:
651 self.origin = Vec2(self.origin.x - scroll_y * 50, self.origin.y)
652
653 self._update_ghost(point, hidden=on_ui or self.tool != "paint")
654
655 # Object drag start (left button just pressed over an object)
656 if lmb_just and not on_ui and self.tool == "paint":
657 for obj in self.objects:
658 if obj.hit(point):
659 self._dragging = obj
660 obj.selected = True
661 obj.mouse_offset = point - obj.position
662 break
663
664 if self._dragging is not None and lmb_held:
665 self._dragging.position = point - self._dragging.mouse_offset
666 # The sky handle's screen Y is the horizon on_draw paints from (a
667 # non-Property read), so keep the backdrop live while it is dragged.
668 if self._dragging is self.sky_handle:
669 self.queue_redraw()
670 if not lmb_held and self._dragging is not None:
671 self._dragging.selected = False
672 self._dragging = None
673
674 # Palette: left picks, right cycles within the quadrant, middle swaps
675 # the palm quadrant between its foreground and background sets.
676 if (lmb_just or rmb_just or mmb_just) and self.palette_rect_contains(screen):
677 new_id = self.palette_click(screen, cycle=rmb_just, swap_layer=mmb_just, pick=lmb_just)
678 if new_id is not None:
679 self.selection_index = new_id
680 self._refresh_palette_icons()
681
682 # Canvas paint: on the press, and while the button stays down
683 if lmb_held and self._dragging is None and not on_ui and self.tool == "paint":
684 self._canvas_paint(point)
685
686 # Canvas erase: the right button always, or the left with the Erase tool
687 if (rmb_held or (lmb_held and self.tool == "erase")) and not on_ui:
688 self._canvas_erase(point)
689
690 # Reset paint cell on mouse up so the same cell can be repainted later
691 if not lmb_held:
692 self.last_painted_cell = None
693
694 # ------------------------------------------------------------------
695 # Selection ghost
696 # ------------------------------------------------------------------
697
698 def _on_selection_changed(self) -> None:
699 """Follow ``selection_index``: new ghost art, new highlighted quadrant.
700
701 This runs from the Property's change hook, which can fire before
702 ``on_ready`` has built either node.
703 """
704 ghost = getattr(self, "_ghost", None)
705 if ghost is not None:
706 preview = EDITOR_DATA.get(self.selection_index, {}).get("preview")
707 ghost.texture = str(preview) if preview else None
708 layer = getattr(self, "_palette_layer", None)
709 if layer is not None:
710 layer.queue_redraw()
711
712 def _update_ghost(self, point: Vec2, *, hidden: bool) -> None:
713 """Follow the pointer with a translucent copy of the current selection."""
714 self._ghost.visible = not hidden and self._dragging is None
715 if not self._ghost.visible:
716 return
717 if EDITOR_DATA.get(self.selection_index, {}).get("type") == "tile":
718 col, row = self._cell_of(point)
719 self._ghost.position = Vec2(col * TILE_SIZE + TILE_SIZE / 2, row * TILE_SIZE + TILE_SIZE / 2)
720 else:
721 self._ghost.position = point
722
723 def _canvas_paint(self, point: Vec2) -> None:
724 sel = self.selection_index
725 data = EDITOR_DATA.get(sel)
726 if data is None:
727 return
728 if data["type"] == "tile":
729 cell = self._cell_of(point)
730 if cell == self.last_painted_cell:
731 return
732 self.place_tile_at(cell[0], cell[1], sel)
733 self.last_painted_cell = cell
734 else:
735 # Place-object cooldown to avoid spam
736 if self._object_place_cooldown > 0:
737 return
738 self._object_place_cooldown = 0.4
739 frames = _ANIMATIONS.get(sel, [])
740 if not frames:
741 return
742 self.add_object(EditorObject(frames=frames, tile_id=sel, position=point))
743
744 def _canvas_erase(self, point: Vec2) -> None:
745 # Object first
746 for obj in list(self.objects):
747 if obj.hit(point) and not obj.locked:
748 self.objects.remove(obj)
749 obj.destroy()
750 return
751 # Then tile
752 cell = self._cell_of(point)
753 if cell in self.canvas_data:
754 tile = self.canvas_data[cell]
755 sel = self.selection_index
756 tile.remove_id(sel)
757 if tile.is_empty:
758 # Remove sprites
759 for sp in tile._sprites:
760 sp.destroy()
761 del self.canvas_data[cell]
762 self._check_neighbours(cell)
763 for dc in (-1, 0, 1):
764 for dr in (-1, 0, 1):
765 self._rebuild_cell_sprites((cell[0] + dc, cell[1] + dr))
766
767 # ------------------------------------------------------------------
768 # Auto-tile
769 # ------------------------------------------------------------------
770
771 def _check_neighbours(self, cell: tuple[int, int]) -> None:
772 for dc in (-1, 0, 1):
773 for dr in (-1, 0, 1):
774 c = (cell[0] + dc, cell[1] + dr)
775 if c in self.canvas_data:
776 tile = self.canvas_data[c]
777 tile.terrain_neighbours = []
778 tile.water_on_top = False
779 for name, (sx, sy) in NEIGHBOUR_DIRECTIONS.items():
780 nbr = (c[0] + sx, c[1] + sy)
781 if nbr in self.canvas_data:
782 nbr_tile = self.canvas_data[nbr]
783 if nbr_tile.has_water and tile.has_water and name == "A":
784 tile.water_on_top = True
785 if nbr_tile.has_terrain:
786 tile.terrain_neighbours.append(name)
787
788 # ------------------------------------------------------------------
789 # Cloud spawner: lightweight Node2D children
790 # ------------------------------------------------------------------
791
792 def _startup_clouds(self) -> None:
793 for _ in range(8):
794 scale = 1.5 if randint(0, 4) < 2 else 1.0
795 pos = Vec2(randint(0, WINDOW_WIDTH), randint(0, int(WINDOW_HEIGHT * 0.4)))
796 self._spawn_cloud(pos, scale)
797
798 def _spawn_cloud(self, pos: Vec2, scale: float) -> None:
799 c = Cloud(frames=_CLOUD_FRAMES, position=pos, scale_xy=scale, speed=randint(20, 50), left_limit=-400.0)
800 self._cloud_layer.add_child(c)
801 self._clouds.append(c)
802
803 def _update_clouds(self, dt: float) -> None:
804 self._cloud_timer += dt
805 if self._cloud_timer > 3.0:
806 self._cloud_timer = 0.0
807 scale = 1.5 if randint(0, 4) < 2 else 1.0
808 pos = Vec2(WINDOW_WIDTH + randint(50, 100), randint(0, int(WINDOW_HEIGHT * 0.5)))
809 self._spawn_cloud(pos, scale)
810 # Reap killed clouds
811 self._clouds = [c for c in self._clouds if not c._is_dead]
812
813 # ------------------------------------------------------------------
814 # Drawing: only for sky/horizon/grid/preview/palette (immediate-mode)
815 # ------------------------------------------------------------------
816
817 def on_draw(self, renderer) -> None:
818 if not self.visible:
819 return
820 view = self._view
821
822 # Sky background fill
823 renderer.draw_rect((0, 0), (view.x, view.y), colour=SKY_COLOUR, filled=True)
824
825 # Sea below the sky handle's screen Y
826 horizon_y = self.origin.y + self.sky_handle.position.y
827 if 0 < horizon_y < view.y:
828 renderer.draw_rect((0, horizon_y), (view.x, view.y - horizon_y), colour=SEA_COLOUR, filled=True)
829 renderer.draw_rect((0, horizon_y - 10), (view.x, 10), colour=HORIZON_TOP_COLOUR, filled=True)
830 renderer.draw_rect((0, horizon_y - 16), (view.x, 4), colour=HORIZON_TOP_COLOUR, filled=True)
831 renderer.draw_rect((0, horizon_y - 20), (view.x, 2), colour=HORIZON_TOP_COLOUR, filled=True)
832 renderer.draw_rect((0, horizon_y), (view.x, 3), colour=HORIZON_COLOUR, filled=True)
833 elif horizon_y <= 0:
834 renderer.draw_rect((0, 0), (view.x, view.y), colour=SEA_COLOUR, filled=True)
835
836 # Tile grid overlay
837 self._draw_grid(renderer)
838
839 def _draw_grid(self, renderer) -> None:
840 view = self._view
841 ox = self.origin.x % TILE_SIZE
842 oy = self.origin.y % TILE_SIZE
843 for c in range(int(view.x // TILE_SIZE) + 2):
844 x = ox + c * TILE_SIZE - TILE_SIZE
845 renderer.draw_rect((x, 0), (1, view.y), colour=LINE_COLOUR, filled=True)
846 for r in range(int(view.y // TILE_SIZE) + 2):
847 y = oy + r * TILE_SIZE - TILE_SIZE
848 renderer.draw_rect((0, y), (view.x, 1), colour=LINE_COLOUR, filled=True)
849
850 def draw_palette(self, renderer) -> None:
851 """Paint the palette panel. Called by its layer, so it sits over the level."""
852 base = self.palette_origin()
853 size = self.palette_size
854
855 # Background
856 renderer.draw_rect((base.x, base.y), (size, size), colour=BUTTON_BG_COLOUR, filled=True)
857
858 sel_group = EDITOR_DATA.get(self.selection_index, {}).get("menu")
859
860 for btn in self.palette_buttons:
861 renderer.draw_rect((btn["x"], btn["y"]), (btn["w"], btn["h"]), colour=BUTTON_BG_COLOUR, filled=True)
862 in_group = btn["group"] == sel_group or (btn["group"] == "palm fg" and sel_group in ("palm fg", "palm bg"))
863 if in_group:
864 bw = 4
865 renderer.draw_rect(
866 (btn["x"] - bw, btn["y"] - bw), (btn["w"] + 2 * bw, bw), colour=BUTTON_LINE_COLOUR, filled=True
867 )
868 renderer.draw_rect(
869 (btn["x"] - bw, btn["y"] + btn["h"]),
870 (btn["w"] + 2 * bw, bw),
871 colour=BUTTON_LINE_COLOUR,
872 filled=True,
873 )
874 renderer.draw_rect((btn["x"] - bw, btn["y"]), (bw, btn["h"]), colour=BUTTON_LINE_COLOUR, filled=True)
875 renderer.draw_rect(
876 (btn["x"] + btn["w"], btn["y"]), (bw, btn["h"]), colour=BUTTON_LINE_COLOUR, filled=True
877 )
878
879 # ------------------------------------------------------------------
880 # Build the level dict (handed to PlayMode)
881 # ------------------------------------------------------------------
882
883 def create_grid(self) -> dict:
884 # Snapshot canvas tiles (without mutating per-cell objects)
885 cells = list(self.canvas_data.keys())
886 if not cells and not any(o for o in self.objects if not o.locked or o.tile_id == 0):
887 return {"water": {}, "bg palms": {}, "terrain": {}, "enemies": {}, "coins": {}, "fg objects": {}}
888
889 # Compute layer dict
890 if cells:
891 left = min(c[0] for c in cells)
892 top = min(c[1] for c in cells)
893 else:
894 left = top = 0
895
896 # Add object cell-membership to canvas_data
897 # (work on a fresh ephemeral dict so we don't pollute live editor state)
898 layers: dict[str, dict] = {
899 "water": {},
900 "bg palms": {},
901 "terrain": {},
902 "enemies": {},
903 "coins": {},
904 "fg objects": {},
905 }
906 for cell, tile in self.canvas_data.items():
907 x = (cell[0] - left) * TILE_SIZE
908 y = (cell[1] - top) * TILE_SIZE
909 if tile.has_water:
910 layers["water"][(x, y)] = "bottom" if tile.water_on_top else "top"
911 if tile.has_terrain:
912 key = tile.get_terrain_key()
913 layers["terrain"][(x, y)] = key if key in _LAND_TILES else "X"
914 if tile.coin is not None:
915 layers["coins"][(x + TILE_SIZE // 2, y + TILE_SIZE // 2)] = tile.coin
916 if tile.enemy is not None:
917 layers["enemies"][(x, y)] = tile.enemy
918
919 # Place free objects into the appropriate buckets. Canvas space is
920 # already level space up to the (left, top) shift, so the object's
921 # position converts directly.
922 bg_ids = {tid for tid, d in EDITOR_DATA.items() if d["style"] == "palm_bg"}
923 for obj in self.objects:
924 key = (
925 int(obj.position.x - left * TILE_SIZE),
926 int(obj.position.y - top * TILE_SIZE),
927 )
928 bucket = "bg palms" if obj.tile_id in bg_ids else "fg objects"
929 layers[bucket][key] = obj.tile_id
930
931 return layers
932
933
934# ---------------------------------------------------------------------------
935# Cloud node: drifts left, kills itself when off-screen
936# ---------------------------------------------------------------------------
937
938
939class Cloud(Node2D):
940 """Drifting background cloud.
941
942 The sprite draws at its texture's native size and is enlarged with the
943 node's ``scale``, so nothing here has to know how big the art is.
944 """
945
946 def __init__(self, frames: list[str], position: Vec2, scale_xy: float, speed: float, left_limit: float):
947 super().__init__(position=position, scale=Vec2(scale_xy, scale_xy))
948 self._is_dead = False
949 self._speed = speed
950 self._left_limit = left_limit
951 # Slightly transparent so the big clouds do not overpower the level.
952 self._sprite = Sprite2D(
953 texture=choice(frames),
954 filter="nearest",
955 colour=(1.0, 1.0, 1.0, 0.85),
956 )
957 self.add_child(self._sprite)
958
959 def on_update(self, dt: float) -> None:
960 if self._is_dead:
961 return
962 self.position = Vec2(self.position.x - self._speed * dt, self.position.y)
963 if self.position.x < self._left_limit:
964 self._is_dead = True
965 self.destroy()