nodes/battler.pyΒΆ
Part of GDQuest Open RPG.
1"""Battler: a sprite + stats + actions, used in BattleScene."""
2
3from __future__ import annotations
4
5import math
6import random
7
8from simvx.core import Sprite2D
9from simvx.core.math.types import Vec2
10
11from . import sprites as art
12from .actions import BattlerAction, actions_for_class
13from .stats import ENEMY_PROTOS, PARTY_PROTOS, BattlerStats
14
15
16def _sprite_for_class(class_id: str):
17 if class_id == "knight":
18 return art.knight_sprite()
19 if class_id == "wizard":
20 return art.wizard_sprite()
21 if class_id == "squirrel":
22 return art.squirrel_sprite()
23 if class_id == "wolf":
24 return art.wolf_sprite()
25 if class_id == "bear":
26 return art.bear_sprite()
27 if class_id == "bugcat":
28 return art.bugcat_sprite()
29 return art.knight_sprite()
30
31
32class Battler(Sprite2D):
33 """A combatant in the battle scene."""
34
35 def __init__(self, class_id: str, is_player: bool, position: Vec2) -> None:
36 super().__init__(texture=_sprite_for_class(class_id))
37 self.filter = "nearest"
38 self.width = 56
39 self.height = 56
40 self.class_id = class_id
41 self.is_player = is_player
42 protos = PARTY_PROTOS if is_player else ENEMY_PROTOS
43 self.stats = BattlerStats(protos[class_id])
44 self.actions = list(actions_for_class(class_id))
45 self.position = position
46 self._home_pos = Vec2(position)
47 # Battle state
48 self.cached_action: BattlerAction | None = None
49 self.cached_targets: list[Battler] = []
50 self.is_active = True
51
52 self.stats.health_depleted.connect(self._on_died)
53
54 # Attack-flash + hit-shake state
55 self._flash_t = 0.0
56 self._shake_t = 0.0
57 self._shake_amt = 0.0
58 # Selection bob
59 self.is_selected = False
60 self._bob_t = 0.0
61
62 def _on_died(self) -> None:
63 self.is_active = False
64 # Fade out in place
65 self.colour = (0.6, 0.4, 0.4, 0.6)
66
67 def move_home(self, position: Vec2) -> None:
68 """Re-anchor the battler (used when the viewport resizes mid-battle)."""
69 self._home_pos = Vec2(position)
70 self.position = Vec2(position)
71
72 def flash(self, duration: float = 0.15) -> None:
73 self._flash_t = duration
74
75 def shake(self, amount: float = 4.0, duration: float = 0.20) -> None:
76 self._shake_t = duration
77 self._shake_amt = amount
78
79 def on_update(self, dt: float) -> None:
80 if self._flash_t > 0:
81 self._flash_t -= dt
82 if self._flash_t > 0:
83 # Quick red-tint flash
84 self.colour = (1.0, 0.4, 0.4, 1.0)
85 else:
86 self.colour = (1.0, 1.0, 1.0, 1.0)
87 if self._shake_t > 0:
88 self._shake_t -= dt
89 jitter_x = random.uniform(-1, 1) * self._shake_amt
90 jitter_y = random.uniform(-1, 1) * self._shake_amt
91 base = self._home_pos
92 self.position = Vec2(base.x + jitter_x, base.y + jitter_y)
93 if self._shake_t <= 0:
94 self.position = self._home_pos
95 # Selection bob
96 if self.is_selected and self.is_active:
97 self._bob_t += dt
98 offset = math.sin(self._bob_t * 7.0) * 3.0
99 base = self._home_pos
100 if self._shake_t <= 0:
101 self.position = Vec2(base.x, base.y + offset)
102 elif self._shake_t <= 0:
103 # Snap back if no shake / no selection
104 if (self.position - self._home_pos).length() > 0.5:
105 # Smooth lerp to home
106 self.position = Vec2(
107 self._home_pos.x + (self.position.x - self._home_pos.x) * 0.5,
108 self._home_pos.y + (self.position.y - self._home_pos.y) * 0.5,
109 )