nodes/level.py¶

Part of PirateMaker.

  1"""PirateMaker play mode.
  2
  3Builds a runnable platformer level from a layered grid dict produced by the
  4editor. Mirrors `source/28_finish/level.py` + `sprites.py` but uses SimVX
  5Node2D children with FolderSprite for animations.
  6"""
  7
  8from __future__ import annotations
  9
 10import sfx
 11from settings import (
 12    EDITOR_DATA,
 13    GFX,
 14    HORIZON_COLOUR,
 15    HORIZON_TOP_COLOUR,
 16    SEA_COLOUR,
 17    SFX,
 18    SKY_COLOUR,
 19    TILE_SIZE,
 20    WINDOW_HEIGHT,
 21    WINDOW_WIDTH,
 22)
 23from support import folder_dict, folder_frames, palm_subfolders
 24
 25from simvx.core import (
 26    AudioClip,
 27    AudioPlayer,
 28    Camera2D,
 29    CanvasLayer,
 30    Node2D,
 31    Signal,
 32    Sprite2D,
 33    Vec2,
 34)
 35
 36from .enemies import Shell, Spikes, Tooth
 37from .folder_sprite import FolderSprite
 38from .hud import play_strip, view_size
 39from .player import Player
 40
 41#: Half-extents of the things the player can touch, in pixels. Paired with the
 42#: player's own `hw` / `hh` to make the overlap tests centre-to-centre AABBs.
 43COIN_HALF = 32.0
 44HAZARD_HALF_W = 40.0
 45HAZARD_HALF_H = 32.0
 46
 47
 48class PlayMode(Node2D):
 49    """Runs a platformer level. Esc, or the strip's button, returns to the editor."""
 50
 51    editor_requested = Signal()
 52
 53    def __init__(self, grid: dict):
 54        super().__init__()
 55        self.grid = grid
 56        self.collision_rects: list[tuple[float, float, float, float]] = []
 57        self.coins: list[Node2D] = []
 58        self.damage_sources: list[Node2D] = []
 59        self.shells: list[Shell] = []
 60        self.player: Player | None = None
 61        self.horizon_y = WINDOW_HEIGHT * 0.5
 62
 63        self._music_player: AudioPlayer | None = None
 64        self._coin_stream: AudioClip = sfx.coin()
 65        self._hit_stream: AudioClip = sfx.hit()
 66        self._jump_stream: AudioClip = sfx.jump()
 67
 68    def on_ready(self) -> None:
 69        # Background layer: drawn behind everything, in screen-space
 70        self._sky_layer = self.add_child(_SkyLayer(self))
 71        self._sky_layer.layer = -10
 72
 73        # The movement pad has to exist before the player, which reads it.
 74        self.hud = self.add_child(play_strip())
 75        self.hud.button_pressed.connect(self._on_strip_button)
 76
 77        self._build()
 78
 79        # Camera follows player (Camera2D claims itself in on_ready)
 80        if self.player:
 81            cam = Camera2D()
 82            cam.target = self.player
 83            cam.smoothing = 8.0
 84            self.add_child(cam)
 85        self._start_music()
 86
 87    def _on_strip_button(self, action: str) -> None:
 88        if action == "editor":
 89            self.editor_requested()
 90
 91    def _start_music(self) -> None:
 92        path = SFX / "SuperHero.ogg"
 93        if not path.exists():
 94            return
 95        try:
 96            stream = AudioClip(str(path))
 97            self._music_player = self.add_child(AudioPlayer(stream=stream, autoplay=True, loop=True, volume_db=-8.0))
 98        except Exception:
 99            self._music_player = None
100
101    def stop_music(self) -> None:
102        if self._music_player is not None:
103            self._music_player.destroy()
104            self._music_player = None
105
106    # ------------------------------------------------------------------
107    # Level build
108    # ------------------------------------------------------------------
109
110    def _build(self) -> None:
111        land_tiles = folder_dict(GFX / "terrain/land")
112        water_bottom = str(GFX / "terrain/water/water_bottom.png")
113        water_top = folder_frames(GFX / "terrain/water/animation")
114        palms = palm_subfolders(GFX / "terrain/palm")
115
116        # Background palms (z = bg)
117        for (x, y), tile_id in self.grid.get("bg palms", {}).items():
118            name = self._palm_name(tile_id)
119            frames = palms.get(name)
120            if frames:
121                sp = FolderSprite(frames=frames, position=Vec2(x + TILE_SIZE / 2, y + TILE_SIZE / 2))
122                self.add_child(sp)
123
124        # Water (animated top vs static bottom)
125        for (x, y), kind in self.grid.get("water", {}).items():
126            if kind == "top" and water_top:
127                sp = FolderSprite(
128                    frames=water_top,
129                    fps=8.0,
130                    position=Vec2(x + TILE_SIZE / 2, y + TILE_SIZE / 2),
131                    width=TILE_SIZE,
132                    height=TILE_SIZE,
133                )
134            else:
135                sp = Sprite2D(
136                    texture=water_bottom,
137                    filter="nearest",
138                    position=Vec2(x + TILE_SIZE / 2, y + TILE_SIZE / 2),
139                    width=TILE_SIZE,
140                    height=TILE_SIZE,
141                )
142            self.add_child(sp)
143
144        # Terrain (collidable)
145        cell_xs = []
146        for (x, y), key in self.grid.get("terrain", {}).items():
147            path = land_tiles.get(key) or land_tiles.get("X")
148            if not path:
149                continue
150            sp = Sprite2D(
151                texture=path,
152                filter="nearest",
153                position=Vec2(x + TILE_SIZE / 2, y + TILE_SIZE / 2),
154                width=TILE_SIZE,
155                height=TILE_SIZE,
156            )
157            self.add_child(sp)
158            self.collision_rects.append((x, y, TILE_SIZE, TILE_SIZE))
159            cell_xs.append(x)
160
161        # Coins
162        for (x, y), tile_id in self.grid.get("coins", {}).items():
163            graphics = EDITOR_DATA[tile_id]["graphics"]
164            frames = folder_frames(graphics) if graphics else []
165            if not frames:
166                continue
167            sp = FolderSprite(frames=frames, fps=8.0, position=Vec2(x, y))
168            self.add_child(sp)
169            self.coins.append(sp)
170
171        # Enemies
172        for (x, y), tile_id in self.grid.get("enemies", {}).items():
173            self._spawn_enemy(tile_id, x, y)
174
175        # Foreground objects: palms + player + sky-handle (sky encoded as tile_id 1)
176        fg_palm_ids = {11, 12, 13, 14}
177        for (x, y), tile_id in self.grid.get("fg objects", {}).items():
178            if tile_id == 0:
179                # Player
180                self.player = Player(
181                    position=Vec2(x, y),
182                    collision_rects=self.collision_rects,
183                    jump_stream=self._jump_stream,
184                    level=self,
185                    pad=self.hud,
186                )
187                self.add_child(self.player)
188            elif tile_id == 1:
189                # Sky handle: drives horizon_y in this play mode
190                self.horizon_y = float(y)
191            elif tile_id in fg_palm_ids:
192                name = self._palm_name(tile_id)
193                frames = palms.get(name)
194                if frames:
195                    sp = FolderSprite(frames=frames, position=Vec2(x + TILE_SIZE / 2, y + TILE_SIZE / 2))
196                    self.add_child(sp)
197                    # Add a small collision block (matches upstream Block size)
198                    self.collision_rects.append((x, y, 76, 50))
199
200        # Default player if none placed
201        if self.player is None:
202            spawn_x = (min(cell_xs) - TILE_SIZE) if cell_xs else 0
203            self.player = Player(
204                position=Vec2(spawn_x, -200),
205                collision_rects=self.collision_rects,
206                jump_stream=self._jump_stream,
207                level=self,
208                pad=self.hud,
209            )
210            self.add_child(self.player)
211
212        # Compute level extents for camera-x clamping (advisory)
213        if cell_xs:
214            self.level_left = min(cell_xs) - WINDOW_WIDTH
215            self.level_right = max(cell_xs) + WINDOW_WIDTH
216
217    def _palm_name(self, tile_id: int) -> str:
218        return {
219            11: "small_fg",
220            12: "large_fg",
221            13: "left_fg",
222            14: "right_fg",
223            15: "small_bg",
224            16: "large_bg",
225            17: "left_bg",
226            18: "right_bg",
227        }.get(tile_id, "small_fg")
228
229    def _spawn_enemy(self, tile_id: int, x: float, y: float) -> None:
230        if tile_id == 7:
231            spikes = Spikes(
232                position=Vec2(x + TILE_SIZE / 2, y + TILE_SIZE / 2), texture=str(GFX / "enemies/spikes/spikes.png")
233            )
234            self.add_child(spikes)
235            self.damage_sources.append(spikes)
236        elif tile_id == 8:
237            tooth = Tooth(spawn_xy=(x, y), collision_rects=self.collision_rects)
238            self.add_child(tooth)
239            self.damage_sources.append(tooth)
240        elif tile_id == 9:
241            shell = Shell(spawn_xy=(x, y), orientation="left", level=self)
242            self.add_child(shell)
243            self.shells.append(shell)
244        elif tile_id == 10:
245            shell = Shell(spawn_xy=(x, y), orientation="right", level=self)
246            self.add_child(shell)
247            self.shells.append(shell)
248
249    # ------------------------------------------------------------------
250    # Per-frame logic
251    # ------------------------------------------------------------------
252
253    def on_update(self, dt: float) -> None:
254        # Overlap tests are centre-to-centre AABBs: the player's own half-extents
255        # plus a half-extent for the thing being touched.
256        if self.player is None:
257            return
258        px, py = self.player.position.x, self.player.position.y
259        hw, hh = self.player.hw, self.player.hh
260
261        # Coin pickup (Pearl projectiles add themselves to damage_sources)
262        for sp in list(self.coins):
263            if abs(sp.position.x - px) < COIN_HALF + hw and abs(sp.position.y - py) < COIN_HALF + hh:
264                sp.destroy()
265                self.coins.remove(sp)
266                self.add_child(AudioPlayer(stream=self._coin_stream, autoplay=True, volume_db=-12.0))
267
268        # Damage sources
269        for src in list(self.damage_sources):
270            if not hasattr(src, "position"):
271                continue
272            if abs(src.position.x - px) < HAZARD_HALF_W + hw and abs(src.position.y - py) < HAZARD_HALF_H + hh:
273                if self.player.try_damage():
274                    self.add_child(AudioPlayer(stream=self._hit_stream, autoplay=True, volume_db=-12.0))
275                    break
276
277
278class _SkyLayer(CanvasLayer):
279    """Screen-space sky+sea+horizon: drawn before everything else, ignores camera."""
280
281    # on_draw recomputes the horizon every frame from the live (non-Property)
282    # camera position (level.player.position.y), so it genuinely redraws each
283    # frame as the view scrolls. Declare it dynamic so the retained 2D cache
284    # re-collects it every frame instead of freezing the sky on scroll.
285    dynamic = True
286
287    def __init__(self, level: PlayMode):
288        super().__init__()
289        self._level = level
290
291    def on_draw(self, renderer) -> None:
292        level = self._level
293        view = view_size(self)
294        cam_y = level.player.position.y - view.y / 2 if level.player else 0
295        h_screen = level.horizon_y - cam_y
296
297        renderer.draw_rect((0, 0), (view.x, view.y), colour=SKY_COLOUR, filled=True)
298        if 0 < h_screen < view.y:
299            renderer.draw_rect((0, h_screen), (view.x, view.y - h_screen), colour=SEA_COLOUR, filled=True)
300            renderer.draw_rect((0, h_screen - 10), (view.x, 10), colour=HORIZON_TOP_COLOUR, filled=True)
301            renderer.draw_rect((0, h_screen - 16), (view.x, 4), colour=HORIZON_TOP_COLOUR, filled=True)
302            renderer.draw_rect((0, h_screen - 20), (view.x, 2), colour=HORIZON_TOP_COLOUR, filled=True)
303            renderer.draw_rect((0, h_screen), (view.x, 3), colour=HORIZON_COLOUR, filled=True)
304        elif h_screen <= 0:
305            renderer.draw_rect((0, 0), (view.x, view.y), colour=SEA_COLOUR, filled=True)