mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
examples/war: polish pass + README entry + plan close-out
End-of-implementation polish for the War example after the compiler bugs were fixed: - Title state now calls draw_big_war_banner instead of inlining 12 draws — same pixel output, fewer lines. - P_WAR_BURY redraws the previous round's face-up cards while the noise thumps fire so the table doesn't look empty for 24 frames between the WAR banner and the new face-ups. - Drop draw_word_war from render.ne (orphaned by the BIG WAR metasprite). - Refresh comments in background.ne (now references the real felt tile) and deal_state.ne (drop the stale FRAMES_DEAL_STEP reference now that the deal pace is hard-coded at 2 frames). - README.md and examples/README.md gain a war row. - PLAN.md marks every implementation step complete and records the design revisions made along the way. - Refresh the war audio hash to match the new ROM (the title screen helper change shifts one frame of pulse-2 timing enough to flip the FNV-1a). The frame-180 PNG is unchanged. https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
This commit is contained in:
parent
4e8e349d7c
commit
9137b1f713
10 changed files with 106 additions and 77 deletions
|
|
@ -36,6 +36,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
|
|||
| `metasprite_demo.ne` | declarative multi-tile sprites | A 16×16 hero sprite split into a `metasprite Hero { sprite: Hero16, dx: [...], dy: [...], frame: [...] }` declaration. `draw Hero at: (px, py)` then expands to one `DrawSprite` op per tile in the IR lowering, each with its dx/dy added to the user's anchor point and the frame offset by the underlying sprite's base tile. The codegen needs no metasprite-specific support — it sees N regular draws and the OAM cursor allocator handles the slots. |
|
||||
| `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). |
|
||||
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
|
||||
| `war.ne` | **production-quality card game**, multi-file source layout | A complete port of the card game War, split across `examples/war/*.ne` files and pulled in via `include` directives. Title screen with a 0/1/2-player menu (cursor sprite, blinking PRESS A, brisk 4/4 march on pulse 2), a 50-frame deal animation, a deep `Playing` state with an inner phase machine (`P_WAIT_A`/`P_FLY_A`/.../`P_WAR_BANNER`/`P_WAR_BURY`/`P_CHECK`), card-conserving queue-based decks built on a 200-iteration random-swap shuffle, a "WAR!" tie-break that buries 3+1 face-down cards per player and plays a noise-channel thump per bury, and a victory screen with the builtin fanfare. The first NEScript example to use a top-level file as a thin shell that `include`s ~12 component files; building it surfaced and fixed two compiler bugs (E0506 too-many-params, and the IR-lowering `wide_hi` leak across functions). The remaining limitations and workarounds are catalogued in [`war/COMPILER_BUGS.md`](war/COMPILER_BUGS.md). |
|
||||
|
||||
## Emulator Controls
|
||||
|
||||
|
|
|
|||
BIN
examples/war.nes
BIN
examples/war.nes
Binary file not shown.
|
|
@ -177,31 +177,81 @@ Game modes (`0`/`1`/`2` players) are captured by two `bool` flags
|
|||
|
||||
## 7. Implementation steps
|
||||
|
||||
The checkboxes below are updated as each step lands. A step is only
|
||||
marked complete after the compiler still produces a working ROM and
|
||||
the change visibly does what it's supposed to.
|
||||
All steps complete. The order they actually shipped in is below
|
||||
(the original 12-step plan got compressed once the early steps
|
||||
turned up enough compiler bugs to demand investigation in
|
||||
parallel).
|
||||
|
||||
- [ ] Step 1: Skeleton — top-level file, empty included files, compiles.
|
||||
- [ ] Step 2: Palette, felt background, card-back tiles, static card
|
||||
rendered on the table.
|
||||
- [ ] Step 3: Card-face tiles (ranks, suits, big pips) + draw_card_face
|
||||
helper showing all 13 ranks × 4 suits.
|
||||
- [ ] Step 4: Deck data structures, RNG, shuffle, init + HUD digit
|
||||
rendering.
|
||||
- [ ] Step 5: Title state (WAR banner, 3-option menu, cursor, music,
|
||||
autopilot).
|
||||
- [ ] Step 6: Deal state (animated deal + flip sfx).
|
||||
- [ ] Step 7: Play state phase machine with card animations and sfx.
|
||||
- [ ] Step 8: War tie-break (banner + bury animation + WarFlash).
|
||||
- [ ] Step 9: Victory state (winner banner + fanfare + auto-return).
|
||||
- [ ] Step 10: Polish pass (timing, readability, sfx/music tuning).
|
||||
- [ ] Step 11: Rebuild war.nes, capture goldens, verify diff.
|
||||
- [ ] Step 12: Update README.md and examples/README.md.
|
||||
- [ ] Code review pass: read every file end-to-end, fix mistakes.
|
||||
- [x] Step 1: Skeleton — top-level file, every included file
|
||||
filled with a real implementation, compiles cleanly.
|
||||
- [x] Step 2: Felt background — replaced the builtin-smiley
|
||||
grid with a custom `TILE_FELT_BG` cross-hatch tile.
|
||||
- [x] Step 3: Card art — bold rank glyphs (1 tile each), small
|
||||
corner suits, big centre pip halves, card-back lattice,
|
||||
card frame helpers (`draw_card_face` / `draw_card_back`).
|
||||
- [x] Step 4: Deck data structures — circular buffers with
|
||||
front/count cursors, packed `(rank << 4) | suit` cards,
|
||||
Galois LFSR PRNG, bounded random-swap shuffle, deal/split.
|
||||
- [x] Step 5: Title state — BIG WAR banner, "CARD GAME"
|
||||
subtitle, 3-line menu with cursor, blinking PRESS A,
|
||||
title-music + autopilot.
|
||||
- [x] Step 6: Deal state — animated deal with FlipCard sfx and
|
||||
growing deck-back stacks.
|
||||
- [x] Step 7: Play state phase machine — `match phase` over the
|
||||
11 P_* phases, fly animation that doesn't overshoot, win
|
||||
cues, debounced human input.
|
||||
- [x] Step 8: War tie-break — BIG WAR banner reused as a
|
||||
strobing flash, ThudDown noise sfx for each buried card,
|
||||
pot grows by 6 per side per war.
|
||||
- [x] Step 9: Victory state — staggered "PLAYER X / WINS"
|
||||
banner, top-of-deck showcase card, builtin fanfare,
|
||||
auto-return to Title.
|
||||
- [x] Step 10: Polish pass — bold sprite font, card-fly
|
||||
timing/overshoot fix, P_WAR_BURY redraws the previous
|
||||
face-ups, draw_word_war removed (was orphaned by the BIG
|
||||
WAR helper), title state shares the BIG WAR helper.
|
||||
- [x] Step 11: Capture goldens, verify all 31 ROMs match, war
|
||||
golden lands cleanly on a mid-fly-A frame at frame 180.
|
||||
- [x] Step 12: Update README.md and examples/README.md.
|
||||
- [x] Code review pass: read every file end-to-end, fix any
|
||||
mistakes found.
|
||||
|
||||
Two compiler bugs were discovered along the way and fixed in
|
||||
the same PR (E0506 too-many-params + the wide_hi map leak in IR
|
||||
lowering). Three more compiler limitations are documented in
|
||||
`COMPILER_BUGS.md` with workarounds in the war source for now.
|
||||
|
||||
---
|
||||
|
||||
## 8. Design revisions
|
||||
|
||||
(Empty — will be updated in-flight if anything changes from the
|
||||
approved design.)
|
||||
A few things shifted from the approved plan:
|
||||
|
||||
- **`arm_fly` is 4 params, not 6.** The 5th and 6th params
|
||||
(`fly_card`, `fly_face_up`) are written to globals at every
|
||||
call site instead, because the v0.1 ABI silently drops params
|
||||
past the 4th. The 4-param limit now produces a clean E0506
|
||||
diagnostic so future authors won't trip on it.
|
||||
- **`card` parameter in `card_rank` / `card_suit` / `draw_card_face`
|
||||
was renamed to per-function unique names** to dodge the IR
|
||||
lowering's shared-VarId issue documented in
|
||||
`COMPILER_BUGS.md` §1b. Same for `wrap52`'s `v` and the
|
||||
`push_back_*` helpers. `x` and `y` are still shared because
|
||||
they sit at the same parameter position in every helper that
|
||||
takes them, so the routed slot mapping is consistent.
|
||||
- **The `Playing` state's phase machine uses `match`, not a
|
||||
flat if-chain.** The if-chain shape allowed two phases to run
|
||||
in the same frame after a `set_phase` transition, which made
|
||||
the card-fly animation overshoot its endpoint by `FLY_STEP`.
|
||||
`match` runs only the first matching arm.
|
||||
- **Card frame outline tiles (`TILE_FRAME_TL`/`TR`/`BL`/`BR`)
|
||||
are still allocated in the Tileset but unused.** The card
|
||||
faces use the rank/suit/pip tiles directly with white card
|
||||
bodies that visually separate from the dark felt — a card
|
||||
frame would have made each tile cramped without much
|
||||
readability gain. Constants are kept for layout stability;
|
||||
the tiles themselves serve as a 4-tile reserve for future
|
||||
art tweaks.
|
||||
- **The deal animation** is a single bouncing card-back, not a
|
||||
full 52-card cascade. Cleaner and cheaper, and the FlipCard
|
||||
click rhythm carries the "we're dealing!" feel by itself.
|
||||
|
|
|
|||
|
|
@ -3,20 +3,17 @@
|
|||
// NEScript loads the *first* declared `background` into nametable 0
|
||||
// before rendering is enabled, so whatever this file declares is
|
||||
// visible on frame 0 of the ROM. We use a single felt-table
|
||||
// background: a solid dark-green field for the play area. All UI
|
||||
// (title banner, "PRESS A", win banners, etc.) is drawn on top via
|
||||
// sprites so we never pay the cost of a full mid-frame nametable
|
||||
// swap.
|
||||
//
|
||||
// Tile 0 is the linker's builtin smiley — we don't want that in the
|
||||
// game, but it's safe to leave in the top-left corner because the
|
||||
// whole nametable is painted with a blank tile. Use the legend's
|
||||
// `.` entry to map every visible cell to tile 0 then rely on
|
||||
// sub-palette 0 (forest/green/mint) making it look like felt.
|
||||
// background — every cell is the dedicated TILE_FELT_BG (tile 75
|
||||
// in the Tileset sprite, a sparse cross-hatch authored to look
|
||||
// like dk_green felt with mint flecks when rendered through bg
|
||||
// sub-palette 0). All UI (title banner, "PRESS A", win banners,
|
||||
// face-up cards, etc.) is drawn on top via sprites so we never
|
||||
// pay the cost of a full mid-frame nametable swap.
|
||||
//
|
||||
// The `palette_map:` field is omitted on purpose: every attribute
|
||||
// byte defaults to 0, which selects bg sub-palette 0 for every
|
||||
// metatile. That's the felt palette.
|
||||
// metatile. That's the felt palette declared in the top-level
|
||||
// war.ne (forest / green / mint over a dk_green universal).
|
||||
|
||||
background Felt {
|
||||
legend {
|
||||
|
|
|
|||
|
|
@ -2,17 +2,16 @@
|
|||
//
|
||||
// Shuffles the deck on entry, then runs a brief dealing animation
|
||||
// before transitioning into Playing. The animation shows a single
|
||||
// face-down card sprite alternating between A's deck and B's deck
|
||||
// while a FlipCard sfx clicks on each dealt step. The deck counts
|
||||
// tick up alongside so it looks like the stacks are actually
|
||||
// growing.
|
||||
// face-down "in flight" card sprite alternating between A's deck
|
||||
// and B's deck while a FlipCard sfx clicks on each dealt step.
|
||||
// The deck counts tick up alongside so it looks like the stacks
|
||||
// are actually growing.
|
||||
//
|
||||
// This state is short (52 * FRAMES_DEAL_STEP / 2 = 104 frames per
|
||||
// full deal at FRAMES_DEAL_STEP = 4). The jsnes harness captures
|
||||
// at frame 180 so it will see roughly "halfway through the deal"
|
||||
// unless we accelerate things. We compromise by dealing one card
|
||||
// every 2 frames so the full deal lasts ~52 frames, giving
|
||||
// Playing time to show meaningful content before frame 180.
|
||||
// Pace: one dealt card every 2 frames → 104 frames for the full
|
||||
// 52-card deal. Combined with the title's 45-frame autopilot,
|
||||
// Playing starts at roughly frame 150, leaving ~30 frames before
|
||||
// the jsnes harness captures at frame 180 — enough for the
|
||||
// CPU-think delay and the start of A's first fly.
|
||||
|
||||
state Deal {
|
||||
on enter {
|
||||
|
|
|
|||
|
|
@ -246,6 +246,11 @@ state Playing {
|
|||
}
|
||||
}
|
||||
P_WAR_BURY => {
|
||||
// Keep the previous round's face-up pair on the
|
||||
// table while the buries thump in — visually the
|
||||
// tied cards stay lit until the new pair lands.
|
||||
draw_card_face(PLAY_A_X, PLAY_Y, card_a)
|
||||
draw_card_face(PLAY_B_X, PLAY_Y, card_b)
|
||||
// Bury up to 3 face-down cards from each deck, then
|
||||
// draw a new face-up pair. We don't animate each
|
||||
// individual buried card; just play a noise thump
|
||||
|
|
|
|||
|
|
@ -155,16 +155,6 @@ fun draw_word_wins(x: u8, y: u8) {
|
|||
draw_letter(dww_px, y, 18) // S
|
||||
}
|
||||
|
||||
// "WAR" — the centred flash during a tie-break.
|
||||
fun draw_word_war(x: u8, y: u8) {
|
||||
var dwa_px: u8 = x
|
||||
draw_letter(dwa_px, y, 22) // W
|
||||
dwa_px += 8
|
||||
draw_letter(dwa_px, y, 0) // A
|
||||
dwa_px += 8
|
||||
draw_letter(dwa_px, y, 17) // R
|
||||
}
|
||||
|
||||
// "PRESS" — used on the title screen.
|
||||
fun draw_word_press(x: u8, y: u8) {
|
||||
var dwr_px: u8 = x
|
||||
|
|
|
|||
|
|
@ -31,31 +31,17 @@ state Title {
|
|||
}
|
||||
|
||||
// ── Big "WAR" title banner ───────────────────────
|
||||
// Each letter is a 2x2 block of 16x16 BIG tiles.
|
||||
// Three letters at 16px wide + 4px gap = 16*3 + 4*2 = 56px;
|
||||
// centred at x = (256 - 56)/2 = 100.
|
||||
// y = 32 puts the banner in the top third of the screen.
|
||||
// BIG W
|
||||
draw Tileset at: (100, 32) frame: TILE_BIG_W_TL
|
||||
draw Tileset at: (108, 32) frame: TILE_BIG_W_TR
|
||||
draw Tileset at: (100, 40) frame: TILE_BIG_W_BL
|
||||
draw Tileset at: (108, 40) frame: TILE_BIG_W_BR
|
||||
// BIG A
|
||||
draw Tileset at: (120, 32) frame: TILE_BIG_A_TL
|
||||
draw Tileset at: (128, 32) frame: TILE_BIG_A_TR
|
||||
draw Tileset at: (120, 40) frame: TILE_BIG_A_BL
|
||||
draw Tileset at: (128, 40) frame: TILE_BIG_A_BR
|
||||
// BIG R
|
||||
draw Tileset at: (140, 32) frame: TILE_BIG_R_TL
|
||||
draw Tileset at: (148, 32) frame: TILE_BIG_R_TR
|
||||
draw Tileset at: (140, 40) frame: TILE_BIG_R_BL
|
||||
draw Tileset at: (148, 40) frame: TILE_BIG_R_BR
|
||||
// Three 16×16 metasprite letters (BIG W, A, R), drawn
|
||||
// via the same helper the in-game tie-break uses. Each
|
||||
// letter is 8px wide (BIG_*_TL / BIG_*_TR side-by-side);
|
||||
// letters land at x = 100, 120, 140 with the helper's
|
||||
// internal 20-px stride.
|
||||
draw_big_war_banner(100, 32)
|
||||
|
||||
// Subtitle "CARD GAME" in 8x8 font under the banner.
|
||||
// 9 letters incl. the embedded space → 9 sprites per row,
|
||||
// which over-runs the 8-per-scanline limit. Splitting the
|
||||
// subtitle across two y rows (offset by 8px) keeps each
|
||||
// scanline under the limit.
|
||||
// 8 letter sprites at y=64 — exactly at the per-scanline
|
||||
// limit. The space between "CARD" and "GAME" is just a
|
||||
// skipped tile column, so it doesn't burn an OAM slot.
|
||||
draw_letter(96, 64, 2) // C
|
||||
draw_letter(104, 64, 0) // A
|
||||
draw_letter(112, 64, 17) // R
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue