nodes/unit.pyΒΆ
Part of Tanks of Freedom.
1"""Unit nodes: Sprite2D wrapper with stats, AP and per-turn flags.
2
3Visual is a procedural numpy texture from ``textures.make_unit``; a small
4health bar Sprite2D is parented to the unit so it follows during animation.
5
6Movement is fire-and-forget, call ``begin_move(path)`` and the unit walks
7along the cells over time. ``move_finished`` signal fires when done so the
8world can chain follow-up actions (capture / attack / end-of-step audio).
9"""
10
11from __future__ import annotations
12
13from simvx.core import Node2D, Signal
14from simvx.core.animation.sprite import Sprite2D
15
16from .data import PLAYER_BLUE, PLAYER_RED, TILE_H, UNIT_HELICOPTER, UNIT_STATS
17from .textures import make_health_bar, make_unit
18
19# Step duration in seconds: keeps animation snappy but readable.
20STEP_TIME = 0.18
21
22
23class Unit(Node2D):
24 """Single combat unit on the iso grid."""
25
26 def __init__(self, *, unit_type: int, owner: int, cell: tuple[int, int], tile_map, **kwargs):
27 super().__init__(name=f"unit_{owner}_{unit_type}_{cell[0]}_{cell[1]}", **kwargs)
28 self.type = unit_type
29 self.owner = owner
30 self.cell = cell
31 self._tile_map = tile_map
32
33 stats = UNIT_STATS[unit_type]
34 self.life = stats["life"]
35 self.max_life = stats["max_life"]
36 self.max_ap = stats["max_ap"]
37 self.ap = self.max_ap
38 self.attack_ap = stats["attack_ap"]
39 self.attacks_left = stats["max_attacks"]
40 self.is_air = unit_type == UNIT_HELICOPTER
41 self.can_capture = stats["can_capture"]
42
43 # Position the unit at the cell centre.
44 wx, wy = tile_map.map_to_world(cell)
45 # Float units a bit above the iso ground so they stack visually.
46 self.position = (wx, wy - TILE_H * 0.4)
47
48 self._sprite = Sprite2D(
49 texture=make_unit(unit_type, owner),
50 width=48,
51 height=54,
52 filter="nearest",
53 position=(0, 0),
54 )
55 self.add_child(self._sprite)
56
57 self._healthbar = Sprite2D(
58 texture=make_health_bar(1.0),
59 width=28,
60 height=5,
61 filter="nearest",
62 position=(0, -30),
63 )
64 self.add_child(self._healthbar)
65
66 # Signals
67 self.move_finished = Signal() # Emits when current path animation ends.
68 self.died = Signal() # Emits right before destroy().
69
70 # Animation state.
71 self._path: list[tuple[int, int]] = []
72 self._step_t = 0.0
73 self._anim_from: tuple[float, float] | None = None
74 self._anim_to: tuple[float, float] | None = None
75 self._moving = False
76
77 # ----------------------------------------------------------- properties
78 def is_blue(self) -> bool:
79 return self.owner == PLAYER_BLUE
80
81 def is_red(self) -> bool:
82 return self.owner == PLAYER_RED
83
84 @property
85 def life_fraction(self) -> float:
86 return self.life / self.max_life if self.max_life > 0 else 0.0
87
88 @property
89 def is_moving(self) -> bool:
90 """True while the unit is animating along a path."""
91 return self._moving
92
93 # --------------------------------------------------------------- combat
94 def can_attack(self) -> bool:
95 """True when the unit still has the AP and the attack slot to strike."""
96 return self.ap >= self.attack_ap and self.attacks_left > 0
97
98 def consume_attack(self) -> None:
99 self.ap = max(0, self.ap - self.attack_ap)
100 self.attacks_left = max(0, self.attacks_left - 1)
101
102 def end_turn_refresh(self) -> None:
103 """Reset AP at the start of the owner's next turn."""
104 self.ap = self.max_ap
105 self.attacks_left = UNIT_STATS[self.type]["max_attacks"]
106
107 def kill(self) -> None:
108 self.died()
109 self.destroy()
110
111 # -------------------------------------------------------- move animation
112 def begin_move(self, path: list[tuple[int, int]]) -> None:
113 """Walk along ``path`` (cells). First cell should equal current cell.
114
115 AP is decremented by ``len(path) - 1`` immediately so the unit can't
116 be re-issued. ``move_finished`` fires when the last step lands.
117 """
118 if len(path) <= 1:
119 self.cell = path[-1] if path else self.cell
120 self.move_finished()
121 return
122 steps = path[1:]
123 self.ap = max(0, self.ap - len(steps))
124 self._path = steps
125 self._step_t = 0.0
126 self._begin_step()
127 self._moving = True
128
129 def _begin_step(self) -> None:
130 target = self._path[0]
131 wx, wy = self._tile_map.map_to_world(target)
132 self._anim_from = (self.position.x, self.position.y)
133 self._anim_to = (wx, wy - TILE_H * 0.4)
134
135 def on_update(self, dt: float) -> None:
136 if not self._moving:
137 return
138 self._step_t += dt
139 u = min(1.0, self._step_t / STEP_TIME)
140 # Linear lerp: small distance per step keeps it crisp.
141 ax, ay = self._anim_from
142 bx, by = self._anim_to
143 self.position = (ax + (bx - ax) * u, ay + (by - ay) * u)
144 if u >= 1.0:
145 # Land
146 self.cell = self._path.pop(0)
147 self._step_t = 0.0
148 if self._path:
149 self._begin_step()
150 else:
151 self._moving = False
152 self.move_finished()
153
154 # ----------------------------------------------------------- visuals
155 def refresh_health_bar(self) -> None:
156 """Repaint the health bar after ``life`` changed.
157
158 Combat resolution writes ``life`` directly (it is a pure function over
159 plain attributes), so the world calls this once the damage is in.
160 """
161 # Replace texture in place by reassigning .texture (Sprite2D
162 # invalidates the GPU id and the next frame uploads the new array).
163 self._healthbar.texture = make_health_bar(self.life_fraction)
164
165 def set_dim(self, dim: bool) -> None:
166 """Grey the sprite out while it is not this unit's side's turn."""
167 self._sprite.colour = (0.55, 0.55, 0.6, 1.0) if dim else (1.0, 1.0, 1.0, 1.0)