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()]