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)