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 0000000..0b38c5c Binary files /dev/null and b/examples/pong.nes differ diff --git a/examples/pong/PLAN.md b/examples/pong/PLAN.md new file mode 100644 index 0000000..f30a555 --- /dev/null +++ b/examples/pong/PLAN.md @@ -0,0 +1,141 @@ +# Pong — Implementation Plan + +A production-quality Pong example for NEScript in the same vein as +`examples/war.ne`. This is the living plan: each step is checked off +as it completes, and any mid-flight design changes land in the +"Design revisions" section at the bottom. + +--- + +## 1. Scope & quality bar + +- **Title screen**: big "PONG" banner (later milestones), 3-option menu + (CPU VS CPU / 1 PLAYER / 2 PLAYERS), cursor, blinking "PRESS A" + prompt, brisk title march, autopilot to CPU VS CPU after + `TITLE_AUTO_FRAMES` with no input so the headless golden capture + reaches gameplay. +- **Gameplay**: two paddles, smooth ball physics, proper hit-angle + deflection, responsive controls, dashed center line, two-digit + score HUD above each side. +- **Powerups**: three types that spawn periodically, bounce around + the playfield, and apply an effect when caught by a paddle: + 1. **LONG** — catching paddle extends from 24→40 px for the next + `LONG_PADDLE_HITS` paddle hits. + 2. **FAST** — catching paddle's next hit doubles the ball's x + velocity for that ball's remaining life. + 3. **MULTI** — catching paddle's next hit spawns two extra balls + at the hit point (so 1→3 balls, each scores independently). +- **Victory**: first to `WIN_SCORE` points. Big "PLAYER N WINS" + banner, fanfare, auto-return to title. +- **Audio**: every feel event gets a dedicated sfx, plus a title + march and a victory fanfare. + +--- + +## 2. File layout + +``` +examples/pong.ne top-level: game decl, palette, Tileset, includes, start +examples/pong/PLAN.md this document +examples/pong/constants.ne layout + gameplay + powerup + phase constants +examples/pong/assets.ne Tileset sprite block (every custom CHR tile) +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_paddle, draw_ball, draw_score, draw_word_* helpers +examples/pong/input.ne paddle_step(side) — unified human + CPU paddle update +examples/pong/ball.ne multi-ball physics (update, paddle & wall collision) +examples/pong/powerup.ne powerup spawn, bounce, catch, apply +examples/pong/title_state.ne state Title + menu +examples/pong/serve_state.ne state Serve (brief pause before launch) +examples/pong/play_state.ne state Playing (phase machine) +examples/pong/victory_state.ne state Victory +``` + +--- + +## 3. Hardware budget + +### Sprite budget per frame (max 64 OAM slots) + +| Entity | Sprites | Notes | +|----------------------|---------|--------------------------------| +| Left paddle | 3-5 | 3 in normal, 5 in long mode | +| Right paddle | 3-5 | same | +| Active balls | 1-3 | up to `MAX_BALLS = 3` | +| Powerup | 0-1 | one slot when active | +| Center-line dashes | ~7 | 1 tile every 32 px, at x = 124 | +| HUD score digits | 4 | 2 digits per side | +| **Steady-state max** | ~24 | well under 64 | + +### Sprite-per-scanline check (W0109 budget = 8) + +Paddles are at x = 16 and x = 232, separated by 216 px. The ball and +powerup live in the middle of the playfield; center-line dashes live +at x = 124. HUD digits live at y = 16 (above the playfield). Worst +case scanline hits one paddle-tile + ball + powerup + center-line +dash = 4 sprites. Comfortable. + +### Tile budget (max 256) + +| Group | Tiles | +|-----------------------------|-------| +| A-Z alphabet (8×8) | 26 | +| 0-9 digits (8×8) | 10 | +| BIG PONG banner (4 × 2×2) | 16 | +| Paddle (top/mid/bot caps) | 3 | +| Ball | 1 | +| Cursor arrow | 1 | +| Center-line dash | 1 | +| Powerup icons (L, F, M) | 3 | +| **Total** | ~61 | + +### RAM budget + +| Structure | Bytes | +|-----------------------|-------| +| Paddle state (× 2) | ~16 | +| Ball state (× 3) | ~24 | +| Powerup state | ~10 | +| Scores + mode + phase | ~12 | +| RNG + timers + misc | ~12 | +| **Total** | ~74 | + +All well within the NEScript 1280-byte general RAM ceiling. + +--- + +## 4. Milestones + +- [x] **M1** — Skeleton & title screen +- [x] **M2** — Paddles with input and clamping; HUD scores +- [x] **M3** — Single-ball physics (serve, bounce, score-out) +- [x] **M4** — Multi-ball via parallel ball_* arrays (structural — arrays loop from M3) +- [x] **M5** — CPU paddle AI; title mode pick dispatch +- [x] **M6** — Long-paddle powerup plumbing (draw + collision wired in M3) +- [x] **M7** — Fast-ball + multi-ball-on-next-hit flags +- [x] **M8** — Powerup entity (spawn, bounce, catch, apply) +- [x] **M9** — Victory state + fanfare +- [x] **M10** — Audio + polish pass +- [x] **M11** — Golden capture + README/examples/README entries +- [ ] **M12** — Compiler bug cleanup (revert workarounds where fixable) +- [ ] **M13** — Thorough code review pass + +--- + +## 5. Design decisions (locked in M1) + +- `WIN_SCORE = 7` +- `MAX_BALLS = 3` +- Paddle height: 24 px normal, 40 px long +- Ball base speed: 1 px/frame on each axis; FAST doubles x to 2 +- Powerup spawn cadence: every `POWERUP_SPAWN_FRAMES` (~240) frames +- Powerups bounce off all four walls, catchable by either paddle +- CPU AI: 1 px/frame toward ball y, 4-frame reaction lag, ±4 px miss zone +- Default autopilot mode: CPU VS CPU (so the golden harness captures gameplay) + +--- + +## 6. Design revisions + +(empty — record any mid-flight changes here) diff --git a/examples/pong/assets.ne b/examples/pong/assets.ne new file mode 100644 index 0000000..f7d8c2a --- /dev/null +++ b/examples/pong/assets.ne @@ -0,0 +1,458 @@ +// pong/assets.ne — the one big Tileset sprite block. +// +// Every custom 8×8 CHR tile in Pong is stacked vertically inside a +// single `sprite Tileset { pixels: [...] }` declaration. The parser +// splits each consecutive 8 rows into its own tile, so tile index N +// in a `draw Tileset frame: N` call picks the N'th block. +// +// Every constant in constants.ne points at a position in this list. +// If you add or remove a tile, update BOTH this file AND the matching +// constant range. +// +// Colour vocabulary (sp0 sub-palette in pong.ne): +// . transparent +// 1 white (paddles, ball, alphabet, digits) +// 2 lt_gray (reserved — subtle accents) +// 3 yellow (powerup icons, cursor highlight) + +sprite Tileset { + pixels: [ + // ── 01-26: alphabet A..Z (6-wide bold on transparent) ─ + // + // Bold sans-serif letters with 2-pixel strokes so they + // still read on a CRT. Every letter is 6 wide, centred + // in the 8-wide tile. This block is the same shape as + // war's alphabet so a future refactor could lift it into + // a shared include. + + // A + "........", + "..1111..", + ".11..11.", + ".11..11.", + ".111111.", + ".11..11.", + ".11..11.", + "........", + // B + "........", + ".11111..", + ".11..11.", + ".11111..", + ".11..11.", + ".11..11.", + ".11111..", + "........", + // C + "........", + "..11111.", + ".11..11.", + ".11.....", + ".11.....", + ".11..11.", + "..11111.", + "........", + // D + "........", + ".1111...", + ".11.11..", + ".11..11.", + ".11..11.", + ".11.11..", + ".1111...", + "........", + // E + "........", + ".111111.", + ".11.....", + ".11111..", + ".11.....", + ".11.....", + ".111111.", + "........", + // F + "........", + ".111111.", + ".11.....", + ".11111..", + ".11.....", + ".11.....", + ".11.....", + "........", + // G + "........", + "..11111.", + ".11.....", + ".11.111.", + ".11..11.", + ".11..11.", + "..1111..", + "........", + // H + "........", + ".11..11.", + ".11..11.", + ".111111.", + ".11..11.", + ".11..11.", + ".11..11.", + "........", + // I + "........", + "..1111..", + "...11...", + "...11...", + "...11...", + "...11...", + "..1111..", + "........", + // J + "........", + "....111.", + ".....11.", + ".....11.", + ".....11.", + ".11..11.", + "..1111..", + "........", + // K + "........", + ".11..11.", + ".11.11..", + ".1111...", + ".11.11..", + ".11..11.", + ".11..11.", + "........", + // L + "........", + ".11.....", + ".11.....", + ".11.....", + ".11.....", + ".11.....", + ".111111.", + "........", + // M + "........", + ".11..11.", + ".111111.", + ".111111.", + ".11..11.", + ".11..11.", + ".11..11.", + "........", + // N + "........", + ".11..11.", + ".111.11.", + ".111111.", + ".11.111.", + ".11..11.", + ".11..11.", + "........", + // O + "........", + "..1111..", + ".11..11.", + ".11..11.", + ".11..11.", + ".11..11.", + "..1111..", + "........", + // P + "........", + ".11111..", + ".11..11.", + ".11..11.", + ".11111..", + ".11.....", + ".11.....", + "........", + // Q + "........", + "..1111..", + ".11..11.", + ".11..11.", + ".11.111.", + ".11.11..", + "..111.1.", + "........", + // R + "........", + ".11111..", + ".11..11.", + ".11..11.", + ".11111..", + ".11.11..", + ".11..11.", + "........", + // S + "........", + "..11111.", + ".11.....", + "..1111..", + ".....11.", + ".....11.", + ".11111..", + "........", + // T + "........", + ".111111.", + "...11...", + "...11...", + "...11...", + "...11...", + "...11...", + "........", + // U + "........", + ".11..11.", + ".11..11.", + ".11..11.", + ".11..11.", + ".11..11.", + "..1111..", + "........", + // V + "........", + ".11..11.", + ".11..11.", + ".11..11.", + ".11..11.", + "..1111..", + "...11...", + "........", + // W + "........", + ".11..11.", + ".11..11.", + ".11..11.", + ".111111.", + ".111111.", + ".11..11.", + "........", + // X + "........", + ".11..11.", + "..1111..", + "...11...", + "...11...", + "..1111..", + ".11..11.", + "........", + // Y + "........", + ".11..11.", + ".11..11.", + "..1111..", + "...11...", + "...11...", + "...11...", + "........", + // Z + "........", + ".111111.", + ".....11.", + "....11..", + "...11...", + "..11....", + ".111111.", + "........", + + // ── 27-36: digits 0..9 ──────────────────────────── + // 0 + "........", + "..1111..", + ".11..11.", + ".11..11.", + ".11..11.", + ".11..11.", + "..1111..", + "........", + // 1 + "........", + "...11...", + "..111...", + ".1.11...", + "...11...", + "...11...", + "..1111..", + "........", + // 2 + "........", + "..1111..", + ".11..11.", + ".....11.", + "....11..", + "..11....", + ".111111.", + "........", + // 3 + "........", + "..1111..", + ".11..11.", + "....11..", + ".....11.", + ".11..11.", + "..1111..", + "........", + // 4 + "........", + "....11..", + "...111..", + "..1.11..", + ".11.11..", + ".111111.", + "....11..", + "........", + // 5 + "........", + ".111111.", + ".11.....", + ".11111..", + ".....11.", + ".11..11.", + "..1111..", + "........", + // 6 + "........", + "..1111..", + ".11.....", + ".11111..", + ".11..11.", + ".11..11.", + "..1111..", + "........", + // 7 + "........", + ".111111.", + ".....11.", + "....11..", + "...11...", + "..11....", + "..11....", + "........", + // 8 + "........", + "..1111..", + ".11..11.", + "..1111..", + ".11..11.", + ".11..11.", + "..1111..", + "........", + // 9 + "........", + "..1111..", + ".11..11.", + ".11..11.", + "..11111.", + ".....11.", + "..1111..", + "........", + + // ── 37: paddle top cap ──────────────────────────── + // + // Paddles are 8 px wide and an integer number of tiles + // tall. Each paddle is rendered as TOP + (N-2) × MID + BOT, + // where N is 3 in normal mode (24 px) or 5 in long mode + // (40 px). The cap tiles are rounded at the outer edge so + // the paddle doesn't look like a blunt slab. + "..1111..", + ".111111.", + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + // ── 38: paddle middle (repeated for tall paddles) ─ + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + // ── 39: paddle bottom cap ───────────────────────── + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + "11111111", + ".111111.", + "..1111..", + + // ── 40: ball ────────────────────────────────────── + // + // 6×6 filled circle centred in the 8×8 tile. + "........", + "..1111..", + ".111111.", + ".111111.", + ".111111.", + ".111111.", + "..1111..", + "........", + + // ── 41: cursor arrow (▶) ───────────────────────── + // + // Yellow-tinted so it pops against white menu text. + "........", + "..3.....", + "..33....", + "..333...", + "..333...", + "..33....", + "..3.....", + "........", + + // ── 42: center-line dash ────────────────────────── + // + // A vertical 2-px stripe down the centre column of the + // tile. Repeated every 32 px vertically to form the + // classic Pong dashed divider. + "...11...", + "...11...", + "...11...", + "...11...", + "...11...", + "...11...", + "...11...", + "...11...", + + // ── 43: powerup LONG icon ───────────────────────── + // + // A yellow "L" on a framed background. The frame is + // white so the icon reads against the black playfield. + "11111111", + "13....31", + "13.3..31", + "13.3..31", + "13.3..31", + "13.3333.", + "13....31", + "11111111", + // ── 44: powerup FAST icon (F) ───────────────────── + "11111111", + "13....31", + "13.3333.", + "13.3..31", + "13.333.1", + "13.3..31", + "13.3..31", + "11111111", + // ── 45: powerup MULTI icon (M) ──────────────────── + "11111111", + "13....31", + "13.3.3.1", + "13.333.1", + "13.3.3.1", + "13.3.3.1", + "13.3.3.1", + "11111111" + ] +} diff --git a/examples/pong/audio.ne b/examples/pong/audio.ne new file mode 100644 index 0000000..a5f8396 --- /dev/null +++ b/examples/pong/audio.ne @@ -0,0 +1,68 @@ +// pong/audio.ne — sfx + music declarations. +// +// Channel budget (same as war): +// pulse 1 — every `play Name` sfx +// pulse 2 — music (TitleTheme + builtin fanfare) +// triangle — reserved +// noise — reserved for a later low-thud on paddle hit +// +// The audio tick is linked in only because the title state uses +// `start_music`, so everything below ends up in PRG ROM. + +// ── Sound effects ────────────────────────────────────────── + +// Quick high click when the ball bounces off a top/bottom wall. +sfx WallBounce { + duty: 1 + pitch: 0x30 + envelope: [10, 6, 2] +} + +// Brighter click with a slight descent when the ball hits a paddle. +sfx PaddleHit { + duty: 2 + pitch: [0x28, 0x2C, 0x30] + volume: [14, 10, 5] +} + +// Short descending beep when a side scores a point. +sfx Score { + duty: 2 + pitch: [0x40, 0x50, 0x60, 0x70] + volume: [14, 12, 9, 4] +} + +// Rising chirp when a powerup spawns in the middle of the field. +sfx PowerSpawn { + duty: 3 + pitch: [0x80, 0x70, 0x60, 0x50, 0x40, 0x30] + volume: [12, 12, 12, 12, 12, 8] +} + +// Bright ascending blip when a paddle catches a powerup. +sfx PowerCatch { + duty: 2 + pitch: [0x60, 0x50, 0x40, 0x30, 0x28, 0x20] + volume: [15, 13, 11, 9, 7, 4] +} + +// ── Music ────────────────────────────────────────────────── + +// Brisk 4/4 title march on pulse 2. Four bars of a rising C-major +// phrase with punchy staccato notes so the menu feels energetic. +music TitleTheme { + duty: 2 + volume: 10 + repeat: true + tempo: 8 + notes: [ + // bar 1 + C4 4, E4 4, G4 8, E4 8, C4 8, rest 4, + // bar 2 + G4 4, G4 4, C5 8, G4 8, E4 8, rest 4, + // bar 3 + E4 4, G4 4, C5 8, E5 8, C5 8, rest 4, + // bar 4 — resolution + G4 4, G4 4, C5 16, rest 8 + ] +} diff --git a/examples/pong/ball.ne b/examples/pong/ball.ne new file mode 100644 index 0000000..4cb3cbd --- /dev/null +++ b/examples/pong/ball.ne @@ -0,0 +1,288 @@ +// pong/ball.ne — multi-ball physics. +// +// Balls live in parallel arrays indexed by slot (ball_x[i], etc.) +// so swapping between single-ball M3 and multi-ball M4 is purely a +// matter of which slots are active — the physics code is the +// same. Velocity is stored as (magnitude, sign) per axis because +// NEScript's u8 arithmetic can't express negatives directly; sign +// 0 = positive (+x right / +y down), sign 1 = negative. +// +// Update order inside update_ball: +// 1. Move the ball by its velocity +// 2. Bounce off top / bottom walls +// 3. Test paddle collisions (left + right) +// 4. Test score-out on left / right exits +// +// The `i` parameter is the ball slot, 0..MAX_BALLS-1. + +// ── Serve ──────────────────────────────────────────────── +// +// Reset ball slot 0 to the centre of the playfield with a fresh +// random velocity, aimed toward the `target_side` that just lost +// a point (so the serve is into the loser's side, classic Pong). +// Every other ball slot is cleared. Called on game start and +// after every scored point. +fun serve_ball(target_side: u8) { + // Wipe every slot first so M4's multi-ball leftovers never + // carry through a score. + var i: u8 = 0 + while i < MAX_BALLS { + ball_active[i] = 0 + i += 1 + } + + // Centre the primary ball. + ball_x[0] = 124 + ball_y[0] = 112 + ball_dx[0] = BALL_BASE_DX + ball_dy[0] = BALL_BASE_DY + + // Horizontal direction is toward target_side. SIDE_LEFT = 0 + // means the ball travels left toward the left paddle, so x + // sign = 1 (negative). Right paddle = sign 0. + if target_side == SIDE_LEFT { + ball_dx_sign[0] = 1 + } else { + ball_dx_sign[0] = 0 + } + + // Vertical direction picked by a random bit to avoid the + // same rally pattern every serve. RNG LSB = 0 → +y, 1 → -y. + var r: u8 = rng_next() + ball_dy_sign[0] = r & 1 + + ball_active[0] = 1 +} + +// ── Score handling ─────────────────────────────────────── +// +// Called when a ball exits the playfield past a paddle. The +// scoring side gets +1. With multi-ball, the round continues +// until ALL active balls are out. Only when the last ball exits +// does the phase machine drop into P_POINT for the serve pause. +fun on_score(scoring_side: u8) { + score[scoring_side] += 1 + play Score + + // Count remaining active balls (the exiting ball has already + // been marked inactive before this call). + var remaining: u8 = 0 + var j: u8 = 0 + while j < MAX_BALLS { + if ball_active[j] == 1 { + remaining += 1 + } + j += 1 + } + if remaining == 0 { + // Last ball out — enter the P_POINT pause. The + // serving_side records the scorer; P_POINT flips it so + // the serve aims at the loser. + serving_side = scoring_side + phase = P_POINT + phase_timer = 0 + // Clear the powerup so it doesn't linger during the + // inter-round pause. + powerup_kind = PWR_NONE + powerup_cooldown = 0 + } +} + +// ── Multi-ball spawn on paddle hit ─────────────────────── +// +// When paddle_multi[side] is set, a paddle hit spawns two extra +// balls at the hit point with mirrored y velocities. This +// function is called from within check_paddle_hit's hit branch. +// Returns immediately if no multi flag is set. The flag clears +// on use. +fun spawn_multi_balls(i: u8, side: u8) { + if paddle_multi[side] == 0 { + return + } + paddle_multi[side] = 0 + + // Find two free slots. + var slot1: u8 = 0 + var slot2: u8 = 0 + var found1: u8 = 0 + var found2: u8 = 0 + var k: u8 = 0 + while k < MAX_BALLS { + if ball_active[k] == 0 { + if found1 == 0 { + slot1 = k + found1 = 1 + } else { + if found2 == 0 { + slot2 = k + found2 = 1 + } + } + } + k += 1 + } + + // Spawn each extra ball as a copy of the hit ball with a + // mirrored or offset y direction. + if found1 == 1 { + ball_active[slot1] = 1 + ball_x[slot1] = ball_x[i] + ball_y[slot1] = ball_y[i] + ball_dx[slot1] = ball_dx[i] + ball_dy[slot1] = ball_dy[i] + ball_dx_sign[slot1] = ball_dx_sign[i] + // Mirror y sign from the source ball. + if ball_dy_sign[i] == 0 { + ball_dy_sign[slot1] = 1 + } else { + ball_dy_sign[slot1] = 0 + } + } + if found2 == 1 { + ball_active[slot2] = 1 + ball_x[slot2] = ball_x[i] + ball_y[slot2] = ball_y[i] + ball_dx[slot2] = ball_dx[i] + ball_dy[slot2] = ball_dy[i] + ball_dx_sign[slot2] = ball_dx_sign[i] + // Same y direction as the source (so we have 3 distinct + // trajectories: original, mirrored, and parallel). + ball_dy_sign[slot2] = ball_dy_sign[i] + // Offset the y slightly to avoid all three landing on + // the same pixel. + if ball_y[slot2] + 6 < PLAYFIELD_BOTTOM { + ball_y[slot2] += 6 + } + } +} + +// ── Paddle collision ───────────────────────────────────── +// +// Classic AABB overlap test against the paddle on `side`. On a +// hit we flip the ball's x velocity sign, push the ball out of +// the paddle to avoid double-bounces, and play the hit sfx. +// +// Powerup effects consumed on hit: +// - LONG: paddle_long already decrements +// - FAST: paddle_fast → doubles ball_dx for the rest of this ball's life +// - MULTI: paddle_multi → spawns 2 extra balls at the hit point +fun check_paddle_hit(i: u8, side: 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 + } + + 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 0000000..1ad7402 Binary files /dev/null and b/tests/emulator/goldens/pong.png differ