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()