Casual Crusade¶
a card-laying dungeon crawler with path-walk scoring.
▶ Run in browserUpstream: https://github.com/anttihaavikko/casual-crusade
Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.
Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.
Tags: port tier-1
Casual Crusade (SimVX port)¶
A SimVX port of Antti Haavikko’s js13k 2023 entry, a card-laying dungeon crawler on a domino-style board.
Licensing: a clean re-implementation under MIT, drawn entirely with procedural
on_draw calls (no upstream art, audio, or fonts are bundled). See
ATTRIBUTION.md and LICENSE.
Run¶
# the export runs in a copy made by `simvx examples copy ports_casual_crusade`
simvx examples run ports_casual_crusade # interactive
simvx examples run ports_casual_crusade --test # headless capture (3 frames)
simvx export web main.py \
-o casual_crusade.html
Controls¶
Action |
Input |
|---|---|
Place card |
Drag from hand onto a legal (highlighted) tile |
Cancel drag |
Right-click |
Restart |
|
Load run |
|
Save run |
|
Everything is reachable with the mouse alone (so it plays on touch): the three chips on the bottom controls strip mirror the three keys.
Gameplay summary¶
Each card has 1-4 directional edges. Place a card so at least one edge meets a matching neighbour edge already on the board.
The pilgrim then walks the newly connected path, scoring at each step. Later steps in a walk are worth more, and the level number multiplies the lot.
Stepping next to a chest loots it and opens a reward picker: the card you pick joins your permanent deck. Each option shows its gem’s name and effect.
Gems bend the rules: Fibonacci’s Boon draws an extra card, Pope’s Blessing heals, Khan’s Legacy fills the neighbouring tiles with blanks, Penance recycles a card in hand, Dynasty doubles the running step multiplier, and Indulgence scores tenfold.
Empty (non-reward) tiles at level end cost one life each. The run ends at zero lives.
Layout¶
The port lays out in a fixed 1280x720 design space: every rect and hit-test in
nodes/constants.py and nodes/game.py is in those coordinates.
Saving writes saves/casual_crusade.json relative to the directory you launched
from, never into the example’s source tree.
File map¶
casual_crusade/
├── main.py # entry point: root node + input actions
├── nodes/
│ ├── constants.py # design resolution, colours, gem table, level titles
│ ├── card_data.py # CardData + random_card + starter_deck
│ ├── card.py # Card (drag, hover, procedural draw)
│ ├── tile.py # Tile (board cell, accepts/marked/hilite, chest)
│ ├── dude.py # the pilgrim (path-walk hop + procedural draw)
│ └── game.py # Game (board, hand, scoring, picker, save/load)
└── screenshots/ # harness + idle captures
Source files¶
File |
Summary |
Lines |
|---|---|---|
Casual Crusade: a card-laying dungeon crawler with path-walk scoring. |
78 |
|
Scripted-input harness: exercises menu, hover, drag-drop, scoring, save/load. |
162 |
|
0 |
||
Card: interactive node that draws a card face procedurally. |
180 |
|
Card data record and deck-generation helpers. |
55 |
|
Casual Crusade: global constants. |
94 |
|
Dude: pilgrim avatar that walks the path of placed cards. |
78 |
|
Game: the scene managing board, hand, scoring, pilgrim and save/load. |
1137 |
|
Tile: single board cell. Owns optional Card content; draws grass or chest. |
100 |
Source¶
1# /// simvx
2# tags = ["port", "tier-1"]
3# upstream = "https://github.com/anttihaavikko/casual-crusade"
4# web = { width = 1280, height = 720, responsive = true }
5# ///
6"""Casual Crusade: a card-laying dungeon crawler with path-walk scoring.
7
8A port of Antti Haavikko's js13k 2023 entry. Drag cards from your hand onto the
9board so their direction lines meet; the pilgrim then walks the newly connected
10path and scores every step. Stepping beside a chest loots it for a reward card,
11gems on the cards bend the rules, and every tile still empty when the level ends
12costs a life.
13
14Everything on screen is drawn procedurally in ``on_draw`` (no image assets):
15coroutines animate the walk and time the banner messages, polled input actions
16drive the drag-and-drop with snap targets, ``Text2D`` carries the HUD, and a run
17saves to and loads from JSON.
18
19Run:
20 simvx examples run ports_casual_crusade
21 simvx examples run ports_casual_crusade --test
22"""
23
24from __future__ import annotations
25
26import sys
27from pathlib import Path
28
29_PORT_DIR = Path(__file__).parent
30
31from nodes.constants import HEIGHT, WIDTH # noqa: E402
32from nodes.game import Game # noqa: E402
33
34from simvx.core import InputMap, Key, Mouse, Node2D
35from simvx.graphics import App # noqa: E402
36
37
38class CasualCrusadeRoot(Node2D):
39 """Root node: owns the input actions and hosts the Game subtree."""
40
41 autostart = False
42
43 def on_ready(self) -> None:
44 # Actions are registered here, in the root's ready path, because there is
45 # no tree yet in ``main()`` for ``InputMap`` to write to.
46 # Everything the Game polls goes through these names.
47 InputMap.add_action("primary", [Mouse.LEFT])
48 InputMap.add_action("secondary", [Mouse.RIGHT])
49 InputMap.add_action("save", [Key.S])
50 InputMap.add_action("load", [Key.L])
51 InputMap.add_action("restart", [Key.R])
52 self.game = self.add_child(Game(autostart=self.autostart))
53
54
55def main() -> None:
56 headless = "--test" in sys.argv
57 if headless:
58 from simvx.graphics import save_png
59
60 capture_at = [30, 60, 120]
61 app = App(width=WIDTH, height=HEIGHT, title="Casual Crusade (SimVX)", visible=False)
62 # Run with autostart so the headless capture exercises gameplay UI.
63 root = CasualCrusadeRoot()
64 root.autostart = True
65 frames = app.run_headless(root, frames=121, capture_frames=capture_at)
66 out_dir = _PORT_DIR / "screenshots"
67 out_dir.mkdir(exist_ok=True)
68 for idx, img in zip(capture_at, frames, strict=False):
69 out_path = out_dir / f"frame_{idx}.png"
70 save_png(img, out_path)
71 print(f"saved {out_path}")
72 else:
73 app = App(width=WIDTH, height=HEIGHT, title="Casual Crusade (SimVX)")
74 app.run(CasualCrusadeRoot())
75
76
77if __name__ == "__main__":
78 main()