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)