1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00
nescript/examples/platformer.ne
Claude ad951a835f
examples: adopt the friendly asset syntax
Rewrites every example with non-trivial asset declarations to use
the pleasant QoL syntax introduced in the previous commit. Every
example still compiles to a byte-identical ROM (verified by a
temp-path diff before committing), so the committed `.nes` files
and the 23 emulator goldens are unchanged.

  * platformer.ne — the centerpiece end-to-end demo:
      - `palette Main` goes to grouped form with a shared
        `universal: 0x22` (sky blue), one shared colour per
        sub-palette, and named NES colours throughout; the
        long-standing `$3F10` mirror-trap warning is now handled
        by the parser and the manual pitfall comment is gone.
      - `sprite Tileset` is 15 tiles of ASCII pixel art instead
        of 240 bytes of inline hex.
      - `background Level` uses a `legend { '.': 15, '#': 9, ... }`
        block plus `map:` strings for the 32×30 nametable, and
        `palette_map:` rows for the attribute table. The map
        reads top-down like the rendered screen.
      - SFX latch-once `pitch: 0x30` scalars + `envelope:` alias.
      - `music Theme` uses note names + `tempo: 10` default.
  * audio_demo.ne — scalar sfx pitches, `envelope:` alias, and a
    note-name `C4, E4, G4, ...` music track.
  * palette_and_background.ne — grouped CoolBlues / WarmReds
    palettes with `universal: black` + named colours, plus
    `legend` + `map:` tilemaps for the two backgrounds.
  * sprites_and_palettes.ne — Arrow and Heart sprites rewritten
    as `pixels:` ASCII art.

Along the way, two small parser extensions support the rewrites:

  - `parse_pixel_art` now accepts `a/b/c` as aliases for `#/%/@`,
    matching the vocabulary every NES editor (and our own
    gen_platformer_tiles.rs generator) uses.
  - `palette_map_to_attrs` allows up to 16 metatile rows (the
    full attribute-table coverage, including the off-screen
    bottom half) and auto-replicates row 14 → row 15 when only
    15 rows are supplied so the visible bottom of the screen
    gets consistent sub-palette assignments by default. The old
    15-row cap couldn't match a hand-packed `0xAA` attribute
    table for the last row; the platformer required this to
    stay byte-identical.

`scripts/gen_platformer_tiles.rs` is updated to emit the new
syntax directly (pixel-art `pixels:` block + `legend`/`map:`/
`palette_map:` for the background), so regenerating the
platformer tiles stays a one-liner.

474 lib tests + 64 integration tests pass (3 new parser tests
for `palette_map:` 15/16/17 rows and the `abc` alias). All 23
emulator goldens still match pixel- and sample-for-sample.

https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
2026-04-13 18:04:21 +00:00

575 lines
20 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Platformer — an end-to-end side-scrolling demo game that
// exercises nearly every subsystem of the NEScript compiler in
// one program: custom CHR tiles, a full 32×30 background nametable
// with per-region attribute palettes, a multi-tile metasprite
// player with gravity/jump physics, wrap-around horizontal
// scrolling, moving enemies, collectible coins, a user-declared
// SFX envelope, a user-declared music track, and a title →
// playing → game-over state machine driven by both controller
// input and an autopilot so the jsnes golden harness (which runs
// headless with no simulated buttons) still captures an
// interesting frame-180 composition.
//
// Controls (when played by a human):
// D-pad left/right — walk
// A — jump
// Start — on title screen: begin; after game over: retry
//
// Build: cargo run --release -- build examples/platformer.ne
// Output: examples/platformer.nes
//
// All CHR tile art, nametable bytes, and attribute bytes in this
// file are generated by `cargo run --bin gen_platformer_tiles`.
// Regenerate them from `scripts/gen_platformer_tiles.rs` if you
// want to tweak the level.
game "Platformer" {
mapper: NROM
// Horizontal arrangement means nametable 1 mirrors nametable 0
// horizontally, so our single loaded nametable tiles endlessly
// as the camera scrolls right.
mirroring: horizontal
}
// ── Palette ──────────────────────────────────────────────────
// Authored in grouped form: one shared `universal:` colour fills
// every sub-palette's index-0 slot automatically. That single
// declaration is enough to dodge the notorious `$3F10/$3F14/$3F18/
// $3F1C` PPU mirror trap — when a program writes 8 distinct
// "index 0" bytes sequentially, the last write clobbers the
// shared background colour and the screen turns the wrong colour
// (or all-black). Using one shared universal everywhere is what
// every shipped NES game does; the parser handles it for us.
//
// `0x22` is the lavender-blue NES master palette slot Super Mario
// Bros uses for its iconic sky. The colour names map to the
// curated set documented in `docs/language-guide.md`.
palette Main {
universal: 0x22 // sky blue (NES $22)
// Background sub-palettes
bg0: [white, gray, black] // sky / clouds
bg1: [red, orange, dk_red] // bricks, Q-blocks
bg2: [bright_green, dk_orange, brown] // grass, hills, bushes, dirt
bg3: [yellow, orange, dk_orange] // reserved
// Sprite sub-palette 0 — every `draw` uses this one slot
// because the OAM attribute byte is always 0 in v0.1.
// 1 = red cap, 2 = orange skin, 3 = white highlight.
sp0: [red, orange, white]
sp1: [brown, dk_orange, orange] // reserved
sp2: [neon_green, bright_green, white] // reserved
sp3: [yellow, olive, cream] // reserved
}
// ── Tileset ──────────────────────────────────────────────────
// One big sprite declaration holds every custom CHR tile used by
// both sprites and the background nametable. `draw Tileset ...
// frame: N` overrides the OAM tile-index byte with `N`, so the
// same declaration drives the player, enemies, coins, and every
// background tile after tile 0. Tile 0 is reserved by the linker
// for the built-in smiley — the background leaves it unreferenced.
//
// All 15 tiles are authored as 8×8 ASCII pixel art and stacked
// vertically (1 tile wide × 15 tiles tall) so the parser splits
// them into consecutive CHR tile indices in reading order. The
// art uses the `.abc` vocabulary (`. = 0`, `a = 1`, `b = 2`,
// `c = 3`); regenerate with `cargo run --bin gen_platformer_tiles`.
sprite Tileset {
pixels: [
// tile 1: Player head L
"..aaaaaa",
".aaaaaaa",
".aaabbbb",
".aabbbbc",
"aabbcccc",
"aabbccbc",
"aabbbbbb",
"aabbbbbb",
// tile 2: Player head R
"aaaaaa..",
"aaaaaaa.",
"bbbbaaa.",
"cbbbbbaa",
"cccccbbb",
"bcbbccbb",
"bbbbbbbb",
"bbbbbbbb",
// tile 3: Player body L
".aaaaaaa",
"aaacaaaa",
"aaaccaaa",
"aaaaaaaa",
".bbbbbbb",
".bbbbbbb",
".bb..bbb",
"aaa..bbb",
// tile 4: Player body R
"aaaaaaa.",
"aaaacaaa",
"aaaccaaa",
"aaaaaaaa",
"bbbbbbb.",
"bbbbbbb.",
"bbb..bb.",
"bbb..aaa",
// tile 5: Enemy
"..aaaa..",
".aaaaaa.",
".aacbaa.",
"aacccaaa",
"abbccbba",
".aaaaaa.",
"a.aaaa.a",
"a..aa..a",
// tile 6: Coin
"...bb...",
"..bbbb..",
".bbccbb.",
".bcbbcb.",
".bcbbcb.",
".bbccbb.",
"..bbbb..",
"...bb...",
// tile 7: Grass top
"aaaaaaaa",
"a.aaa.aa",
"aaaaaaaa",
"accacaca",
"ccccaccc",
"cccccccc",
"cbccccbc",
"cccccccc",
// tile 8: Dirt
"cccccccc",
"cbccccbc",
"cccccccc",
"ccccbccc",
"cccccccc",
"cbccccbc",
"ccccbccc",
"cccccccc",
// tile 9: Brick
"cccccccc",
"caaaaaca",
"caaaaaca",
"caaaaaca",
"cccccccc",
"aaacaaaa",
"aaacaaaa",
"aaacaaaa",
// tile 10: Cloud L
"........",
"...aaa..",
"..aaaaa.",
".aaaaaaa",
".aaaaaaa",
".bbaaaaa",
"..bbbbba",
".....bbb",
// tile 11: Cloud R
"........",
".aaa....",
"aaaaa...",
"aaaaaaa.",
"aaaaaaa.",
"aaaaabb.",
"abbbbb..",
"bbb.....",
// tile 12: Hill
"........",
"....aa..",
"...aaaa.",
"..aaaaaa",
"..abaaaa",
".abbabaa",
".aaababa",
"aabbabba",
// tile 13: Bush
"........",
"...aa...",
"..aaaa..",
".aaaaac.",
"aaaaaaca",
"aaaaacca",
"aaaaacca",
"acacacac",
// tile 14: Q Block
"cccccccc",
"caaaaaac",
"cabbbbac",
"cabccbac",
"cabcbbac",
"cabbbbac",
"caaaaaac",
"cccccccc",
// tile 15: Sky (blank)
"........",
"........",
"........",
"........",
"........",
"........",
"........",
"........"
]
}
// Tile index constants — must match `scripts/gen_platformer_tiles.rs`.
const TILE_PLAYER_HL: u8 = 1
const TILE_PLAYER_HR: u8 = 2
const TILE_PLAYER_BL: u8 = 3
const TILE_PLAYER_BR: u8 = 4
const TILE_ENEMY: u8 = 5
const TILE_COIN: u8 = 6
// ── Background ──────────────────────────────────────────────
// The 32×30 nametable is authored as ASCII art with a `legend`
// block naming each tile by a single character. Horizontal
// scrolling pans this single nametable via `scroll()` so it only
// needs to look interesting at any X offset.
//
// `palette_map:` is the friendlier alternative to a 64-byte
// hand-packed attribute table: 16×15 metatile entries, one
// digit per 16×16 metatile, and the parser packs the 2-bit
// `(br<<6)|(bl<<4)|(tr<<2)|tl` quadrants automatically. The
// three palette regions are sky (`0`), brick row (`1`), and
// ground (`2`).
background Level {
legend {
".": 15 // sky (blank)
"<": 10 // cloud left half
">": 11 // cloud right half
"#": 9 // brick
"Q": 14 // question block
"^": 12 // hill silhouette
"*": 13 // bush
"=": 7 // grass top
"%": 8 // dirt
}
map: [
"................................",
"................................",
"................................",
"................................",
"................................",
"....<>..............<>..........",
"............<>..................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"................................",
"......##Q#........###...........",
"................................",
"................................",
"................................",
"................................",
"..^^^.................^^^^......",
"..........***..............**...",
"================================",
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%",
"%%%%%#%%%%%%%%%%%%#%%%%%%%%%%%%%",
"%%%%%%%%%%%#%%%%%%%%%%%%#%%%%%%%",
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"
]
palette_map: [
"0000000000000000", // metatile rows 0-5 → sky sub-palette
"0000000000000000",
"0000000000000000",
"0000000000000000",
"0000000000000000",
"0000000000000000",
"1111111111111111", // metatile rows 6-7 → brick sub-palette
"1111111111111111",
"0000000000000000", // metatile rows 8-9 → sky again
"0000000000000000",
"2222222222222222", // metatile rows 10-15 → ground sub-palette
"2222222222222222", // (rows 15 sits off-screen but the PPU
"2222222222222222", // still reads its attribute byte, so
"2222222222222222", // we emit it explicitly to match the
"2222222222222222", // hand-packed attribute table the
"2222222222222222" // old form was using.)
]
}
// ── Audio ────────────────────────────────────────────────────
//
// SFX: scalar `pitch:` matches the v1 driver's latch-once
// behaviour — pulse 1's period is sampled exactly once when the
// effect triggers and never updated, so a single byte is the
// natural form for the current pipeline. `envelope:` is the
// friendlier alias for `volume:`.
// Custom boingy jump sound — fixed-pitch arc on pulse 1.
sfx Boing {
duty: 2
pitch: 0x30
envelope: [12, 11, 10, 9, 7, 5, 2]
}
// Custom coin ding — short rising blip.
sfx Ding {
duty: 2
pitch: 0x20
envelope: [14, 12, 8, 3]
}
// Custom looping underworld theme — playful four-bar loop on
// pulse 2 that plays while the player is alive. Note names
// (C4 / E4 / etc.) plus a default `tempo:` so each note only
// has to spell its frame count when it deviates from the beat.
music Theme {
duty: 2
volume: 9
repeat: true
tempo: 10
notes: [
C4, E4, G4, C5, G4, E4,
D4 15,
rest 5,
B3, E4, G4,
C5 20,
rest 10
]
}
// ── Gameplay constants ──────────────────────────────────────
const PLAYER_SCREEN_X: u8 = 80 // fixed camera-relative X
const GROUND_Y: u8 = 160 // feet land here (y of head)
const JUMP_RISE: u8 = 12 // upward frames after take-off
const JUMP_PX: u8 = 3 // px moved each rise frame
const GRAVITY_CAP: u8 = 4 // terminal fall speed
const ENEMY_Y: u8 = 168 // enemies walk on the grass
const COIN_Y: u8 = 144 // coins float a bit above
// World-space X positions of the moving entities. Wrap-around
// arithmetic (u8) makes the level feel like an endless loop.
const ENEMY1_WORLD_X: u8 = 100
const ENEMY2_WORLD_X: u8 = 200
const COIN1_WORLD_X: u8 = 50
const COIN2_WORLD_X: u8 = 150
// ── Game state ──────────────────────────────────────────────
// Player physics
var player_y: u8 = 160
var on_ground: u8 = 1
var rise_count: u8 = 0 // frames of upward motion remaining
var fall_vy: u8 = 0 // gravity accumulator
// World/camera
var camera_x: u8 = 0
var frame_tick: u8 = 0 // free-running frame counter
var jump_timer: u8 = 0 // autopilot: jump every N frames
var anim_tick: u8 = 0 // visual animation phase
// ── Helper functions ────────────────────────────────────────
// Try to start a jump. Only valid when the player is on the
// ground (on_ground == 1); otherwise this is a no-op.
fun try_jump() {
if on_ground == 1 {
rise_count = JUMP_RISE
on_ground = 0
fall_vy = 0
play Boing
}
}
// Advance player physics one frame. Handles the jump-rise phase
// (upward movement while rise_count > 0) and the gravity fall
// phase (increment fall_vy each frame up to GRAVITY_CAP, clamp to
// GROUND_Y on landing).
fun step_physics() {
if rise_count > 0 {
// Clamp underflow so a too-high jump doesn't wrap into the
// bottom of the screen on an 8-bit subtraction.
if player_y >= JUMP_PX {
player_y -= JUMP_PX
} else {
player_y = 0
}
rise_count -= 1
} else {
player_y += fall_vy
if fall_vy < GRAVITY_CAP {
fall_vy += 1
}
if player_y >= GROUND_Y {
player_y = GROUND_Y
on_ground = 1
fall_vy = 0
}
}
}
// Draw the 2×2 hero metasprite at (PLAYER_SCREEN_X, player_y).
// Each `draw` statement writes a fresh OAM slot via the runtime
// cursor, so four calls produce four sprites occupying a 16×16
// block.
fun draw_player() {
draw Tileset at: (PLAYER_SCREEN_X, player_y ) frame: TILE_PLAYER_HL
draw Tileset at: (PLAYER_SCREEN_X + 8, player_y ) frame: TILE_PLAYER_HR
draw Tileset at: (PLAYER_SCREEN_X, player_y + 8) frame: TILE_PLAYER_BL
draw Tileset at: (PLAYER_SCREEN_X + 8, player_y + 8) frame: TILE_PLAYER_BR
}
// ── States ──────────────────────────────────────────────────
state Title {
var blink: u8 = 0
on enter {
blink = 0
frame_tick = 0
start_music Theme
}
on frame {
frame_tick += 1
blink += 1
if blink >= 60 {
blink = 0
}
// Blink a "press start" marker at roughly 0.5 Hz. We use
// the coin tile for visual continuity with the game world.
if blink < 30 {
draw Tileset at: (120, 120) frame: TILE_COIN
}
// Auto-advance so the jsnes golden harness (which never
// presses start) still reaches Playing and captures gameplay
// at frame 180. A human player can also press Start to skip
// the wait early.
if frame_tick >= 20 {
transition Playing
}
if button.start {
transition Playing
}
}
}
state Playing {
on enter {
player_y = GROUND_Y
on_ground = 1
rise_count = 0
fall_vy = 0
camera_x = 0
frame_tick = 0
jump_timer = 0
anim_tick = 0
}
on frame {
frame_tick += 1
anim_tick += 1
// ── Input ─────────────────────────────────────────────
// Human controls — these work even while the autopilot
// is running so you can stomp the demo at any time.
if button.a {
try_jump()
}
if button.right {
camera_x += 1 // walk forward by scrolling right
}
if button.left {
// Walk back (wraps the camera — the level is cyclic).
camera_x -= 1
}
// ── Autopilot ────────────────────────────────────────
// The headless golden harness never presses buttons, so
// we advance the camera and periodically hop all by
// ourselves. A human holding right still accelerates
// forward — the two effects compose.
camera_x += 1
jump_timer += 1
if jump_timer >= 40 {
jump_timer = 0
try_jump()
}
// ── Physics ──────────────────────────────────────────
step_physics()
// ── Collisions ───────────────────────────────────────
// Coin pickup when the player's screen X is within ±8 of
// a coin's screen position and the coin's Y range overlaps
// the player. Uses u8 subtraction so "screen X" of a
// world entity is just (world_x - camera_x).
var e1_sx: u8 = ENEMY1_WORLD_X - camera_x
var e2_sx: u8 = ENEMY2_WORLD_X - camera_x
var c1_sx: u8 = COIN1_WORLD_X - camera_x
var c2_sx: u8 = COIN2_WORLD_X - camera_x
// Coin collect: player occupies [80..96). A coin is "hit"
// when its screen X is in [72..96) (8-px tolerance on each
// side of the player's left edge).
if c1_sx >= 72 and c1_sx < 96 {
if player_y <= COIN_Y + 16 {
play Ding
}
}
if c2_sx >= 72 and c2_sx < 96 {
if player_y <= COIN_Y + 16 {
play Ding
}
}
// Enemy stomp: if we're above an enemy horizontally *and*
// we're mid-fall (fall_vy > 0), count the stomp and
// bounce. Otherwise just ignore — the enemy slides past.
if e1_sx >= 72 and e1_sx < 96 {
if fall_vy > 0 {
if player_y + 16 >= ENEMY_Y {
play hit
rise_count = 6
fall_vy = 0
}
}
}
// ── Drawing ──────────────────────────────────────────
draw_player()
// Enemies — walking on the grass line. anim_tick drives
// a small vertical bob so they visibly move on the golden.
var ey: u8 = ENEMY_Y
if (anim_tick & 16) != 0 {
ey = ENEMY_Y - 2
}
draw Tileset at: (e1_sx, ey) frame: TILE_ENEMY
draw Tileset at: (e2_sx, ENEMY_Y) frame: TILE_ENEMY
// Coins — alternate two vertical positions to fake a
// spin via position change (OAM attr flip isn't wired up
// in the current runtime).
var cy: u8 = COIN_Y
if (anim_tick & 8) != 0 {
cy = COIN_Y - 1
}
draw Tileset at: (c1_sx, cy) frame: TILE_COIN
draw Tileset at: (c2_sx, COIN_Y) frame: TILE_COIN
// ── PPU scroll latch ─────────────────────────────────
// Write the scroll at the very end of the frame so it's
// the last $2005 pair before the implicit wait_frame /
// NMI handshake, minimizing mid-frame artifacts.
scroll(camera_x, 0)
}
}
start Title