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)