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