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