nodes/battle.py¶
Part of GDQuest Open RPG.
1"""BattleScene: turn-based combat.
2
3State machine:
4 SETUP -> SELECT (player picks actions for each living party member)
5 -> EXECUTE (queued actions run in speed order) -> SELECT/END
6 END signals victory or defeat back to root.
7
8Both the roster layout and the HUD are derived from the live viewport, and
9every menu row / target is clickable as well as key-navigable, so the scene
10works at any window size and on touch.
11"""
12
13from __future__ import annotations
14
15import math
16import random
17
18from simvx.core import Node2D
19from simvx.core.input.state import Input
20from simvx.core.math.types import Vec2
21from simvx.core.signals import Signal
22
23from .actions import BattlerAction
24from .battler import Battler
25from .floating_label import FloatingLabel
26from .layout import viewport_size
27from .settings import (
28 ARENA_BG,
29 ARENA_FG,
30 CRIT,
31 DAMAGE,
32 ENERGY_BG,
33 ENERGY_FG,
34 HEAL,
35 HP_BG,
36 HP_FG,
37 MISS,
38 PANEL,
39 PANEL_BORDER,
40 PANEL_BORDER_FOCUS,
41 TEXT,
42 TEXT_DIM,
43)
44
45# Battle layout: columns are fractions of the viewport width, rows a fixed gap
46# around the vertical centre.
47PLAYER_X_FRAC = 1.0 / 3.0
48ENEMY_X_FRAC = 0.75
49ROW_GAP = 110
50PARTY_Y_OFFSET = 40
51ENEMY_Y_OFFSET = -40
52BATTLER_HALF = 32.0 # click radius around a battler's centre
53
54# Action-menu metrics
55MENU_W = 260
56MENU_ROW_H = 32
57MENU_HEAD = 30
58MENU_BOTTOM_GAP = 90
59CANCEL_W = 116
60CANCEL_H = 30
61
62# State enum
63S_SETUP = "setup"
64S_SELECT_ACTION = "select_action"
65S_SELECT_TARGET = "select_target"
66S_EXECUTE = "execute"
67S_END = "end"
68
69
70_ENEMY_ROSTERS = {
71 "wolves": ["wolf", "wolf"],
72 "bears": ["bear", "wolf"],
73 "bugcats": ["bugcat", "bugcat", "bugcat"],
74}
75
76
77def _in_rect(pos, rect: tuple[float, float, float, float]) -> bool:
78 x, y, w, h = rect
79 return x <= pos[0] <= x + w and y <= pos[1] <= y + h
80
81
82class BattleScene(Node2D):
83 """Turn-based battle. Owns roster, UI, and action queue."""
84
85 victory = Signal()
86 defeat = Signal()
87
88 # The HUD animates every frame from plain attributes (menu highlight, HP
89 # bars, cursor bob), so the retained 2D pipeline re-collects it each frame.
90 dynamic = True
91
92 def __init__(self, enemy_kind: str, audio=None) -> None:
93 super().__init__()
94 self.enemy_kind = enemy_kind
95 self.audio = audio
96
97 # Battlers: 3 player party + 2-3 enemies
98 self.players: list[Battler] = []
99 self.enemies: list[Battler] = []
100 self.action_queue: list[Battler] = [] # waiting to execute, speed-sorted
101 self.players_to_select: list[Battler] = []
102 self.current_actor: Battler | None = None
103 self.current_action: BattlerAction | None = None
104
105 # UI state
106 self.state = S_SETUP
107 self.menu_index = 0 # action menu cursor (action selection)
108 self.target_index = 0 # target cursor
109 self.candidate_targets: list[Battler] = []
110
111 # Effects
112 self._cursor_t = 0.0
113 self._end_timer = 0.0
114
115 def on_ready(self) -> None:
116 for class_id in ("knight", "wizard", "squirrel"):
117 b = Battler(class_id, is_player=True, position=Vec2(0, 0))
118 self.add_child(b)
119 self.players.append(b)
120 for class_id in _ENEMY_ROSTERS.get(self.enemy_kind, ["wolf", "wolf"]):
121 b = Battler(class_id, is_player=False, position=Vec2(0, 0))
122 self.add_child(b)
123 self.enemies.append(b)
124 self.layout_battlers()
125
126 self._begin_round()
127
128 def layout_battlers(self) -> None:
129 """Place both rosters from the current viewport (also on resize)."""
130 w, h = viewport_size(self)
131 for column, battlers, offset in (
132 (w * PLAYER_X_FRAC, self.players, PARTY_Y_OFFSET),
133 (w * ENEMY_X_FRAC, self.enemies, ENEMY_Y_OFFSET),
134 ):
135 top = (h - (len(battlers) - 1) * ROW_GAP) * 0.5 + offset
136 for i, b in enumerate(battlers):
137 b.move_home(Vec2(column, top + i * ROW_GAP))
138
139 # ------------------------------------------------------------------
140 # Round flow
141 # ------------------------------------------------------------------
142 def _begin_round(self) -> None:
143 # Living players need to choose actions
144 self.players_to_select = [p for p in self.players if p.is_active]
145 # AI cache for living enemies happens immediately
146 for e in self.enemies:
147 if e.is_active:
148 self._ai_choose_action(e)
149 # Then ask players
150 if self.players_to_select:
151 self._enter_action_select(self.players_to_select[0])
152 else:
153 self._enter_execute()
154
155 def _ai_choose_action(self, e: Battler) -> None:
156 # Random valid action + random alive player target
157 valid = [a for a in e.actions if a.can_use(e)]
158 if not valid:
159 return
160 action = random.choice(valid)
161 targets = [p for p in self.players if p.is_active]
162 if not targets:
163 return
164 if action.targets_all:
165 chosen = targets
166 else:
167 chosen = [random.choice(targets)]
168 e.cached_action = action
169 e.cached_targets = chosen
170
171 def _enter_action_select(self, p: Battler) -> None:
172 self.state = S_SELECT_ACTION
173 self.current_actor = p
174 for q in self.players:
175 q.is_selected = q is p
176 self.menu_index = 0
177
178 def _enter_target_select(self, action: BattlerAction) -> None:
179 if action.targets_player:
180 self.candidate_targets = [p for p in self.players if p.is_active]
181 else:
182 self.candidate_targets = [e for e in self.enemies if e.is_active]
183 if action.targets_all and self.candidate_targets:
184 # Skip cursor; just commit
185 self.current_actor.cached_action = action
186 self.current_actor.cached_targets = list(self.candidate_targets)
187 self._after_player_selected()
188 return
189 if not self.candidate_targets:
190 self._after_player_selected()
191 return
192 self.target_index = 0
193 self.state = S_SELECT_TARGET
194
195 def _after_player_selected(self) -> None:
196 # Move to next player or execute
197 if self.audio:
198 self.audio.play_sfx("select")
199 idx = self.players_to_select.index(self.current_actor)
200 if idx + 1 < len(self.players_to_select):
201 self._enter_action_select(self.players_to_select[idx + 1])
202 else:
203 self._enter_execute()
204
205 def _enter_execute(self) -> None:
206 # Build queue: all actors with cached_action, sorted by speed desc
207 queue = []
208 for b in self.players + self.enemies:
209 if b.is_active and b.cached_action is not None:
210 queue.append(b)
211 queue.sort(key=lambda x: -x.stats.speed)
212 self.action_queue = queue
213 for q in self.players:
214 q.is_selected = False
215 self.current_actor = None
216 self.current_action = None
217 self.state = S_EXECUTE
218
219 def _step_execute(self, dt: float) -> None:
220 # Drive current action; pop next when done.
221 if self.current_action is None:
222 if not self.action_queue:
223 # Round done: check end
224 if self._check_end():
225 return
226 self._begin_round()
227 return
228 actor = self.action_queue.pop(0)
229 if not actor.is_active:
230 return
231 action = actor.cached_action
232 actor.cached_action = None
233 if action is None:
234 return
235 actor.stats.spend_energy(action.energy_cost)
236 # Filter dead targets
237 alive = [t for t in actor.cached_targets if t.is_active]
238 if not alive:
239 # Re-target: pick random living opponent (or skip)
240 opp = self.enemies if actor.is_player else self.players
241 alive = [b for b in opp if b.is_active]
242 if not alive:
243 return
244 alive = alive[:1] if not action.targets_all else alive
245 self.current_actor = actor
246 self.current_action = action
247 action.start(actor, alive, self)
248 return
249 if self.current_action.tick(dt):
250 self.current_action = None
251 self.current_actor = None
252 # End-of-action
253 if self._check_end():
254 return
255
256 def _check_end(self) -> bool:
257 any_player = any(p.is_active for p in self.players)
258 any_enemy = any(e.is_active for e in self.enemies)
259 if not any_enemy:
260 self.state = S_END
261 self._end_timer = 0.6
262 return True
263 if not any_player:
264 self.state = S_END
265 self._end_timer = 0.6
266 return True
267 return False
268
269 # ------------------------------------------------------------------
270 # Process: input + state pump
271 # ------------------------------------------------------------------
272 def on_update(self, dt: float) -> None:
273 self._cursor_t += dt
274 if self.state == S_SETUP:
275 return
276 if self.state == S_SELECT_ACTION:
277 self._tick_select_action()
278 elif self.state == S_SELECT_TARGET:
279 self._tick_select_target()
280 elif self.state == S_EXECUTE:
281 self._step_execute(dt)
282 elif self.state == S_END:
283 self._end_timer -= dt
284 if self._end_timer <= 0:
285 if any(p.is_active for p in self.players):
286 self.victory.emit()
287 else:
288 self.defeat.emit()
289 self.state = "done"
290
291 def _tick_select_action(self) -> None:
292 actor = self.current_actor
293 if actor is None or not actor.is_active:
294 self._after_player_selected()
295 return
296 actions = actor.actions
297 if Input.is_action_just_pressed("up"):
298 self.menu_index = (self.menu_index - 1) % len(actions)
299 if self.audio:
300 self.audio.play_sfx("blip")
301 elif Input.is_action_just_pressed("down"):
302 self.menu_index = (self.menu_index + 1) % len(actions)
303 if self.audio:
304 self.audio.play_sfx("blip")
305 elif Input.is_action_just_pressed("confirm"):
306 self._confirm_action()
307 elif Input.is_action_just_pressed("primary"):
308 # Mouse / touch: tapping a row picks it and commits in one go.
309 row = self._menu_row_at(Input.mouse_position)
310 if row is not None:
311 self.menu_index = row
312 self._confirm_action()
313
314 def _confirm_action(self) -> None:
315 actor = self.current_actor
316 action = actor.actions[self.menu_index]
317 if not action.can_use(actor):
318 if self.audio:
319 self.audio.play_sfx("miss")
320 return
321 self._enter_target_select(action)
322
323 def _tick_select_target(self) -> None:
324 if Input.is_action_just_pressed("up") or Input.is_action_just_pressed("left"):
325 self.target_index = (self.target_index - 1) % len(self.candidate_targets)
326 if self.audio:
327 self.audio.play_sfx("blip")
328 elif Input.is_action_just_pressed("down") or Input.is_action_just_pressed("right"):
329 self.target_index = (self.target_index + 1) % len(self.candidate_targets)
330 if self.audio:
331 self.audio.play_sfx("blip")
332 elif Input.is_action_just_pressed("cancel"):
333 self.state = S_SELECT_ACTION
334 elif Input.is_action_just_pressed("confirm"):
335 self._confirm_target(self.target_index)
336 elif Input.is_action_just_pressed("primary"):
337 # Mouse / touch: tap a battler to strike it, or the cancel chip to
338 # go back to the action menu.
339 pos = Input.mouse_position
340 if _in_rect(pos, self._cancel_button_rect()):
341 self.state = S_SELECT_ACTION
342 return
343 hit = self._target_at(pos)
344 if hit is not None:
345 self.target_index = hit
346 self._confirm_target(hit)
347
348 def _confirm_target(self, index: int) -> None:
349 actor = self.current_actor
350 actor.cached_action = actor.actions[self.menu_index]
351 actor.cached_targets = [self.candidate_targets[index]]
352 self._after_player_selected()
353
354 # ------------------------------------------------------------------
355 # Pointer hit-tests (shared geometry with the draw pass)
356 # ------------------------------------------------------------------
357 def _action_menu_rect(self) -> tuple[float, float, float, float]:
358 w, h = viewport_size(self)
359 rows = len(self.current_actor.actions) if self.current_actor else 1
360 menu_h = 24 + MENU_ROW_H * rows + 36
361 return w - MENU_W - 20, h - menu_h - MENU_BOTTOM_GAP, float(MENU_W), float(menu_h)
362
363 def _menu_row_rect(self, index: int) -> tuple[float, float, float, float]:
364 x, y, w, _h = self._action_menu_rect()
365 return x + 6, y + MENU_HEAD + index * MENU_ROW_H - 2, w - 12, MENU_ROW_H - 2
366
367 def _menu_row_at(self, pos) -> int | None:
368 actor = self.current_actor
369 if actor is None:
370 return None
371 for i in range(len(actor.actions)):
372 if _in_rect(pos, self._menu_row_rect(i)):
373 return i
374 return None
375
376 def _target_at(self, pos) -> int | None:
377 for i, b in enumerate(self.candidate_targets):
378 if abs(pos[0] - b.position.x) <= BATTLER_HALF and abs(pos[1] - b.position.y) <= BATTLER_HALF:
379 return i
380 return None
381
382 def _cancel_button_rect(self) -> tuple[float, float, float, float]:
383 w, h = viewport_size(self)
384 return w - CANCEL_W - 20, h - MENU_BOTTOM_GAP - CANCEL_H, float(CANCEL_W), float(CANCEL_H)
385
386 # ------------------------------------------------------------------
387 # Event hooks called by actions
388 # ------------------------------------------------------------------
389 def on_action_hit(self, source: Battler, target: Battler, dmg: int, *, critical: bool) -> None:
390 target.flash()
391 target.shake(amount=5.0, duration=0.18)
392 col = CRIT if critical else DAMAGE
393 text = f"-{dmg}!" if critical else f"-{dmg}"
394 self.add_child(FloatingLabel(text, target.position, colour=col))
395 if self.audio:
396 self.audio.play_sfx("hit")
397 # Wizard regenerates a tick of energy on hit (mirrors upstream's "energy refills")
398 if not source.is_player:
399 for p in self.players:
400 if p.class_id == "wizard" and p.is_active:
401 p.stats.restore_energy(1)
402
403 def on_action_heal(self, source: Battler, target: Battler, delta: int) -> None:
404 if delta > 0:
405 self.add_child(FloatingLabel(f"+{delta}", target.position, colour=HEAL))
406 if self.audio:
407 self.audio.play_sfx("heal")
408
409 def on_action_miss(self, source: Battler, target: Battler) -> None:
410 self.add_child(FloatingLabel("MISS", target.position, colour=MISS))
411 if self.audio:
412 self.audio.play_sfx("miss")
413
414 # ------------------------------------------------------------------
415 # Draw: arena background + UI overlay
416 # ------------------------------------------------------------------
417 def on_draw(self, renderer) -> None:
418 w, h = viewport_size(self)
419 # Arena background
420 renderer.draw_rect((0, 0), (w, h), colour=ARENA_BG, filled=True)
421 # "Ground" stripe
422 renderer.draw_rect((0, h - 90), (w, 90), colour=ARENA_FG, filled=True)
423 # Subtle vignette
424 renderer.draw_rect((0, 0), (w, 60), colour=(0, 0, 0, 0.30), filled=True)
425
426 # Party HP/Energy panel: left side
427 self._draw_party_panel(renderer)
428
429 # Action menu / target cursor / status overlay
430 if self.state == S_SELECT_ACTION:
431 self._draw_action_menu(renderer)
432 elif self.state == S_SELECT_TARGET:
433 self._draw_target_cursor(renderer)
434
435 # Round / state banner
436 if self.state == S_END:
437 text = "VICTORY!" if any(p.is_active for p in self.players) else "DEFEAT"
438 col = (1.0, 0.95, 0.30, 1.0) if "VICTORY" in text else (0.9, 0.3, 0.3, 1.0)
439 text_w = renderer.text_width(text, 3.0)
440 renderer.draw_rect(
441 (w * 0.5 - text_w * 0.5 - 24, h * 0.5 - 36),
442 (text_w + 48, 72),
443 colour=(0.05, 0.05, 0.10, 0.92),
444 filled=True,
445 )
446 renderer.draw_text(text, (w * 0.5 - text_w * 0.5, h * 0.5 - 24), colour=col, scale=3.0)
447
448 def _draw_party_panel(self, renderer) -> None:
449 x0 = 16
450 y0 = 16
451 w = 230
452 line = 60
453 h = 12 + line * len(self.players)
454 # Backdrop
455 renderer.draw_rect((x0, y0), (w, h), colour=PANEL, filled=True)
456 renderer.draw_rect((x0, y0), (w, h), colour=PANEL_BORDER, filled=False)
457 for i, p in enumerate(self.players):
458 yy = y0 + 8 + i * line
459 # Highlight current actor
460 if p is self.current_actor and self.state in (S_SELECT_ACTION, S_SELECT_TARGET):
461 renderer.draw_rect((x0 + 2, yy - 2), (w - 4, line - 4), colour=(1, 1, 1, 0.10), filled=True)
462 renderer.draw_rect((x0 + 2, yy - 2), (w - 4, line - 4), colour=PANEL_BORDER_FOCUS, filled=False)
463 name = p.stats.spec.name
464 colour = TEXT if p.is_active else TEXT_DIM
465 renderer.draw_text(name, (x0 + 12, yy), colour=colour, scale=1.3)
466 # HP bar
467 ratio = p.stats.health / max(1, p.stats.max_health)
468 self._draw_bar(renderer, x0 + 12, yy + 22, w - 24, 8, ratio, HP_FG, HP_BG)
469 renderer.draw_text(f"{p.stats.health}/{p.stats.max_health}", (x0 + w - 80, yy + 18), colour=TEXT, scale=1.0)
470 # Energy bar
471 eratio = p.stats.energy / max(1, p.stats.max_energy)
472 self._draw_bar(renderer, x0 + 12, yy + 36, w - 24, 5, eratio, ENERGY_FG, ENERGY_BG)
473
474 # Enemy hp bars (right side, smaller)
475 for e in self.enemies:
476 ex = e.position.x - 30
477 ey = e.position.y - 50
478 ratio = e.stats.health / max(1, e.stats.max_health)
479 self._draw_bar(renderer, ex, ey, 60, 6, ratio, HP_FG, HP_BG)
480
481 def _draw_bar(self, renderer, x, y, w, h, ratio, fg, bg) -> None:
482 renderer.draw_rect((x, y), (w, h), colour=bg, filled=True)
483 renderer.draw_rect((x, y), (max(0, int(w * ratio)), h), colour=fg, filled=True)
484 renderer.draw_rect((x, y), (w, h), colour=(0, 0, 0, 0.4), filled=False)
485
486 def _draw_action_menu(self, renderer) -> None:
487 actor = self.current_actor
488 if actor is None:
489 return
490 x, y, w, h = self._action_menu_rect()
491 renderer.draw_rect((x, y), (w, h), colour=PANEL, filled=True)
492 renderer.draw_rect((x, y), (w, h), colour=PANEL_BORDER, filled=False)
493 renderer.draw_text(f"{actor.stats.spec.name}'s turn", (x + 12, y + 6), colour=TEXT_DIM, scale=1.1)
494 for i, a in enumerate(actor.actions):
495 rx, ry, rw, rh = self._menu_row_rect(i)
496 if i == self.menu_index:
497 renderer.draw_rect((rx, ry), (rw, rh), colour=(1, 0.85, 0.30, 0.18), filled=True)
498 renderer.draw_rect((rx, ry), (rw, rh), colour=PANEL_BORDER_FOCUS, filled=False)
499 label = a.name
500 if a.energy_cost > 0:
501 label += f" [{a.energy_cost} EN]"
502 colour = TEXT if a.can_use(actor) else TEXT_DIM
503 renderer.draw_text(label, (x + 24, ry + 2), colour=colour, scale=1.3)
504 # Action description
505 chosen = actor.actions[self.menu_index]
506 renderer.draw_text(chosen.description, (x + 12, y + h - 28), colour=TEXT_DIM, scale=1.0)
507
508 def _draw_target_cursor(self, renderer) -> None:
509 if not self.candidate_targets:
510 return
511 _w, h = viewport_size(self)
512 target = self.candidate_targets[self.target_index]
513 # Pulsing arrow above target
514 bob = math.sin(self._cursor_t * 6.0) * 4.0
515 x = target.position.x - 8
516 y = target.position.y - 50 + bob
517 renderer.draw_rect((x, y), (16, 4), colour=PANEL_BORDER_FOCUS, filled=True)
518 renderer.draw_rect((x + 4, y + 4), (8, 4), colour=PANEL_BORDER_FOCUS, filled=True)
519 renderer.draw_rect((x + 6, y + 8), (4, 4), colour=PANEL_BORDER_FOCUS, filled=True)
520 # Outline
521 renderer.draw_rect(
522 (target.position.x - BATTLER_HALF, target.position.y - BATTLER_HALF),
523 (BATTLER_HALF * 2, BATTLER_HALF * 2),
524 colour=PANEL_BORDER_FOCUS,
525 filled=False,
526 )
527 # Cancel chip: clickable twin of the ESC key.
528 cx, cy, cw, ch = self._cancel_button_rect()
529 renderer.draw_rect((cx, cy), (cw, ch), colour=PANEL, filled=True)
530 renderer.draw_rect((cx, cy), (cw, ch), colour=PANEL_BORDER, filled=False)
531 renderer.draw_text("Cancel (ESC)", (cx + 12, cy + 8), colour=TEXT_DIM, scale=1.0)
532 # Hint
533 renderer.draw_text("Click a foe, or ENTER to confirm", (24, h - 60), colour=TEXT_DIM, scale=1.0)