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)