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))