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