nodes/game.py¶

Part of Casual Crusade.

   1"""Game: the scene managing board, hand, scoring, pilgrim and save/load.
   2
   3Lifecycle:
   4    on_ready  -> build the HUD, the first level, the deck and the hand
   5    on_update -> refresh the HUD, then run the drag-drop state machine on
   6                 polled input actions
   7    on_draw   -> board background, controls strip, draw pile and chips
   8
   9A run saves as plain JSON: the state that matters lives in ordinary Python
  10lists, not ``Property`` descriptors, so it is written by hand rather than
  11through ``simvx.core.save_manager``. Presentation state (animation timers,
  12hover lifts) is rebuilt on load instead of being persisted.
  13"""
  14
  15from __future__ import annotations
  16
  17import json
  18import random
  19from pathlib import Path
  20
  21from simvx.core import Node2D, Text2D
  22from simvx.core.coroutines import wait
  23from simvx.core.input.state import Input
  24from simvx.core.math.types import Vec2
  25
  26from .card import Card
  27from .card_data import CardData, random_card, starter_deck
  28from .constants import (
  29    BG_COLOUR,
  30    CARD_BORDER_COL,
  31    CHEST_GOLD,
  32    DARK_TEXT,
  33    DECK_BACK_COL,
  34    DIRECTIONS,
  35    GEM_COLOURS,
  36    GEM_NAMES,
  37    HAND_SIZE,
  38    HAND_Y_OFFSET,
  39    HEIGHT,
  40    LEVEL_TITLES,
  41    LIGHT_GREY_PANEL,
  42    NEIGHBOURS,
  43    TILE_HEIGHT,
  44    TILE_WIDTH,
  45    WIDTH,
  46    WIN_TITLES,
  47    opposite,
  48)
  49from .dude import Dude
  50from .tile import Tile
  51
  52# Saves live next to wherever the game was launched from, never inside the
  53# example's source tree (the repo gitignores a root-level ``saves/``).
  54SAVE_PATH = Path.cwd() / "saves" / "casual_crusade.json"
  55
  56# Compact controls-strip chips, ordered right to left, with the method each runs.
  57CHIPS = (("RESET", "restart"), ("LOAD", "load"), ("SAVE", "save"))
  58
  59
  60def _board_origin() -> Vec2:
  61    """Top-left of the (0,0) tile cell."""
  62    return Vec2(WIDTH * 0.5 - TILE_WIDTH * 1.5, HEIGHT * 0.5 - TILE_HEIGHT * 1.75)
  63
  64
  65def _tile_pos(gx: int, gy: int) -> Vec2:
  66    o = _board_origin()
  67    return Vec2(o.x + gx * TILE_WIDTH, o.y + gy * TILE_HEIGHT)
  68
  69
  70def _play_button_rect() -> tuple[float, float, float, float]:
  71    """(x, y, w, h) of the menu PLAY button. Single source for draw + hitbox."""
  72    bw, bh = 250.0, 76.0
  73    return (WIDTH / 2 - bw / 2, HEIGHT * 0.62, bw, bh)
  74
  75
  76def _restart_button_rect() -> tuple[float, float, float, float]:
  77    """(x, y, w, h) of the game-over RESTART button (PLAY-sized, centred)."""
  78    bw, bh = 280.0, 76.0
  79    return (WIDTH / 2 - bw / 2, HEIGHT * 0.58, bw, bh)
  80
  81
  82def _chip_rect(index: int) -> tuple[float, float, float, float]:
  83    """(x, y, w, h) of controls-strip chip *index*, counted right to left."""
  84    cw, ch = 96.0, 28.0
  85    return (WIDTH - (cw + 10) * (index + 1) - 4, HEIGHT - ch - 4, cw, ch)
  86
  87
  88def _picker_slot_rect(index: int) -> tuple[float, float, float, float]:
  89    """(x, y, w, h) of reward-picker option *index*. Single source for draw + hitbox."""
  90    cw, ch = TILE_WIDTH * 1.4, TILE_HEIGHT * 1.4
  91    gap = 130.0
  92    n = 3
  93    origin_x = WIDTH / 2 - (n * cw + (n - 1) * gap) / 2
  94    return (origin_x + index * (cw + gap), HEIGHT / 2 - ch / 2, cw, ch)
  95
  96
  97class _Overlay(Node2D):
  98    """Top-most child of Game: draws the menu + reward picker.
  99
 100    Carries a high ``z_index`` so it renders after every board tile, card and
 101    the pilgrim regardless of tree-add order. Tree order alone is fragile: each
 102    level rebuild and hand refill appends fresh nodes *after* the overlay, which
 103    would otherwise drop it behind the board again (the "blocks behind the map"
 104    / wrong-z regression on level 2+). z_index makes the ordering durable.
 105    """
 106
 107    def __init__(self, game: Game):
 108        super().__init__(name="Overlay")
 109        self.game = game
 110        self.z_index = 1000
 111
 112    def on_draw(self, renderer) -> None:
 113        g = self.game
 114        if g.menu_visible:
 115            renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=(0.0, 0.0, 0.0, 0.78), filled=True)
 116            bx, by, bw, bh = _play_button_rect()
 117            renderer.draw_rect((bx, by), (bw, bh), colour=(0.0, 0.0, 0.0, 1.0), filled=True)
 118            renderer.draw_rect((bx + 4, by + 4), (bw - 8, bh - 8), colour=CHEST_GOLD, filled=True)
 119        if g.game_over:
 120            # Dim the board, then a big touch-friendly RESTART button.
 121            renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=(0.0, 0.0, 0.0, 0.78), filled=True)
 122            bx, by, bw, bh = _restart_button_rect()
 123            renderer.draw_rect((bx, by), (bw, bh), colour=(0.0, 0.0, 0.0, 1.0), filled=True)
 124            renderer.draw_rect((bx + 4, by + 4), (bw - 8, bh - 8), colour=CHEST_GOLD, filled=True)
 125        if g.picker_visible:
 126            # Near-opaque band: the option labels have to out-read the bright
 127            # chests and cards the modal covers.
 128            renderer.draw_rect((0, HEIGHT * 0.18), (WIDTH, HEIGHT * 0.64), colour=(0, 0, 0, 0.93), filled=True)
 129            for i, opt in enumerate(g.picker_options):
 130                x, y, cw, ch = _picker_slot_rect(i)
 131                renderer.draw_rect((x, y), (cw, ch), colour=(0, 0, 0, 1), filled=True)
 132                renderer.draw_rect((x + 6, y + 6), (cw - 12, ch - 12), colour=(1, 1, 1, 1), filled=True)
 133                cx = x + cw / 2
 134                cy = y + ch / 2
 135                thick = 6.0
 136                for d in opt.directions:
 137                    if d == "u":
 138                        renderer.draw_rect(
 139                            (cx - thick / 2, y + 8), (thick, ch / 2 - 8), colour=CARD_BORDER_COL, filled=True
 140                        )
 141                    elif d == "d":
 142                        renderer.draw_rect(
 143                            (cx - thick / 2, cy), (thick, ch / 2 - 8), colour=CARD_BORDER_COL, filled=True
 144                        )
 145                    elif d == "l":
 146                        renderer.draw_rect(
 147                            (x + 8, cy - thick / 2), (cw / 2 - 8, thick), colour=CARD_BORDER_COL, filled=True
 148                        )
 149                    elif d == "r":
 150                        renderer.draw_rect(
 151                            (cx, cy - thick / 2), (cw / 2 - 8, thick), colour=CARD_BORDER_COL, filled=True
 152                        )
 153                if opt.directions:
 154                    renderer.draw_circle((cx, cy), 9, colour=CARD_BORDER_COL, filled=True, segments=20)
 155                if opt.gem:
 156                    renderer.draw_circle((cx, cy), 13, colour=CARD_BORDER_COL, filled=True, segments=20)
 157                    renderer.draw_circle((cx, cy), 7, colour=GEM_COLOURS[opt.gem], filled=True, segments=20)
 158
 159
 160class Game(Node2D):
 161    """Main scene root."""
 162
 163    def __init__(self, *, autostart: bool = True):
 164        super().__init__(name="Game")
 165        self.autostart = autostart
 166        # Persistent run state (saveable)
 167        self.score = 0
 168        self.coins = 0  # accumulated rewards-pending counter
 169        self.life = 5
 170        self.max_life = 5
 171        self.level_no = 1
 172        self.multi = 1
 173        self.hand_size = HAND_SIZE
 174        self.deck: list[CardData] = []
 175        self.permanent: list[CardData] = []  # cards permanently in deck (added via picks)
 176        # Live state
 177        self.board: list[Tile] = []
 178        self.starter_card: Card | None = None
 179        self.hand: list[Card] = []  # in-hand cards (not placed)
 180        self.placed: list[Card] = []  # placed cards (locked, on tiles)
 181        self._dragging: Card | None = None
 182        self._press_card: Card | None = None
 183        self._press_mouse: Vec2 = Vec2(0, 0)
 184        self.dude: Dude | None = None
 185        self.started = False
 186        self.menu_visible = True
 187        self.game_over = False
 188        # Picker (modal reward)
 189        self.picker_visible = False
 190        self.picker_options: list[CardData] = []
 191        # HUD nodes, all built in on_ready.
 192        self.life_text: Text2D | None = None
 193        self.score_text: Text2D | None = None
 194        self.level_text: Text2D | None = None
 195        self.message_text: Text2D | None = None
 196        self.pile_count_text: Text2D | None = None
 197        self.chip_texts: list[Text2D] = []
 198        self.picker_name_texts: list[Text2D] = []
 199        self.picker_desc_texts: list[Text2D] = []
 200        self._level_end_running = False
 201
 202    # ----------------------------------------------------------------
 203    # Setup
 204    # ----------------------------------------------------------------
 205    def on_ready(self) -> None:
 206        # The input actions this scene polls are registered by CasualCrusadeRoot.
 207        # In-game HUD text (corners; drawn under the board but never overlapping).
 208        self.controls_text = self.add_child(
 209            Text2D(
 210                text="DRAG card to a legal tile  .  RIGHT CLICK cancels  .  or tap the chips at the right",
 211                position=(20, HEIGHT - 22),
 212                font_scale=0.7,
 213                colour=(0.1, 0.1, 0.1, 1.0),
 214            )
 215        )
 216        # In-game HUD: dark text so it stays readable on the light-green board.
 217        self.life_text = self.add_child(
 218            Text2D(text=f"LIFE: {self.life}/{self.max_life}", position=(20, 44), font_scale=1.1, colour=DARK_TEXT)
 219        )
 220        self.score_text = self.add_child(Text2D(text="0", position=(WIDTH - 140, 64), font_scale=1.6, colour=DARK_TEXT))
 221        self.level_text = self.add_child(
 222            Text2D(text=f"CRUSADE {self.level_no}", position=(WIDTH - 240, 22), font_scale=0.9, colour=DARK_TEXT)
 223        )
 224        self.message_text = self.add_child(
 225            Text2D(text="", position=(WIDTH * 0.5, 180), font_scale=1.6, colour=DARK_TEXT, align="centre")
 226        )
 227        # Deck count, centred on the draw-pile box. The pile never moves, so the
 228        # position is set once here and only the count changes.
 229        pile_p = self._pile_position()
 230        self.pile_count_text = self.add_child(
 231            Text2D(
 232                text="",
 233                position=(pile_p.x + TILE_WIDTH / 2, pile_p.y + TILE_HEIGHT / 2 - 11),
 234                font_scale=1.1,
 235                colour=(0.96, 0.96, 0.96, 1.0),
 236                align="centre",
 237            )
 238        )
 239        # Compact chip labels on the bottom controls strip: touch-reachable
 240        # equivalents of the R / L / S keys.
 241        self.chip_texts = []
 242        for i, (label, _method) in enumerate(CHIPS):
 243            cx, cy, cw, _ch = _chip_rect(i)
 244            self.chip_texts.append(
 245                self.add_child(
 246                    Text2D(
 247                        text=label,
 248                        position=(cx + cw / 2, cy + 6),
 249                        font_scale=0.8,
 250                        colour=DARK_TEXT,
 251                        align="centre",
 252                    )
 253                )
 254            )
 255
 256        # Build the board / deck / hand before the overlay so the overlay is the
 257        # LAST child (draws on top of every tile, card and the pilgrim).
 258        self._build_level()
 259        self.deck = starter_deck()
 260        self.permanent = list(self.deck)
 261        self._fill_hand()
 262        self.dude = self.add_child(Dude(_tile_pos(1, 1).x, _tile_pos(1, 1).y))
 263        # Pilgrim draws above the cards (so it's never hidden by a card a later
 264        # level-rebuild appends after it) but below the menu/picker overlay.
 265        self.dude.z_index = 10
 266
 267        # --- Menu (clean vertical stack), parented to the top-most overlay so
 268        # its text draws over the dim background. ---
 269        self.overlay = self.add_child(_Overlay(self))
 270        self.title_text = self.overlay.add_child(
 271            Text2D(text="CASUAL CRUSADE", position=(WIDTH * 0.5, 120), font_scale=2.6, align="centre")
 272        )
 273        self.subtitle_text = self.overlay.add_child(
 274            Text2D(
 275                text="by Antti Haavikko (js13k 2023)  -  ported to SimVX",
 276                position=(WIDTH * 0.5, 182),
 277                font_scale=0.9,
 278                colour=(0.95, 0.95, 0.95, 1.0),
 279                align="centre",
 280            )
 281        )
 282        howto = (
 283            "Drag a card from your hand onto a highlighted tile.",
 284            "Match card edges to connect them; the pilgrim walks the path and scores.",
 285            "Loot chests for new cards.  Empty tiles cost a life.  R restarts.",
 286        )
 287        self.howto_texts = [
 288            self.overlay.add_child(
 289                Text2D(
 290                    text=line,
 291                    position=(WIDTH * 0.5, 290 + i * 42),
 292                    font_scale=0.95,
 293                    colour=(0.95, 0.95, 0.95, 1.0),
 294                    align="centre",
 295                )
 296            )
 297            for i, line in enumerate(howto)
 298        ]
 299        bx, by, bw, bh = _play_button_rect()
 300        self.play_text = self.overlay.add_child(
 301            Text2D(
 302                text="PLAY",
 303                position=(WIDTH * 0.5, by + bh / 2 - 18),
 304                font_scale=2.0,
 305                colour=(0.0, 0.0, 0.0, 1.0),
 306                align="centre",
 307            )
 308        )
 309        self.start_text = self.overlay.add_child(
 310            Text2D(
 311                text="Click PLAY to begin",
 312                position=(WIDTH * 0.5, by + bh + 24),
 313                font_scale=0.9,
 314                colour=(0.95, 0.95, 0.95, 1.0),
 315                align="centre",
 316            )
 317        )
 318        # --- Game-over UI (drawn over the game-over dim by _Overlay) ---
 319        rx, ry, rw, rh = _restart_button_rect()
 320        self.gameover_title = self.overlay.add_child(
 321            Text2D(
 322                text="CRUSADE FAILED",
 323                position=(WIDTH * 0.5, ry - 110),
 324                font_scale=2.0,
 325                colour=(0.95, 0.95, 0.95, 1.0),
 326                align="centre",
 327            )
 328        )
 329        self.restart_text = self.overlay.add_child(
 330            Text2D(
 331                text="RESTART",
 332                position=(WIDTH * 0.5, ry + rh / 2 - 18),
 333                font_scale=2.0,
 334                colour=(0.0, 0.0, 0.0, 1.0),
 335                align="centre",
 336            )
 337        )
 338        self.gameover_hint = self.overlay.add_child(
 339            Text2D(
 340                text="tap RESTART  or  press R",
 341                position=(WIDTH * 0.5, ry + rh + 24),
 342                font_scale=0.9,
 343                colour=(0.95, 0.95, 0.95, 1.0),
 344                align="centre",
 345            )
 346        )
 347        # --- Reward-picker labels: the gem's name and what it actually does, so
 348        # a power is never a mystery dot. ``rect`` + ``fit_to_width`` shrink a
 349        # long effect line to the slot instead of spilling into its neighbour. ---
 350        self.picker_header = self.overlay.add_child(
 351            Text2D(
 352                text="CHOOSE A CARD",
 353                position=(WIDTH * 0.5, HEIGHT * 0.25),
 354                font_scale=1.4,
 355                colour=CHEST_GOLD,
 356                align="centre",
 357            )
 358        )
 359        for i in range(3):
 360            px, py, pw, ph = _picker_slot_rect(i)
 361            box_x, box_w = px - 60, pw + 120
 362            self.picker_name_texts.append(
 363                self.overlay.add_child(
 364                    Text2D(
 365                        text="",
 366                        rect=(box_x, py + ph + 16, box_w, 24),
 367                        font_scale=0.85,
 368                        colour=CHEST_GOLD,
 369                        align="centre",
 370                        fit_to_width=True,
 371                    )
 372                )
 373            )
 374            self.picker_desc_texts.append(
 375                self.overlay.add_child(
 376                    Text2D(
 377                        text="",
 378                        rect=(box_x, py + ph + 44, box_w, 22),
 379                        font_scale=0.7,
 380                        colour=(0.9, 0.9, 0.9, 1.0),
 381                        align="centre",
 382                        fit_to_width=True,
 383                    )
 384                )
 385            )
 386        if self.autostart:
 387            self.started = True
 388            self.menu_visible = False
 389            self._show_message(LEVEL_TITLES[(self.level_no - 1) % len(LEVEL_TITLES)], 1.6)
 390
 391    # ----------------------------------------------------------------
 392    # Level / board generation
 393    # ----------------------------------------------------------------
 394    def _build_level(self) -> None:
 395        for t in self.board:
 396            self.remove_child(t)
 397        self.board = []
 398        for c in self.placed:
 399            self.remove_child(c)
 400        self.placed = []
 401        # Cross of 5 starting tiles, like Level.next()
 402        cross = [(0, 1), (1, 0), (1, 1), (1, 2), (2, 1)]
 403        for gx, gy in cross:
 404            p = _tile_pos(gx, gy)
 405            t = Tile(gx, gy, p.x, p.y)
 406            self.board.append(t)
 407            self.add_child(t)
 408        # Add (level-1)*2 random edge tiles
 409        for _ in range((self.level_no - 1) * 2):
 410            self._extend_random_edge()
 411        # Reward chests: tiles equal to level number, placed at edges of unpopulated cells
 412        chest_count = max(0, self.level_no - max(0, self.level_no - 8) * 3)
 413        spots = [t for t in self.board if (t.gx, t.gy) != (1, 1)]
 414        random.shuffle(spots)
 415        spots.sort(key=lambda t: abs(t.gx - 1) + abs(t.gy - 1))
 416        for tile in spots[:chest_count]:
 417            edge = self._edge_tile(tile)
 418            if edge is None:
 419                continue
 420            ex, ey = edge
 421            p = _tile_pos(ex, ey)
 422            chest = Tile(ex, ey, p.x, p.y)
 423            chest.reward = True
 424            self.board.append(chest)
 425            self.add_child(chest)
 426        # Place starter (always at (1,1)). The starter card persists across
 427        # levels, but the teardown above (`for c in self.placed: remove_child`)
 428        # detaches it from the tree, so it must be (re-)added every build, not
 429        # only when first created -- otherwise it vanishes from level 2 on.
 430        center = next(t for t in self.board if (t.gx, t.gy) == (1, 1))
 431        if self.starter_card is None:
 432            self.starter_card = Card(
 433                CardData(directions=list(DIRECTIONS)), center.position.x, center.position.y, locked=True
 434            )
 435        else:
 436            self.starter_card.position = center.position
 437            self.starter_card.target_position = center.position
 438        if self.starter_card.parent is not self:
 439            self.add_child(self.starter_card)
 440        center.content = self.starter_card
 441        # The pilgrim starts on the starter card without walking, so chests
 442        # adjacent to it would never be looted by _walk_path. Loot them now; the
 443        # pending pick(s) surface from on_update once gameplay is active.
 444        self._loot_neighbours(center)
 445
 446    def _extend_random_edge(self) -> None:
 447        """Add one tile adjacent to a current non-full board tile."""
 448        candidates = []
 449        for t in self.board:
 450            for _d, dx, dy in NEIGHBOURS:
 451                nx, ny = t.gx + dx, t.gy + dy
 452                if not (-2 <= nx <= 4 and -1 <= ny <= 3):
 453                    continue
 454                if self._tile_at(nx, ny) is not None:
 455                    continue
 456                candidates.append((nx, ny))
 457        if not candidates:
 458            return
 459        nx, ny = random.choice(candidates)
 460        p = _tile_pos(nx, ny)
 461        t = Tile(nx, ny, p.x, p.y)
 462        self.board.append(t)
 463        self.add_child(t)
 464
 465    def _tile_at(self, gx: int, gy: int) -> Tile | None:
 466        """The board tile at grid (gx, gy), or None if that cell is off-board."""
 467        return next((t for t in self.board if t.gx == gx and t.gy == gy), None)
 468
 469    def _edge_tile(self, tile: Tile) -> tuple[int, int] | None:
 470        """The first free grid cell orthogonally adjacent to *tile*, if any."""
 471        for _d, dx, dy in NEIGHBOURS:
 472            nx, ny = tile.gx + dx, tile.gy + dy
 473            if self._tile_at(nx, ny) is None:
 474                return (nx, ny)
 475        return None
 476
 477    # ----------------------------------------------------------------
 478    # Hand / deck management
 479    # ----------------------------------------------------------------
 480    def _fill_hand(self) -> None:
 481        """Pull cards into hand up to hand_size."""
 482        while len(self.hand) < self.hand_size and self.deck:
 483            self._pull_one()
 484        self._reposition_hand()
 485
 486    def _pull_one(self) -> None:
 487        if not self.deck:
 488            return
 489        data = self.deck.pop()
 490        # Spawn at the pile location (left of hand)
 491        pile_p = self._pile_position()
 492        c = Card(data, pile_p.x, pile_p.y)
 493        self.hand.append(c)
 494        self.add_child(c)
 495
 496    def _pile_position(self) -> Vec2:
 497        # Fixed near the left edge so it never drifts off-screen as the hand grows.
 498        return Vec2(30.0, HEIGHT - HAND_Y_OFFSET)
 499
 500    def _reposition_hand(self) -> None:
 501        n = len(self.hand)
 502        cx = WIDTH * 0.5
 503        cy = HEIGHT - HAND_Y_OFFSET
 504        # Compress the spacing so the hand always fits on screen, however large it
 505        # grows (Fibonacci's Boon keeps adding cards); cards overlap when needed.
 506        # Symmetric clearance keeps the row clear of the draw pile on the left.
 507        avail = WIDTH - 360.0
 508        natural = TILE_WIDTH * 1.05
 509        step = natural if n <= 1 else min(natural, (avail - TILE_WIDTH) / (n - 1))
 510        for i, c in enumerate(self.hand):
 511            tx = cx + (i - (n - 1) / 2) * step - TILE_WIDTH / 2
 512            c.target_position = Vec2(tx, cy)
 513
 514    def _set_board_visible(self, vis: bool) -> None:
 515        """Show/hide all live board visuals (tiles, cards, pilgrim, HUD)."""
 516        for node in (*self.board, *self.placed, *self.hand):
 517            node.visible = vis
 518        if self.starter_card is not None:
 519            self.starter_card.visible = vis
 520        if self.dude is not None:
 521            self.dude.visible = vis
 522        for hud in (self.life_text, self.score_text, self.level_text):
 523            hud.visible = vis
 524
 525    # ----------------------------------------------------------------
 526    # Per-frame logic
 527    # ----------------------------------------------------------------
 528    def on_update(self, dt: float) -> None:
 529        self._refresh_hud()
 530
 531        # Game-over: a touch-friendly RESTART button (drawn by _Overlay). Handle
 532        # its click here, before the menu/picker branches, so it always responds.
 533        if self.game_over:
 534            mp = Input.mouse_position
 535            bx, by, bw, bh = _restart_button_rect()
 536            if Input.is_action_just_pressed("primary") and bx <= mp.x <= bx + bw and by <= mp.y <= by + bh:
 537                self.restart()
 538            if Input.is_action_just_pressed("restart"):
 539                self.restart()
 540            return
 541
 542        # Menu interaction (PLAY button)
 543        if self.menu_visible:
 544            self._update_menu(dt)
 545            return
 546
 547        # Surface the next pending reward pick (one chest at a time). Done here
 548        # -- after the menu early-return -- so a pick looted at level start never
 549        # draws over the menu; it appears the moment gameplay begins.
 550        if not self.picker_visible and self.coins > 0:
 551            self._open_picker()
 552
 553        # Reward picker overrides everything
 554        if self.picker_visible:
 555            self._update_picker(dt)
 556            return
 557
 558        # Drag-drop state machine
 559        mp = Input.mouse_position
 560        primary = Input.is_action_just_pressed("primary")
 561        # Controls-strip chips (touch-reachable RESET / LOAD / SAVE). Checked
 562        # before the card press so a tap on a chip never starts a drag.
 563        if primary and self._tap_chip(mp):
 564            return
 565        # Hover check: only the topmost (last-drawn) card under the cursor lights
 566        # up, so overlapping cards in a large hand don't all highlight at once.
 567        hovered = None
 568        for c in self.hand:
 569            if c.contains(mp):
 570                hovered = c
 571        for c in self.hand:
 572            c.hovered = c is hovered
 573        # Press
 574        if primary:
 575            self._on_press(mp)
 576        # Drag follow / hilite legal tiles
 577        if self._dragging is not None:
 578            self._dragging.drag_to(mp, dt)
 579            self._update_snap_highlight(mp)
 580        # Release
 581        if Input.is_action_just_released("primary"):
 582            self._on_release(mp)
 583
 584        # Right click: cancel drag
 585        if Input.is_action_just_pressed("secondary"):
 586            self._cancel_drag()
 587
 588        # Save/Load/Restart, the keyboard twins of the controls-strip chips.
 589        if Input.is_action_just_pressed("save"):
 590            self.save()
 591        if Input.is_action_just_pressed("load"):
 592            self.load()
 593        if Input.is_action_just_pressed("restart"):
 594            self.restart()
 595
 596    def _refresh_hud(self) -> None:
 597        """Push the current run state onto the HUD labels.
 598
 599        Plain assignment every frame: a ``Property`` setter compares before it
 600        writes, so re-assigning the same string or flag is a no-op (no glyph
 601        layout, no redraw) and there is no dirty-tracking to keep in sync.
 602        """
 603        in_play = not self.menu_visible and not self.game_over
 604        self.life_text.text = f"LIFE: {self.life}/{self.max_life}"
 605        self.score_text.text = str(self.score)
 606        self.level_text.text = f"CRUSADE {self.level_no}"
 607        for n in (self.title_text, self.subtitle_text, self.start_text, self.play_text, *self.howto_texts):
 608            n.visible = self.menu_visible
 609        self.controls_text.visible = not self.menu_visible
 610        self._set_board_visible(not self.menu_visible)
 611        for n in (self.gameover_title, self.restart_text, self.gameover_hint):
 612            n.visible = self.game_over
 613        self.pile_count_text.visible = in_play and bool(self.hand)
 614        self.pile_count_text.text = str(len(self.deck))
 615        for chip in self.chip_texts:
 616            chip.visible = in_play
 617        # The modal picker owns the banner row, so the transient level/reward
 618        # message steps aside rather than printing through the header.
 619        self.picker_header.visible = self.picker_visible
 620        self.message_text.visible = not self.picker_visible
 621
 622    def _tap_chip(self, mp: Vec2) -> bool:
 623        """Run the controls-strip chip under *mp*, if any. True when one fired."""
 624        for i, (_label, method) in enumerate(CHIPS):
 625            cx, cy, cw, ch = _chip_rect(i)
 626            if cx <= mp.x <= cx + cw and cy <= mp.y <= cy + ch:
 627                getattr(self, method)()
 628                return True
 629        return False
 630
 631    # ----------------------------------------------------------------
 632    # Menu
 633    # ----------------------------------------------------------------
 634    def _update_menu(self, dt: float) -> None:
 635        mp = Input.mouse_position
 636        # PLAY button hitbox (shared geometry with the overlay draw)
 637        bx, by, bw, bh = _play_button_rect()
 638        if Input.is_action_just_pressed("primary"):
 639            if bx <= mp.x <= bx + bw and by <= mp.y <= by + bh:
 640                self.menu_visible = False
 641                self.started = True
 642                self._show_message(LEVEL_TITLES[(self.level_no - 1) % len(LEVEL_TITLES)], 1.6)
 643
 644    def _show_message(self, msg: str, duration: float = 1.5) -> None:
 645        if self.message_text is None:
 646            return
 647        self.message_text.text = msg
 648        self.start_coroutine(self._clear_message_after(duration))
 649
 650    def _clear_message_after(self, duration: float):
 651        elapsed = 0.0
 652        while elapsed < duration:
 653            dt = yield
 654            elapsed += dt or 0.0
 655        if self.message_text is not None:
 656            self.message_text.text = ""
 657
 658    # ----------------------------------------------------------------
 659    # Drag/drop handling
 660    # ----------------------------------------------------------------
 661    def _on_press(self, mp: Vec2) -> None:
 662        # Check hand cards (in reverse so topmost wins)
 663        for c in reversed(self.hand):
 664            if not c.locked and c.contains(mp):
 665                self._press_card = c
 666                self._press_mouse = mp
 667                c.begin_drag(mp)
 668                self._dragging = c
 669                self._mark_legal_tiles(c)
 670                return
 671
 672    def _on_release(self, mp: Vec2) -> None:
 673        if self._dragging is None:
 674            return
 675        c = self._dragging
 676        # Find best snap tile
 677        target = self._best_snap_tile(c)
 678        c.end_drag()
 679        self._dragging = None
 680        # Clear marks/hilite
 681        for t in self.board:
 682            t.marked = False
 683            t.hilite = False
 684        if target is None:
 685            # Snap back home
 686            return
 687        # Place
 688        self._place_card(c, target)
 689
 690    def _cancel_drag(self) -> None:
 691        if self._dragging is None:
 692            return
 693        c = self._dragging
 694        c.end_drag()
 695        self._dragging = None
 696        for t in self.board:
 697            t.marked = False
 698            t.hilite = False
 699
 700    def _mark_legal_tiles(self, c: Card) -> None:
 701        for t in self.board:
 702            t.marked = t.accepts(c.data, self.board)
 703
 704    def _update_snap_highlight(self, mp: Vec2) -> None:
 705        candidates = [t for t in self.board if t.accepts(self._dragging.data, self.board)]
 706        # Within proximity to card centre
 707        cc = self._dragging.world_centre()
 708        near = [(t, (t.world_centre() - cc).length()) for t in candidates]
 709        near.sort(key=lambda kv: kv[1])
 710        for t in self.board:
 711            t.hilite = False
 712        if near and near[0][1] < 110.0:
 713            near[0][0].hilite = True
 714
 715    def _best_snap_tile(self, c: Card):
 716        candidates = [t for t in self.board if t.accepts(c.data, self.board)]
 717        if not candidates:
 718            return None
 719        cc = c.world_centre()
 720        best = min(candidates, key=lambda t: (t.world_centre() - cc).length())
 721        if (best.world_centre() - cc).length() > 110.0:
 722            return None
 723        return best
 724
 725    # ----------------------------------------------------------------
 726    # Placement -> path-find -> score
 727    # ----------------------------------------------------------------
 728    def _place_card(self, card: Card, tile: Tile) -> None:
 729        self.hand.remove(card)
 730        self.placed.append(card)
 731        card.locked = True
 732        card.tile = tile
 733        tile.content = card
 734        card.settle_to(tile.position)
 735        card.scale_pulse = 1.18  # placement punch
 736        # Gem-on-place effects (the on-step ones fire from _walk_path).
 737        if card.data.gem == "b":  # FIBONACCI'S BOON: draw an extra card
 738            self.hand_size += 1
 739        elif card.data.gem == "r":  # POPE'S BLESSING: heal one
 740            self.heal(1)
 741        elif card.data.gem == "g":  # KHAN'S LEGACY: fill neighbours with blanks
 742            self._fill_neighbours_with_blanks(tile)
 743        # Walk dude across new path
 744        self.start_coroutine(self._walk_path(tile))
 745        # Refresh hand
 746        self._reposition_hand()
 747
 748    def _fill_neighbours_with_blanks(self, tile: Tile) -> None:
 749        """KHAN'S LEGACY: drop a directionless card on every empty neighbour.
 750
 751        Blank cards connect to nothing, so they never extend a scoring path;
 752        what they buy is the empty-tile life penalty at level end.
 753        """
 754        for _d, dx, dy in NEIGHBOURS:
 755            nb = self._tile_at(tile.gx + dx, tile.gy + dy)
 756            if nb is None or nb.reward or nb.hidden or nb.content is not None:
 757                continue
 758            blank = Card(CardData(), nb.position.x, nb.position.y, locked=True)
 759            blank.tile = nb
 760            nb.content = blank
 761            self.placed.append(blank)
 762            self.add_child(blank)
 763
 764    def _recycle_random_card(self) -> None:
 765        """PENANCE: swap one random hand card for the next card off the deck."""
 766        pool = [c for c in self.hand if c is not self._dragging]
 767        if not pool or not self.deck:
 768            return
 769        old = random.choice(pool)
 770        self.hand.remove(old)
 771        self.remove_child(old)
 772        self.deck.insert(0, old.data)  # to the bottom; _pull_one draws off the top
 773        self._pull_one()
 774        self._reposition_hand()
 775
 776    def _walk_path(self, target_tile: Tile):
 777        """BFS path from dude's current tile through connected placed cards.
 778
 779        Find a path of card-to-card edges from where the dude stands to
 780        target_tile (just placed), then animate hops along it. Award score.
 781        """
 782        if self.dude is None:
 783            return
 784        # Locate dude tile (closest tile to dude.position)
 785        dx = self.dude.position.x
 786        dy = self.dude.position.y
 787        start = min(self.board, key=lambda t: (t.position.x - dx) ** 2 + (t.position.y - dy) ** 2)
 788        path = self._find_path(start, target_tile)
 789        if not path or len(path) < 2:
 790            return
 791        # Score & walk
 792        self.multi = 1
 793        for i, t in enumerate(path):
 794            if i == 0:
 795                continue
 796            yield from self.dude.hop_to(t.position, 0.28)
 797            # Activate gem effect on stepped card
 798            if t.content and t.content.data.gem == "o":  # DYNASTY: doubles step score
 799                self.multi *= 2
 800            if t.content and t.content.data.gem == "p":  # PENANCE: recycle a hand card
 801                self._recycle_random_card()
 802            # Score (step-index * multi * level)
 803            if t.content:
 804                gain = i * self.multi * self.level_no * (10 if t.content.data.gem == "y" else 1)
 805                self.score += gain
 806                t.content.visited = True
 807                t.content.scale_pulse = 1.18
 808            # Loot any neighbouring chests
 809            self._loot_neighbours(t)
 810        # Path-walk done; un-flag visited after a beat
 811        yield from wait(0.4)
 812        for t in path:
 813            if t.content:
 814                t.content.visited = False
 815        # Refill hand
 816        self._fill_hand()
 817        # Check for level end
 818        self._check_level_end()
 819
 820    def _find_path(self, start: Tile, goal: Tile):
 821        """BFS where edges are card-to-card legal connections (matching dirs)."""
 822        if start.content is None:
 823            return []
 824        from collections import deque
 825
 826        prev = {start: None}
 827        q = deque([start])
 828        while q:
 829            cur = q.popleft()
 830            if cur is goal:
 831                break
 832            if cur.content is None:
 833                continue
 834            for d, dx, dy in NEIGHBOURS:
 835                if not cur.content.data.has(d):
 836                    continue
 837                nb = self._tile_at(cur.gx + dx, cur.gy + dy)
 838                if nb is None or nb.content is None:
 839                    continue
 840                if not nb.content.data.has(opposite(d)):
 841                    continue
 842                if nb in prev:
 843                    continue
 844                prev[nb] = cur
 845                q.append(nb)
 846        if goal not in prev:
 847            return []
 848        # Reconstruct
 849        path = []
 850        cur = goal
 851        while cur is not None:
 852            path.append(cur)
 853            cur = prev[cur]
 854        path.reverse()
 855        return path
 856
 857    def _loot_neighbours(self, tile: Tile) -> None:
 858        """Loot every adjacent chest. Each chest queues one pending pick via
 859        ``coins``; the picker is surfaced (one chest at a time) from on_update,
 860        so N chests looted in one turn yield N sequential picks -- and a pick
 861        queued while the menu is up simply waits until the player clicks PLAY."""
 862        for _d, dx, dy in NEIGHBOURS:
 863            nb = self._tile_at(tile.gx + dx, tile.gy + dy)
 864            if nb and nb.reward and not nb.looted:
 865                nb.looted = True
 866                nb.lid_open_t = 1.0
 867                self.coins += 1
 868
 869    # ----------------------------------------------------------------
 870    # Reward picker
 871    # ----------------------------------------------------------------
 872    def _open_picker(self) -> None:
 873        self.picker_visible = True
 874        self.picker_options = [random_card(1.0, True) for _ in range(3)]
 875        self._label_picker()
 876
 877    def _label_picker(self) -> None:
 878        """Write each option's gem name + effect under its card (blank if none)."""
 879        for i, (name_text, desc_text) in enumerate(zip(self.picker_name_texts, self.picker_desc_texts, strict=True)):
 880            gem = self.picker_options[i].gem if i < len(self.picker_options) else None
 881            name, desc = GEM_NAMES[gem] if gem else ("", "")
 882            name_text.text = name
 883            desc_text.text = desc
 884
 885    def _update_picker(self, dt: float) -> None:
 886        if Input.is_action_just_pressed("primary"):
 887            mp = Input.mouse_position
 888            for i, opt in enumerate(self.picker_options):
 889                x, y, cw, ch = _picker_slot_rect(i)
 890                if x <= mp.x <= x + cw and y <= mp.y <= y + ch:
 891                    self._take_reward(opt)
 892                    return
 893            # True modal: clicking empty space does nothing. The player MUST
 894            # pick one of the option cards to dismiss the picker and proceed.
 895
 896    def _take_reward(self, opt: CardData) -> None:
 897        """Resolve one pending pick: add the chosen card to the deck, and to the
 898        hand if there is room, so the pick is immediately visible. With the hand
 899        full the card would otherwise vanish into the deck and read as 'nothing
 900        happened'. Decrement ``coins``; if more picks are pending on_update will
 901        re-open a fresh picker next frame (one chest -> one card)."""
 902        self.permanent.append(opt)
 903        self.coins -= 1
 904        self.picker_visible = False
 905        self.picker_options = []
 906        self._label_picker()
 907        if len(self.hand) < self.hand_size:
 908            # Spawn into the hand so the player sees the new card straight away.
 909            c = Card(opt, self._pile_position().x, self._pile_position().y)
 910            c.scale_pulse = 1.18
 911            self.hand.append(c)
 912            self.add_child(c)
 913            self._reposition_hand()
 914        else:
 915            # No hand room: card joins the draw pile, shuffled in.
 916            self.deck.append(opt)
 917            random.shuffle(self.deck)
 918        self._show_message("CARD ACQUIRED", 1.0)
 919
 920    # ----------------------------------------------------------------
 921    # Level end / next
 922    # ----------------------------------------------------------------
 923    def _check_level_end(self) -> None:
 924        if self._level_end_running:
 925            return
 926        # Any non-reward, non-content tiles still legal for any hand card?
 927        empty = [t for t in self.board if not t.reward and not t.hidden and t.content is None]
 928        if not empty:
 929            self.start_coroutine(self._next_level())
 930            return
 931        if not self.hand:
 932            # Out of cards
 933            self.start_coroutine(self._next_level())
 934            return
 935        # Are any plays legal?
 936        if not any(t.accepts(c.data, self.board) for t in empty for c in self.hand):
 937            self.start_coroutine(self._next_level())
 938            return
 939
 940    def _next_level(self):
 941        self._level_end_running = True
 942        # Penalise empty (non-reward) tiles: -1 life each
 943        empty = [t for t in self.board if not t.reward and not t.hidden and t.content is None]
 944        for t in empty:
 945            self.life -= 1
 946            t.hidden = True
 947            yield from wait(0.2)
 948        if self.life <= 0:
 949            # Enter the game-over state: _Overlay draws a big RESTART button and
 950            # on_update routes its click (and R) to restart().
 951            self.game_over = True
 952            self._level_end_running = False
 953            return
 954        self._show_message(random.choice(WIN_TITLES), 1.5)
 955        yield from wait(1.5)
 956        # Bump level
 957        self.level_no += 1
 958        # Tear down placed cards (except starter)
 959        for c in list(self.placed):
 960            if c is self.starter_card:
 961                continue
 962            self.remove_child(c)
 963        self.placed = [self.starter_card] if self.starter_card is not None else []
 964        # Reset deck from permanent
 965        self.deck = list(self.permanent)
 966        random.shuffle(self.deck)
 967        # Empty hand back into deck
 968        for c in list(self.hand):
 969            self.remove_child(c)
 970        self.hand = []
 971        # Rebuild board
 972        self._build_level()
 973        # Reset dude
 974        if self.dude is not None:
 975            self.dude.position = _tile_pos(1, 1)
 976        self._fill_hand()
 977        self._show_message(LEVEL_TITLES[(self.level_no - 1) % len(LEVEL_TITLES)], 1.5)
 978        self._level_end_running = False
 979
 980    # ----------------------------------------------------------------
 981    # Misc effects
 982    # ----------------------------------------------------------------
 983    def heal(self, amt: int) -> None:
 984        self.life = min(self.max_life, self.life + amt)
 985
 986    def restart(self) -> None:
 987        # Wipe state, rebuild
 988        self.game_over = False
 989        self.score = 0
 990        self.life = self.max_life = 5
 991        self.level_no = 1
 992        self.hand_size = HAND_SIZE
 993        self.permanent = []
 994        # Clear pending picks before rebuilding: _build_level loots the chests
 995        # around the starter tile and queues fresh ones.
 996        self.coins = 0
 997        self.picker_visible = False
 998        self.picker_options = []
 999        self._label_picker()
1000        # Remove all cards
1001        for c in list(self.placed) + list(self.hand):
1002            self.remove_child(c)
1003        self.placed = []
1004        self.hand = []
1005        self.starter_card = None
1006        # Rebuild
1007        self._build_level()
1008        self.deck = starter_deck()
1009        self.permanent = list(self.deck)
1010        self._fill_hand()
1011        if self.dude is not None:
1012            self.dude.position = _tile_pos(1, 1)
1013        self._show_message("RESTARTED", 1.0)
1014
1015    # ----------------------------------------------------------------
1016    # Save / load
1017    # ----------------------------------------------------------------
1018    def save(self) -> None:
1019        SAVE_PATH.parent.mkdir(parents=True, exist_ok=True)
1020        data = {
1021            "score": self.score,
1022            "life": self.life,
1023            "max_life": self.max_life,
1024            "level_no": self.level_no,
1025            "hand_size": self.hand_size,
1026            "deck": [c.to_dict() for c in self.deck],
1027            "permanent": [c.to_dict() for c in self.permanent],
1028            "hand": [c.data.to_dict() for c in self.hand],
1029            "placed": [
1030                {"data": c.data.to_dict(), "tile": [c.tile.gx, c.tile.gy] if c.tile else None} for c in self.placed
1031            ],
1032            "board": [
1033                {"gx": t.gx, "gy": t.gy, "reward": t.reward, "looted": t.looted, "hidden": t.hidden} for t in self.board
1034            ],
1035        }
1036        SAVE_PATH.write_text(json.dumps(data, indent=2))
1037        self._show_message(f"SAVED to {SAVE_PATH.name}", 1.0)
1038
1039    def load(self) -> None:
1040        if not SAVE_PATH.exists():
1041            self._show_message("NO SAVE FILE", 1.0)
1042            return
1043        data = json.loads(SAVE_PATH.read_text())
1044        # Wipe live state
1045        for c in list(self.hand) + list(self.placed):
1046            if c is self.starter_card:
1047                continue
1048            self.remove_child(c)
1049        for t in list(self.board):
1050            self.remove_child(t)
1051        self.hand = []
1052        self.placed = []
1053        self.board = []
1054        # Restore scalars
1055        self.score = data["score"]
1056        self.life = data["life"]
1057        self.max_life = data["max_life"]
1058        self.level_no = data["level_no"]
1059        self.hand_size = data["hand_size"]
1060        self.deck = [CardData.from_dict(d) for d in data.get("deck", [])]
1061        self.permanent = [CardData.from_dict(d) for d in data.get("permanent", [])]
1062        # Rebuild board (use saved layout if present, else generate)
1063        if data.get("board"):
1064            for tinfo in data["board"]:
1065                p = _tile_pos(tinfo["gx"], tinfo["gy"])
1066                t = Tile(tinfo["gx"], tinfo["gy"], p.x, p.y)
1067                t.reward = tinfo.get("reward", False)
1068                t.looted = tinfo.get("looted", False)
1069                t.hidden = tinfo.get("hidden", False)
1070                self.board.append(t)
1071                self.add_child(t)
1072        else:
1073            self._build_level()
1074        # Place starter card on (1,1), re-parenting so it draws AFTER the freshly
1075        # rebuilt tiles (on_draw runs in child order, and tiles must draw under
1076        # cards so the cards are visible).
1077        center = next((t for t in self.board if (t.gx, t.gy) == (1, 1)), None)
1078        if center is not None:
1079            if self.starter_card is not None:
1080                self.remove_child(self.starter_card)
1081            self.starter_card = Card(
1082                CardData(directions=list(DIRECTIONS)), center.position.x, center.position.y, locked=True
1083            )
1084            self.add_child(self.starter_card)
1085            center.content = self.starter_card
1086            self.placed.append(self.starter_card)
1087        # Restore placed cards
1088        for entry in data.get("placed", []):
1089            tile_idx = entry.get("tile")
1090            if tile_idx is None:
1091                continue
1092            gx, gy = tile_idx
1093            if (gx, gy) == (1, 1):
1094                continue
1095            tile = next((t for t in self.board if t.gx == gx and t.gy == gy), None)
1096            if tile is None:
1097                continue
1098            cd = CardData.from_dict(entry["data"])
1099            c = Card(cd, tile.position.x, tile.position.y, locked=True)
1100            self.add_child(c)
1101            self.placed.append(c)
1102            tile.content = c
1103            c.tile = tile
1104        # Restore hand
1105        for d in data.get("hand", []):
1106            cd = CardData.from_dict(d)
1107            c = Card(cd, _board_origin().x, HEIGHT - HAND_Y_OFFSET)
1108            self.add_child(c)
1109            self.hand.append(c)
1110        self._reposition_hand()
1111        if self.dude is not None:
1112            self.dude.position = _tile_pos(1, 1)
1113        self._show_message("LOADED", 1.0)
1114
1115    # ----------------------------------------------------------------
1116    # Background tartan + HUD overlays drawn via on_draw
1117    # ----------------------------------------------------------------
1118    def on_draw(self, renderer) -> None:
1119        # Background tartan stripes
1120        renderer.draw_rect((0, 0), (WIDTH, HEIGHT), colour=BG_COLOUR, filled=True)
1121        for x in range(0, WIDTH, 50):
1122            renderer.draw_rect((x, 0), (3, HEIGHT), colour=(0.40, 0.66, 0.40, 1.0), filled=True)
1123        for y in range(0, HEIGHT, 50):
1124            renderer.draw_rect((0, y), (WIDTH, 3), colour=(0.40, 0.66, 0.40, 1.0), filled=True)
1125        # Bottom controls strip (light grey, port UX baseline)
1126        strip_h = 36
1127        renderer.draw_rect((0, HEIGHT - strip_h), (WIDTH, strip_h), colour=LIGHT_GREY_PANEL, filled=True)
1128        # Draw-pile box (left of hand) -- gameplay only, never under the menu.
1129        # Reads as a deck "card back" (deep purple), not a blank white card; the
1130        # remaining deck count is drawn over it by ``pile_count_text``.
1131        if self.hand and not self.menu_visible and not self.game_over:
1132            pile_p = self._pile_position()
1133            # Black outline, purple card-back, and two inset pips for a "stack" feel.
1134            renderer.draw_rect(
1135                (pile_p.x + 4, pile_p.y + 4),
1136                (TILE_WIDTH - 8, TILE_HEIGHT - 8),
1137                colour=CARD_BORDER_COL,
1138                filled=True,
1139            )
1140            renderer.draw_rect(
1141                (pile_p.x + 9, pile_p.y + 9),
1142                (TILE_WIDTH - 18, TILE_HEIGHT - 18),
1143                colour=DECK_BACK_COL,
1144                filled=True,
1145            )
1146            renderer.draw_rect(
1147                (pile_p.x + 16, pile_p.y + 16),
1148                (TILE_WIDTH - 32, TILE_HEIGHT - 32),
1149                colour=CARD_BORDER_COL,
1150                filled=False,
1151            )
1152        # Controls-strip chip backgrounds (the labels on top are Game children,
1153        # so they draw over these fills).
1154        if not self.menu_visible and not self.game_over:
1155            for i in range(len(CHIPS)):
1156                cx, cy, cw, ch = _chip_rect(i)
1157                renderer.draw_rect((cx, cy), (cw, ch), colour=CARD_BORDER_COL, filled=True)
1158                renderer.draw_rect((cx + 2, cy + 2), (cw - 4, ch - 4), colour=LIGHT_GREY_PANEL, filled=True)
1159        # The menu dim + PLAY button, reward picker and game-over RESTART are
1160        # drawn by ``_Overlay`` (top-most) so they sit ON TOP of the board.