nodes/level.py¶
Part of Clear Code Zelda.
1"""Level: the actual gameplay scene.
2
3Spawns the world from CSV layouts (FloorBlocks, Grass, Objects, Entities),
4owns the player, enemies, attacks, particles, HUD and upgrade overlay.
5Implements y-sort drawing via :class:`simvx.core.YSortContainer`.
6
7Camera-follow is implemented by translating the y-sort container so the
8player stays centred. Pygame's original used a `YSortCameraGroup` for the
9same idea.
10"""
11
12from __future__ import annotations
13
14import random
15from pathlib import Path
16
17from settings import (
18 GROUND_COLOUR,
19 HITBOX_OFFSET,
20 TILESIZE,
21 audio_asset,
22 map_csv,
23 monster_data,
24)
25from support import import_csv_layout, import_folder
26
27from simvx.core import (
28 AudioClip,
29 AudioPlayer,
30 CanvasLayer,
31 Controller,
32 NavGrid2D,
33 Node2D,
34 Sprite2D,
35 UpdateMode,
36 Vec2,
37 YSortContainer,
38)
39from simvx.core.ui import TouchController
40
41from .enemy import Enemy
42from .magic import FlameProjectile, MagicPlayer
43from .particles import AnimationLibrary, ParticleEffect
44from .player import Player
45from .ui import HUD
46from .upgrade import UpgradeMenu
47from .weapon import Weapon
48
49#: Sound effects loaded from ``assets/audio/`` as (key, file, volume in dB).
50SFX = (
51 ("sword", "sword.wav", -12.0),
52 ("hit", "hit.wav", -14.0),
53 ("heal", "heal.wav", -12.0),
54 ("flame", "Fire.wav", -12.0),
55 ("death", "death.wav", -12.0),
56)
57
58
59# ---- Helpers ----------------------------------------------------------------
60
61
62class Tile(Node2D):
63 """Static decoration (grass, objects, invisible boundaries).
64
65 Boundary tiles ('invisible') have no sprite; they're collision-only.
66 """
67
68 def __init__(self, position: Vec2, sprite_type: str, image: str | None = None, attackable: bool = False, **kwargs):
69 super().__init__(position=position, **kwargs)
70 self.sprite_type = sprite_type
71 self.attackable = attackable
72 if image:
73 sprite = Sprite2D(texture=image, name=f"{sprite_type}Sprite")
74 self.add_child(sprite)
75 # AABB hitbox, mirroring the upstream `inflate(0, n)` shape
76 offset_y = HITBOX_OFFSET.get(sprite_type, 0)
77 self.hitbox_w = TILESIZE
78 self.hitbox_h = max(8, TILESIZE + offset_y)
79
80
81# ---- Level scene ------------------------------------------------------------
82
83
84class Level(Node2D):
85 """Top-down ARPG level. Owns the player, enemies, particles, HUD."""
86
87 # The ground rect is drawn from the (non-Property) camera offset each frame.
88 dynamic = True
89
90 def __init__(self, **kwargs):
91 super().__init__(name="Level", **kwargs)
92 self.player: Player | None = None
93 self.obstacle_tiles: list[Tile] = []
94 self.attackable_tiles: list[Tile] = [] # grass that can be slashed
95 self.enemies: list[Enemy] = []
96 self.attack_sprites: list[Weapon] = []
97 self.flame_projectiles: list[FlameProjectile] = []
98 self.current_attack: Weapon | None = None
99
100 # World container is y-sorted; HUD lives on a CanvasLayer above.
101 self._world: YSortContainer | None = None
102 self._hud_layer: CanvasLayer | None = None
103 self._hud: HUD | None = None
104 self._upgrade: UpgradeMenu | None = None
105 self._game_paused = False
106
107 self._anims = AnimationLibrary()
108 self._magic = MagicPlayer(self._anims)
109 self._sfx: dict[str, AudioPlayer] = {}
110 self._music: AudioPlayer | None = None
111 # Which sound each attack type makes, so an enemy's swing can be heard
112 # without the enemy having to know anything about audio.
113 self._attack_sounds = {info["attack_type"]: info["attack_sound"] for info in monster_data.values()}
114
115 # Pathfinding grid
116 self._nav: NavGrid2D | None = None
117 self._map_w = 0
118 self._map_h = 0
119
120 # -- setup --------------------------------------------------------------
121
122 def on_ready(self):
123 # Gameplay freezes while the upgrade overlay pauses the tree, whatever
124 # the root does (the root stays ALWAYS so ESC keeps working).
125 self.update_mode = UpdateMode.PAUSABLE
126
127 # World container is a regular y-sort group; we move IT to fake camera.
128 self._world = self.add_child(YSortContainer(name="World"))
129
130 # HUD layer is CanvasLayer (always screen-space). It keeps updating while
131 # the tree is paused, so the upgrade overlay can close itself again.
132 self._hud_layer = self.add_child(CanvasLayer(name="HUDLayer"))
133 self._hud_layer.layer = 100
134 self._hud_layer.update_mode = UpdateMode.ALWAYS
135
136 self._setup_audio()
137 self._build_map()
138 if self.player is None:
139 # Fallback: spawn at centre if the map didn't include a player marker
140 self.player = self._world.add_child(Player(position=Vec2(800, 600), name="Player"))
141 self._wire_player()
142
143 # Enemies chase the player, so they can only be wired once the whole map
144 # is built: the player marker may come after enemy rows in the CSV.
145 for enemy in self.enemies:
146 enemy.set_chase_target(self.player, self._nav)
147
148 # HUD + upgrade overlay (after the player, so they can reference it).
149 self._hud = self._hud_layer.add_child(HUD(self.player))
150 self._upgrade = self._hud_layer.add_child(UpgradeMenu(self.player))
151 self._upgrade.toggle_requested.connect(self.toggle_menu)
152
153 # The on-screen pad publishes a real controller, so the Controller and
154 # Controller stick bindings the game already has are the whole wiring. It
155 # keeps running through the pause, because START is how a touch player
156 # closes the upgrade screen again, and its stick drives the selection.
157 pad = self.add_child(
158 TouchController(
159 buttons=(Controller.A, Controller.B, Controller.LEFT_BUMPER, Controller.RIGHT_BUMPER),
160 name="TouchPad",
161 )
162 )
163 pad.update_mode = UpdateMode.ALWAYS
164
165 def _setup_audio(self):
166 """One reusable AudioPlayer per sound; positional panning is not used
167 because the camera is faked by translating the world container."""
168 for key, filename, volume_db in SFX:
169 self._add_sfx(key, filename, volume_db)
170 for info in monster_data.values():
171 self._add_sfx(info["attack_sound"], info["attack_sound"], -14.0)
172
173 music = Path(audio_asset("main.ogg"))
174 if music.exists():
175 self._music = self.add_child(
176 AudioPlayer(
177 stream=AudioClip(str(music)),
178 bus="Music",
179 loop=True,
180 volume_db=-24.0,
181 autoplay=True,
182 name="Music",
183 )
184 )
185
186 def _add_sfx(self, key: str, filename: str, volume_db: float):
187 path = Path(audio_asset(filename))
188 if key in self._sfx or not path.exists():
189 return
190 safe = key.replace("/", "_").replace(".", "_")
191 self._sfx[key] = self.add_child(
192 AudioPlayer(
193 stream=AudioClip(str(path)),
194 bus="SFX",
195 volume_db=volume_db,
196 name=f"Sfx_{safe}",
197 )
198 )
199
200 def _play(self, key: str):
201 sfx = self._sfx.get(key)
202 if sfx is not None:
203 sfx.play()
204
205 def _build_map(self):
206 floor_csv = import_csv_layout(map_csv("map_FloorBlocks.csv"))
207 grass_csv = import_csv_layout(map_csv("map_Grass.csv"))
208 object_csv = import_csv_layout(map_csv("map_Objects.csv"))
209 entity_csv = import_csv_layout(map_csv("map_Entities.csv"))
210
211 self._map_h = len(floor_csv)
212 self._map_w = len(floor_csv[0]) if floor_csv else 0
213 # "strict" allows diagonals but not the ones that cut the corner between
214 # two blocked tiles, which a monster with a hitbox cannot pass.
215 self._nav = NavGrid2D(self._map_w, self._map_h, cell_size=TILESIZE, corners="strict")
216
217 grass_paths = import_folder("grass")
218 object_paths = import_folder("objects")
219 # The ground is one big rect drawn in `on_draw`, which is far cheaper
220 # than 2850 tiny sprite nodes for a flat colour.
221
222 # Boundary (invisible walls)
223 for ry, row in enumerate(floor_csv):
224 for cx, cell in enumerate(row):
225 if cell != "-1":
226 tile = self._world.add_child(
227 Tile(
228 position=Vec2(cx * TILESIZE + TILESIZE / 2, ry * TILESIZE + TILESIZE / 2),
229 sprite_type="invisible",
230 )
231 )
232 self.obstacle_tiles.append(tile)
233 self._nav.set_solid(cx, ry, True)
234
235 # Grass (attackable)
236 for ry, row in enumerate(grass_csv):
237 for cx, cell in enumerate(row):
238 if cell != "-1" and grass_paths:
239 img = random.choice(grass_paths)
240 tile = self._world.add_child(
241 Tile(
242 position=Vec2(cx * TILESIZE + TILESIZE / 2, ry * TILESIZE + TILESIZE / 2),
243 sprite_type="grass",
244 image=img,
245 attackable=True,
246 )
247 )
248 # Grass is decorative: walkable, but slashable
249 self.attackable_tiles.append(tile)
250
251 # Objects (rocks, bushes, etc.)
252 for ry, row in enumerate(object_csv):
253 for cx, cell in enumerate(row):
254 if cell != "-1":
255 try:
256 idx = int(cell)
257 except ValueError:
258 continue
259 if 0 <= idx < len(object_paths):
260 tile = self._world.add_child(
261 Tile(
262 position=Vec2(cx * TILESIZE + TILESIZE / 2, ry * TILESIZE + TILESIZE / 2),
263 sprite_type="object",
264 image=object_paths[idx],
265 )
266 )
267 self.obstacle_tiles.append(tile)
268 self._nav.set_solid(cx, ry, True)
269
270 # Entities (player + enemies)
271 for ry, row in enumerate(entity_csv):
272 for cx, cell in enumerate(row):
273 if cell == "-1":
274 continue
275 wx = cx * TILESIZE + TILESIZE / 2
276 wy = ry * TILESIZE + TILESIZE / 2
277 if cell == "394":
278 self.player = self._world.add_child(
279 Player(
280 position=Vec2(wx, wy),
281 name="Player",
282 )
283 )
284 self._wire_player()
285 else:
286 name = {"390": "bamboo", "391": "spirit", "392": "raccoon"}.get(cell, "squid")
287 enemy = self._world.add_child(Enemy(name, Vec2(wx, wy)))
288 enemy.attacked.connect(self._on_enemy_attack)
289 enemy.died.connect(self._on_enemy_died)
290 self.enemies.append(enemy)
291
292 def _wire_player(self):
293 self.player.attack_started.connect(self._create_attack)
294 self.player.attack_finished.connect(self._destroy_attack)
295 self.player.magic_cast.connect(self._create_magic)
296 self.player.damaged.connect(self._on_player_damaged)
297
298 # -- signal handlers ----------------------------------------------------
299
300 def _create_attack(self):
301 self.current_attack = self._world.add_child(Weapon(self.player))
302 self.attack_sprites.append(self.current_attack)
303 self._play("sword")
304
305 def _destroy_attack(self):
306 if self.current_attack is not None and self.current_attack in self.attack_sprites:
307 self.attack_sprites.remove(self.current_attack)
308 self.current_attack.destroy()
309 self.current_attack = None
310
311 def _create_magic(self, style: str, strength: float, cost: int):
312 if style == "heal":
313 self._magic.heal(self.player, strength, cost, self._world)
314 self._play("heal")
315 elif style == "flame":
316 proj = self._magic.flame(self.player, strength, cost, self._world)
317 if proj is not None:
318 self.flame_projectiles.append(proj)
319 self._play("flame")
320
321 def _on_enemy_attack(self, amount: int, attack_type: str):
322 self._play(self._attack_sounds.get(attack_type, ""))
323 self.player.take_damage(amount, attack_type)
324
325 def _on_player_damaged(self, amount: int, attack_type: str):
326 self._world.add_child(
327 ParticleEffect(
328 self._anims.get(attack_type) or self._anims.get("slash"),
329 Vec2(self.player.position.x, self.player.position.y),
330 fps=14.0,
331 size=64,
332 )
333 )
334
335 def _on_enemy_died(self, enemy: Enemy):
336 self._trigger_death_particles(Vec2(enemy.position.x, enemy.position.y), enemy.monster_name)
337 self.player.exp += enemy.exp
338 self.player.kills += 1
339 self._play("death")
340 if enemy in self.enemies:
341 self.enemies.remove(enemy)
342 enemy.destroy()
343
344 def _trigger_death_particles(self, position: Vec2, monster_name: str):
345 frames = self._anims.get(monster_name) or self._anims.get("slash")
346 if frames:
347 self._world.add_child(ParticleEffect(frames, position, fps=14.0, size=64))
348
349 # -- per-frame ----------------------------------------------------------
350
351 def toggle_menu(self):
352 """Open or close the upgrade overlay, pausing the world behind it."""
353 self._game_paused = not self._game_paused
354 self._upgrade.visible = self._game_paused
355 # Hide the world container while paused so the upgrade overlay reads cleanly.
356 # Sprite2D draws happen in a separate engine pass from `on_draw` rectangles,
357 # so toggling the container's visibility is the right way to fully hide them.
358 if self._world is not None:
359 self._world.visible = not self._game_paused
360 if self.tree is not None:
361 # The HUD layer is UpdateMode.ALWAYS, so the overlay still runs.
362 self.tree.paused = self._game_paused
363
364 def on_update(self, dt: float):
365 # Resolve player wall collisions
366 self._resolve_obstacles(self.player)
367 # Resolve enemy wall collisions
368 for e in self.enemies:
369 self._resolve_obstacles(e)
370
371 # Player attacks vs attackable / enemies
372 self._player_attack_logic()
373
374 # Flame projectile vs enemies
375 self._flame_attack_logic()
376
377 # Camera follow (move world container so player is centred)
378 sw, sh = self.viewport_size
379 if self.player is not None:
380 self._world.position = Vec2(sw / 2 - self.player.position.x, sh / 2 - self.player.position.y)
381
382 # Knocked out: full-heal instead of a fail state, like the upstream.
383 if self.player.hp <= 0:
384 self.player.revive()
385
386 def on_draw(self, renderer):
387 # Procedural floor: single rect under everything (drawn before children)
388 # In world space, the y-sort container is offset, so we account for it.
389 if self._world is None:
390 return
391 # World rect (covers whole map in world space, then translated)
392 world_w = self._map_w * TILESIZE
393 world_h = self._map_h * TILESIZE
394 ox = self._world.position.x
395 oy = self._world.position.y
396 renderer.rect((ox, oy), (world_w, world_h), fill=GROUND_COLOUR)
397
398 # -- collision resolution ---------------------------------------------
399
400 def _resolve_obstacles(self, entity):
401 """Resolve AABB overlaps between an entity and obstacle tiles."""
402 if entity is None:
403 return
404 # Treat the entity as a 32x32 AABB centred on its position.
405 eh_w = 28.0
406 eh_h = 28.0
407 ex, ey = entity.position.x, entity.position.y
408 for tile in self.obstacle_tiles:
409 if tile.sprite_type == "invisible":
410 # Boundary walls are 64x64.
411 hw = TILESIZE * 0.5 + eh_w * 0.5
412 hh = TILESIZE * 0.5 + eh_h * 0.5
413 else:
414 hw = tile.hitbox_w * 0.5 + eh_w * 0.5
415 hh = tile.hitbox_h * 0.5 + eh_h * 0.5
416 dx = ex - tile.position.x
417 dy = ey - tile.position.y
418 if abs(dx) < hw and abs(dy) < hh:
419 # Push out along the axis of least overlap
420 px = hw - abs(dx)
421 py = hh - abs(dy)
422 if px < py:
423 ex += px if dx > 0 else -px
424 else:
425 ey += py if dy > 0 else -py
426 entity.position = Vec2(ex, ey)
427
428 # -- combat logic -----------------------------------------------------
429
430 def _player_attack_logic(self):
431 if not self.attack_sprites:
432 return
433 for atk in list(self.attack_sprites):
434 # Slash grass
435 for tile in list(self.attackable_tiles):
436 if not tile.parent: # already destroyed
437 self.attackable_tiles.remove(tile)
438 continue
439 if atk.overlaps(tile.position, TILESIZE, TILESIZE):
440 # leaf burst
441 leaves = self._anims.grass_leaf_frames()
442 if leaves:
443 for _ in range(random.randint(3, 5)):
444 ox = random.uniform(-12, 12)
445 oy = random.uniform(-30, 0)
446 self._world.add_child(
447 ParticleEffect(
448 leaves,
449 Vec2(tile.position.x + ox, tile.position.y + oy),
450 fps=12.0,
451 size=42,
452 )
453 )
454 self.attackable_tiles.remove(tile)
455 tile.destroy()
456 # Hit enemies. `take_damage` can retire an enemy, so iterate a copy.
457 for e in list(self.enemies):
458 if e.vulnerable and atk.overlaps(e.position, 56, 56):
459 if e.take_damage(self.player, "weapon"):
460 self._play("hit")
461
462 def _flame_attack_logic(self):
463 if not self.flame_projectiles:
464 return
465 for proj in list(self.flame_projectiles):
466 if proj.parent is None:
467 self.flame_projectiles.remove(proj)
468 continue
469 for e in list(self.enemies):
470 if not e.vulnerable:
471 continue
472 if abs(proj.position.x - e.position.x) < 36 and abs(proj.position.y - e.position.y) < 36:
473 if e.take_damage(self.player, "magic"):
474 self._play("hit")
475 proj.consume()
476 self.flame_projectiles.remove(proj)
477 break