mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 01:16:12 +00:00
A complete, playable port of the card game War: title screen with 0/1/2 player menu, animated deal, sliding cards, deck-count HUD, a "WAR!" tie-break with buried cards, and a victory screen with a fanfare. Source split across examples/war/*.ne (constants, assets, audio, deck/queue logic, RNG, render helpers, and one state file per game state) and pulled in via examples/war.ne. Drives nearly every NEScript subsystem at once: custom 88-tile sprite sheet (card frames, ranks, suits, font, BIG WAR letters); felt background nametable; pulse-1 / pulse-2 / noise sfx; looping march on pulse 2; an 8-bit Galois LFSR PRNG; queue-based decks that conserve cards across rounds; a phase machine inside the Playing state that handles draw/reveal/win/war/check; and an autopilot that boots straight into 0-PLAYERS mode so the headless jsnes harness captures real gameplay at frame 180. While building this I uncovered five compiler bugs / limitations in the v0.1 implementation; each is documented with a minimal reproduction, root cause, current workaround, and proposed fix in examples/war/COMPILER_BUGS.md. The most painful was the parameter-VarId aliasing one (#1b) — two functions sharing a parameter NAME end up sharing a single zero-page slot mapping across the whole program. Once those compiler bugs are fixed, the workarounds in war/*.ne should be reverted in the same PR. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
170 lines
6.7 KiB
Text
170 lines
6.7 KiB
Text
// war/constants.ne — gameplay and layout constants.
|
||
//
|
||
// Everything that feeds the game's positional layout, animation
|
||
// timing, phase machine, or card encoding lives here so one central
|
||
// edit can retune the whole game. No code, just `const` entries.
|
||
|
||
// ── Card encoding ─────────────────────────────────────────
|
||
//
|
||
// Each card in a deck is packed into one u8:
|
||
//
|
||
// high nibble = rank, 1..13 (A=1, 2..10, J=11, Q=12, K=13)
|
||
// low nibble = suit, 0..3 (0=♠, 1=♥, 2=♦, 3=♣)
|
||
//
|
||
// Rank nibble lives in bits 7..4 and extracts cleanly via >>4;
|
||
// suit nibble lives in bits 3..0 and extracts via &0x0F. No
|
||
// division, no multiplication.
|
||
const SUIT_SPADE: u8 = 0
|
||
const SUIT_HEART: u8 = 1
|
||
const SUIT_DIAMOND: u8 = 2
|
||
const SUIT_CLUB: u8 = 3
|
||
|
||
const RANK_ACE: u8 = 1
|
||
const RANK_JACK: u8 = 11
|
||
const RANK_QUEEN: u8 = 12
|
||
const RANK_KING: u8 = 13
|
||
|
||
const DECK_SIZE: u8 = 52 // standard 52-card deck
|
||
const HALF_DECK: u8 = 26 // starting count per player
|
||
|
||
// ── Tile index base pointers into the Tileset sprite ─────
|
||
//
|
||
// The Tileset sprite (declared in war/assets.ne) stacks every
|
||
// custom tile vertically in reading order, so tile index N in a
|
||
// `draw Tileset frame: N` call picks the N'th 8×8 block.
|
||
//
|
||
// Tile 0 is reserved by the linker for the builtin smiley, so
|
||
// every custom tile starts at 1. The ranges below must match the
|
||
// order the Tileset body lists them in — if you move a tile,
|
||
// move its constant too.
|
||
|
||
// Card frame (shared across every face-up card)
|
||
const TILE_FRAME_TL: u8 = 1 // top-left corner with inset
|
||
const TILE_FRAME_TR: u8 = 2 // top-right corner
|
||
const TILE_FRAME_BL: u8 = 3 // bottom-left corner
|
||
const TILE_FRAME_BR: u8 = 4 // bottom-right corner
|
||
const TILE_FRAME_BLANK_L: u8 = 5 // left-half blank cell (white body)
|
||
const TILE_FRAME_BLANK_R: u8 = 6 // right-half blank cell
|
||
|
||
// Card back — 4 tiles arranged as a 2x2 repeating diamond lattice
|
||
const TILE_BACK_TL: u8 = 7
|
||
const TILE_BACK_TR: u8 = 8
|
||
const TILE_BACK_BL: u8 = 9
|
||
const TILE_BACK_BR: u8 = 10
|
||
|
||
// Rank glyphs: one tile per rank, indexed by RANK_TILE_BASE + rank - 1
|
||
// so rank 1 (Ace) lands at RANK_TILE_BASE + 0.
|
||
const TILE_RANK_BASE: u8 = 11 // 13 tiles: A, 2..10, J, Q, K
|
||
|
||
// Small suit glyphs for the card corner, one per suit.
|
||
const TILE_SUIT_SMALL_BASE: u8 = 24 // 4 tiles: ♠ ♥ ♦ ♣
|
||
|
||
// Big centre-pip, authored as a 16×8 pair per suit (left half + right half).
|
||
const TILE_PIP_L_BASE: u8 = 28 // 4 tiles
|
||
const TILE_PIP_R_BASE: u8 = 32 // 4 tiles
|
||
|
||
// Alphanumerics (8×8) used for all on-screen text that lives on the
|
||
// sprite layer. Letters A-Z then digits 0-9, contiguous.
|
||
const TILE_LETTER_BASE: u8 = 36 // 26 letters: A=+0, Z=+25
|
||
const TILE_DIGIT_BASE: u8 = 62 // 10 digits: 0=+0, 9=+9
|
||
|
||
// UI bits
|
||
const TILE_CURSOR: u8 = 72 // right-pointing arrow for menus
|
||
const TILE_HEART_TINY: u8 = 73 // tiny pip used as a victory marker
|
||
const TILE_DOT: u8 = 74 // single 8x8 card stack marker
|
||
const TILE_FELT_BG: u8 = 75 // subtle felt cross-hatch (bg fill)
|
||
|
||
// Big WAR title letters — each letter is a 2x2 block of tiles
|
||
// addressed by its quadrant.
|
||
const TILE_BIG_W_TL: u8 = 76
|
||
const TILE_BIG_W_TR: u8 = 77
|
||
const TILE_BIG_W_BL: u8 = 78
|
||
const TILE_BIG_W_BR: u8 = 79
|
||
const TILE_BIG_A_TL: u8 = 80
|
||
const TILE_BIG_A_TR: u8 = 81
|
||
const TILE_BIG_A_BL: u8 = 82
|
||
const TILE_BIG_A_BR: u8 = 83
|
||
const TILE_BIG_R_TL: u8 = 84
|
||
const TILE_BIG_R_TR: u8 = 85
|
||
const TILE_BIG_R_BL: u8 = 86
|
||
const TILE_BIG_R_BR: u8 = 87
|
||
|
||
// ── Screen-space layout ──────────────────────────────────
|
||
//
|
||
// Positions are in pixels. The screen is 256×240.
|
||
// Cards are 16 wide × 24 tall (2 cols × 3 rows of 8×8 tiles).
|
||
const CARD_W: u8 = 16
|
||
const CARD_H: u8 = 24
|
||
|
||
// Decks sit on the upper half of the play area; face-up cards sit
|
||
// on the lower half so the two bands never share scanlines.
|
||
//
|
||
// The X and Y deltas are deliberately exactly 64 px so the
|
||
// per-frame step animation (FLY_STEP * FRAMES_FLY = 4 * 16 = 64)
|
||
// lands a flying card exactly on its destination — no rounding,
|
||
// no overshoot, no need to clamp at the end.
|
||
//
|
||
// DECK_A_X -> PLAY_A_X = 32 -> 96 (Δ = +64)
|
||
// DECK_B_X -> PLAY_B_X = 208 -> 144 (Δ = -64)
|
||
// DECK_Y -> PLAY_Y = 64 -> 128 (Δ = +64)
|
||
const DECK_Y: u8 = 64 // top edge of deck card-back sprite
|
||
const PLAY_Y: u8 = 128 // top edge of face-up card sprite
|
||
|
||
const DECK_A_X: u8 = 32 // left deck
|
||
const DECK_B_X: u8 = 208 // right deck
|
||
const PLAY_A_X: u8 = 96 // centre-left face-up slot
|
||
const PLAY_B_X: u8 = 144 // centre-right face-up slot
|
||
|
||
// War banner is centred horizontally in the middle of the screen.
|
||
// Coordinates are the top-left of the first banner sprite.
|
||
const BANNER_X: u8 = 104
|
||
const BANNER_Y: u8 = 96
|
||
|
||
// HUD card counts sit above each deck.
|
||
const COUNT_A_X: u8 = 32
|
||
const COUNT_B_X: u8 = 208
|
||
const COUNT_Y: u8 = 56
|
||
|
||
// ── Animation timing ─────────────────────────────────────
|
||
//
|
||
// Every animation uses a power-of-two frame count so the lerp
|
||
// (delta * t / FRAMES) can be compiled into a shift-right by the
|
||
// optimizer, avoiding the software multiply/divide warning.
|
||
const FRAMES_FLY: u8 = 16 // card-draw and win-return slides
|
||
const FRAMES_BURY: u8 = 8 // faster bury animation during a war
|
||
const FRAMES_REVEAL:u8 = 32 // held pause after both cards are up
|
||
const FRAMES_BANNER:u8 = 48 // "WAR!" banner dwell
|
||
const FRAMES_DEAL_STEP: u8 = 4 // one dealt card every N frames
|
||
|
||
// Title auto-advance for the headless jsnes harness: the menu
|
||
// confirms "0 PLAYERS" automatically after this many frames of no
|
||
// input so the golden capture at frame 180 always lands in
|
||
// gameplay.
|
||
const TITLE_AUTO_FRAMES: u8 = 45
|
||
|
||
// CPU "thinking" delay before it draws a card.
|
||
const CPU_THINK_FRAMES: u8 = 20
|
||
|
||
// Victory auto-return timer.
|
||
const VICTORY_LINGER_FRAMES: u8 = 180
|
||
|
||
// ── Phase values for the Playing state's inner machine ───
|
||
//
|
||
// A plain `enum` would collide with other `Title` / `Victory` /
|
||
// etc identifiers, so we use explicit constants instead.
|
||
const P_WAIT_A: u8 = 0
|
||
const P_FLY_A: u8 = 1
|
||
const P_WAIT_B: u8 = 2
|
||
const P_FLY_B: u8 = 3
|
||
const P_REVEAL: u8 = 4
|
||
const P_RESOLVE: u8 = 5
|
||
const P_WIN_A: u8 = 6
|
||
const P_WIN_B: u8 = 7
|
||
const P_WAR_BANNER:u8 = 8
|
||
const P_WAR_BURY: u8 = 9
|
||
const P_CHECK: u8 = 10
|
||
|
||
// Game-mode bookkeeping: 0 / 1 / 2 players selected from the title.
|
||
const MODE_CPU_VS_CPU: u8 = 0
|
||
const MODE_HUMAN_VS_CPU: u8 = 1
|
||
const MODE_HUMAN_VS_HUMAN: u8 = 2
|