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