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 the pointer controls on a screen-space
  6CanvasLayer above 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, Node2D, Property, UpdateMode, Vec2, WorldEnvironment
 31
 32from .effects import EnemyDeathEffect, GrassEffect, HitEffect
 33from .enemy import Enemy
 34from .grass import Grass
 35from .hud import HUD
 36from .player import Player
 37from .ui import TouchControls
 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._touch: TouchControls | None = None
122        self._screen: 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_screen_size()
140        self._route_pointer()
141        self._check_collisions()
142        self._prune_destroyed()
143
144    def on_draw(self, renderer):
145        renderer.draw_rect((0, 0), (WORLD_WIDTH, WORLD_HEIGHT), colour=COLOUR_GRASS, filled=True)
146        # One simple (non self-intersecting) band: the right-hand edge down the
147        # centre line, then the left-hand edge back up.
148        right = [(x + PATH_HALF_WIDTH, y) for x, y in PATH_CENTRE_LINE]
149        left = [(x - PATH_HALF_WIDTH, y) for x, y in reversed(PATH_CENTRE_LINE)]
150        renderer.draw_polygon(right + left, colour=COLOUR_DIRT)
151
152    # ── Spawning ─────────────────────────────────────────────────────────────
153
154    def _spawn_player(self):
155        self.player = Player(position=Vec2(WORLD_WIDTH / 2, WORLD_HEIGHT / 2))
156        self._entities.add_child(self.player)
157        self.player.health_changed.connect(self._on_player_health_changed)
158
159    def _spawn_enemies(self):
160        cx, cy = WORLD_WIDTH / 2, WORLD_HEIGHT / 2
161        for dx, dy in ENEMY_SPAWN_OFFSETS:
162            enemy = Enemy(position=Vec2(cx + dx, cy + dy), player_ref=self.player)
163            enemy.hit_effect_signal.connect(self._spawn_hit_effect)
164            enemy.death_effect_signal.connect(self._spawn_death_effect)
165            self.enemies.append(enemy)
166            self._entities.add_child(enemy)
167
168    def _spawn_grass(self):
169        rng = random.Random(SCATTER_SEED)
170        for _ in range(GRASS_COUNT):
171            grass = Grass(position=Vec2(rng.uniform(30, WORLD_WIDTH - 30), rng.uniform(30, WORLD_HEIGHT - 30)))
172            grass.destroyed.connect(self._spawn_grass_effect)
173            self.grasses.append(grass)
174            self._entities.add_child(grass)
175
176    def _spawn_decorations(self):
177        for x, y in TREE_POSITIONS:
178            self._decorations.add_child(Tree(position=Vec2(x, y)))
179        for x, y in BUSH_POSITIONS:
180            self._decorations.add_child(Bush(position=Vec2(x, y)))
181
182    # ── Camera, HUD and pointer controls ─────────────────────────────────────
183
184    def _setup_camera(self):
185        camera = Camera2D(zoom=CAMERA_ZOOM)
186        # ``target`` is a plain attribute assigned after construction: Camera2D
187        # clears it in __init__, so it cannot be passed as a keyword.
188        camera.target = self.player
189        camera.smoothing = 8.0
190        self._camera = self.add_child(camera)
191        self._track_screen_size()
192
193    def _track_screen_size(self):
194        """Re-clamp the camera whenever the window size changes.
195
196        The limits are the world edges pulled in by half a screenful, so the
197        view never shows past the map. Where the world is narrower than the
198        viewport both limits collapse onto its centre, which centres the world
199        rather than inverting the clamp.
200        """
201        size = self.tree.screen_size if self.tree else (1280.0, 720.0)
202        if size == self._screen or self._camera is None:
203            return
204        self._screen = 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 pointer controls, pinned to the screen above the world."""
215        self._hud = self.add_child(HUD(max_hearts=PLAYER_MAX_HP))
216        self._touch = self.add_child(TouchControls())
217        self._touch.attack_pressed.connect(self.player.request_attack)
218        self._touch.roll_pressed.connect(self.player.request_roll)
219
220    def set_pointer_controls_visible(self, visible: bool):
221        """Hide the stick and the action buttons while the title card is up."""
222        if self._touch is not None:
223            self._touch.visible = visible
224
225    def _on_player_health_changed(self, health: int):
226        if self._hud:
227            self._hud.set_health(health)
228
229    def _route_pointer(self):
230        """Feed the on-screen stick to the player as this frame's steering."""
231        if self.player and self._touch:
232            self.player.touch_direction = self._touch.direction
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        px, py = self.position
326        renderer.draw_circle((px, py + 2), 8, colour=(0.0, 0.0, 0.0, 0.18), filled=True)
327        renderer.draw_rect((px - 3, py - 6), (6, 10), colour=(0.30, 0.20, 0.10, 1.0), filled=True)
328        renderer.draw_circle((px, py - 14), 12, colour=(0.18, 0.40, 0.14, 1.0), filled=True)
329        renderer.draw_circle((px - 5, py - 10), 8, colour=(0.22, 0.47, 0.17, 1.0), filled=True)
330        renderer.draw_circle((px + 6, py - 12), 7, colour=(0.22, 0.47, 0.17, 1.0), filled=True)
331
332
333class Bush(Node2D):
334    """Non-interactive bush: three overlapping lobes."""
335
336    def on_draw(self, renderer):
337        px, py = self.position
338        renderer.draw_circle((px, py + 2), 6, colour=(0.0, 0.0, 0.0, 0.16), filled=True)
339        renderer.draw_circle((px, py - 3), 7, colour=(0.25, 0.50, 0.18, 1.0), filled=True)
340        renderer.draw_circle((px - 5, py - 1), 5, colour=(0.30, 0.55, 0.20, 1.0), filled=True)
341        renderer.draw_circle((px + 5, py - 1), 5, colour=(0.30, 0.55, 0.20, 1.0), filled=True)