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)