nodes/actions.py¶
Part of GDQuest Open RPG.
1"""Battler actions: attack, heal, area-attack. Coroutine-style execution."""
2
3from __future__ import annotations
4
5import random
6from collections.abc import Iterable
7
8from simvx.core.math.types import Vec2
9
10
11class BattlerAction:
12 """Base class. Each action is a state machine driven per-frame.
13
14 The battle orchestrator calls `start(source, targets)`, then `tick(dt)`
15 each frame until `tick` returns True (action complete).
16 """
17
18 name = "Action"
19 energy_cost = 0
20 targets_all = False
21 targets_player = False # True for self/ally targeting (heal)
22 base_damage = 0
23 description = ""
24
25 def can_use(self, source) -> bool:
26 return source.stats.energy >= self.energy_cost
27
28 def start(self, source, targets, scene) -> None:
29 self.source = source
30 self.targets = list(targets)
31 self.scene = scene
32 self.t = 0.0
33
34 def tick(self, dt: float) -> bool:
35 self.t += dt
36 return True
37
38
39class AttackAction(BattlerAction):
40 """Melee dash, hit roll, damage, return."""
41
42 name = "Attack"
43 energy_cost = 0
44 base_damage = 8
45 description = "Melee strike."
46
47 DASH_TIME = 0.15
48 HIT_TIME = 0.10
49 RETURN_TIME = 0.18
50
51 def __init__(self, base_damage: int = 8) -> None:
52 self.base_damage = base_damage
53
54 def start(self, source, targets, scene) -> None:
55 super().start(source, targets, scene)
56 self._phase = "dash"
57 self._origin = Vec2(source.position)
58 target = self.targets[0]
59 toward = target.position - source.position
60 # Move 60% of the way to the target
61 self._dest = source.position + Vec2(toward.x * 0.6, toward.y * 0.6)
62
63 def tick(self, dt: float) -> bool:
64 self.t += dt
65 if self._phase == "dash":
66 u = min(1.0, self.t / self.DASH_TIME)
67 ease = 1 - (1 - u) ** 3
68 self.source.position = Vec2(
69 self._origin.x + (self._dest.x - self._origin.x) * ease,
70 self._origin.y + (self._dest.y - self._origin.y) * ease,
71 )
72 if u >= 1.0:
73 self._phase = "hit"
74 self.t = 0.0
75 self._apply_hit()
76 return False
77 if self._phase == "hit":
78 if self.t >= self.HIT_TIME:
79 self._phase = "return"
80 self.t = 0.0
81 return False
82 if self._phase == "return":
83 u = min(1.0, self.t / self.RETURN_TIME)
84 self.source.position = Vec2(
85 self._dest.x + (self._origin.x - self._dest.x) * u,
86 self._dest.y + (self._origin.y - self._dest.y) * u,
87 )
88 if u >= 1.0:
89 self.source.position = self._origin
90 return True
91 return False
92 return True
93
94 def _apply_hit(self) -> None:
95 target = self.targets[0]
96 # Hit roll
97 roll = random.uniform(0, 100)
98 chance = self.source.stats.hit_chance - target.stats.evasion
99 if roll > chance:
100 self.scene.on_action_miss(self.source, target)
101 return
102 # Damage with ±10% variance
103 base = self.base_damage + self.source.stats.attack - target.stats.defense
104 base = max(1, base)
105 dmg = int(base * random.uniform(0.9, 1.1))
106 critical = random.random() < 0.10
107 if critical:
108 dmg = int(dmg * 1.5)
109 target.stats.take_damage(dmg)
110 self.scene.on_action_hit(self.source, target, dmg, critical=critical)
111
112
113class HealAction(BattlerAction):
114 """Heal an ally (or self)."""
115
116 name = "Heal"
117 energy_cost = 4
118 targets_player = True
119 description = "Restore 14 HP to one ally."
120
121 HEAL_AMOUNT = 14
122 JUMP_TIME = 0.20
123 HOLD = 0.20
124
125 def __init__(self, amount: int = 14) -> None:
126 self.HEAL_AMOUNT = amount
127
128 def start(self, source, targets, scene) -> None:
129 super().start(source, targets, scene)
130 self._phase = "jump_up"
131 self._origin = Vec2(source.position)
132
133 def tick(self, dt: float) -> bool:
134 self.t += dt
135 if self._phase == "jump_up":
136 u = min(1.0, self.t / self.JUMP_TIME)
137 self.source.position = Vec2(self._origin.x, self._origin.y - 18 * u)
138 if u >= 1.0:
139 self._phase = "hold"
140 self.t = 0.0
141 self._apply()
142 return False
143 if self._phase == "hold":
144 if self.t >= self.HOLD:
145 self._phase = "down"
146 self.t = 0.0
147 return False
148 if self._phase == "down":
149 u = min(1.0, self.t / self.JUMP_TIME)
150 self.source.position = Vec2(self._origin.x, self._origin.y - 18 * (1 - u))
151 if u >= 1.0:
152 self.source.position = self._origin
153 return True
154 return False
155 return True
156
157 def _apply(self) -> None:
158 target = self.targets[0]
159 delta = target.stats.heal(self.HEAL_AMOUNT)
160 self.scene.on_action_heal(self.source, target, delta)
161
162
163class AreaAttackAction(BattlerAction):
164 """Area attack: damage all enemies (squirrel's "Arrow Storm")."""
165
166 name = "Storm"
167 energy_cost = 5
168 targets_all = True
169 base_damage = 5
170 description = "Area attack: hit all enemies for ~5."
171
172 DASH_TIME = 0.12
173 HOLD = 0.12
174 RETURN_TIME = 0.18
175
176 def start(self, source, targets, scene) -> None:
177 super().start(source, targets, scene)
178 self._phase = "up"
179 self._origin = Vec2(source.position)
180
181 def tick(self, dt: float) -> bool:
182 self.t += dt
183 if self._phase == "up":
184 u = min(1.0, self.t / self.DASH_TIME)
185 self.source.position = Vec2(self._origin.x, self._origin.y - 24 * u)
186 if u >= 1.0:
187 self._phase = "rain"
188 self.t = 0.0
189 self._apply_all()
190 return False
191 if self._phase == "rain":
192 if self.t >= self.HOLD:
193 self._phase = "down"
194 self.t = 0.0
195 return False
196 if self._phase == "down":
197 u = min(1.0, self.t / self.RETURN_TIME)
198 self.source.position = Vec2(self._origin.x, self._origin.y - 24 * (1 - u))
199 if u >= 1.0:
200 self.source.position = self._origin
201 return True
202 return False
203 return True
204
205 def _apply_all(self) -> None:
206 for target in self.targets:
207 roll = random.uniform(0, 100)
208 chance = self.source.stats.hit_chance - target.stats.evasion
209 if roll > chance:
210 self.scene.on_action_miss(self.source, target)
211 continue
212 base = self.base_damage + self.source.stats.attack - target.stats.defense
213 dmg = max(1, int(base * random.uniform(0.85, 1.15)))
214 target.stats.take_damage(dmg)
215 self.scene.on_action_hit(self.source, target, dmg, critical=False)
216
217
218def actions_for_class(class_id: str) -> Iterable[BattlerAction]:
219 if class_id == "knight":
220 return [AttackAction(base_damage=10)]
221 if class_id == "wizard":
222 return [AttackAction(base_damage=6), HealAction(amount=16)]
223 if class_id == "squirrel":
224 return [AttackAction(base_damage=4), AreaAttackAction()]
225 return [AttackAction()]