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