nodes/world.py¶

Part of Tanks of Freedom.

  1"""TanksWorld: top-level game scene.
  2
  3Owns the iso TileMap, all units and buildings, the cursor / overlay layer,
  4the turn manager and the AI controller. Routes mouse / key input into game
  5actions: hover, select unit, plan path, confirm move, attack adjacent enemy,
  6end turn.
  7"""
  8
  9from __future__ import annotations
 10
 11from simvx.core import Input, Node2D, Signal
 12from simvx.core.audio import AudioClip, AudioPlayer
 13
 14from .ai import AIController
 15from .building import Building
 16from .combat import resolve_attack
 17from .cursor import Cursor, OverlayLayer
 18from .data import (
 19    ATTACK_RANGE,
 20    HUD_HEIGHT,
 21    INFO_PANEL_W,
 22    PLAYER_BLUE,
 23    PLAYER_RED,
 24    SFX_BUTTON,
 25    SFX_END_TURN,
 26    SFX_EXPLOSION,
 27    SFX_HURT,
 28    SFX_MOVE,
 29    SFX_NO_MOVES,
 30    SFX_SELECT,
 31    STARTING_BUILDINGS,
 32    STARTING_UNITS,
 33    UNIT_STATS,
 34    WINDOW_WIDTH,
 35    can_attack_unit_type,
 36)
 37from .effects import capture_flash, damage_flash, explosion
 38from .hud import Hud
 39from .pathfinder import cells_within_range, find_path, reachable_cells
 40from .tile_map import TankTileMap
 41from .turn import GameState, TurnManager
 42from .unit import Unit
 43
 44
 45class TanksWorld(Node2D):
 46    game_over = Signal()  # (winner)
 47
 48    def __init__(self, *, asset_dir=None, **kwargs):
 49        super().__init__(name="TanksWorld", **kwargs)
 50        self._asset_dir = asset_dir
 51
 52        self.tile_map = TankTileMap()
 53        self.add_child(self.tile_map)
 54
 55        self.units: list[Unit] = []
 56        self.buildings: list[Building] = []
 57        self.cursor = Cursor(self.tile_map)
 58        self.tile_map.add_child(self.cursor)
 59        self.overlay = OverlayLayer(self.tile_map)
 60        self.tile_map.add_child(self.overlay)
 61
 62        # HUD on top.
 63        self.hud = Hud()
 64        self.add_child(self.hud)
 65        self.hud.end_turn_pressed.connect(self._on_end_turn_button)
 66        self.hud.new_game_pressed.connect(self.restart)
 67
 68        # State
 69        self.turn = TurnManager()
 70        self.turn.turn_started.connect(self._on_turn_started)
 71        self.ai = AIController(self)
 72
 73        self._selected_unit: Unit | None = None
 74        self._reachable: dict[tuple[int, int], int] = {}
 75        self._attack_targets: list[tuple[int, int]] = []
 76        self._planned_path: list[tuple[int, int]] = []
 77        self._hover_cell: tuple[int, int] | None = None
 78        self._ai_action_timer = 0.0
 79        self._ai_busy = False
 80        self._game_over_emitted = False
 81
 82        # Audio players (lazy: only created if assets resolve).
 83        self._sfx: dict[str, AudioPlayer] = {}
 84
 85        self._spawn_starting_pieces()
 86        self._refresh_status("Click a blue unit to select")
 87
 88    # ============================================================ lifecycle
 89    def on_ready(self) -> None:
 90        self._init_audio()
 91        self.hud.set_turn(self.turn.current, self.turn.turn_number)
 92
 93    def _init_audio(self) -> None:
 94        if self._asset_dir is None:
 95            return
 96        sfx_dir = self._asset_dir / "sfx"
 97        if not sfx_dir.exists():
 98            return
 99        for name in (SFX_SELECT, SFX_MOVE, SFX_END_TURN, SFX_EXPLOSION, SFX_HURT, SFX_BUTTON, SFX_NO_MOVES):
100            path = sfx_dir / name
101            if path.exists():
102                player = AudioPlayer(stream=AudioClip(str(path)))
103                self.add_child(player)
104                self._sfx[name] = player
105
106    def _play(self, sfx_name: str) -> None:
107        p = self._sfx.get(sfx_name)
108        if p is not None:
109            p.play()
110
111    # ============================================================ spawn
112    def _spawn_starting_pieces(self) -> None:
113        for x, y, btype, owner in STARTING_BUILDINGS:
114            b = Building(building_type=btype, owner=owner, cell=(x, y), tile_map=self.tile_map)
115            self.tile_map.add_child(b)
116            self.buildings.append(b)
117        for x, y, utype, owner in STARTING_UNITS:
118            u = Unit(unit_type=utype, owner=owner, cell=(x, y), tile_map=self.tile_map)
119            self.tile_map.add_child(u)
120            self.units.append(u)
121
122    # ============================================================ accessors
123    def unit_at(self, cell: tuple[int, int]) -> Unit | None:
124        for u in self.units:
125            if u.cell == cell and u.life > 0:
126                return u
127        return None
128
129    def building_at(self, cell: tuple[int, int]) -> Building | None:
130        for b in self.buildings:
131            if b.cell == cell:
132                return b
133        return None
134
135    def is_passable_for(self, unit: Unit, x: int, y: int) -> bool:
136        return self.tile_map.is_passable(x, y, is_air=unit.is_air)
137
138    # ============================================================ input
139    def on_update(self, dt: float) -> None:
140        if self.turn.state == GameState.GAME_OVER:
141            return
142
143        # Hover-cursor update. The board occupies everything below the top bar
144        # and left of the info panel; in_bounds() rejects the rest.
145        mp = Input.mouse_position
146        mx, my = float(mp[0]), float(mp[1])
147        in_world = my > HUD_HEIGHT and mx < WINDOW_WIDTH - INFO_PANEL_W
148        if in_world:
149            local_x = mx - self.tile_map.position.x
150            local_y = my - self.tile_map.position.y
151            cx, cy = self.tile_map.world_to_map((local_x, local_y))
152            if self.tile_map.in_bounds(cx, cy):
153                self._hover_cell = (cx, cy)
154                self.cursor.show_at((cx, cy))
155            else:
156                self._hover_cell = None
157                self.cursor.visible = False
158        else:
159            self._hover_cell = None
160            self.cursor.visible = False
161
162        # Path preview if a unit is selected and hovered cell is reachable
163        if (
164            self._selected_unit is not None
165            and self._hover_cell is not None
166            and self._hover_cell in self._reachable
167            and not self._selected_unit.is_moving
168        ):
169            path = find_path(
170                self._selected_unit.cell,
171                self._hover_cell,
172                passable=lambda x, y: self.is_passable_for(self._selected_unit, x, y),
173                blocked=lambda x, y: self.unit_at((x, y)) is not None and (x, y) != self._selected_unit.cell,
174            )
175            self._planned_path = path
176            self.overlay.show_path(path)
177        else:
178            self._planned_path = []
179            self.overlay.show_path([])
180
181        # Click handling
182        if Input.is_action_just_pressed("primary"):
183            self._handle_click()
184
185        # Keyboard shortcuts
186        if Input.is_action_just_pressed("end_turn"):
187            self._on_end_turn_button()
188        if Input.is_action_just_pressed("cancel"):
189            self._clear_selection()
190
191        # Drive the AI a step at a time with a small delay so movement is visible.
192        if self.turn.state == GameState.AI_TURN:
193            self._tick_ai(dt)
194
195    def _handle_click(self) -> None:
196        if self.turn.state != GameState.PLAYER_TURN:
197            return
198        cell = self._hover_cell
199        if cell is None:
200            return
201
202        clicked_unit = self.unit_at(cell)
203
204        # Case 1: switch selection to one of our own units.
205        if clicked_unit is not None and clicked_unit.owner == PLAYER_BLUE and clicked_unit is not self._selected_unit:
206            self._select_unit(clicked_unit)
207            return
208
209        if self._selected_unit is None:
210            self._show_building_info(cell)
211            return
212
213        # Case 2: attacking a highlighted enemy.
214        if clicked_unit is not None and cell in self._attack_targets:
215            self._do_attack(self._selected_unit, clicked_unit)
216            return
217
218        # Case 3: moving to a reachable empty cell (or cell with a building).
219        if cell in self._reachable and clicked_unit is None:
220            self._do_move(self._selected_unit, cell)
221            return
222
223        # Case 4: clicked an empty / unreachable cell, clear selection.
224        self._clear_selection()
225        self._show_building_info(cell)
226
227    def _show_building_info(self, cell: tuple[int, int]) -> None:
228        """Fill the side panel with the building on ``cell``, if there is one."""
229        bld = self.building_at(cell)
230        if bld is not None:
231            self.hud.set_building_panel(bld)
232
233    # ============================================================ select / move
234    def _select_unit(self, unit: Unit) -> None:
235        self._selected_unit = unit
236        self.hud.set_unit_panel(unit)
237        self._reachable = reachable_cells(
238            unit.cell,
239            unit.ap,
240            passable=lambda x, y: self.is_passable_for(unit, x, y),
241            blocked=lambda x, y: self.unit_at((x, y)) is not None and (x, y) != unit.cell,
242        )
243        # Drop the start cell from highlighted move-range (it's already where the unit is).
244        move_cells = [c for c in self._reachable if c != unit.cell]
245        self.overlay.show_move_range(move_cells)
246        self._attack_targets = self._attack_targets_for(unit)
247        self.overlay.show_attack_range(self._attack_targets)
248        # Audio doubles as feedback for a unit that is spent for this turn.
249        self._play(SFX_SELECT if move_cells or self._attack_targets else SFX_NO_MOVES)
250
251    def _attack_targets_for(self, unit: Unit) -> list[tuple[int, int]]:
252        """Cells holding an enemy this unit is allowed to strike right now."""
253        if not unit.can_attack():
254            return []
255        targets = []
256        for c in cells_within_range(unit.cell, ATTACK_RANGE[unit.type]):
257            other = self.unit_at(c)
258            if other is None or other.owner == unit.owner:
259                continue
260            if can_attack_unit_type(unit.type, other.type):
261                targets.append(c)
262        return targets
263
264    def _clear_selection(self) -> None:
265        self._selected_unit = None
266        self._reachable = {}
267        self._attack_targets = []
268        self._planned_path = []
269        self.overlay.clear()
270        self.hud.set_unit_panel(None)
271
272    def _do_move(self, unit: Unit, dest: tuple[int, int]) -> None:
273        path = find_path(
274            unit.cell,
275            dest,
276            passable=lambda x, y: self.is_passable_for(unit, x, y),
277            blocked=lambda x, y: self.unit_at((x, y)) is not None and (x, y) != unit.cell,
278        )
279        if not path or len(path) <= 1:
280            return
281        self._play(SFX_MOVE)
282        self._clear_selection()
283        # once=True: the handler must not survive into the unit's next move,
284        # or a later move would re-run this one's capture check.
285        unit.move_finished.connect(lambda u=unit: self._on_move_finished(u), once=True)
286        unit.begin_move(path)
287
288    def _on_move_finished(self, unit: Unit) -> None:
289        self._capture_under(unit)
290        self._refresh_status(f"{UNIT_STATS[unit.type]['name'].lower()} moved")
291
292    def _capture_under(self, unit: Unit) -> None:
293        """Flip the building the unit just landed on, when it may capture."""
294        bld = self.building_at(unit.cell)
295        if bld is not None and unit.can_capture and bld.owner != unit.owner:
296            bld.set_owner(unit.owner)
297            capture_flash(self, unit.cell, unit.owner)
298            self._play(SFX_BUTTON)
299            self._check_victory()
300
301    def _do_attack(self, attacker: Unit, defender: Unit) -> None:
302        if not attacker.can_attack() or not can_attack_unit_type(attacker.type, defender.type):
303            return
304        # Spend attack AP up front.
305        attacker.consume_attack()
306        result = resolve_attack(attacker, defender)
307        attacker.refresh_health_bar()
308        defender.refresh_health_bar()
309        damage_flash(self, defender.cell)
310        if result.attacker_dmg_taken > 0:
311            damage_flash(self, attacker.cell)
312        self._play(SFX_HURT)
313        if not result.defender_alive:
314            explosion(self, defender.cell)
315            self._play(SFX_EXPLOSION)
316            self._kill_unit(defender)
317        if not result.attacker_alive:
318            explosion(self, attacker.cell)
319            self._play(SFX_EXPLOSION)
320            self._kill_unit(attacker)
321        self._clear_selection()
322        self._refresh_status(
323            f"{UNIT_STATS[attacker.type]['name'].lower()} attacks "
324            f"{UNIT_STATS[defender.type]['name'].lower()} "
325            f"(-{result.defender_dmg_taken} HP)"
326        )
327        self._check_victory()
328
329    def _kill_unit(self, unit: Unit) -> None:
330        if unit in self.units:
331            self.units.remove(unit)
332        unit.kill()
333
334    # ============================================================ turn flow
335    def _on_end_turn_button(self) -> None:
336        if self.turn.state != GameState.PLAYER_TURN:
337            return
338        self._end_player_turn()
339
340    def _end_player_turn(self) -> None:
341        self._play(SFX_END_TURN)
342        self._clear_selection()
343        self.turn.end_turn()
344
345    def _on_turn_started(self, player: int) -> None:
346        for u in self.units:
347            if u.owner == player:
348                u.end_turn_refresh()
349                u.set_dim(False)
350            else:
351                u.set_dim(True)
352        self.hud.set_turn(player, self.turn.turn_number)
353        if player == PLAYER_BLUE:
354            self._refresh_status("Your turn")
355        else:
356            self._refresh_status("Red is thinking…")
357            self.ai.begin_turn()
358            self._ai_action_timer = 0.0
359            self._ai_busy = False
360
361    def _tick_ai(self, dt: float) -> None:
362        if self._ai_busy:
363            return
364        self._ai_action_timer -= dt
365        if self._ai_action_timer > 0:
366            return
367
368        action = self.ai.step()
369        if action is None:
370            # AI turn over.
371            self.turn.end_turn()
372            return
373
374        kind = action[0]
375        if kind == "move":
376            _, unit, path = action
377            self._ai_busy = True
378            self._play(SFX_MOVE)
379            unit.move_finished.connect(lambda u=unit: self._on_ai_move_finished(u), once=True)
380            unit.begin_move(path)
381        elif kind == "attack":
382            _, attacker, defender = action
383            self._do_attack(attacker, defender)
384            self._ai_action_timer = 0.45
385        else:
386            self._ai_action_timer = 0.2
387
388    def _on_ai_move_finished(self, unit: Unit) -> None:
389        self._capture_under(unit)
390        self._ai_busy = False
391        self._ai_action_timer = 0.25
392
393    # ============================================================ victory
394    def _check_victory(self) -> None:
395        blue_alive = any(u.owner == PLAYER_BLUE for u in self.units)
396        red_alive = any(u.owner == PLAYER_RED for u in self.units)
397        # HQ ownership.
398        blue_hq = next((b for b in self.buildings if b.is_hq and b.owner == PLAYER_BLUE), None)
399        red_hq = next((b for b in self.buildings if b.is_hq and b.owner == PLAYER_RED), None)
400
401        winner = None
402        if not red_alive or red_hq is None:
403            winner = PLAYER_BLUE
404        elif not blue_alive or blue_hq is None:
405            winner = PLAYER_RED
406
407        if winner is not None and self.turn.state != GameState.GAME_OVER:
408            self.turn.declare_winner(winner)
409            self.hud.show_victory(winner)
410            if not self._game_over_emitted:
411                self._game_over_emitted = True
412                self.game_over(winner)
413
414    # ============================================================ misc
415    def _refresh_status(self, msg: str) -> None:
416        self.hud.set_status(msg)
417
418    def restart(self) -> None:
419        # The root spawns a fresh world; we just emit so the root can swap us out.
420        self.game_over(-1)