nodes/combat.pyΒΆ

Part of Dungeon Explorer.

  1"""Combat system: hitbox management, damage formulas, cooldowns."""
  2
  3from simvx.core import Node2D, Property, Signal, Vec2
  4
  5
  6def _target_radius(target, default: float = 10.0) -> float:
  7    """Best-effort collision radius of a CharacterBody2D-style target.
  8
  9    The new physics stack stores a body's geometry in a ``shape`` resource
 10    (``CircleShape2D(radius=...)``) rather than the old ``.collision`` child node,
 11    so read the radius from there, falling back to a sane default.
 12    """
 13    shape = getattr(target, "shape", None)
 14    r = getattr(shape, "radius", None)
 15    return float(r) if r is not None else default
 16
 17
 18def calculate_damage(base_damage: int, attacker_str: int = 0, crit_chance: float = 0.0, rng=None) -> tuple[int, bool]:
 19    """Calculate final damage with strength scaling and crit.
 20
 21    Returns (damage, was_crit).
 22    """
 23    import random
 24
 25    rng = rng or random
 26
 27    damage = base_damage + attacker_str // 2
 28    is_crit = rng.random() < crit_chance
 29    if is_crit:
 30        damage = int(damage * 1.5)
 31    return max(1, damage), is_crit
 32
 33
 34def apply_defence(raw_damage: int, defence: int) -> int:
 35    """Reduce damage by defence. Always deals at least 1."""
 36    return max(1, raw_damage - defence // 2)
 37
 38
 39class Hitbox(Node2D):
 40    """A temporary damage area that checks for overlapping targets.
 41
 42    Used for melee attacks and projectile impacts. Created, lives for
 43    `lifetime` seconds, then destroys itself.
 44    """
 45
 46    # Transient effect: never persisted (a save mid-attack must re-apply cleanly).
 47    __save_persist__ = False
 48
 49    damage = Property(10, range=(0, 9999), group="Combat")
 50    knockback = Property(100.0, range=(0, 1000), group="Combat")
 51    lifetime = Property(0.15, range=(0.01, 2.0), group="Combat")
 52
 53    hit = Signal()  # (target_node)
 54
 55    #: Hit radius of the melee/impact area (world units).
 56    radius = Property(14.0, range=(1, 200), group="Combat")
 57
 58    def __init__(self, damage: int = 10, direction: Vec2 | None = None, **kwargs):
 59        super().__init__(**kwargs)
 60        self.damage = damage
 61        self._direction = direction or Vec2(0, 1)
 62        self._timer = 0.0
 63        self._hit_targets: set = set()
 64
 65    def on_update(self, dt: float):
 66        self._timer += dt
 67        if self._timer >= self.lifetime:
 68            self.destroy()
 69
 70    def check_hits(self, targets: list) -> list:
 71        """Check collision against a list of CharacterBody2D targets.
 72
 73        Returns list of newly hit targets.
 74        """
 75        newly_hit = []
 76        hp = self.world_position
 77        for target in targets:
 78            if target in self._hit_targets:
 79                continue
 80            d = target.world_position - hp
 81            reach = self.radius + _target_radius(target)
 82            if float(d.x) ** 2 + float(d.y) ** 2 <= reach * reach:
 83                self._hit_targets.add(target)
 84                newly_hit.append(target)
 85                self.hit(target)
 86        return newly_hit
 87
 88
 89class DamageNumber(Node2D):
 90    """Floating damage number that drifts up and fades out.
 91
 92    Features: damage-scaled size, random x-offset, crit bounce, heal green.
 93    """
 94
 95    # Transient effect: never persisted (a save mid-combat must re-apply cleanly).
 96    __save_persist__ = False
 97
 98    def __init__(self, value: int, is_crit: bool = False, is_heal: bool = False, **kwargs):
 99        super().__init__(**kwargs)
100        import random
101
102        self._value = value
103        self._is_crit = is_crit
104        self._is_heal = is_heal
105        self._timer = 0.0
106        self._duration = 1.0 if is_crit else 0.8
107        self._x_offset = random.uniform(-12, 12)
108        # Crit bounce: starts moving up fast, decelerates, then drifts
109        self._vy = -80.0 if is_crit else -30.0
110        self._bounce_done = False
111
112    def on_update(self, dt: float):
113        self._timer += dt
114        # Crit bounce effect
115        if self._is_crit and not self._bounce_done:
116            self._vy += 200 * dt  # Decelerate
117            if self._vy > 0:
118                self._vy = -20.0
119                self._bounce_done = True
120        self.position = Vec2(self.position.x, self.position.y + self._vy * dt)
121        if self._timer >= self._duration:
122            self.destroy()
123
124    def on_draw(self, renderer):
125        alpha = max(0.0, 1.0 - self._timer / self._duration)
126        # Scale by damage magnitude
127        base_scale = min(2.0, 0.8 + self._value * 0.02)
128        if self._is_heal:
129            colour = (0.2, 1.0, 0.3, alpha)
130            text = f"+{self._value}"
131            scale = base_scale
132        elif self._is_crit:
133            colour = (1.0, 0.2, 0.2, alpha)
134            text = str(self._value)
135            scale = base_scale * 1.5
136        else:
137            colour = (1.0, 1.0, 0.3, alpha)
138            text = str(self._value)
139            scale = base_scale
140        renderer.draw_text(text, (self.position.x + self._x_offset, self.position.y), scale=scale, colour=colour)