nodes/td_world.py¶
Part of Tower Defence.
1"""TowerDefenceWorld -- the main gameplay scene.
2
3Pulls together:
4 - waypoint-following enemies (one wave per "level"),
5 - tile-aligned turret placement / upgrade,
6 - currency + lives + win/lose,
7 - side panel with controls (start wave, fast-forward, switch turret type,
8 cancel, upgrade, restart).
9
10Every piece of HUD text is a ``Text2D`` widget: they are screen-pinned, laid out
11with real kerning, and only re-upload when their text changes. ``on_draw`` is kept
12for the procedural chrome that has no node equivalent (the panel plate, the button
13rectangles, the placement preview, the tracer lines).
14"""
15
16from __future__ import annotations
17
18import random
19from pathlib import Path
20
21from simvx.core import (
22 Input,
23 MouseButton,
24 Node2D,
25 Property,
26 Signal,
27 Sprite2D,
28 Text2D,
29 Vec2,
30)
31
32from .enemy import Enemy
33from .level_data import COLS, ROWS, WAYPOINTS, is_grass
34from .td_data import (
35 ENEMY_SPAWN_DATA,
36 LEVEL_COMPLETE_REWARD,
37 SCREEN_HEIGHT,
38 SCREEN_WIDTH,
39 SIDE_PANEL,
40 SPAWN_COOLDOWN,
41 START_HEALTH,
42 START_MONEY,
43 TILE_SIZE,
44 TOTAL_LEVELS,
45 TURRET_COSTS,
46 TURRET_LABELS,
47 TURRET_TYPES,
48 UPGRADE_COST,
49 WINDOW_HEIGHT,
50 WINDOW_WIDTH,
51)
52from .turret import Turret
53
54_ASSETS = Path(__file__).parent.parent / "assets"
55
56# Draw order. The map sits below everything so the panel chrome, the placement
57# preview and the tracers land on top of it; the end-game plate and its banner
58# sit above the turrets and enemies as well.
59Z_MAP = -10
60Z_DIALOG = 5
61Z_BANNER = 6
62
63
64# ---------------------------------------------------------------------------
65# Tracer FX
66# ---------------------------------------------------------------------------
67
68
69class Tracer(Node2D):
70 """Short-lived line drawn from a turret to the enemy it hit."""
71
72 duration = Property(0.08, range=(0.01, 1.0))
73
74 def __init__(self, start: Vec2, end: Vec2, colour=(1.0, 0.95, 0.5, 1.0), **kwargs):
75 super().__init__(**kwargs)
76 self._start = Vec2(start)
77 self._end = Vec2(end)
78 self._t = 0.0
79 self._colour = colour
80 # on_draw fades the tracer's alpha from self._t (a plain float advanced
81 # in on_update); nothing dirties this node, so mark it dynamic to
82 # re-collect every frame -- otherwise the fade freezes at spawn alpha.
83 self.dynamic = True
84
85 def on_update(self, dt: float) -> None:
86 self._t += dt
87 if self._t >= self.duration:
88 self.destroy()
89
90 def on_draw(self, renderer) -> None:
91 alpha = max(0.0, 1.0 - self._t / self.duration)
92 c = (self._colour[0], self._colour[1], self._colour[2], self._colour[3] * alpha)
93 renderer.draw_lines([self._start, self._end], colour=c)
94
95
96# ---------------------------------------------------------------------------
97# Side-panel rectangle helper
98# ---------------------------------------------------------------------------
99
100
101def _rect_contains(rect: tuple[float, float, float, float], pt: Vec2) -> bool:
102 x, y, w, h = rect
103 return x <= pt.x <= x + w and y <= pt.y <= y + h
104
105
106# ---------------------------------------------------------------------------
107# End-game plate
108# ---------------------------------------------------------------------------
109
110
111class _Dialog(Node2D):
112 """Dark plate behind the GAME OVER / YOU WIN banner.
113
114 A node of its own so the plate can be ordered above the map, the turrets and
115 the enemies, which the world's own chrome is not. It is shown and hidden with
116 ``visible``, so it costs nothing while the game is being played.
117 """
118
119 visible = Property(
120 False,
121 coerce=bool,
122 hint="Whether this node and its subtree are drawn",
123 on_change="_on_visible_changed",
124 )
125
126 def __init__(self, world: TowerDefenceWorld, **kwargs):
127 super().__init__(z_index=Z_DIALOG, **kwargs)
128 self._world = world
129
130 def on_draw(self, renderer) -> None:
131 renderer.draw_rect(
132 self._world.board_to_screen(SCREEN_WIDTH / 2 - 220, SCREEN_HEIGHT / 2 - 80),
133 (440, 220),
134 colour=(0.1, 0.12, 0.18, 0.92),
135 filled=True,
136 )
137
138
139# ---------------------------------------------------------------------------
140# Scene
141# ---------------------------------------------------------------------------
142
143
144class TowerDefenceWorld(Node2D):
145 """Main gameplay scene -- one map, infinite restart, 15 waves."""
146
147 money = Property(START_MONEY, range=(0, 99999))
148 lives = Property(START_HEALTH, range=(0, 999))
149 # A numeric Property range hard-clamps every assignment, so the upper bound is
150 # one past the last wave: clearing wave TOTAL_LEVELS has to be able to push
151 # ``level`` out of range, otherwise the last wave would repeat forever.
152 level = Property(1, range=(1, TOTAL_LEVELS + 1))
153 game_speed = Property(1, range=(1, 4))
154
155 # Scene events. The world reacts to a win or a loss itself (dialog + RESTART
156 # button); these let whatever hosts the scene react too.
157 level_started = Signal()
158 game_won = Signal()
159 game_lost = Signal()
160
161 # Panel button rects (immutable -- drawn in on_draw, hit-tested in
162 # on_update). x is in panel coords (panel starts at SCREEN_WIDTH).
163 BTN_BUY = (SCREEN_WIDTH + 30, 130, 100, 50)
164 BTN_TYPE_PREV = (SCREEN_WIDTH + 30, 200, 40, 30)
165 BTN_TYPE_NEXT = (SCREEN_WIDTH + 220, 200, 40, 30)
166 BTN_CANCEL = (SCREEN_WIDTH + 150, 130, 100, 50)
167 BTN_UPGRADE = (SCREEN_WIDTH + 30, 250, 240, 44)
168 BTN_BEGIN = (SCREEN_WIDTH + 60, 320, 180, 50)
169 BTN_FAST = (SCREEN_WIDTH + 60, 390, 180, 40)
170 BTN_RESTART = (SCREEN_WIDTH + 60, 450, 180, 50)
171
172 TURRET_TYPE_ORDER = ["basic", "slow", "sniper"]
173
174 def __init__(self, **kwargs):
175 super().__init__(**kwargs)
176
177 # Scene state
178 self.placing = False
179 self.placing_type = "basic"
180 self.selected_turret: Turret | None = None
181 self.spawn_timer = 0.0
182 self.spawn_index = 0
183 self.spawn_queue: list[str] = []
184 self.spawned_count = 0
185 self.killed_count = 0
186 self.escaped_count = 0
187 self.wave_running = False
188 self.outcome: int = 0 # -1 lost, 1 won, 0 in-progress
189
190 # Background level image, ordered below everything else in the scene.
191 self.bg = self.add_child(
192 Sprite2D(
193 texture=str(_ASSETS / "level.png"),
194 position=Vec2(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2),
195 width=SCREEN_WIDTH,
196 height=SCREEN_HEIGHT,
197 z_index=Z_MAP,
198 )
199 )
200
201 # HUD text widgets -- updated in on_update. Font_colour is dark so it
202 # reads on the light panel background.
203 hud_col = (0.05, 0.05, 0.08, 1.0)
204 self.hud_level = self.add_child(
205 Text2D(text="", position=(SCREEN_WIDTH + 14, 14), font_scale=1.2, colour=hud_col)
206 )
207 self.hud_health = self.add_child(
208 Text2D(text="", position=(SCREEN_WIDTH + 64, 44), font_scale=1.2, colour=hud_col)
209 )
210 self.hud_money = self.add_child(
211 Text2D(text="", position=(SCREEN_WIDTH + 64, 78), font_scale=1.2, colour=hud_col)
212 )
213 self.hud_status = self.add_child(
214 Text2D(text="", position=(SCREEN_WIDTH + 14, 104), font_scale=0.85, colour=(0.55, 0.05, 0.05, 1.0))
215 )
216 # End-game plate + its banner, both above the board.
217 self.dialog = self.add_child(_Dialog(self))
218 self.hud_outcome = self.add_child(
219 Text2D(
220 text="",
221 position=(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 20),
222 align="centre",
223 font_scale=2.0,
224 colour=(1.0, 1.0, 1.0, 1.0),
225 z_index=Z_BANNER,
226 )
227 )
228
229 # Inline icon labels (text) -- the heart/coin PNGs have a fairly thick
230 # alpha-halo around them and overlap the HUD numbers; using emoji-like
231 # text keeps the panel readable.
232 self.add_child(
233 Text2D(text="HP", position=(SCREEN_WIDTH + 14, 44), font_scale=1.2, colour=(0.85, 0.15, 0.15, 1.0))
234 )
235 self.add_child(
236 Text2D(text="$", position=(SCREEN_WIDTH + 14, 78), font_scale=1.4, colour=(0.85, 0.6, 0.05, 1.0))
237 )
238
239 # Window size the board was last centred for (see _centre_board).
240 self._laid_out: tuple[int, int] | None = None
241
242 self._reset_wave()
243
244 # ------------------------------------------------------------------
245 # Lifecycle
246 # ------------------------------------------------------------------
247
248 def on_ready(self) -> None:
249 # Input actions registered in TowerDefenceRoot.on_ready (web-export
250 # rule). World only consumes them.
251 #
252 # This node's on_draw is an interactive HUD: it renders the mouse-
253 # following turret preview (Input.mouse_position) and the side-panel
254 # chrome from plain-attribute state (placing, wave_running,
255 # selected_turret, outcome) that mutates on clicks and in on_update with
256 # no coincident Property write to auto-dirty the retained 2D layer.
257 # Marking the node dynamic re-collects its ~30 draw ops every frame, so
258 # the cursor preview tracks the mouse and a button never shows a stale
259 # enabled/disabled state.
260 self.dynamic = True
261 self._centre_board()
262
263 # ------------------------------------------------------------------
264 # Window fit
265 # ------------------------------------------------------------------
266
267 def _centre_board(self) -> None:
268 """Keep the fixed-size board + panel centred in the window.
269
270 The board is a 15x15 grid of 48px tiles plus a 300px panel, so the layout
271 is authored at one size and centred in whatever window it gets. Moving
272 this node carries every child with it (the map sprite, the turrets, the
273 enemies, the HUD labels); ``on_draw`` geometry is authored in screen
274 coordinates rather than carried by the node transform, so the panel
275 chrome adds the same offset itself through ``board_to_screen``.
276 """
277 size = (int(self.app.width), int(self.app.height))
278 if size == self._laid_out:
279 return
280 self._laid_out = size
281 self.position = Vec2(
282 max(0.0, (size[0] - WINDOW_WIDTH) / 2),
283 max(0.0, (size[1] - WINDOW_HEIGHT) / 2),
284 )
285 # The plate only redraws when it changes, and the offset it reads just did.
286 self.dialog.queue_redraw()
287
288 def board_to_screen(self, x: float, y: float) -> tuple[float, float]:
289 """Board coordinates -> screen coordinates, for ``on_draw`` geometry."""
290 pos = self.position
291 return (x + pos.x, y + pos.y)
292
293 def _board_mouse(self) -> Vec2:
294 """The cursor in board coordinates (window-centring offset removed)."""
295 return Input.mouse_position - self.position
296
297 # ------------------------------------------------------------------
298 # Wave plumbing
299 # ------------------------------------------------------------------
300
301 def _reset_wave(self) -> None:
302 spawn_data = ENEMY_SPAWN_DATA[(self.level - 1) % len(ENEMY_SPAWN_DATA)]
303 self.spawn_queue = []
304 for enemy_type, count in spawn_data.items():
305 self.spawn_queue.extend([enemy_type] * count)
306 random.shuffle(self.spawn_queue)
307 self.spawned_count = 0
308 self.killed_count = 0
309 self.escaped_count = 0
310 self.spawn_timer = 0.0
311 self.wave_running = False
312
313 def _begin_wave(self) -> None:
314 if self.wave_running or self.outcome != 0:
315 return
316 self.wave_running = True
317 self.spawn_timer = 0.0
318 self.level_started()
319
320 def _spawn_one(self) -> None:
321 if self.spawned_count >= len(self.spawn_queue):
322 return
323 enemy_type = self.spawn_queue[self.spawned_count]
324 self.spawned_count += 1
325 enemy = Enemy(enemy_type, WAYPOINTS)
326 self.add_child(enemy)
327 enemy.died.connect(self._on_enemy_killed)
328 enemy.escaped.connect(self._on_enemy_escaped)
329
330 def _on_enemy_killed(self, reward: int) -> None:
331 self.killed_count += 1
332 self.money += reward
333
334 def _on_enemy_escaped(self) -> None:
335 self.escaped_count += 1
336 self.lives = max(0, self.lives - 1)
337
338 def _wave_complete(self) -> bool:
339 return (
340 self.wave_running
341 and self.spawned_count >= len(self.spawn_queue)
342 and (self.killed_count + self.escaped_count) >= len(self.spawn_queue)
343 )
344
345 # ------------------------------------------------------------------
346 # Placement / selection
347 # ------------------------------------------------------------------
348
349 def _can_place(self, tx: int, ty: int) -> bool:
350 if not is_grass(tx, ty):
351 return False
352 for t in self.tree.group("turrets"):
353 if t.tile_x == tx and t.tile_y == ty:
354 return False
355 return True
356
357 def _place_turret(self, tx: int, ty: int) -> bool:
358 cost = TURRET_COSTS[self.placing_type]
359 if self.money < cost:
360 return False
361 if not self._can_place(tx, ty):
362 return False
363 turret = Turret(self.placing_type, tx, ty)
364 turret.target_acquired.connect(self._on_turret_fired)
365 self.add_child(turret)
366 self.money -= cost
367 return True
368
369 def _on_turret_fired(self, start: Vec2, end: Vec2) -> None:
370 # A brief tracer makes successive shots obvious. Its line is on_draw
371 # geometry, so it is spawned in screen coordinates.
372 self.add_child(
373 Tracer(
374 Vec2(*self.board_to_screen(start.x, start.y)),
375 Vec2(*self.board_to_screen(end.x, end.y)),
376 )
377 )
378
379 def _select_turret_at(self, world_pos: Vec2) -> None:
380 if self.selected_turret is not None:
381 self.selected_turret.selected = False
382 self.selected_turret = None
383 tx = int(world_pos.x // TILE_SIZE)
384 ty = int(world_pos.y // TILE_SIZE)
385 for t in self.tree.group("turrets"):
386 if t.tile_x == tx and t.tile_y == ty:
387 self.selected_turret = t
388 t.selected = True
389 return
390
391 def _cycle_type(self, direction: int) -> None:
392 idx = self.TURRET_TYPE_ORDER.index(self.placing_type)
393 self.placing_type = self.TURRET_TYPE_ORDER[(idx + direction) % len(self.TURRET_TYPE_ORDER)]
394
395 # ------------------------------------------------------------------
396 # Restart
397 # ------------------------------------------------------------------
398
399 def restart(self) -> None:
400 for t in list(self.tree.group("turrets")):
401 t.destroy()
402 for e in list(self.tree.group("enemies")):
403 e.destroy()
404 self.money = START_MONEY
405 self.lives = START_HEALTH
406 self.level = 1
407 self.outcome = 0
408 self.placing = False
409 self.selected_turret = None
410 self.game_speed = 1
411 self._reset_wave()
412
413 # ------------------------------------------------------------------
414 # Tick
415 # ------------------------------------------------------------------
416
417 def on_update(self, dt: float) -> None:
418 self._centre_board()
419
420 # Fast-forward scales the port's own dt: spawn pacing here, movement and
421 # cooldowns in Enemy / Turret. The desktop App exposes a global
422 # ``time_scale``, but the browser runtime has no equivalent, so scaling
423 # our own dt is what keeps the two exports identical.
424 sdt = dt * self.game_speed
425
426 # Quit / restart hotkeys
427 if Input.is_action_just_pressed("quit"):
428 self.app.quit()
429 return
430 if Input.is_action_just_pressed("restart"):
431 self.restart()
432 return
433
434 # Lose condition (the win is decided when the last wave is cleared).
435 if self.outcome == 0 and self.lives <= 0:
436 self.outcome = -1
437 self.game_lost()
438
439 # Mouse polling -- the engine tracks the press edge for us, so one click
440 # is one action however many frames the button is held for.
441 mouse_pos = self._board_mouse()
442 mouse_just = Input.is_mouse_button_just_pressed(MouseButton.LEFT)
443
444 if mouse_just and self.outcome == 0:
445 self._handle_click(mouse_pos)
446 elif mouse_just and self.outcome != 0:
447 # Restart panel
448 if _rect_contains(self.BTN_RESTART, mouse_pos):
449 self.restart()
450
451 # Keyboard placement shortcuts
452 if Input.is_action_just_pressed("place_basic"):
453 self.placing_type = "basic"
454 self.placing = True
455 self._clear_selection()
456 if Input.is_action_just_pressed("place_slow"):
457 self.placing_type = "slow"
458 self.placing = True
459 self._clear_selection()
460 if Input.is_action_just_pressed("place_sniper"):
461 self.placing_type = "sniper"
462 self.placing = True
463 self._clear_selection()
464 if Input.is_action_just_pressed("cancel_place"):
465 self.placing = False
466 if Input.is_action_just_pressed("upgrade") and self.selected_turret:
467 self._try_upgrade()
468 if Input.is_action_just_pressed("begin_wave"):
469 self._begin_wave()
470 if Input.is_action_just_pressed("fast_forward"):
471 self.game_speed = 1 if self.game_speed >= 2 else 2
472
473 # Wave spawn
474 if self.wave_running and self.outcome == 0 and self.spawned_count < len(self.spawn_queue):
475 self.spawn_timer += sdt
476 if self.spawn_timer >= SPAWN_COOLDOWN:
477 self.spawn_timer = 0.0
478 self._spawn_one()
479
480 # Wave complete -> next level
481 if self._wave_complete() and self.outcome == 0:
482 self.money += LEVEL_COMPLETE_REWARD
483 self.level += 1
484 if self.level <= TOTAL_LEVELS:
485 self._reset_wave()
486 else:
487 self.outcome = 1
488 self.game_won()
489
490 # HUD text refresh
491 self.hud_level.text = f"LEVEL {min(self.level, TOTAL_LEVELS)}/{TOTAL_LEVELS}"
492 self.hud_health.text = str(self.lives)
493 self.hud_money.text = str(int(self.money))
494 if self.placing:
495 self.hud_status.text = (
496 f"PLACING: {TURRET_LABELS[self.placing_type]} " f"({TURRET_COSTS[self.placing_type]}c)"
497 )
498 elif self.selected_turret:
499 t = self.selected_turret
500 self.hud_status.text = (
501 f"SELECTED L{t.upgrade_level}/{len(TURRET_TYPES[t.turret_type])} " f"{TURRET_LABELS[t.turret_type]}"
502 )
503 else:
504 self.hud_status.text = ""
505
506 # Outcome plate + banner
507 self.dialog.visible = self.outcome != 0
508 if self.outcome == -1:
509 self.hud_outcome.text = "GAME OVER"
510 self.hud_outcome.colour = (1.0, 0.4, 0.4, 1.0)
511 elif self.outcome == 1:
512 self.hud_outcome.text = "YOU WIN!"
513 self.hud_outcome.colour = (0.7, 1.0, 0.7, 1.0)
514 else:
515 self.hud_outcome.text = ""
516
517 # ------------------------------------------------------------------
518 # Click dispatch
519 # ------------------------------------------------------------------
520
521 def _handle_click(self, pos: Vec2) -> None:
522 # Side panel buttons
523 if pos.x >= SCREEN_WIDTH:
524 if _rect_contains(self.BTN_BUY, pos) and self.outcome == 0:
525 self.placing = True
526 self._clear_selection()
527 return
528 if _rect_contains(self.BTN_CANCEL, pos):
529 self.placing = False
530 return
531 if _rect_contains(self.BTN_TYPE_PREV, pos):
532 self._cycle_type(-1)
533 return
534 if _rect_contains(self.BTN_TYPE_NEXT, pos):
535 self._cycle_type(1)
536 return
537 if _rect_contains(self.BTN_UPGRADE, pos) and self.selected_turret:
538 self._try_upgrade()
539 return
540 if _rect_contains(self.BTN_BEGIN, pos):
541 self._begin_wave()
542 return
543 if _rect_contains(self.BTN_FAST, pos):
544 self.game_speed = 1 if self.game_speed >= 2 else 2
545 return
546 if _rect_contains(self.BTN_RESTART, pos):
547 self.restart()
548 return
549 return
550
551 # Game area click -- placement or selection
552 if pos.y >= SCREEN_HEIGHT:
553 return
554 tx = int(pos.x // TILE_SIZE)
555 ty = int(pos.y // TILE_SIZE)
556 if not (0 <= tx < COLS and 0 <= ty < ROWS):
557 return
558 self._clear_selection()
559 if self.placing:
560 self._place_turret(tx, ty)
561 # Stay in placement mode so multiple turrets can be queued.
562 else:
563 self._select_turret_at(pos)
564
565 def _clear_selection(self) -> None:
566 if self.selected_turret is not None:
567 self.selected_turret.selected = False
568 self.selected_turret = None
569
570 def _try_upgrade(self) -> None:
571 t = self.selected_turret
572 if t is None or not t.can_upgrade or self.money < UPGRADE_COST:
573 return
574 t.upgrade()
575 self.money -= UPGRADE_COST
576
577 # ------------------------------------------------------------------
578 # Side-panel chrome (drawn from root to land *under* HUD overlay)
579 # ------------------------------------------------------------------
580
581 def on_draw(self, renderer) -> None:
582 # Panel background
583 renderer.draw_rect(
584 self.board_to_screen(SCREEN_WIDTH, 0),
585 (SIDE_PANEL, SCREEN_HEIGHT),
586 colour=(0.92, 0.92, 0.94, 1.0),
587 filled=True,
588 )
589 renderer.draw_rect(
590 self.board_to_screen(SCREEN_WIDTH, 0),
591 (SIDE_PANEL, SCREEN_HEIGHT),
592 colour=(0.15, 0.15, 0.18, 1.0),
593 filled=False,
594 )
595
596 # Panel button strip (light grey controls area at the bottom)
597 strip_y = SCREEN_HEIGHT - 110
598 renderer.draw_rect(
599 self.board_to_screen(SCREEN_WIDTH, strip_y),
600 (SIDE_PANEL, 110),
601 colour=(0.86, 0.86, 0.88, 1.0),
602 filled=True,
603 )
604
605 # Buy turret
606 self._draw_button(
607 renderer,
608 self.BTN_BUY,
609 f"BUY {TURRET_LABELS[self.placing_type]}",
610 highlighted=self.placing,
611 disabled=(self.money < TURRET_COSTS[self.placing_type] or self.outcome != 0),
612 )
613 # Cancel placement
614 self._draw_button(
615 renderer,
616 self.BTN_CANCEL,
617 "CANCEL",
618 disabled=not self.placing,
619 )
620 # Type cycling: a caption above the row, the current type between the arrows
621 renderer.draw_text(
622 "TYPE",
623 rect=(*self.board_to_screen(SCREEN_WIDTH + 30, 186), 240, 16),
624 alignment="centre",
625 vertical_alignment="centre",
626 scale=0.9,
627 colour=(0.35, 0.35, 0.40, 1.0),
628 )
629 renderer.draw_text(
630 TURRET_LABELS[self.placing_type],
631 rect=(*self.board_to_screen(SCREEN_WIDTH + 80, 200), 140, 30),
632 alignment="centre",
633 vertical_alignment="centre",
634 fit_to_width=True,
635 scale=1.5,
636 colour=(0.0, 0.0, 0.0, 1.0),
637 )
638 self._draw_button(renderer, self.BTN_TYPE_PREV, "<")
639 self._draw_button(renderer, self.BTN_TYPE_NEXT, ">")
640 # Upgrade
641 upg_label = "UPGRADE"
642 if self.selected_turret:
643 t = self.selected_turret
644 upg_label = f"UPGRADE -> L{t.upgrade_level + 1} ({UPGRADE_COST}c)" if t.can_upgrade else "MAX LEVEL"
645 self._draw_button(
646 renderer,
647 self.BTN_UPGRADE,
648 upg_label,
649 disabled=(not self.selected_turret or not self.selected_turret.can_upgrade or self.money < UPGRADE_COST),
650 )
651 # Begin wave / fast forward (in the bottom strip)
652 self._draw_button(
653 renderer,
654 self.BTN_BEGIN,
655 "BEGIN WAVE" if not self.wave_running else "WAVE RUNNING",
656 disabled=(self.wave_running or self.outcome != 0),
657 )
658 self._draw_button(
659 renderer,
660 self.BTN_FAST,
661 "FAST x2" if self.game_speed < 2 else "NORMAL x1",
662 )
663 self._draw_button(renderer, self.BTN_RESTART, "RESTART")
664
665 # Cursor turret preview
666 if self.placing and self.outcome == 0:
667 mp = self._board_mouse()
668 if 0 <= mp.x < SCREEN_WIDTH and 0 <= mp.y < SCREEN_HEIGHT:
669 tx = int(mp.x // TILE_SIZE)
670 ty = int(mp.y // TILE_SIZE)
671 cx = (tx + 0.5) * TILE_SIZE
672 cy = (ty + 0.5) * TILE_SIZE
673 ok = self._can_place(tx, ty) and self.money >= TURRET_COSTS[self.placing_type]
674 colour = (0.4, 1.0, 0.4, 0.55) if ok else (1.0, 0.4, 0.4, 0.55)
675 # Clipped to the board so a range circle near an edge cannot spill
676 # over the side panel or into the window margin.
677 bx, by = self.board_to_screen(0, 0)
678 renderer.push_clip(int(bx), int(by), SCREEN_WIDTH, SCREEN_HEIGHT)
679 renderer.draw_rect(
680 self.board_to_screen(tx * TILE_SIZE, ty * TILE_SIZE),
681 (TILE_SIZE, TILE_SIZE),
682 colour=colour,
683 filled=True,
684 )
685 # Show the range
686 tier = TURRET_TYPES[self.placing_type][0]
687 renderer.draw_circle(
688 self.board_to_screen(cx, cy),
689 tier["range"],
690 colour=(1.0, 1.0, 1.0, 0.18),
691 filled=True,
692 segments=48,
693 )
694 renderer.pop_clip()
695
696 def _draw_button(self, renderer, rect, text: str, *, highlighted: bool = False, disabled: bool = False) -> None:
697 (x, y), (w, h) = self.board_to_screen(rect[0], rect[1]), (rect[2], rect[3])
698 if disabled:
699 bg = (0.7, 0.7, 0.72, 1.0)
700 fg = (0.45, 0.45, 0.48, 1.0)
701 elif highlighted:
702 bg = (1.0, 0.85, 0.4, 1.0)
703 fg = (0.1, 0.1, 0.12, 1.0)
704 else:
705 bg = (0.97, 0.97, 0.98, 1.0)
706 fg = (0.1, 0.1, 0.12, 1.0)
707 renderer.draw_rect((x, y), (w, h), colour=bg, filled=True)
708 renderer.draw_rect((x, y), (w, h), colour=(0.1, 0.1, 0.12, 1.0), filled=False)
709
710 # Box mode: the text builder measures the run and centres it in the
711 # button rect (inset so glyphs never touch the border), shrinking the
712 # scale when a label (the upgrade one grows with the tier) would
713 # otherwise overflow.
714 renderer.draw_text(
715 text,
716 rect=(x + 6, y, w - 12, h),
717 alignment="centre",
718 vertical_alignment="centre",
719 fit_to_width=True,
720 scale=1.2,
721 colour=fg,
722 )