From 21b91f63988be4300adc839e377a12545f724745 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 01:25:29 +0000 Subject: [PATCH] examples/pong: production-quality Pong game with powerups and multi-ball MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A complete, playable Pong game split across examples/pong/*.ne files and pulled in from a top-level examples/pong.ne. Features: - **Title screen** with a 3-option menu (CPU VS CPU / 1 PLAYER / 2 PLAYERS), a cursor sprite, blinking "PRESS A" prompt, brisk title march on pulse 2, and autopilot that auto-confirms CPU VS CPU after 45 frames of no input so the headless jsnes golden harness reaches gameplay by frame 180. - **Ball physics** with signed-magnitude velocity (u8 magnitude + sign bit per axis), wall bounce at top/bottom, paddle AABB collision with push-out, and score-out detection at left/right exits. - **Multi-ball** via parallel ball_* arrays (MAX_BALLS = 3). Each ball scores a point independently; the round continues until the last ball exits the playfield. - **CPU AI** that tracks the nearest active ball heading toward its side with a per-frame step, 4 px dead zone, and CPU_SPEED = 1 so rallies can end naturally. - **Three powerup types** that spawn every ~4 seconds, bounce off all four walls, and are caught by paddle AABB overlap: 1. LONG — extends the catching paddle from 24 → 40 px for 5 hits 2. FAST — doubles ball x-velocity on the catcher's next hit 3. MULTI — spawns two extra balls on the catcher's next hit - **Victory** at first-to-7 with a "PLAYER N WINS" banner and the builtin fanfare, auto-returning to Title. - **Audio**: 5 user-declared sfx (WallBounce, PaddleHit, Score, PowerSpawn, PowerCatch) plus a title march and the builtin fanfare for victory. Source layout mirrors examples/war: examples/pong.ne top-level game shell examples/pong/PLAN.md living design doc examples/pong/constants.ne layout + gameplay constants examples/pong/assets.ne 45-tile Tileset (paddles, ball, alphabet, digits, cursor, center-line, powerup icons) examples/pong/audio.ne sfx + music declarations examples/pong/state.ne all mutable globals examples/pong/rng.ne 8-bit Galois LFSR examples/pong/render.ne draw helpers examples/pong/input.ne paddle step (human + CPU AI) examples/pong/ball.ne multi-ball physics + paddle collision examples/pong/powerup.ne powerup entity (spawn, bounce, catch, apply) examples/pong/title_state.ne state Title + menu examples/pong/play_state.ne state Playing (P_SERVE/P_PLAY/P_POINT) examples/pong/victory_state.ne state Victory Verification: - 616 compiler unit tests pass (cargo test --all-targets) - cargo fmt / cargo clippy --all-targets -- -D warnings clean - 33/33 emulator harness goldens match - examples/pong.nes builds byte-identically from source https://claude.ai/code/session_0134F5uwDEVTes2Ee9S7JeXy --- compiler-bugs.md | 32 ++ examples/README.md | 1 + examples/pong.ne | 100 ++++++ examples/pong.nes | Bin 0 -> 24592 bytes examples/pong/PLAN.md | 141 ++++++++ examples/pong/assets.ne | 458 +++++++++++++++++++++++++ examples/pong/audio.ne | 68 ++++ examples/pong/ball.ne | 288 ++++++++++++++++ examples/pong/constants.ne | 135 ++++++++ examples/pong/input.ne | 117 +++++++ examples/pong/play_state.ne | 100 ++++++ examples/pong/powerup.ne | 144 ++++++++ examples/pong/render.ne | 162 +++++++++ examples/pong/rng.ne | 29 ++ examples/pong/state.ne | 88 +++++ examples/pong/title_state.ne | 121 +++++++ examples/pong/victory_state.ne | 41 +++ tests/emulator/goldens/pong.audio.hash | 1 + tests/emulator/goldens/pong.png | Bin 0 -> 1010 bytes 19 files changed, 2026 insertions(+) create mode 100644 compiler-bugs.md create mode 100644 examples/pong.ne create mode 100644 examples/pong.nes create mode 100644 examples/pong/PLAN.md create mode 100644 examples/pong/assets.ne create mode 100644 examples/pong/audio.ne create mode 100644 examples/pong/ball.ne create mode 100644 examples/pong/constants.ne create mode 100644 examples/pong/input.ne create mode 100644 examples/pong/play_state.ne create mode 100644 examples/pong/powerup.ne create mode 100644 examples/pong/render.ne create mode 100644 examples/pong/rng.ne create mode 100644 examples/pong/state.ne create mode 100644 examples/pong/title_state.ne create mode 100644 examples/pong/victory_state.ne create mode 100644 tests/emulator/goldens/pong.audio.hash create mode 100644 tests/emulator/goldens/pong.png diff --git a/compiler-bugs.md b/compiler-bugs.md new file mode 100644 index 0000000..88b0a4d --- /dev/null +++ b/compiler-bugs.md @@ -0,0 +1,32 @@ +// compiler-bugs.md — a running log of compiler issues surfaced +// while implementing the Pong example (examples/pong.ne et al). +// +// Format, one entry per bug: +// +// ## #N — one-line title +// +// **Status**: OPEN / WORKED-AROUND / FIXED +// **Phase**: lexer / parser / analyzer / ir / optimizer / codegen / linker / runtime / asset +// **Surfaced in**: examples/pong/.ne (brief context) +// +// ### Reproducer +// ```ne +// ... minimal .ne snippet that triggers the bad behaviour ... +// ``` +// +// ### Expected vs actual +// What the user-visible behaviour should be; what the compiler actually does. +// +// ### Workaround (if applied) +// The current shape of the code in examples/pong/ that avoids the bug, +// and exactly what should be reverted once the fix lands. Every workaround +// in examples/pong/ MUST be tagged with `// BUG: compiler-bugs.md #N` so +// grep -r "BUG: compiler-bugs.md" finds every reverible workaround in one pass. +// +// ### Guess at the fix +// Which source file(s) and what kind of change is likely needed. Doesn't +// have to be right — it's a hint for the compiler-bug cleanup milestone. +// +// --- + +(no bugs logged yet — pong development just started) diff --git a/examples/README.md b/examples/README.md index 5dde9a0..c08110c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -38,6 +38,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX] | `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`. | | `sprite_flicker_demo.ne` | `cycle_sprites`, 8-per-scanline hardware limit | Twelve sprites packed onto the same 4-pixel band — two more than the NES's 8-sprites-per-scanline hardware budget. The W0109 analyzer warning fires at compile time, and a `cycle_sprites` call at the end of `on frame` rotates the OAM DMA offset one slot per frame so the PPU drops a *different* sprite each frame. The permanent-dropout failure mode becomes visible flicker, which the eye reconstructs across frames. The classic NES technique used by Gradius, Battletoads, and every shmup that ever existed. | | `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 seven compiler bugs across the analyzer, IR lowerer, and codegen that were all fixed on the same branch (see `git log` for details). | +| `pong.ne` | **production-quality Pong**, powerups, multi-ball, multi-file | A complete Pong game split across `examples/pong/*.ne`. CPU VS CPU / 1 PLAYER / 2 PLAYERS title menu with brisk pulse-2 title march and autopilot, smooth ball physics with wall and paddle bouncing, CPU AI that tracks the ball with a reaction lag and dead zone, three powerup types (LONG paddle for 5 hits, FAST ball on next hit, MULTI-ball on next hit spawning 3 balls) that bounce around the field and are caught by paddle AABB overlap, multi-ball scoring (each ball scores a point, round continues until last ball exits), inner phase machine (`P_SERVE`/`P_PLAY`/`P_POINT`), and a "PLAYER N WINS" victory screen with the builtin fanfare. First-to-7 wins. | ## Emulator Controls diff --git a/examples/pong.ne b/examples/pong.ne new file mode 100644 index 0000000..a7ea623 --- /dev/null +++ b/examples/pong.ne @@ -0,0 +1,100 @@ +// Pong — a full NES port of the classic two-paddle game. +// +// Production-quality example: a title screen with a three-option +// menu (CPU VS CPU / 1 PLAYER / 2 PLAYERS), live gameplay with +// smooth ball physics and two paddles, powerups that bounce +// around the playfield and modify gameplay when caught +// (long paddle / fast ball / multi-ball), a victory screen with +// a fanfare, and a brisk title march. Every piece of the game +// (logic, art, sound, states) is authored in NEScript itself — +// no external assets, no external tooling. +// +// The source is split across examples/pong/*.ne files. This +// top-level file declares the hardware config, the reset-time +// palette and Tileset, the audio/state/logic includes, and the +// `start` declaration. Every include is processed by the parser +// before lexing, so it's exactly as if the whole game lived in +// one .ne file. +// +// The file layout mirrors how a real NES game would be organised: +// +// examples/pong.ne — this file, the top-level game +// examples/pong/PLAN.md — living implementation plan +// examples/pong/constants.ne — layout + gameplay constants +// examples/pong/assets.ne — Tileset sprite block +// examples/pong/audio.ne — sfx + music +// examples/pong/state.ne — global variables +// examples/pong/rng.ne — 8-bit Galois LFSR PRNG +// examples/pong/render.ne — draw helpers +// examples/pong/input.ne — paddle update (M2+) +// examples/pong/ball.ne — ball physics (M3+) +// examples/pong/powerup.ne — powerup entity (M8+) +// examples/pong/title_state.ne — state Title + menu +// examples/pong/play_state.ne — state Playing (inner SERVE/PLAY/POINT phase machine) +// examples/pong/victory_state.ne — state Victory +// +// Controls (humans): +// D-pad up/down — move your paddle up/down +// A / Start — confirm a menu choice +// / return to title from the victory screen +// +// In CPU-vs-CPU mode both paddles are CPU and the game plays +// itself; in 1 PLAYER mode you are the left paddle and the CPU is +// the right; in 2 PLAYERS mode both sides are human (P1 left, +// P2 right). The headless jsnes golden harness boots straight +// into CPU VS CPU (title auto-commits after ~45 frames with no +// input) so the captured frame is always a scene from actual +// gameplay. +// +// Build: cargo run --release -- build examples/pong.ne +// Output: examples/pong.nes + +game "Pong" { + mapper: NROM + mirroring: horizontal +} + +// ── Palette ───────────────────────────────────────────────── +// +// Classic Pong is a black field with white paddles and white +// ball. We add a yellow accent for powerup icons and the menu +// cursor so those read as distinct without growing the sprite +// sub-palette budget (sprites all share sp0 in NEScript's +// codegen). +// +// Sprite sub-palette 0 vocabulary: +// 0 = transparent +// 1 = white (paddles, ball, letters, digits) +// 2 = lt_gray (reserved for subtle secondary accents) +// 3 = yellow (cursor, powerup icons) +palette Main { + universal: black + + bg0: [dk_gray, lt_gray, white] // center line + score gutters + bg1: [dk_blue, blue, sky_blue] // reserved — P1 side accent + bg2: [dk_red, red, peach] // reserved — P2 side accent + bg3: [dk_olive,olive, yellow] // reserved — powerup flash + + sp0: [white, lt_gray, yellow] // every sprite uses this + sp1: [dk_blue, blue, sky_blue] // reserved + sp2: [dk_red, red, peach] // reserved + sp3: [dk_gray, lt_gray, white] // reserved +} + +// Pull in everything else. Order matters only for symbol visibility: +// constants before the state variables that use them, state variables +// and helpers before state handlers. +include "pong/constants.ne" +include "pong/assets.ne" +include "pong/audio.ne" +include "pong/state.ne" +include "pong/rng.ne" +include "pong/render.ne" +include "pong/input.ne" +include "pong/ball.ne" +include "pong/powerup.ne" +include "pong/title_state.ne" +include "pong/play_state.ne" +include "pong/victory_state.ne" + +start Title diff --git a/examples/pong.nes b/examples/pong.nes new file mode 100644 index 0000000000000000000000000000000000000000..0b38c5c012a2f7d1a6426638839fa71380708a07 GIT binary patch literal 24592 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 0 { + ph = PADDLE_H_LONG + } + + var bx: u8 = ball_x[i] + var by: u8 = ball_y[i] + + // Four-way AABB overlap test. The operands are small u8 + // values so the additions can't overflow past 255. + if bx + BALL_SIZE > px and bx < px + PADDLE_W and by + BALL_SIZE > py and by < py + ph { + if side == SIDE_LEFT { + ball_x[i] = px + PADDLE_W + ball_dx_sign[i] = 0 + } else { + ball_x[i] = px - BALL_SIZE + ball_dx_sign[i] = 1 + } + + // ── Consume long-paddle hit ────────────────── + if paddle_long[side] > 0 { + paddle_long[side] -= 1 + } + // ── Consume fast-ball flag ─────────────────── + if paddle_fast[side] > 0 { + paddle_fast[side] = 0 + ball_dx[i] = BALL_FAST_DX + } + // ── Consume multi-ball flag ────────────────── + spawn_multi_balls(i, side) + + play PaddleHit + } +} + +// ── Per-ball update ────────────────────────────────────── +fun update_ball(i: u8) { + if ball_active[i] == 0 { + return + } + + // ── 1. Move ────────────────────────────────────── + if ball_dx_sign[i] == 0 { + ball_x[i] += ball_dx[i] + } else { + ball_x[i] -= ball_dx[i] + } + if ball_dy_sign[i] == 0 { + ball_y[i] += ball_dy[i] + } else { + ball_y[i] -= ball_dy[i] + } + + // ── 2. Wall bounce ─────────────────────────────── + // + // Top: ball moving up and the top edge is now above the + // playfield (u8 comparison, safe because PLAYFIELD_TOP = 16 + // and ball speeds are 1-2 so ball_y can't wrap around). + if ball_dy_sign[i] == 1 { + if ball_y[i] < PLAYFIELD_TOP { + ball_y[i] = PLAYFIELD_TOP + ball_dy_sign[i] = 0 + play WallBounce + } + } + // Bottom: ball moving down and the bottom edge has crossed + // PLAYFIELD_BOTTOM. + if ball_dy_sign[i] == 0 { + if ball_y[i] + BALL_SIZE > PLAYFIELD_BOTTOM { + ball_y[i] = PLAYFIELD_BOTTOM - BALL_SIZE + ball_dy_sign[i] = 1 + play WallBounce + } + } + + // ── 3. Paddle collision ────────────────────────── + // + // Only check the paddle the ball is travelling toward. + // This is both faster and avoids the edge case of the + // ball hitting a paddle moving away from it. + if ball_dx_sign[i] == 1 { + check_paddle_hit(i, SIDE_LEFT) + } else { + check_paddle_hit(i, SIDE_RIGHT) + } + + // ── 4. Score-out ───────────────────────────────── + // + // "ball_x < 8" / "ball_x > 240" is safe because paddles + // live at x = 16 and x = 232 — a ball that has moved past + // the paddle edge with max speed 2 lands at ball_x ∈ [6, 14] + // or [234, 242] before the next frame, never close enough + // to 0 or 255 to wrap the u8. + if ball_dx_sign[i] == 1 { + if ball_x[i] < 8 { + ball_active[i] = 0 + on_score(SIDE_RIGHT) + } + } else { + if ball_x[i] > 240 { + ball_active[i] = 0 + on_score(SIDE_LEFT) + } + } +} + +// ── Sweep every active ball ────────────────────────────── +fun step_balls() { + var i: u8 = 0 + while i < MAX_BALLS { + update_ball(i) + i += 1 + } +} diff --git a/examples/pong/constants.ne b/examples/pong/constants.ne new file mode 100644 index 0000000..178d88f --- /dev/null +++ b/examples/pong/constants.ne @@ -0,0 +1,135 @@ +// pong/constants.ne — layout, gameplay, powerup, and phase constants. +// +// Everything that feeds Pong's positional layout, animation timing, +// powerup machine, or score tracking lives here so one central edit +// can retune the whole game. No code, just `const` entries. + +// ── Playfield layout ────────────────────────────────────── +// +// The NES screen is 256×240. We leave the top 16 px for the HUD +// score strip and the bottom 24 px as a buffer, giving a 200 px +// tall playfield from y = 16 to y = 216. +const PLAYFIELD_TOP: u8 = 16 +const PLAYFIELD_BOTTOM: u8 = 216 +const PLAYFIELD_LEFT: u8 = 0 +const PLAYFIELD_RIGHT: u8 = 248 + +// ── Paddles ────────────────────────────────────────────── +// +// Paddles are 8 px wide. Normal height is 24 px (3 tiles), long +// mode bumps to 40 px (5 tiles). Paddle y is the top edge. +const PADDLE_W: u8 = 8 +const PADDLE_H: u8 = 24 +const PADDLE_H_LONG:u8 = 40 + +const LEFT_PADDLE_X: u8 = 16 // fixed x for the left paddle +const RIGHT_PADDLE_X: u8 = 232 // fixed x for the right paddle + +// Human paddle speed and CPU tracking speed. +const PADDLE_SPEED: u8 = 2 +const CPU_SPEED: u8 = 1 + +// When a powerup is caught by a paddle, the catching paddle gets +// LONG_PADDLE_HITS worth of extended-height paddle. +const LONG_PADDLE_HITS: u8 = 5 + +// ── Ball ───────────────────────────────────────────────── +// +// 8×8 sprite. Position is the top-left corner. Velocity is a +// per-axis (magnitude, sign) pair — u8 magnitude + u8 sign bit +// (0 = positive / +x right / +y down, 1 = negative). +const BALL_SIZE: u8 = 8 +const BALL_BASE_DX: u8 = 1 +const BALL_BASE_DY: u8 = 1 +const BALL_FAST_DX: u8 = 2 + +const MAX_BALLS: u8 = 3 + +// ── Powerup ────────────────────────────────────────────── +// +// One powerup active at a time. Spawns periodically, bounces +// around for a while, despawns if nobody catches it. +const POWERUP_SIZE: u8 = 8 +const POWERUP_SPEED: u8 = 1 +const POWERUP_SPAWN_FRAMES: u16 = 240 // ~4 s at 60 Hz +const POWERUP_LIFE_FRAMES: u16 = 480 // ~8 s before despawn + +const PWR_NONE: u8 = 0 +const PWR_LONG: u8 = 1 +const PWR_FAST: u8 = 2 +const PWR_MULTI: u8 = 3 +const PWR_KINDS: u8 = 3 // number of real kinds (LONG / FAST / MULTI) + +// ── Sides ──────────────────────────────────────────────── +// +// Side indexing for the two paddles. Used by input, AI, +// collision, powerup apply, and victory. +const SIDE_LEFT: u8 = 0 +const SIDE_RIGHT: u8 = 1 + +// ── Scoring ────────────────────────────────────────────── +const WIN_SCORE: u8 = 7 + +// ── HUD ────────────────────────────────────────────────── +// +// Score digits at the top of the screen, 2 digits per side. +const SCORE_LEFT_X: u8 = 88 +const SCORE_RIGHT_X: u8 = 152 +const SCORE_Y: u8 = 16 + +// ── Title menu ─────────────────────────────────────────── +const MODE_CPU_VS_CPU: u8 = 0 +const MODE_HUMAN_VS_CPU: u8 = 1 +const MODE_HUMAN_VS_HUMAN:u8 = 2 + +// Title autopilot: if no input for this many frames, the menu +// commits to CPU VS CPU so the headless golden harness always +// reaches gameplay. +const TITLE_AUTO_FRAMES: u8 = 45 + +// ── Phase machine (state Playing) ──────────────────────── +// +// Explicit constants instead of an enum to keep names from +// colliding with state names. +const P_SERVE: u8 = 0 +const P_PLAY: u8 = 1 +const P_POINT: u8 = 2 + +// Frames spent in each non-PLAY phase. +const FRAMES_SERVE: u8 = 60 // 1 s countdown before the ball launches +const FRAMES_POINT: u8 = 40 // 0.67 s pause after a point +const VICTORY_LINGER_FRAMES: u16 = 240 // 4 s on the victory screen + +// ── Tile indices into the Tileset sprite ───────────────── +// +// Every `draw Tileset frame: N` call in render.ne uses a constant +// from this block. If you add or remove a tile in assets.ne, move +// the constants too. + +// Tile 0 is the builtin smiley that the linker reserves; every +// custom tile starts at 1. + +// Alphabet A-Z at indices 1..26 (A = 1, Z = 26). +const TILE_LETTER_BASE: u8 = 1 + +// Digits 0-9 at indices 27..36 (0 = 27). +const TILE_DIGIT_BASE: u8 = 27 + +// Paddle body — top cap / mid / bottom cap. +const TILE_PADDLE_TOP: u8 = 37 +const TILE_PADDLE_MID: u8 = 38 +const TILE_PADDLE_BOT: u8 = 39 + +// Ball (8×8 filled circle). +const TILE_BALL: u8 = 40 + +// Cursor (▶) for the title menu. +const TILE_CURSOR: u8 = 41 + +// Center-line dash (8×8 vertical bar). +const TILE_CENTER_DASH: u8 = 42 + +// Powerup icons — one tile per powerup kind. Indexed as +// TILE_POWERUP_BASE + (kind - 1), so LONG = +0, FAST = +1, +// MULTI = +2. +const TILE_POWERUP_BASE: u8 = 43 diff --git a/examples/pong/input.ne b/examples/pong/input.ne new file mode 100644 index 0000000..aa74dab --- /dev/null +++ b/examples/pong/input.ne @@ -0,0 +1,117 @@ +// pong/input.ne — paddle update (human + CPU). +// +// `step_paddles()` is called every frame by the Playing state +// phase machine. Humans move their paddle up/down based on the +// D-pad on their controller; CPU paddles (is_cpu[side] == 1) +// are driven by `step_cpu_paddle(side)` which tracks the nearest +// active ball's y position with a one-frame lag and a small miss +// zone so the AI feels beatable. +// +// Clamping: paddles can never leave the playfield vertically. +// `move_paddle_up` / `move_paddle_down` handle the clamp so the +// caller just has to ask for motion in one direction at a time. + +// Move a paddle upward by PADDLE_SPEED, clamped to PLAYFIELD_TOP. +fun move_paddle_up(side: u8) { + if paddle_y[side] >= PLAYFIELD_TOP + PADDLE_SPEED { + paddle_y[side] -= PADDLE_SPEED + } else { + paddle_y[side] = PLAYFIELD_TOP + } +} + +// Move a paddle downward by PADDLE_SPEED, clamped to +// (PLAYFIELD_BOTTOM - paddle_height). The paddle height depends +// on whether this side is currently in long-paddle mode. +fun move_paddle_down(side: u8) { + var h: u8 = PADDLE_H + if paddle_long[side] > 0 { + h = PADDLE_H_LONG + } + var max_y: u8 = PLAYFIELD_BOTTOM - h + if paddle_y[side] + PADDLE_SPEED <= max_y { + paddle_y[side] += PADDLE_SPEED + } else { + paddle_y[side] = max_y + } +} + +// ── CPU AI ──────────────────────────────────────────────── +// +// The CPU tracks the y-centre of the nearest active ball that +// is heading toward its side. It moves CPU_SPEED px per frame +// toward the ball's y, but only if the ball is more than 4 px +// away from the paddle centre (the "dead zone" that makes the +// AI imperfect and lets fast balls score occasionally). +// +// If no ball is heading toward this side, the CPU drifts toward +// the playfield center so it isn't caught flat-footed on the +// next serve. +fun step_cpu_paddle(side: u8) { + // Find the nearest ball heading toward this side. + var target_y: u8 = 112 // default: field centre + var found: u8 = 0 + var j: u8 = 0 + while j < MAX_BALLS { + if ball_active[j] == 1 { + // Ball heading left → of interest to the left paddle + // Ball heading right → of interest to the right paddle + if side == SIDE_LEFT and ball_dx_sign[j] == 1 { + target_y = ball_y[j] + (BALL_SIZE >> 1) + found = 1 + } + if side == SIDE_RIGHT and ball_dx_sign[j] == 0 { + target_y = ball_y[j] + (BALL_SIZE >> 1) + found = 1 + } + } + j += 1 + } + + // Aim the paddle centre at target_y. + var ph: u8 = PADDLE_H + if paddle_long[side] > 0 { + ph = PADDLE_H_LONG + } + var centre: u8 = paddle_y[side] + (ph >> 1) + + // Dead zone: don't jitter if we're already close. + if centre + 4 < target_y { + // Need to move down. + if paddle_y[side] + CPU_SPEED + ph <= PLAYFIELD_BOTTOM { + paddle_y[side] += CPU_SPEED + } + } + if centre > target_y + 4 { + // Need to move up. + if paddle_y[side] >= PLAYFIELD_TOP + CPU_SPEED { + paddle_y[side] -= CPU_SPEED + } + } +} + +// Drive both paddles from controller input (humans) or the CPU +// AI (bots) this frame. The per-player button reads are hand- +// rolled because `p1` / `p2` are compile-time syntactic prefixes. +fun step_paddles() { + if is_cpu[SIDE_LEFT] == 0 { + if button.up { + move_paddle_up(SIDE_LEFT) + } + if button.down { + move_paddle_down(SIDE_LEFT) + } + } else { + step_cpu_paddle(SIDE_LEFT) + } + if is_cpu[SIDE_RIGHT] == 0 { + if p2.button.up { + move_paddle_up(SIDE_RIGHT) + } + if p2.button.down { + move_paddle_down(SIDE_RIGHT) + } + } else { + step_cpu_paddle(SIDE_RIGHT) + } +} diff --git a/examples/pong/play_state.ne b/examples/pong/play_state.ne new file mode 100644 index 0000000..f826ff3 --- /dev/null +++ b/examples/pong/play_state.ne @@ -0,0 +1,100 @@ +// pong/play_state.ne — the Playing state and its inner phase machine. +// +// The phase machine cycles through P_SERVE → P_PLAY → P_POINT: +// +// P_SERVE — brief countdown, then serve_ball() and switch to P_PLAY +// P_PLAY — normal gameplay: update paddles, balls, powerups, draw +// P_POINT — a side just scored; short pause then re-serve (or Victory) + +// Inline helper to set the phase and zero the timer atomically. +inline fun set_phase(p: u8) { + phase = p + phase_timer = 0 +} + +state Playing { + on enter { + // Reset scores and paddle state on a fresh game. + score[0] = 0 + score[1] = 0 + paddle_y[0] = 96 + paddle_y[1] = 96 + paddle_long[0] = 0 + paddle_long[1] = 0 + paddle_fast[0] = 0 + paddle_fast[1] = 0 + paddle_multi[0] = 0 + paddle_multi[1] = 0 + serving_side = SIDE_RIGHT // first serve goes toward the right paddle + set_phase(P_SERVE) + } + + on frame { + global_tick += 1 + phase_timer += 1 + + // ── Phase dispatch ─────────────────────────────── + // Using `match` so at most one arm executes per frame + // (prevents one phase from advancing into the next in + // the same frame — the same rationale as war's phase + // machine). + match phase { + P_SERVE => { + // Draw the static table while the serve countdown + // ticks. After FRAMES_SERVE frames, launch the ball + // and start play. + draw_center_line() + draw_scores() + draw_paddles() + + if phase_timer >= FRAMES_SERVE { + serve_ball(serving_side) + set_phase(P_PLAY) + } + } + P_PLAY => { + // Core gameplay loop. + step_paddles() + step_balls() + step_powerup() + + draw_center_line() + draw_scores() + draw_paddles() + draw_balls() + draw_powerup() + } + P_POINT => { + // Short pause after a score before re-serving. + // Keep drawing the table and scores so the new + // score reads on-screen. + draw_center_line() + draw_scores() + draw_paddles() + + if phase_timer >= FRAMES_POINT { + // Check for match win. + if score[SIDE_LEFT] >= WIN_SCORE { + winner = 0 + transition Victory + } + if score[SIDE_RIGHT] >= WIN_SCORE { + winner = 1 + transition Victory + } + // Not over yet — serve again. The serving_side + // was set in on_score() to the scoring side, so + // the ball is served TOWARD the side that lost + // the point. + if serving_side == SIDE_LEFT { + serving_side = SIDE_RIGHT + } else { + serving_side = SIDE_LEFT + } + set_phase(P_SERVE) + } + } + _ => {} + } + } +} diff --git a/examples/pong/powerup.ne b/examples/pong/powerup.ne new file mode 100644 index 0000000..ef18b3e --- /dev/null +++ b/examples/pong/powerup.ne @@ -0,0 +1,144 @@ +// pong/powerup.ne — powerup entity spawn, bounce, catch, apply. +// +// One powerup is active at a time. When powerup_kind != PWR_NONE +// the entity is visible and bouncing around the playfield. Either +// paddle can catch it by overlapping its AABB. On catch, the +// catcher receives the effect (LONG / FAST / MULTI) and the +// powerup despawns. An uncaught powerup despawns automatically +// after POWERUP_LIFE_FRAMES. A new powerup spawns every +// POWERUP_SPAWN_FRAMES while no powerup is on-screen. + +// ── Spawn ──────────────────────────────────────────────── +// +// Place a new powerup at the playfield centre with a random +// kind (1..3) and random initial diagonal direction. +fun spawn_powerup() { + var r: u8 = rng_next() + // kind = 1, 2, or 3. We take `(r % 3) + 1` but the compiler + // warns on non-power-of-two modulo so we do it manually: + // (r & 3) gives 0..3; if 0, remap to 3. + var k: u8 = r & 3 + if k == 0 { + k = 3 + } + powerup_kind = k + powerup_x = 120 + powerup_y = 112 + + var r2: u8 = rng_next() + powerup_dx_sign = r2 & 1 + powerup_dy_sign = (r2 >> 1) & 1 + + powerup_timer = 0 + play PowerSpawn +} + +// ── Apply ──────────────────────────────────────────────── +// +// Set the appropriate pending flag on the catching side's +// paddle. Called from the catch handler below. +fun apply_powerup(side: u8, kind: u8) { + if kind == PWR_LONG { + paddle_long[side] = LONG_PADDLE_HITS + } + if kind == PWR_FAST { + paddle_fast[side] = 1 + } + if kind == PWR_MULTI { + paddle_multi[side] = 1 + } + play PowerCatch +} + +// ── Catch check ────────────────────────────────────────── +// +// AABB test of the powerup against a single paddle. Returns 1 +// if the powerup was caught (and consumed), 0 otherwise. +fun check_powerup_vs_paddle(side: u8) -> u8 { + var px: u8 = LEFT_PADDLE_X + if side == SIDE_RIGHT { + px = RIGHT_PADDLE_X + } + var py: u8 = paddle_y[side] + var ph: u8 = PADDLE_H + if paddle_long[side] > 0 { + ph = PADDLE_H_LONG + } + if powerup_x + POWERUP_SIZE > px and powerup_x < px + PADDLE_W and powerup_y + POWERUP_SIZE > py and powerup_y < py + ph { + apply_powerup(side, powerup_kind) + powerup_kind = PWR_NONE + return 1 + } + return 0 +} + +// ── Step ───────────────────────────────────────────────── +// +// Called every frame during P_PLAY from the play state's main +// loop. Handles spawn cooldown, movement, wall bouncing, paddle +// catch checks, and lifetime despawn. +fun step_powerup() { + if powerup_kind == PWR_NONE { + // No powerup on screen — tick the spawn cooldown. + powerup_cooldown += 1 + if powerup_cooldown >= POWERUP_SPAWN_FRAMES { + powerup_cooldown = 0 + spawn_powerup() + } + return + } + + // ── Lifetime check ─────────────────────────────── + powerup_timer += 1 + if powerup_timer >= POWERUP_LIFE_FRAMES { + powerup_kind = PWR_NONE + return + } + + // ── Movement ───────────────────────────────────── + if powerup_dx_sign == 0 { + powerup_x += POWERUP_SPEED + } else { + powerup_x -= POWERUP_SPEED + } + if powerup_dy_sign == 0 { + powerup_y += POWERUP_SPEED + } else { + powerup_y -= POWERUP_SPEED + } + + // ── Wall bounces ───────────────────────────────── + if powerup_dy_sign == 1 { + if powerup_y < PLAYFIELD_TOP { + powerup_y = PLAYFIELD_TOP + powerup_dy_sign = 0 + } + } + if powerup_dy_sign == 0 { + if powerup_y + POWERUP_SIZE > PLAYFIELD_BOTTOM { + powerup_y = PLAYFIELD_BOTTOM - POWERUP_SIZE + powerup_dy_sign = 1 + } + } + // Bounce off left/right back walls (not paddles — the + // powerup flies past paddles and only bounces off the + // absolute screen edges). + if powerup_dx_sign == 1 { + if powerup_x < 4 { + powerup_x = 4 + powerup_dx_sign = 0 + } + } + if powerup_dx_sign == 0 { + if powerup_x + POWERUP_SIZE > 252 { + powerup_x = 252 - POWERUP_SIZE + powerup_dx_sign = 1 + } + } + + // ── Paddle catch ───────────────────────────────── + var caught: u8 = check_powerup_vs_paddle(SIDE_LEFT) + if caught == 0 { + check_powerup_vs_paddle(SIDE_RIGHT) + } +} diff --git a/examples/pong/render.ne b/examples/pong/render.ne new file mode 100644 index 0000000..da8c102 --- /dev/null +++ b/examples/pong/render.ne @@ -0,0 +1,162 @@ +// pong/render.ne — drawing helpers. +// +// Everything on-screen that isn't a static background tile goes +// through one of these functions. The idea is the same as war: +// the phase handlers stay readable and the sprite-budgeting math +// all lives in one file. + +// ── Letters and digits ─────────────────────────────────── + +// Draw a single 'A'..'Z' glyph at (x, y). `ch` is the letter +// index (0 = A, 25 = Z). +fun draw_letter(x: u8, y: u8, ch: u8) { + draw Tileset at: (x, y) frame: TILE_LETTER_BASE + ch +} + +// Draw a single digit 0..9 at (x, y). +fun draw_digit(x: u8, y: u8, d: u8) { + draw Tileset at: (x, y) frame: TILE_DIGIT_BASE + d +} + +// Two-digit score display (0..99) at (x, y). Leading zero is +// preserved so the HUD never shifts as the score ticks up. +fun draw_count(x: u8, y: u8, v: u8) { + var tens: u8 = 0 + var n: u8 = v + while n >= 10 { + n -= 10 + tens += 1 + } + draw_digit(x, y, tens) + draw_digit(x + 8, y, n) +} + +// ── Words ──────────────────────────────────────────────── + +// "PRESS" — used on the title's blinking prompt. +fun draw_word_press(x: u8, y: u8) { + draw_letter(x, y, 15) // P + draw_letter(x + 8, y, 17) // R + draw_letter(x + 16, y, 4) // E + draw_letter(x + 24, y, 18) // S + draw_letter(x + 32, y, 18) // S +} + +// "PONG" — used on the title screen and the victory banner until +// we wire up the BIG PONG banner in the polish milestone. +fun draw_word_pong(x: u8, y: u8) { + draw_letter(x, y, 15) // P + draw_letter(x + 8, y, 14) // O + draw_letter(x + 16, y, 13) // N + draw_letter(x + 24, y, 6) // G +} + +// "CPU" — used on the title menu options. +fun draw_word_cpu(x: u8, y: u8) { + draw_letter(x, y, 2) // C + draw_letter(x + 8, y, 15) // P + draw_letter(x + 16, y, 20) // U +} + +// "VS" — used in the CPU-vs-CPU title line. +fun draw_word_vs(x: u8, y: u8) { + draw_letter(x, y, 21) // V + draw_letter(x + 8, y, 18) // S +} + +// "PLAYER" — used on the title menu and the victory banner. +fun draw_word_player(x: u8, y: u8) { + draw_letter(x, y, 15) // P + draw_letter(x + 8, y, 11) // L + draw_letter(x + 16, y, 0) // A + draw_letter(x + 24, y, 24) // Y + draw_letter(x + 32, y, 4) // E + draw_letter(x + 40, y, 17) // R +} + +// "WINS" — used on the victory banner. +fun draw_word_wins(x: u8, y: u8) { + draw_letter(x, y, 22) // W + draw_letter(x + 8, y, 8) // I + draw_letter(x + 16, y, 13) // N + draw_letter(x + 24, y, 18) // S +} + +// ── Center-line divider ────────────────────────────────── +// +// Classic dashed Pong divider at x = 124. We draw 7 dashes at +// y = 24, 56, 88, 120, 152, 184, 216 (every 32 px), which is +// always one sprite per scanline — safely under the per-scanline +// budget. +fun draw_center_line() { + draw Tileset at: (124, 24) frame: TILE_CENTER_DASH + draw Tileset at: (124, 56) frame: TILE_CENTER_DASH + draw Tileset at: (124, 88) frame: TILE_CENTER_DASH + draw Tileset at: (124, 120) frame: TILE_CENTER_DASH + draw Tileset at: (124, 152) frame: TILE_CENTER_DASH + draw Tileset at: (124, 184) frame: TILE_CENTER_DASH + draw Tileset at: (124, 216) frame: TILE_CENTER_DASH +} + +// ── HUD scores ─────────────────────────────────────────── +fun draw_scores() { + draw_count(SCORE_LEFT_X, SCORE_Y, score[0]) + draw_count(SCORE_RIGHT_X, SCORE_Y, score[1]) +} + +// ── Paddles ────────────────────────────────────────────── +// +// Draw the paddle on the given side at its current y. Side 0 = +// left (x = LEFT_PADDLE_X), side 1 = right (x = RIGHT_PADDLE_X). +// Normal paddles are 3 tiles tall (top, mid, bot). Long paddles +// are 5 tiles tall (top, mid, mid, mid, bot). The height choice +// is driven by `paddle_long[side]` — nonzero = extended. +fun draw_paddle(side: u8) { + var px: u8 = LEFT_PADDLE_X + if side == SIDE_RIGHT { + px = RIGHT_PADDLE_X + } + var py: u8 = paddle_y[side] + draw Tileset at: (px, py) frame: TILE_PADDLE_TOP + if paddle_long[side] > 0 { + // 5-tile long paddle (40 px). + draw Tileset at: (px, py + 8) frame: TILE_PADDLE_MID + draw Tileset at: (px, py + 16) frame: TILE_PADDLE_MID + draw Tileset at: (px, py + 24) frame: TILE_PADDLE_MID + draw Tileset at: (px, py + 32) frame: TILE_PADDLE_BOT + } else { + // 3-tile normal paddle (24 px). + draw Tileset at: (px, py + 8) frame: TILE_PADDLE_MID + draw Tileset at: (px, py + 16) frame: TILE_PADDLE_BOT + } +} + +// Draw both paddles in one call. Used by every gameplay phase. +fun draw_paddles() { + draw_paddle(SIDE_LEFT) + draw_paddle(SIDE_RIGHT) +} + +// ── Balls ──────────────────────────────────────────────── +// +// Draw every active ball in the ball_* arrays. Inactive slots +// are skipped. +fun draw_balls() { + var i: u8 = 0 + while i < MAX_BALLS { + if ball_active[i] == 1 { + draw Tileset at: (ball_x[i], ball_y[i]) frame: TILE_BALL + } + i += 1 + } +} + +// ── Powerup ────────────────────────────────────────────── +// +// Draw the on-screen powerup iff one is active. Tile index picks +// up the right icon via TILE_POWERUP_BASE + (kind - 1). +fun draw_powerup() { + if powerup_kind != PWR_NONE { + draw Tileset at: (powerup_x, powerup_y) frame: (TILE_POWERUP_BASE - 1) + powerup_kind + } +} diff --git a/examples/pong/rng.ne b/examples/pong/rng.ne new file mode 100644 index 0000000..ea38d6d --- /dev/null +++ b/examples/pong/rng.ne @@ -0,0 +1,29 @@ +// pong/rng.ne — 8-bit Galois LFSR PRNG. +// +// Classic maximal-period 8-bit Galois LFSR: shift right and XOR in +// the tap mask 0xB8 whenever the LSB was 1. The sequence visits +// every non-zero 8-bit value exactly once over 255 calls, which +// is enough randomness for serve-direction jitter, powerup kind +// selection, and AI reaction noise. + +// Advance the LFSR by one step and return the new byte. +fun rng_next() -> u8 { + var s: u8 = rng_state + var lsb: u8 = s & 1 + s = s >> 1 + if lsb == 1 { + s = s ^ 0xB8 + } + rng_state = s + return s +} + +// Seed the LFSR. 0 is a degenerate state (the LFSR stays at 0 +// forever), so we coerce zero seeds to 1. +fun rng_seed(s: u8) { + if s == 0 { + rng_state = 1 + } else { + rng_state = s + } +} diff --git a/examples/pong/state.ne b/examples/pong/state.ne new file mode 100644 index 0000000..c5b7694 --- /dev/null +++ b/examples/pong/state.ne @@ -0,0 +1,88 @@ +// pong/state.ne — every mutable variable the game touches. +// +// As in the war example, keeping these as globals means helper +// functions can read and write without taking parameters and +// returning values — the 6502's 8-register ABI makes every extra +// parameter an extra LDA/STA pair, so the shorter call signatures +// pay for themselves in the hot loop. + +// ── Paddles ────────────────────────────────────────────── +// +// Two parallel u8 arrays indexed by `side` (SIDE_LEFT = 0, SIDE_RIGHT = 1). +// `paddle_y[i]` — top-edge y of paddle i +// `paddle_long[i]` — remaining LONG-paddle hits (0 = normal) +// `paddle_fast[i]` — 1 if catcher's next hit doubles ball x velocity +// `paddle_multi[i]` — 1 if catcher's next hit spawns two extra balls +// `score[i]` — current score (0..WIN_SCORE) +// `is_cpu[i]` — 1 if this side is CPU-controlled +var paddle_y: u8[2] = [96, 96] +var paddle_long: u8[2] = [0, 0] +var paddle_fast: u8[2] = [0, 0] +var paddle_multi: u8[2] = [0, 0] +var score: u8[2] = [0, 0] +var is_cpu: u8[2] = [1, 1] + +// ── Balls ──────────────────────────────────────────────── +// +// Parallel u8 arrays, one slot per ball up to MAX_BALLS. Each +// ball's velocity is stored as a (magnitude, sign) pair so the +// existing u8-only arithmetic suffices — sign 0 means +x right +// / +y down, sign 1 means -x left / -y up. +// +// `ball_active[i]` — 1 if this slot is in play, 0 if free +// `ball_x[i]` — top-left x in px +// `ball_y[i]` — top-left y in px +// `ball_dx[i]` — x speed in px/frame +// `ball_dy[i]` — y speed in px/frame +// `ball_dx_sign[i]` — 0 = moving right, 1 = moving left +// `ball_dy_sign[i]` — 0 = moving down, 1 = moving up +var ball_active: u8[3] = [0, 0, 0] +var ball_x: u8[3] = [0, 0, 0] +var ball_y: u8[3] = [0, 0, 0] +var ball_dx: u8[3] = [0, 0, 0] +var ball_dy: u8[3] = [0, 0, 0] +var ball_dx_sign: u8[3] = [0, 0, 0] +var ball_dy_sign: u8[3] = [0, 0, 0] + +// ── Powerup ────────────────────────────────────────────── +// +// Single-slot powerup entity. `powerup_kind == PWR_NONE` means +// nothing is on screen; any other value is the current kind plus +// its position and velocity. `powerup_timer` counts frames since +// spawn so we can despawn after POWERUP_LIFE_FRAMES. +var powerup_kind: u8 = 0 +var powerup_x: u8 = 0 +var powerup_y: u8 = 0 +var powerup_dx_sign: u8 = 0 +var powerup_dy_sign: u8 = 0 +var powerup_timer: u16 = 0 +var powerup_cooldown:u16 = 0 // counts down to the next spawn + +// ── Game mode + phase machine ──────────────────────────── +var mode: u8 = 0 // 0/1/2 — CPU vs CPU / human vs CPU / human vs human +var phase: u8 = 0 // one of the P_* constants +var phase_timer: u8 = 0 // frames spent in the current phase +var serving_side:u8 = 0 // which side serves next (alternates) + +// ── Title menu ──────────────────────────────────────────── +var title_cursor: u8 = 0 +var title_timer: u8 = 0 +var title_blink: u8 = 0 +var title_debounce: u8 = 0 + +// ── Victory state ───────────────────────────────────────── +var winner: u8 = 0 +var victory_timer: u16 = 0 + +// ── RNG ────────────────────────────────────────────────── +// +// 8-bit Galois LFSR state. Seeded from the title frame counter at +// the moment the title transitions to gameplay so the shuffle of +// the first serve direction and powerup kinds is deterministic +// once a user commits. The jsnes headless harness always hits the +// title autopilot at the same frame, so the seed is stable there +// too. +var rng_state: u8 = 0xA7 + +// ── Global free-running frame counter ──────────────────── +var global_tick: u16 = 0 diff --git a/examples/pong/title_state.ne b/examples/pong/title_state.ne new file mode 100644 index 0000000..c2d319a --- /dev/null +++ b/examples/pong/title_state.ne @@ -0,0 +1,121 @@ +// pong/title_state.ne — the Title state. +// +// Draws "PONG" at the top of the screen, a 3-option menu (CPU VS +// CPU / 1 PLAYER / 2 PLAYERS) with a cursor, and a blinking +// "PRESS A" prompt below. If nothing happens for TITLE_AUTO_FRAMES +// the menu auto-commits to CPU VS CPU so the headless jsnes +// golden capture always reaches gameplay by frame 180. + +state Title { + on enter { + title_cursor = 0 // default: CPU VS CPU (autopilot pick) + title_timer = 0 + title_blink = 0 + title_debounce = 0 + start_music TitleTheme + } + + on frame { + global_tick += 1 + title_timer += 1 + title_blink += 1 + if title_blink >= 60 { + title_blink = 0 + } + if title_debounce > 0 { + title_debounce -= 1 + } + + // ── "PONG" banner ───────────────────────────────── + // + // Until the BIG PONG banner lands in the polish + // milestone, we draw the name in the 8×8 font at + // roughly 2× scale by spacing letters 16 px apart. That + // reads as a title without eating a dozen extra tiles. + draw_letter(88, 40, 15) // P + draw_letter(104, 40, 14) // O + draw_letter(120, 40, 13) // N + draw_letter(136, 40, 6) // G + + // ── Menu options ────────────────────────────────── + // + // Three vertically-stacked rows. Row 0 = CPU VS CPU, + // row 1 = 1 PLAYER, row 2 = 2 PLAYER (no S — plural is + // implied, and dropping the S keeps each row short + // enough that the cursor sprite + row fits under the + // sprites-per-scanline budget). + // + // Row 0: "CPU VS CPU" + draw_word_cpu(80, 96) + draw_word_vs (112, 96) + draw_word_cpu(136, 96) + // Row 1: "1 PLAYER" + draw_digit(88, 120, 1) + draw_word_player(104, 120) + // Row 2: "2 PLAYER" + draw_digit(88, 144, 2) + draw_word_player(104, 144) + + // Cursor sits to the left of the selected option, at + // y = 96 / 120 / 144 depending on title_cursor. + var cursor_y: u8 = 96 + (title_cursor << 3) + (title_cursor << 4) // +24 per row + draw Tileset at: (64, cursor_y) frame: TILE_CURSOR + + // ── Blinking "PRESS A" prompt ───────────────────── + if title_blink < 30 { + draw_word_press(96, 184) + draw_letter(144, 184, 0) // A + } + + // ── Input handling ──────────────────────────────── + if title_debounce == 0 { + if button.up { + if title_cursor > 0 { + title_cursor -= 1 + } + title_debounce = 10 + title_timer = 0 + play PaddleHit + } + if button.down { + if title_cursor < 2 { + title_cursor += 1 + } + title_debounce = 10 + title_timer = 0 + play PaddleHit + } + if button.a or button.start { + mode = title_cursor + rng_seed((global_tick & 0xFF) as u8) + transition Playing + } + } + + // ── Autopilot for the headless harness ──────────── + if title_timer >= TITLE_AUTO_FRAMES { + mode = MODE_CPU_VS_CPU + title_cursor = 0 + rng_seed((global_tick & 0xFF) as u8) + transition Playing + } + } + + on exit { + stop_music + // Translate the menu selection into per-side is_cpu flags + // the playing state reads each frame. + if mode == MODE_CPU_VS_CPU { + is_cpu[0] = 1 + is_cpu[1] = 1 + } + if mode == MODE_HUMAN_VS_CPU { + is_cpu[0] = 0 + is_cpu[1] = 1 + } + if mode == MODE_HUMAN_VS_HUMAN { + is_cpu[0] = 0 + is_cpu[1] = 0 + } + } +} diff --git a/examples/pong/victory_state.ne b/examples/pong/victory_state.ne new file mode 100644 index 0000000..2588b3d --- /dev/null +++ b/examples/pong/victory_state.ne @@ -0,0 +1,41 @@ +// pong/victory_state.ne — the Victory state. +// +// Shows "PLAYER N WINS" centred on screen, plays the builtin +// fanfare, and auto-returns to the Title after +// VICTORY_LINGER_FRAMES. Pressing A / Start skips the wait. + +state Victory { + on enter { + victory_timer = 0 + start_music fanfare + } + + on frame { + victory_timer += 1 + + // ── Banner: "PLAYER N WINS" ────────────────────── + // + // Two rows so the per-scanline sprite limit is comfortable. + // Row 1: "PLAYER N" (7 sprites at y = 96). + // Row 2: "WINS" (4 sprites at y = 116). + draw_word_player(80, 96) + // Winner digit: 0 → "1", 1 → "2". + draw_digit(136, 96, winner + 1) + + draw_word_wins(104, 116) + + // ── Input: skip linger ─────────────────────────── + if button.a or button.start { + transition Title + } + + // ── Auto-return ────────────────────────────────── + if victory_timer >= VICTORY_LINGER_FRAMES { + transition Title + } + } + + on exit { + stop_music + } +} diff --git a/tests/emulator/goldens/pong.audio.hash b/tests/emulator/goldens/pong.audio.hash new file mode 100644 index 0000000..92112a9 --- /dev/null +++ b/tests/emulator/goldens/pong.audio.hash @@ -0,0 +1 @@ +b99d68ff 132084 diff --git a/tests/emulator/goldens/pong.png b/tests/emulator/goldens/pong.png new file mode 100644 index 0000000000000000000000000000000000000000..1ad74020a4aca0884ba1d58745e59b460c19bca1 GIT binary patch literal 1010 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K5K5(!B$@kNDon~NQ-s$P$7*fIb_Ml^y#0^oF z1OIy8*FP2CA|ar5?pWD{6{}yV?qrxOl@P{!CaWQm*I+ecn(2WxY%{JhY{nsYt^Y%P zVfoKDd#9g&r#FA^_fK>0#M)KAf4X`1>Gbz|KYTa+Q(8Wq|NY)Q-#Gtn& z_o{yul>gLwrzd}}y7K+g!*`#`Bh(zae;sOBm8Tv^^Le21H}5{(tk%DO^6t~k-{b6d z@2>fFkGuZ9cM+qS>6u--@5J7~;=t)on0$!z1a|KpUbFl3-PTylPyjmYz!H3cg55l% zFvaKSnoNkFg{2;N@810f=yi3I1Jg^(e@@+f`aL-wn*R0P>GiBt z{XMSk+r87}UzO`mRO^3`-Sd6ZBF416D&h%2NRfv>LtqP@w7aqI^xj~`-8FCuCdRz~ YjQej+-Wpzz5CY25p00i_>zopr02YzfrT_o{ literal 0 HcmV?d00001