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)