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