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        )