nodes/world.py

Part of HeartBeast Action RPG.

  1"""World: the playfield, and the collision dispatcher that drives combat.
  2
  3Mirrors the upstream HeartBeast World scene: ground, player, bats, grass and
  4tree/bush decorations under a camera that follows the player and stops at the
  5world edge, with a heart HUD and an on-screen controller pinned to the screen
  6above it.
  7
  8Sword-vs-bat, sword-vs-grass, bat-vs-player and bat-vs-bat overlaps are all
  9resolved here rather than inside the entities, so each entity only has to
 10answer "where is my hurtbox".
 11"""
 12
 13from __future__ import annotations
 14
 15import math
 16import random
 17
 18from settings import (
 19    CAMERA_ZOOM,
 20    COLOUR_DIRT,
 21    COLOUR_GRASS,
 22    ENEMY_DAMAGE,
 23    PLAYER_ATTACK_DAMAGE,
 24    PLAYER_HURTBOX_RADIUS,
 25    PLAYER_MAX_HP,
 26    WORLD_HEIGHT,
 27    WORLD_WIDTH,
 28)
 29
 30from simvx.core import Camera2D, Controller, Node2D, Property, UpdateMode, Vec2, WorldEnvironment
 31from simvx.core.ui import TouchController
 32
 33from .effects import EnemyDeathEffect, GrassEffect, HitEffect
 34from .enemy import Enemy
 35from .grass import Grass
 36from .hud import HUD
 37from .player import Player
 38
 39#: Fixed seed, so the grass scatter (and every screenshot of it) is reproducible.
 40SCATTER_SEED = 20240607
 41GRASS_COUNT = 80
 42
 43#: Bat spawns, as offsets from the world centre. The first four sit just past the
 44#: detection radius of the starting screenful, so the field reads as populated
 45#: without the player being swarmed on the first frame.
 46ENEMY_SPAWN_OFFSETS = [
 47    (-120, -62),
 48    (112, 58),
 49    (-88, 78),
 50    (128, -52),
 51    (-360, -170),
 52    (350, -130),
 53    (-420, 190),
 54    (420, 200),
 55]
 56
 57TREE_POSITIONS = [
 58    (120, 110),
 59    (300, 90),
 60    (700, 120),
 61    (980, 140),
 62    (1180, 260),
 63    (180, 540),
 64    (430, 470),
 65    (560, 560),
 66    (880, 560),
 67    (1120, 470),
 68    (522, 402),
 69    (768, 246),
 70]
 71BUSH_POSITIONS = [
 72    (220, 230),
 73    (420, 180),
 74    (860, 240),
 75    (1060, 360),
 76    (260, 400),
 77    (620, 520),
 78    (940, 430),
 79    (1160, 130),
 80    (580, 232),
 81    (715, 412),
 82]
 83
 84#: The dirt path: a band of this half-width around a sine that winds one and a
 85#: half times down the map. Sampling it finely keeps the outline simple, which
 86#: is what the polygon fill needs.
 87PATH_HALF_WIDTH = 15.0
 88PATH_AMPLITUDE = 90.0
 89PATH_SAMPLES = 25
 90PATH_CENTRE_LINE = [
 91    (
 92        WORLD_WIDTH / 2 + math.sin(i / (PATH_SAMPLES - 1) * math.tau * 1.5) * PATH_AMPLITUDE,
 93        WORLD_HEIGHT * i / (PATH_SAMPLES - 1),
 94    )
 95    for i in range(PATH_SAMPLES)
 96]
 97
 98
 99class World(Node2D):
100    """Playfield: owns the player, the bats, the grass, the camera and the HUD."""
101
102    # The root runs while the tree is paused so ESC still quits behind the
103    # title card; gameplay must not inherit that, so it opts back in here.
104    update_mode = Property(
105        UpdateMode.PAUSABLE,
106        hint="Processing behaviour while the tree is paused",
107        on_change="_invalidate_update_mode_cache",
108    )
109
110    def __init__(self, **kwargs):
111        super().__init__(name="World", **kwargs)
112        self.player: Player | None = None
113        self.enemies: list[Enemy] = []
114        self.grasses: list[Grass] = []
115        self.effects: list[Node2D] = []
116
117        self._entities: Node2D | None = None
118        self._decorations: Node2D | None = None
119        self._camera: Camera2D | None = None
120        self._hud: HUD | None = None
121        self._pad: TouchController | None = None
122        self._viewport: tuple[float, float] = (0.0, 0.0)
123
124    # ── Lifecycle ────────────────────────────────────────────────────────────
125
126    def on_ready(self):
127        # Decorations are added first so the entity band draws over them.
128        self._decorations = self.add_child(Node2D(name="Decorations"))
129        self._entities = self.add_child(Node2D(name="Entities"))
130        self._spawn_player()
131        self._spawn_enemies()
132        self._spawn_grass()
133        self._spawn_decorations()
134        self._setup_camera()
135        self._setup_overlay()
136        self.add_child(WorldEnvironment())
137
138    def on_update(self, dt: float):
139        self._track_viewport_size()
140        self._check_collisions()
141        self._prune_destroyed()
142
143    def on_draw(self, renderer):
144        renderer.rect((0, 0), (WORLD_WIDTH, WORLD_HEIGHT), fill=COLOUR_GRASS)
145        # One simple (non self-intersecting) band: the right-hand edge down the
146        # centre line, then the left-hand edge back up.
147        right = [(x + PATH_HALF_WIDTH, y) for x, y in PATH_CENTRE_LINE]
148        left = [(x - PATH_HALF_WIDTH, y) for x, y in reversed(PATH_CENTRE_LINE)]
149        renderer.polygon(right + left, fill=COLOUR_DIRT)
150
151    # ── Spawning ─────────────────────────────────────────────────────────────
152
153    def _spawn_player(self):
154        self.player = Player(position=Vec2(WORLD_WIDTH / 2, WORLD_HEIGHT / 2))
155        self._entities.add_child(self.player)
156        self.player.health_changed.connect(self._on_player_health_changed)
157
158    def _spawn_enemies(self):
159        cx, cy = WORLD_WIDTH / 2, WORLD_HEIGHT / 2
160        for dx, dy in ENEMY_SPAWN_OFFSETS:
161            enemy = Enemy(position=Vec2(cx + dx, cy + dy), player_ref=self.player)
162            enemy.hit_effect_signal.connect(self._spawn_hit_effect)
163            enemy.death_effect_signal.connect(self._spawn_death_effect)
164            self.enemies.append(enemy)
165            self._entities.add_child(enemy)
166
167    def _spawn_grass(self):
168        rng = random.Random(SCATTER_SEED)
169        for _ in range(GRASS_COUNT):
170            grass = Grass(position=Vec2(rng.uniform(30, WORLD_WIDTH - 30), rng.uniform(30, WORLD_HEIGHT - 30)))
171            grass.destroyed.connect(self._spawn_grass_effect)
172            self.grasses.append(grass)
173            self._entities.add_child(grass)
174
175    def _spawn_decorations(self):
176        for x, y in TREE_POSITIONS:
177            self._decorations.add_child(Tree(position=Vec2(x, y)))
178        for x, y in BUSH_POSITIONS:
179            self._decorations.add_child(Bush(position=Vec2(x, y)))
180
181    # ── Camera, HUD and the on-screen pad ────────────────────────────────────
182
183    def _setup_camera(self):
184        camera = Camera2D(zoom=CAMERA_ZOOM)
185        # ``target`` is a plain attribute assigned after construction: Camera2D
186        # clears it in __init__, so it cannot be passed as a keyword.
187        camera.target = self.player
188        camera.smoothing = 8.0
189        self._camera = self.add_child(camera)
190        self._track_viewport_size()
191
192    def _track_viewport_size(self):
193        """Re-clamp the camera whenever the viewport size changes.
194
195        The limits are the world edges pulled in by half a screenful, so the
196        view never shows past the map. Where the world is narrower than the
197        viewport both limits collapse onto its centre, which centres the world
198        rather than inverting the clamp.
199        """
200        width, height = self.viewport_size
201        size = (float(width), float(height))
202        if size == self._viewport or self._camera is None:
203            return
204        self._viewport = size
205        half_w = size[0] / (2 * CAMERA_ZOOM)
206        half_h = size[1] / (2 * CAMERA_ZOOM)
207        self._camera.limit_left = min(half_w, WORLD_WIDTH / 2)
208        self._camera.limit_right = max(WORLD_WIDTH - half_w, WORLD_WIDTH / 2)
209        self._camera.limit_top = min(half_h, WORLD_HEIGHT / 2)
210        self._camera.limit_bottom = max(WORLD_HEIGHT - half_h, WORLD_HEIGHT / 2)
211        self._camera.snap_to()
212
213    def _setup_overlay(self):
214        """HUD and the on-screen controller, pinned to the screen above the world.
215
216        ``TouchController`` publishes a real pad, so the ``Controller`` and
217        ``Controller.LEFT_STICK`` bindings the player already declares read it and no
218        wiring is needed. It is a CanvasLayer in its own right, so it is added
219        directly rather than wrapped in one, and it hides itself as soon as a
220        physical controller is used.
221        """
222        self._hud = self.add_child(HUD(max_hearts=PLAYER_MAX_HP))
223        self._pad = self.add_child(TouchController(buttons=(Controller.A, Controller.B)))
224
225    def set_touch_pad_visible(self, visible: bool):
226        """Hide the on-screen controller while the title card is up."""
227        if self._pad is not None:
228            self._pad.visible = visible
229
230    def _on_player_health_changed(self, health: int):
231        if self._hud:
232            self._hud.set_health(health)
233
234    # ── Collision ────────────────────────────────────────────────────────────
235
236    def _check_collisions(self):
237        if not self.player or self.player.is_dead:
238            return
239        self._check_sword_hits()
240        self._check_enemy_vs_player()
241        self._check_soft_collision()
242
243    def _check_sword_hits(self):
244        """One sword sweep against both the bats and the grass."""
245        sword = self.player.get_sword_hitbox()
246        if sword is None:
247            return
248        centre, (width, height), _rotation = sword
249        half_w, half_h = width / 2, height / 2
250
251        for enemy in self.enemies:
252            if enemy.is_dead:
253                continue
254            hurt_centre, radius = enemy.get_hurtbox()
255            if self._aabb_circle_overlap(centre, half_w, half_h, hurt_centre, radius):
256                enemy.take_damage(PLAYER_ATTACK_DAMAGE)
257
258        for grass in self.grasses:
259            if not grass.is_alive:
260                continue
261            hurt_centre, radius = grass.get_hurtbox()
262            if self._aabb_circle_overlap(centre, half_w, half_h, hurt_centre, radius):
263                grass.take_damage()
264
265    def _check_enemy_vs_player(self):
266        for enemy in self.enemies:
267            if enemy.is_dead:
268                continue
269            hurt_centre, radius = enemy.get_hurtbox()
270            if (hurt_centre - self.player.position).length() < radius + PLAYER_HURTBOX_RADIUS:
271                self.player.take_damage(ENEMY_DAMAGE)
272
273    def _check_soft_collision(self):
274        alive = [e for e in self.enemies if not e.is_dead]
275        for i, first in enumerate(alive):
276            for second in alive[i + 1 :]:
277                first.apply_soft_collision(second)
278
279    @staticmethod
280    def _aabb_circle_overlap(
281        box_centre: Vec2, box_half_w: float, box_half_h: float, circle_centre: Vec2, circle_r: float
282    ) -> bool:
283        """Axis-aligned box against circle. The sword box is treated as unrotated."""
284        dx = abs(circle_centre[0] - box_centre[0])
285        dy = abs(circle_centre[1] - box_centre[1])
286        if dx > box_half_w + circle_r or dy > box_half_h + circle_r:
287            return False
288        if dx <= box_half_w or dy <= box_half_h:
289            return True
290        return (dx - box_half_w) ** 2 + (dy - box_half_h) ** 2 <= circle_r**2
291
292    # ── Effects ──────────────────────────────────────────────────────────────
293
294    def _spawn_hit_effect(self, position: Vec2):
295        self._add_effect(HitEffect(position=Vec2(position)))
296
297    def _spawn_death_effect(self, position: Vec2):
298        self._add_effect(EnemyDeathEffect(position=Vec2(position)))
299
300    def _spawn_grass_effect(self, position: Vec2):
301        self._add_effect(GrassEffect(position=Vec2(position)))
302
303    def _add_effect(self, effect: Node2D):
304        self.effects.append(effect)
305        self._entities.add_child(effect)
306
307    def _prune_destroyed(self):
308        """Drop the bookkeeping entries for nodes that removed themselves.
309
310        ``destroy()`` detaches the node at the end of the frame, so a node
311        whose ``parent`` is None has already left the tree.
312        """
313        self.effects = [e for e in self.effects if e.parent is not None]
314        self.enemies = [e for e in self.enemies if e.parent is not None]
315        self.grasses = [g for g in self.grasses if g.parent is not None]
316
317
318# ── Decorations ──────────────────────────────────────────────────────────────
319
320
321class Tree(Node2D):
322    """Non-interactive tree: a trunk under a canopy."""
323
324    def on_draw(self, renderer):
325        renderer.circle((0, 2), 8, fill=(0.0, 0.0, 0.0, 0.18))
326        renderer.rect((-3, -6), (6, 10), fill=(0.30, 0.20, 0.10, 1.0))
327        renderer.circle((0, -14), 12, fill=(0.18, 0.40, 0.14, 1.0))
328        renderer.circle((-5, -10), 8, fill=(0.22, 0.47, 0.17, 1.0))
329        renderer.circle((6, -12), 7, fill=(0.22, 0.47, 0.17, 1.0))
330
331
332class Bush(Node2D):
333    """Non-interactive bush: three overlapping lobes."""
334
335    def on_draw(self, renderer):
336        renderer.circle((0, 2), 6, fill=(0.0, 0.0, 0.0, 0.16))
337        renderer.circle((0, -3), 7, fill=(0.25, 0.50, 0.18, 1.0))
338        renderer.circle((-5, -1), 5, fill=(0.30, 0.55, 0.20, 1.0))
339        renderer.circle((5, -1), 5, fill=(0.30, 0.55, 0.20, 1.0))