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