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.