mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
The status bar now paints into NT row 1 (coin + score digits on the left, heart + lives digit on the right) using the `bg3` sub-palette that matches `sp0` pixel-for-pixel. A single OAM slot-0 anchor sprite sits over the coin tile; its one opaque pixel lines up with the coin's bottom row so sprite-0 hit fires at scanline 15, and a trailing `sprite_0_split(camera_x, 0)` latches the playfield scroll starting at scanline 16. NT rows 0-1 stay pinned while scanlines 16+ scroll with the camera. Score / lives updates are shadow-compared (`last_score`, `last_lives`) so the VRAM ring sees an entry only when the backing state actually changes — most frames append zero bytes. OAM footprint drops from 5 sprites per frame down to 1. Tile pipeline gains a 27th entry — a 7-transparent-row + 1-pixel anchor — so the sprite-0 hit lands on scanline 15 instead of scanline 8 (the latter would smear the HUD glyphs across the split). `gen_platformer_tiles.rs` is updated in lockstep. Ancillary changes: `bg3` retuned from `[yellow, orange, dk_orange]` to `[red, orange, white]` (matching `sp0`); `palette_map` row 0 flips from bg0 to bg3; legend gains `o`, `h`, `0`, `3` so the initial map can preload the static HUD tiles and the committed nametable already reads "coin 00 ... heart 3" on frame 0. `docs/future-work.md` loses the sprite-0 HUD follow-up section (this commit lands it). Goldens + gif refreshed.
941 lines
34 KiB
Text
941 lines
34 KiB
Text
// 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 with stomp-or-die contact logic, a
|
||
// sprite-based status bar at the top of the viewport (coin icon +
|
||
// two-digit score on the left, heart icon + lives counter on the
|
||
// right) that rides along through the death screen, cross-state
|
||
// life tracking that cycles GameOver → Playing while hearts last
|
||
// and bounces back to Title when the last heart is spent,
|
||
// collectible coins, user-declared SFX envelopes, 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 the full gameplay loop inside the first ~6
|
||
// seconds of demo.
|
||
//
|
||
// 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: [red, orange, white] // HUD chrome (matches sp0)
|
||
|
||
// 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 16: Digit 0
|
||
"........",
|
||
"..cccc..",
|
||
"..c..c..",
|
||
"..c..c..",
|
||
"..c..c..",
|
||
"..c..c..",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 17: Digit 1
|
||
"........",
|
||
"...cc...",
|
||
"..ccc...",
|
||
"...cc...",
|
||
"...cc...",
|
||
"...cc...",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 18: Digit 2
|
||
"........",
|
||
"..cccc..",
|
||
".....c..",
|
||
"....cc..",
|
||
"...cc...",
|
||
"..cc....",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 19: Digit 3
|
||
"........",
|
||
"..cccc..",
|
||
".....c..",
|
||
"...ccc..",
|
||
".....c..",
|
||
".....c..",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 20: Digit 4
|
||
"........",
|
||
"..c..c..",
|
||
"..c..c..",
|
||
"..cccc..",
|
||
".....c..",
|
||
".....c..",
|
||
".....c..",
|
||
"........",
|
||
// tile 21: Digit 5
|
||
"........",
|
||
"..cccc..",
|
||
"..c.....",
|
||
"..cccc..",
|
||
".....c..",
|
||
"..c..c..",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 22: Digit 6
|
||
"........",
|
||
"..cccc..",
|
||
"..c.....",
|
||
"..cccc..",
|
||
"..c..c..",
|
||
"..c..c..",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 23: Digit 7
|
||
"........",
|
||
"..cccc..",
|
||
".....c..",
|
||
"....c...",
|
||
"...c....",
|
||
"..c.....",
|
||
"..c.....",
|
||
"........",
|
||
// tile 24: Digit 8
|
||
"........",
|
||
"..cccc..",
|
||
"..c..c..",
|
||
"..cccc..",
|
||
"..c..c..",
|
||
"..c..c..",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 25: Digit 9
|
||
"........",
|
||
"..cccc..",
|
||
"..c..c..",
|
||
"..cccc..",
|
||
".....c..",
|
||
".....c..",
|
||
"..cccc..",
|
||
"........",
|
||
// tile 26: Heart
|
||
"........",
|
||
".aa..aa.",
|
||
"aaaaaaaa",
|
||
"aaaaaaaa",
|
||
".aaaaaa.",
|
||
"..aaaa..",
|
||
"...aa...",
|
||
"........",
|
||
// tile 27: Sprite 0 anchor (single opaque pixel at row 7,
|
||
// col 3 — the sprite-0 hit fires on the last
|
||
// HUD scanline so the scroll split lands
|
||
// cleanly on the playfield below)
|
||
"........",
|
||
"........",
|
||
"........",
|
||
"........",
|
||
"........",
|
||
"........",
|
||
"........",
|
||
"...c...."
|
||
]
|
||
}
|
||
|
||
// 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
|
||
// HUD glyphs. The digit tiles are contiguous so
|
||
// `TILE_DIGIT_0 + n` picks digit `n`. Digit + heart tiles are
|
||
// shared between sprite rendering (v0.1 sprites) and background
|
||
// rendering (the HUD row's `nt_set`s) via the matching `bg3`
|
||
// sub-palette.
|
||
const TILE_DIGIT_0: u8 = 16
|
||
const TILE_HEART: u8 = 26
|
||
// OAM slot-0 anchor — a 7-scanline-tall transparent sprite with
|
||
// one opaque pixel at row 7. Aligns with the coin tile's bottom
|
||
// row so sprite-0 hit fires at scanline 15, keeping the scroll
|
||
// split exactly at the HUD-row boundary.
|
||
const TILE_SPRITE0_ANCHOR: u8 = 27
|
||
|
||
// ── 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
|
||
// HUD chrome glyphs — row 0 only. Mapped to the sprite
|
||
// CHR tiles we already declared so sprites and background
|
||
// render identical glyphs under the matching bg3 palette.
|
||
"o": 6 // coin icon (HUD score marker)
|
||
"h": 26 // heart icon (HUD lives marker)
|
||
"0": 16 // digit 0 (initial score tens/ones + fallback)
|
||
"3": 19 // digit 3 (initial lives)
|
||
}
|
||
|
||
// Row 1 carries the status bar: coin + two score digits on the
|
||
// left, heart + single lives digit on the right. Row 0 stays
|
||
// sky because the jsnes golden harness (and many emulators)
|
||
// mask out the top 8 scanlines as overscan — anything painted
|
||
// there would never show in the committed PNG. Sprite 0
|
||
// (painted over column 2's coin tile every frame) overlaps the
|
||
// `o` glyph's opaque pixels so the sprite-0 hit flag fires on
|
||
// a scanline inside the HUD row; `sprite_0_split(camera_x, 0)`
|
||
// in `on frame` picks up that hit and swaps the horizontal
|
||
// scroll from 0 to `camera_x` for the rest of the frame,
|
||
// pinning the HUD while the playfield scrolls underneath. The
|
||
// digits and heart glyph sit at fixed columns so shadow-compare
|
||
// `nt_set` writes can tick the score / lives readouts without
|
||
// repainting the whole row.
|
||
map: [
|
||
"................................",
|
||
"..o.00..................h.3.....",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"....<>..............<>..........",
|
||
"............<>..................",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"......##Q#........###...........",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"................................",
|
||
"..^^^.................^^^^......",
|
||
"..........***..............**...",
|
||
"================================",
|
||
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%#%",
|
||
"%%%%%#%%%%%%%%%%%%#%%%%%%%%%%%%%",
|
||
"%%%%%%%%%%%#%%%%%%%%%%%%#%%%%%%%",
|
||
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
|
||
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
|
||
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%",
|
||
"%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%"
|
||
]
|
||
|
||
palette_map: [
|
||
"3333333333333333", // metatile row 0 (NT rows 0-1)
|
||
// → bg3 (HUD chrome: red / orange / white)
|
||
"0000000000000000", // metatile rows 1-5 → sky sub-palette
|
||
"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
|
||
|
||
// How many auto-jumps the autopilot will pre-queue per life. Two
|
||
// is exactly enough to stomp enemy 1 and enemy 2 once each; after
|
||
// that the autopilot stops assisting so the headless harness gets
|
||
// to see the player walk into the next enemy, die, and retry.
|
||
const AUTOPILOT_JUMPS: u8 = 2
|
||
|
||
// ── Game state ──────────────────────────────────────────────
|
||
|
||
// `frame_tick` is shared: Title reads it to auto-advance, Playing
|
||
// reads it for animation phasing. `stomp_count` bridges
|
||
// Playing → GameOver so the death screen can tally coins. The
|
||
// rest — player physics, camera, liveness, autopilot budget —
|
||
// are only meaningful while Playing is running, so they live on
|
||
// Playing's state block and overlay with the Title / GameOver
|
||
// locals (`blink`, `linger`) at the same bytes.
|
||
|
||
// Cross-state scratch
|
||
var frame_tick: u8 = 0 // free-running frame counter
|
||
var stomp_count: u8 = 0 // successful enemy stomps this life
|
||
var lives: u8 = 3 // lives remaining; HUD heart readout
|
||
|
||
// HUD shadow-compare cache. The initial nametable already paints
|
||
// "coin 00" / "heart 3" into row 0 with the bg3 palette, so the
|
||
// shadow values here start matching the on-screen glyphs — every
|
||
// frame where `stomp_count == last_score` skips the score repaint
|
||
// entirely, and likewise for lives. 255 would also force a
|
||
// guaranteed-initial-paint fallback on the first frame if the map
|
||
// ever drifts from these defaults; we use the real starting values
|
||
// so the harness's frame-180 golden doesn't capture a transient
|
||
// paint flicker.
|
||
var last_score: u8 = 0
|
||
var last_lives: u8 = 3
|
||
|
||
// ── HUD helpers ─────────────────────────────────────────────
|
||
// Background-row HUD, pinned to the top of the viewport with a
|
||
// sprite-0 hit split instead of burning five OAM slots every
|
||
// frame. Two moving pieces:
|
||
//
|
||
// 1. The status glyphs — coin, two score digits, heart, lives
|
||
// digit — live in NT row 0 and are repainted via `nt_set`
|
||
// only when the backing state changes (`stomp_count`,
|
||
// `lives`). The initial map already contains the starting
|
||
// values so the first frame is already correct.
|
||
//
|
||
// 2. A single OAM sprite (slot 0) is drawn over the coin tile
|
||
// every frame. The PPU sets the sprite-0 hit flag when the
|
||
// sprite's opaque pixels overlap the coin tile's opaque
|
||
// pixels — guaranteed to fire on some scanline inside the
|
||
// HUD row — and a `sprite_0_split(camera_x, 0)` call picks
|
||
// up that hit to switch the horizontal scroll from 0 to
|
||
// `camera_x` for the rest of the frame. Row 0 stays pinned,
|
||
// everything below it scrolls with the camera.
|
||
fun draw_hud() {
|
||
// Sprite 0 — must be the FIRST draw of the frame so it lands
|
||
// in OAM slot 0. The anchor tile has one opaque pixel at row
|
||
// 7, col 3 (a single dot), which when positioned at (16, 7)
|
||
// in OAM renders at screen (19, 15) and overlaps column 3 of
|
||
// the coin tile's bottom row in NT row 1. That last-scanline
|
||
// overlap makes the sprite-0 hit flag set at scanline 15, so
|
||
// the `sprite_0_split(camera_x, 0)` call below can write
|
||
// `$2005` before the next HBLANK and the PPU latches the new
|
||
// horizontal scroll starting at scanline 16 — exactly the
|
||
// boundary between the HUD row and the playfield. The rest
|
||
// of the anchor tile is fully transparent, so the sprite is
|
||
// functionally invisible on top of the coin.
|
||
draw Tileset at: (16, 7) frame: TILE_SPRITE0_ANCHOR
|
||
|
||
// Score tens / ones — shadow-compare so the VRAM ring only
|
||
// gets an entry when `stomp_count` actually moved. The
|
||
// comparison runs once per frame and most frames take the
|
||
// no-op path (score only ticks on a stomp).
|
||
if stomp_count != last_score {
|
||
last_score = stomp_count
|
||
nt_set(4, 1, TILE_DIGIT_0 + (stomp_count / 10))
|
||
nt_set(5, 1, TILE_DIGIT_0 + (stomp_count % 10))
|
||
}
|
||
|
||
// Lives digit — same shadow-compare. Only fires on a life
|
||
// transition (death in `GameOver.on_enter`, refill in
|
||
// `Title.on_enter`).
|
||
if lives != last_lives {
|
||
last_lives = lives
|
||
nt_set(26, 1, TILE_DIGIT_0 + lives)
|
||
}
|
||
}
|
||
|
||
// ── 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
|
||
}
|
||
|
||
// Resolve contact with an enemy whose screen X is `e_sx`. Mutates
|
||
// the player's physics / alive flag directly and returns 1 on a
|
||
// successful stomp, 0 on no contact or a fatal hit. The caller
|
||
// checks `alive` after invocation to decide whether to keep
|
||
// running the frame.
|
||
//
|
||
// The player's 16×16 hitbox is at ([80, 96), [player_y, player_y+16)).
|
||
// Each enemy's 8×8 hitbox is at ([e_sx, e_sx+8), [168, 176)). Those
|
||
// overlap horizontally when e_sx ∈ (72, 96) and overlap vertically
|
||
// when player_y ∈ (152, 176).
|
||
//
|
||
// Outcomes:
|
||
// - No overlap → no-op
|
||
// - Overlap while rising (rise_count > 0) → graceful
|
||
// pass-through. rise_count > 0 only happens right after a
|
||
// successful stomp bounce (try_jump() clears rise_count by
|
||
// the end of the same frame) or on the stomp's own upward
|
||
// leg, which we don't want to retrigger.
|
||
// - Overlap while falling, feet near enemy head → STOMP: bounce
|
||
// up, increment `stomp_count`, play Boing
|
||
// - Any other overlap (walking, on ground) → DEATH: set
|
||
// `alive = 0` and play the builtin `hit` sfx
|
||
fun resolve_enemy_hit(e_sx: u8) -> u8 {
|
||
if e_sx > 72 {
|
||
if e_sx < 96 {
|
||
if player_y > 152 {
|
||
if player_y < 176 {
|
||
// Real contact. Stomp if falling; otherwise, if
|
||
// we're not in the post-bounce grace window,
|
||
// die.
|
||
if fall_vy > 0 {
|
||
rise_count = 6
|
||
fall_vy = 0
|
||
stomp_count += 1
|
||
play Boing
|
||
return 1
|
||
}
|
||
if rise_count == 0 {
|
||
alive = 0
|
||
play hit
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// ── States ──────────────────────────────────────────────────
|
||
|
||
state Title {
|
||
var blink: u8 = 0
|
||
|
||
on enter {
|
||
blink = 0
|
||
frame_tick = 0
|
||
// Fresh run — restore the full life bar so the HUD reads 3
|
||
// when Playing kicks in and the GameOver loop doesn't trap
|
||
// the game in a zero-lives state forever.
|
||
lives = 3
|
||
stomp_count = 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 {
|
||
// Physics, camera, liveness, and autopilot budget — all of
|
||
// this is Playing-only. Declaring them inside the state block
|
||
// lets the analyzer overlay them with Title.blink and
|
||
// GameOver.linger; each variable's initializer re-runs on
|
||
// entry, so the retry loop starts each life on the ground
|
||
// with a fresh autopilot budget without any manual reset.
|
||
var player_y: u8 = GROUND_Y
|
||
var on_ground: u8 = 1
|
||
var rise_count: u8 = 0
|
||
var fall_vy: u8 = 0
|
||
var camera_x: u8 = 0
|
||
var anim_tick: u8 = 0
|
||
var alive: u8 = 1
|
||
var auto_jumps: u8 = 0
|
||
|
||
on enter {
|
||
frame_tick = 0
|
||
stomp_count = 0
|
||
start_music Theme
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Always advance camera one px per frame (baseline scroll).
|
||
camera_x += 1
|
||
|
||
// ── Compute screen positions of world entities ──────
|
||
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
|
||
|
||
// ── Proximity autopilot ─────────────────────────────
|
||
// Pre-jump when an enemy is 19 px ahead. The fall phase of
|
||
// a JUMP_RISE=12, GRAVITY_CAP=4 jump lands the player's
|
||
// feet at enemy head height ~20 frames later, by which
|
||
// point the autopilot camera has scrolled the enemy under
|
||
// the player. Limited to AUTOPILOT_JUMPS hops per life so
|
||
// the third enemy encounter becomes a scripted death,
|
||
// giving the golden harness a visible game-over sequence.
|
||
if auto_jumps < AUTOPILOT_JUMPS {
|
||
if on_ground == 1 {
|
||
if e1_sx == 99 {
|
||
try_jump()
|
||
auto_jumps += 1
|
||
}
|
||
if e2_sx == 99 {
|
||
try_jump()
|
||
auto_jumps += 1
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Physics ──────────────────────────────────────────
|
||
step_physics()
|
||
|
||
// ── Coin pickups ─────────────────────────────────────
|
||
// 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 collisions ────────────────────────────────
|
||
// Both enemies get the full stomp-or-die check; the helper
|
||
// mutates player state directly. If `alive` flips to 0
|
||
// we fall through the rest of the frame (drawing is
|
||
// guarded below) and transition to GameOver at the end.
|
||
resolve_enemy_hit(e1_sx)
|
||
resolve_enemy_hit(e2_sx)
|
||
|
||
// ── Drawing ──────────────────────────────────────────
|
||
// `draw_hud()` runs FIRST so its coin sprite lands in OAM
|
||
// slot 0 — that's what makes the PPU's sprite-0 hit flag
|
||
// fire on the coin-tile overlap inside the HUD row, which
|
||
// we pick up with `sprite_0_split` at the end of the
|
||
// frame. Every other `draw` has to come after.
|
||
draw_hud()
|
||
|
||
// Enemies and coins are always drawn so the scene stays
|
||
// coherent even on the fatal-contact frame.
|
||
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
|
||
|
||
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
|
||
|
||
// Player is only drawn while alive — on the dying frame
|
||
// we show the scene without the hero on top of the enemy
|
||
// that killed them, which reads as "player got got".
|
||
if alive == 1 {
|
||
draw_player()
|
||
}
|
||
|
||
// ── Sprite-0 scroll split ────────────────────────────
|
||
// The VRAM-drain in NMI resets `$2005` to `(0, 0)` for
|
||
// the top of each frame; `sprite_0_split` busy-waits for
|
||
// the sprite-0 hit flag (fired mid-HUD-row where OAM
|
||
// slot 0's coin sprite overlaps the NT-row-1 coin tile)
|
||
// and then latches `(camera_x, 0)` for the remainder of
|
||
// the frame. Scanlines above the hit render the HUD
|
||
// pinned; scanlines below render the playfield scrolled.
|
||
sprite_0_split(camera_x, 0)
|
||
|
||
// Fatal contact this frame → kick the state machine over
|
||
// to GameOver on the next frame.
|
||
if alive == 0 {
|
||
transition GameOver
|
||
}
|
||
}
|
||
}
|
||
|
||
state GameOver {
|
||
var linger: u8 = 0
|
||
|
||
on enter {
|
||
linger = 0
|
||
// Burn a life on entry so the HUD's heart counter ticks
|
||
// down visibly each death. Guard against underflow in case
|
||
// something transitions here with lives already at zero.
|
||
if lives > 0 {
|
||
lives -= 1
|
||
}
|
||
stop_music
|
||
}
|
||
|
||
on frame {
|
||
linger += 1
|
||
|
||
// Sync the HUD row first (sprite 0 + shadow-compare
|
||
// `nt_set` for the digits) so the tens/ones glyphs tick
|
||
// down when `lives` decrements in `on_enter`. No
|
||
// `sprite_0_split` call here — GameOver doesn't scroll,
|
||
// so NMI's scroll-reset keeps the whole frame at
|
||
// `(0, 0)` and the HUD row renders normally. The sprite-
|
||
// 0 hit flag fires harmlessly and stays latched until the
|
||
// next Playing frame clears it.
|
||
draw_hud()
|
||
|
||
// "GAME OVER" marker: a row of enemy tiles across the
|
||
// middle of the screen. The death-screen backdrop is
|
||
// whatever Playing last scrolled to — we don't clear it.
|
||
draw Tileset at: (96, 112) frame: TILE_ENEMY
|
||
draw Tileset at: (112, 112) frame: TILE_ENEMY
|
||
draw Tileset at: (128, 112) frame: TILE_ENEMY
|
||
draw Tileset at: (144, 112) frame: TILE_ENEMY
|
||
|
||
// Auto-retry after ~1 s so the headless harness sees the
|
||
// full loop. When the last life is spent, bounce back to
|
||
// the Title screen instead (Title's `on enter` refills the
|
||
// heart bar for the next run). A human can hit Start to
|
||
// skip the wait.
|
||
if linger >= 60 {
|
||
if lives == 0 {
|
||
transition Title
|
||
} else {
|
||
transition Playing
|
||
}
|
||
}
|
||
if button.start {
|
||
if lives == 0 {
|
||
transition Title
|
||
} else {
|
||
transition Playing
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
start Title
|