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 .controls import BATTLE
26from .floating_label import FloatingLabel
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 #: A fight is modal, so the battle's context hides every name below it: the
89 #: overworld's walking and talking go dark while this scene is in the tree
90 #: and come back when it leaves, with nothing to switch off by hand.
91 input_context = BATTLE
92
93 # The HUD animates every frame from plain attributes (menu highlight, HP
94 # bars, cursor bob), so the retained 2D pipeline re-collects it each frame.
95 dynamic = True
96
97 def __init__(self, enemy_kind: str, audio=None) -> None:
98 super().__init__()
99 self.enemy_kind = enemy_kind
100 self.audio = audio
101
102 # Battlers: 3 player party + 2-3 enemies
103 self.players: list[Battler] = []
104 self.enemies: list[Battler] = []
105 self.action_queue: list[Battler] = [] # waiting to execute, speed-sorted
106 self.players_to_select: list[Battler] = []
107 self.current_actor: Battler | None = None
108 self.current_action: BattlerAction | None = None
109
110 # UI state
111 self.state = S_SETUP
112 self.menu_index = 0 # action menu cursor (action selection)
113 self.target_index = 0 # target cursor
114 self.candidate_targets: list[Battler] = []
115
116 # Effects
117 self._cursor_t = 0.0
118 self._end_timer = 0.0
119
120 def on_ready(self) -> None:
121 for class_id in ("knight", "wizard", "squirrel"):
122 b = Battler(class_id, is_player=True, position=Vec2(0, 0))
123 self.add_child(b)
124 self.players.append(b)
125 for class_id in _ENEMY_ROSTERS.get(self.enemy_kind, ["wolf", "wolf"]):
126 b = Battler(class_id, is_player=False, position=Vec2(0, 0))
127 self.add_child(b)
128 self.enemies.append(b)
129 self.layout_battlers()
130
131 self._begin_round()
132
133 def layout_battlers(self) -> None:
134 """Place both rosters from the current viewport (also on resize)."""
135 w, h = self.viewport_size
136 for column, battlers, offset in (
137 (w * PLAYER_X_FRAC, self.players, PARTY_Y_OFFSET),
138 (w * ENEMY_X_FRAC, self.enemies, ENEMY_Y_OFFSET),
139 ):
140 top = (h - (len(battlers) - 1) * ROW_GAP) * 0.5 + offset
141 for i, b in enumerate(battlers):
142 b.move_home(Vec2(column, top + i * ROW_GAP))
143
144 # ------------------------------------------------------------------
145 # Round flow
146 # ------------------------------------------------------------------
147 def _begin_round(self) -> None:
148 # Living players need to choose actions
149 self.players_to_select = [p for p in self.players if p.is_active]
150 # AI cache for living enemies happens immediately
151 for e in self.enemies:
152 if e.is_active:
153 self._ai_choose_action(e)
154 # Then ask players
155 if self.players_to_select:
156 self._enter_action_select(self.players_to_select[0])
157 else:
158 self._enter_execute()
159
160 def _ai_choose_action(self, e: Battler) -> None:
161 # Random valid action + random alive player target
162 valid = [a for a in e.actions if a.can_use(e)]
163 if not valid:
164 return
165 action = random.choice(valid)
166 targets = [p for p in self.players if p.is_active]
167 if not targets:
168 return
169 if action.targets_all:
170 chosen = targets
171 else:
172 chosen = [random.choice(targets)]
173 e.cached_action = action
174 e.cached_targets = chosen
175
176 def _enter_action_select(self, p: Battler) -> None:
177 self.state = S_SELECT_ACTION
178 self.current_actor = p
179 for q in self.players:
180 q.is_selected = q is p
181 self.menu_index = 0
182
183 def _enter_target_select(self, action: BattlerAction) -> None:
184 if action.targets_player:
185 self.candidate_targets = [p for p in self.players if p.is_active]
186 else:
187 self.candidate_targets = [e for e in self.enemies if e.is_active]
188 if action.targets_all and self.candidate_targets:
189 # Skip cursor; just commit
190 self.current_actor.cached_action = action
191 self.current_actor.cached_targets = list(self.candidate_targets)
192 self._after_player_selected()
193 return
194 if not self.candidate_targets:
195 self._after_player_selected()
196 return
197 self.target_index = 0
198 self.state = S_SELECT_TARGET
199
200 def _after_player_selected(self) -> None:
201 # Move to next player or execute
202 if self.audio:
203 self.audio.play_sfx("select")
204 idx = self.players_to_select.index(self.current_actor)
205 if idx + 1 < len(self.players_to_select):
206 self._enter_action_select(self.players_to_select[idx + 1])
207 else:
208 self._enter_execute()
209
210 def _enter_execute(self) -> None:
211 # Build queue: all actors with cached_action, sorted by speed desc
212 queue = []
213 for b in self.players + self.enemies:
214 if b.is_active and b.cached_action is not None:
215 queue.append(b)
216 queue.sort(key=lambda x: -x.stats.speed)
217 self.action_queue = queue
218 for q in self.players:
219 q.is_selected = False
220 self.current_actor = None
221 self.current_action = None
222 self.state = S_EXECUTE
223
224 def _step_execute(self, dt: float) -> None:
225 # Drive current action; pop next when done.
226 if self.current_action is None:
227 if not self.action_queue:
228 # Round done: check end
229 if self._check_end():
230 return
231 self._begin_round()
232 return
233 actor = self.action_queue.pop(0)
234 if not actor.is_active:
235 return
236 action = actor.cached_action
237 actor.cached_action = None
238 if action is None:
239 return
240 actor.stats.spend_energy(action.energy_cost)
241 # Filter dead targets
242 alive = [t for t in actor.cached_targets if t.is_active]
243 if not alive:
244 # Re-target: pick random living opponent (or skip)
245 opp = self.enemies if actor.is_player else self.players
246 alive = [b for b in opp if b.is_active]
247 if not alive:
248 return
249 alive = alive[:1] if not action.targets_all else alive
250 self.current_actor = actor
251 self.current_action = action
252 action.start(actor, alive, self)
253 return
254 if self.current_action.tick(dt):
255 self.current_action = None
256 self.current_actor = None
257 # End-of-action
258 if self._check_end():
259 return
260
261 def _check_end(self) -> bool:
262 any_player = any(p.is_active for p in self.players)
263 any_enemy = any(e.is_active for e in self.enemies)
264 if not any_enemy:
265 self.state = S_END
266 self._end_timer = 0.6
267 return True
268 if not any_player:
269 self.state = S_END
270 self._end_timer = 0.6
271 return True
272 return False
273
274 # ------------------------------------------------------------------
275 # Process: input + state pump
276 # ------------------------------------------------------------------
277 def on_update(self, dt: float) -> None:
278 self._cursor_t += dt
279 if self.state == S_SETUP:
280 return
281 if self.state == S_SELECT_ACTION:
282 self._tick_select_action()
283 elif self.state == S_SELECT_TARGET:
284 self._tick_select_target()
285 elif self.state == S_EXECUTE:
286 self._step_execute(dt)
287 elif self.state == S_END:
288 self._end_timer -= dt
289 if self._end_timer <= 0:
290 if any(p.is_active for p in self.players):
291 self.victory.emit()
292 else:
293 self.defeat.emit()
294 self.state = "done"
295
296 def _tick_select_action(self) -> None:
297 actor = self.current_actor
298 if actor is None or not actor.is_active:
299 self._after_player_selected()
300 return
301 actions = actor.actions
302 if Input.just_pressed("menu_up"):
303 self.menu_index = (self.menu_index - 1) % len(actions)
304 if self.audio:
305 self.audio.play_sfx("blip")
306 elif Input.just_pressed("menu_down"):
307 self.menu_index = (self.menu_index + 1) % len(actions)
308 if self.audio:
309 self.audio.play_sfx("blip")
310 elif Input.just_pressed("confirm"):
311 self._confirm_action()
312 elif Input.just_pressed("select"):
313 # Mouse / touch: tapping a row picks it and commits in one go.
314 row = self._menu_row_at(Input.mouse_position)
315 if row is not None:
316 self.menu_index = row
317 self._confirm_action()
318
319 def _confirm_action(self) -> None:
320 actor = self.current_actor
321 action = actor.actions[self.menu_index]
322 if not action.can_use(actor):
323 if self.audio:
324 self.audio.play_sfx("miss")
325 return
326 self._enter_target_select(action)
327
328 def _tick_select_target(self) -> None:
329 if Input.just_pressed("menu_up") or Input.just_pressed("menu_left"):
330 self.target_index = (self.target_index - 1) % len(self.candidate_targets)
331 if self.audio:
332 self.audio.play_sfx("blip")
333 elif Input.just_pressed("menu_down") or Input.just_pressed("menu_right"):
334 self.target_index = (self.target_index + 1) % len(self.candidate_targets)
335 if self.audio:
336 self.audio.play_sfx("blip")
337 elif Input.just_pressed("cancel"):
338 self.state = S_SELECT_ACTION
339 elif Input.just_pressed("confirm"):
340 self._confirm_target(self.target_index)
341 elif Input.just_pressed("select"):
342 # Mouse / touch: tap a battler to strike it, or the cancel chip to
343 # go back to the action menu.
344 pos = Input.mouse_position
345 if _in_rect(pos, self._cancel_button_rect()):
346 self.state = S_SELECT_ACTION
347 return
348 hit = self._target_at(pos)
349 if hit is not None:
350 self.target_index = hit
351 self._confirm_target(hit)
352
353 def _confirm_target(self, index: int) -> None:
354 actor = self.current_actor
355 actor.cached_action = actor.actions[self.menu_index]
356 actor.cached_targets = [self.candidate_targets[index]]
357 self._after_player_selected()
358
359 # ------------------------------------------------------------------
360 # Pointer hit-tests (shared geometry with the draw pass)
361 # ------------------------------------------------------------------
362 def _action_menu_rect(self) -> tuple[float, float, float, float]:
363 w, h = self.viewport_size
364 rows = len(self.current_actor.actions) if self.current_actor else 1
365 menu_h = 24 + MENU_ROW_H * rows + 36
366 return w - MENU_W - 20, h - menu_h - MENU_BOTTOM_GAP, float(MENU_W), float(menu_h)
367
368 def _menu_row_rect(self, index: int) -> tuple[float, float, float, float]:
369 x, y, w, _h = self._action_menu_rect()
370 return x + 6, y + MENU_HEAD + index * MENU_ROW_H - 2, w - 12, MENU_ROW_H - 2
371
372 def _menu_row_at(self, pos) -> int | None:
373 actor = self.current_actor
374 if actor is None:
375 return None
376 for i in range(len(actor.actions)):
377 if _in_rect(pos, self._menu_row_rect(i)):
378 return i
379 return None
380
381 def _target_at(self, pos) -> int | None:
382 for i, b in enumerate(self.candidate_targets):
383 if abs(pos[0] - b.position.x) <= BATTLER_HALF and abs(pos[1] - b.position.y) <= BATTLER_HALF:
384 return i
385 return None
386
387 def _cancel_button_rect(self) -> tuple[float, float, float, float]:
388 w, h = self.viewport_size
389 return w - CANCEL_W - 20, h - MENU_BOTTOM_GAP - CANCEL_H, float(CANCEL_W), float(CANCEL_H)
390
391 # ------------------------------------------------------------------
392 # Event hooks called by actions
393 # ------------------------------------------------------------------
394 def on_action_hit(self, source: Battler, target: Battler, dmg: int, *, critical: bool) -> None:
395 target.flash()
396 target.shake(amount=5.0, duration=0.18)
397 col = CRIT if critical else DAMAGE
398 text = f"-{dmg}!" if critical else f"-{dmg}"
399 self.add_child(FloatingLabel(text, target.position, colour=col))
400 if self.audio:
401 self.audio.play_sfx("hit")
402 # Wizard regenerates a tick of energy on hit (mirrors upstream's "energy refills")
403 if not source.is_player:
404 for p in self.players:
405 if p.class_id == "wizard" and p.is_active:
406 p.stats.restore_energy(1)
407
408 def on_action_heal(self, source: Battler, target: Battler, delta: int) -> None:
409 if delta > 0:
410 self.add_child(FloatingLabel(f"+{delta}", target.position, colour=HEAL))
411 if self.audio:
412 self.audio.play_sfx("heal")
413
414 def on_action_miss(self, source: Battler, target: Battler) -> None:
415 self.add_child(FloatingLabel("MISS", target.position, colour=MISS))
416 if self.audio:
417 self.audio.play_sfx("miss")
418
419 # ------------------------------------------------------------------
420 # Draw: arena background + UI overlay
421 # ------------------------------------------------------------------
422 def on_draw(self, renderer) -> None:
423 w, h = self.viewport_size
424 # Arena background
425 renderer.rect((0, 0), (w, h), fill=ARENA_BG)
426 # "Ground" stripe
427 renderer.rect((0, h - 90), (w, 90), fill=ARENA_FG)
428 # Subtle vignette
429 renderer.rect((0, 0), (w, 60), fill=(0, 0, 0, 0.30))
430
431 # Party HP/Energy panel: left side
432 self._draw_party_panel(renderer)
433
434 # Action menu / target cursor / status overlay
435 if self.state == S_SELECT_ACTION:
436 self._draw_action_menu(renderer)
437 elif self.state == S_SELECT_TARGET:
438 self._draw_target_cursor(renderer)
439
440 # Round / state banner
441 if self.state == S_END:
442 text = "VICTORY!" if any(p.is_active for p in self.players) else "DEFEAT"
443 col = (1.0, 0.95, 0.30, 1.0) if "VICTORY" in text else (0.9, 0.3, 0.3, 1.0)
444 text_w = renderer.text_width(text, 3.0)
445 renderer.rect((w * 0.5 - text_w * 0.5 - 24, h * 0.5 - 36), (text_w + 48, 72), fill=(0.05, 0.05, 0.10, 0.92))
446 renderer.text(text, (w * 0.5 - text_w * 0.5, h * 0.5 - 24), fill=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.rect((x0, y0), (w, h), fill=PANEL)
456 renderer.rect((x0, y0), (w, h), stroke=PANEL_BORDER)
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.rect((x0 + 2, yy - 2), (w - 4, line - 4), fill=(1, 1, 1, 0.10))
462 renderer.rect((x0 + 2, yy - 2), (w - 4, line - 4), stroke=PANEL_BORDER_FOCUS)
463 name = p.stats.spec.name
464 colour = TEXT if p.is_active else TEXT_DIM
465 renderer.text(name, (x0 + 12, yy), fill=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.text(f"{p.stats.health}/{p.stats.max_health}", (x0 + w - 80, yy + 18), fill=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.rect((x, y), (w, h), fill=bg)
483 renderer.rect((x, y), (max(0, int(w * ratio)), h), fill=fg)
484 renderer.rect((x, y), (w, h), stroke=(0, 0, 0, 0.4))
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.rect((x, y), (w, h), fill=PANEL)
492 renderer.rect((x, y), (w, h), stroke=PANEL_BORDER)
493 renderer.text(f"{actor.stats.spec.name}'s turn", (x + 12, y + 6), fill=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.rect((rx, ry), (rw, rh), fill=(1, 0.85, 0.30, 0.18))
498 renderer.rect((rx, ry), (rw, rh), stroke=PANEL_BORDER_FOCUS)
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.text(label, (x + 24, ry + 2), fill=colour, scale=1.3)
504 # Action description
505 chosen = actor.actions[self.menu_index]
506 renderer.text(chosen.description, (x + 12, y + h - 28), fill=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 = self.viewport_size
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.rect((x, y), (16, 4), fill=PANEL_BORDER_FOCUS)
518 renderer.rect((x + 4, y + 4), (8, 4), fill=PANEL_BORDER_FOCUS)
519 renderer.rect((x + 6, y + 8), (4, 4), fill=PANEL_BORDER_FOCUS)
520 # Outline
521 renderer.rect(
522 (target.position.x - BATTLER_HALF, target.position.y - BATTLER_HALF),
523 (BATTLER_HALF * 2, BATTLER_HALF * 2),
524 stroke=PANEL_BORDER_FOCUS,
525 )
526 # Cancel chip: clickable twin of the ESC key.
527 cx, cy, cw, ch = self._cancel_button_rect()
528 renderer.rect((cx, cy), (cw, ch), fill=PANEL)
529 renderer.rect((cx, cy), (cw, ch), stroke=PANEL_BORDER)
530 renderer.text("Cancel (ESC / B)", (cx + 12, cy + 8), fill=TEXT_DIM, scale=1.0)
531 # Hint
532 renderer.text("Tap a foe, or ENTER / (A) to confirm", (24, h - 60), fill=TEXT_DIM, scale=1.0)