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)