mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +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
188 lines
6 KiB
Text
188 lines
6 KiB
Text
// war/deck.ne — queue operations on the three u8[52] buffers.
|
|
//
|
|
// Each of deck_a, deck_b, and pot is a 52-entry circular buffer
|
|
// storing packed rank/suit bytes. The front index is the next
|
|
// card to draw; the count is the number of cards currently in
|
|
// the buffer. Everything wraps mod 52 — since 52 isn't a power
|
|
// of two, we use `if idx >= 52 { idx -= 52 }` instead of `%` to
|
|
// avoid the expensive software mod routine.
|
|
//
|
|
// NEScript v0.1 has a flat global symbol table for every `var`
|
|
// declaration (function-locals included), so each function's
|
|
// locals are prefixed with the function's short name to avoid
|
|
// E0501 collisions across the program. Function parameters ARE
|
|
// scoped per-function, so we can keep their names short.
|
|
|
|
// ── Helpers shared by every deck ──────────────────────────
|
|
//
|
|
// Wrap a (front + count) sum back into the 0..51 range. Only
|
|
// needed inside push_back because `front` and `count` are both
|
|
// ≤ 51 by construction, so the sum is at most 102 and one
|
|
// subtraction is enough.
|
|
//
|
|
// Every parameter name in this file is unique across the entire
|
|
// program. NEScript v0.1's IR lowering uses a single global
|
|
// var_map for parameter names, so two functions both named
|
|
// `wrap52(v: u8)` and `another(v: u8)` end up sharing a VarId
|
|
// and the codegen routes their parameter reads to whichever
|
|
// zero-page slot the LAST function to be lowered claimed. See
|
|
// COMPILER_BUGS.md §1b for the gory details.
|
|
inline fun wrap52(w52_v: u8) -> u8 {
|
|
if w52_v >= DECK_SIZE {
|
|
return w52_v - DECK_SIZE
|
|
}
|
|
return w52_v
|
|
}
|
|
|
|
// ── deck_a ────────────────────────────────────────────────
|
|
|
|
fun deck_a_empty() -> u8 {
|
|
if deck_a_count == 0 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
fun draw_front_a() -> u8 {
|
|
var dfa_card: u8 = deck_a[deck_a_front]
|
|
deck_a_front = wrap52(deck_a_front + 1)
|
|
deck_a_count -= 1
|
|
return dfa_card
|
|
}
|
|
|
|
fun push_back_a(pba_in: u8) {
|
|
// Snapshot `pba_in` into a local before calling wrap52,
|
|
// because NEScript v0.1's parameter-passing ABI uses fixed
|
|
// zero-page slots — wrap52's first param shares slot $04
|
|
// with our `pba_in` and would silently clobber it.
|
|
var pba_card: u8 = pba_in
|
|
var pba_slot: u8 = wrap52(deck_a_front + deck_a_count)
|
|
deck_a[pba_slot] = pba_card
|
|
deck_a_count += 1
|
|
}
|
|
|
|
// ── deck_b ────────────────────────────────────────────────
|
|
|
|
fun deck_b_empty() -> u8 {
|
|
if deck_b_count == 0 {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
fun draw_front_b() -> u8 {
|
|
var dfb_card: u8 = deck_b[deck_b_front]
|
|
deck_b_front = wrap52(deck_b_front + 1)
|
|
deck_b_count -= 1
|
|
return dfb_card
|
|
}
|
|
|
|
fun push_back_b(pbb_in: u8) {
|
|
var pbb_card: u8 = pbb_in
|
|
var pbb_slot: u8 = wrap52(deck_b_front + deck_b_count)
|
|
deck_b[pbb_slot] = pbb_card
|
|
deck_b_count += 1
|
|
}
|
|
|
|
// ── pot ───────────────────────────────────────────────────
|
|
|
|
fun push_back_pot(pbp_in: u8) {
|
|
pot[pot_count] = pbp_in
|
|
pot_count += 1
|
|
}
|
|
|
|
fun clear_pot() {
|
|
pot_count = 0
|
|
}
|
|
|
|
// Transfer every card currently in the pot into deck_a, in FIFO
|
|
// order (so the face-up and face-down cards layer naturally).
|
|
fun pot_to_a() {
|
|
var pta_i: u8 = 0
|
|
while pta_i < pot_count {
|
|
push_back_a(pot[pta_i])
|
|
pta_i += 1
|
|
}
|
|
pot_count = 0
|
|
}
|
|
|
|
fun pot_to_b() {
|
|
var ptb_i: u8 = 0
|
|
while ptb_i < pot_count {
|
|
push_back_b(pot[ptb_i])
|
|
ptb_i += 1
|
|
}
|
|
pot_count = 0
|
|
}
|
|
|
|
// ── Init + shuffle ────────────────────────────────────────
|
|
//
|
|
// Build a 52-card "master deck" in deck_a's backing array using
|
|
// a rank-major loop: for each rank 1..13, each suit 0..3, write
|
|
// the packed byte (rank << 4) | suit into deck_a[i]. Then
|
|
// bounded-random-swap-shuffle it. Finally, split the first 26
|
|
// into deck_b (copied), leaving the second 26 in deck_a's first
|
|
// half, and reset both queues' cursors.
|
|
//
|
|
// The random-swap shuffle is a bounded alternative to
|
|
// Fisher-Yates: it does N swaps between two random indices, where
|
|
// each index is rand() & 0x3F (0..63) and the swap is only done
|
|
// when both indices are < 52. 200 iterations on a 52-card deck is
|
|
// empirically well-mixed and uses only bitwise ops (no multiply,
|
|
// no divide, no W0101 warning).
|
|
|
|
fun build_master_deck() {
|
|
var bmd_r: u8 = 1
|
|
var bmd_i: u8 = 0
|
|
while bmd_r <= RANK_KING {
|
|
var bmd_s: u8 = 0
|
|
while bmd_s < 4 {
|
|
// Pack rank into the high nibble, suit into the low.
|
|
// rank fits in 4 bits (max 13) and suit fits in 2
|
|
// bits, so the shift-and-or is exact.
|
|
var bmd_shifted: u8 = bmd_r << 4
|
|
deck_a[bmd_i] = bmd_shifted | bmd_s
|
|
bmd_i += 1
|
|
bmd_s += 1
|
|
}
|
|
bmd_r += 1
|
|
}
|
|
}
|
|
|
|
fun shuffle_deck_a() {
|
|
var shf_k: u8 = 0
|
|
while shf_k < 200 {
|
|
var shf_i: u8 = rand_u8() & 0x3F
|
|
var shf_j: u8 = rand_u8() & 0x3F
|
|
if shf_i < DECK_SIZE {
|
|
if shf_j < DECK_SIZE {
|
|
var shf_tmp: u8 = deck_a[shf_i]
|
|
deck_a[shf_i] = deck_a[shf_j]
|
|
deck_a[shf_j] = shf_tmp
|
|
}
|
|
}
|
|
shf_k += 1
|
|
}
|
|
}
|
|
|
|
// After shuffle, split deck_a's 52 cards into two halves: the
|
|
// first 26 stay in deck_a, the second 26 move into deck_b.
|
|
// Reset both queues' front/count cursors in the process.
|
|
fun split_decks() {
|
|
var spd_i: u8 = 0
|
|
while spd_i < HALF_DECK {
|
|
deck_b[spd_i] = deck_a[HALF_DECK + spd_i]
|
|
spd_i += 1
|
|
}
|
|
deck_a_front = 0
|
|
deck_a_count = HALF_DECK
|
|
deck_b_front = 0
|
|
deck_b_count = HALF_DECK
|
|
pot_count = 0
|
|
}
|
|
|
|
fun init_and_shuffle_decks() {
|
|
build_master_deck()
|
|
shuffle_deck_a()
|
|
split_decks()
|
|
}
|