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)