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        )