mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
platformer: end-to-end side-scroller demo + three runtime bug fixes
Adds `examples/platformer.ne`, a full side-scrolling game that
exercises nearly every subsystem of the compiler in one program:
custom CHR tileset, 32×30 background nametable with per-region
attribute palettes, 2×2 metasprite hero with gravity/jump physics,
wrap-around horizontal scrolling, moving enemies, coin pickups,
user-declared SFX + music, and a Title → Playing state machine
with autopilot so the headless jsnes harness captures real
gameplay at frame 180. Tile art + nametable are generated by
`scripts/gen_platformer_tiles.rs` (`cargo run --bin gen_platformer_tiles`).
Building this out uncovered three independent runtime bugs that
together made the example render as black-on-black smileys. All
three are fixed in this commit:
1. **`gen_init` enabled sprite rendering before the linker's
initial palette/background load runs.** The PPU's v-register
auto-increments on every `$2007` write *during active
rendering*, so the palette load (32 B) and nametable load
(1024 B) were scrambled past the first ~72 bytes — every
existing program with a `background Level { ... }` block was
silently rendering zero-filled VRAM. Fix: leave `PPU_MASK = 0`
at the end of `gen_init` and emit a new `gen_enable_rendering`
call *after* all initial VRAM writes complete.
2. **Audio tick corrupted `ZP_CURRENT_STATE`.** The audio
driver's period-table lookup reused `$02/$03` as a temporary
indirect pointer with a comment claiming the slots were free
because the tick doesn't call mul/div. But `$03` is also
`ZP_CURRENT_STATE` used by the state dispatch loop, so every
music note silently overwrote the state index with the high
byte of `__period_table` (`0xC5` in the platformer ROM),
wedging the state machine forever. Fix: `gen_nmi` now PHAs
`$02/$03` on entry and PLA-restores them on exit, and the
audio tick JSR moves inside that save/restore window (it used
to be spliced by the linker *before* the register saves, so
even A/X/Y were technically being trashed pre-save). Only
`audio_demo`'s audio hash shifts (its note timings move a few
cycles); every other golden is unchanged.
3. **Sub-palette mirroring footgun.** Writing a 32-byte palette
blob sequentially causes the sprite sub-palettes' "index 0"
slots at `$3F10/$3F14/$3F18/$3F1C` to clobber the background
universal colour at `$3F00/$3F04/$3F08/$3F0C` via NES hardware
mirroring. The example's palette sets all eight first bytes
to `$22` (sky blue) for this reason; `docs/future-work.md`
picks up a TODO to warn on inconsistent first-byte values in
the analyzer.
Also:
- `docs/platformer.gif` — 6-second recording of the example
running in jsnes, generated by the new
`tests/emulator/record_gif.mjs` puppeteer helper (encodes via
`gifenc`, committed as a dev-dependency under
`tests/emulator/package.json`).
- README / examples/README tables and the 497-test count are
updated to cover the new example.
https://claude.ai/code/session_01BcCcHi6FUmTh8jC7UgkA3A
This commit is contained in:
parent
958f41d340
commit
688d9afcec
16 changed files with 1171 additions and 25 deletions
505
examples/platformer.ne
Normal file
505
examples/platformer.ne
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
// 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 ──────────────────────────────────────────────────
|
||||
// The background palette drives the sky / hill / ground look.
|
||||
// Each sub-palette reserves index 0 for the shared background
|
||||
// colour (sky blue $22), plus three working colours.
|
||||
// bg 0 (sky region): sky blue, white, light gray, black
|
||||
// bg 1 (air/blocks row): sky blue, red, peach, dark red
|
||||
// bg 2 (ground row): sky blue, lime green, dark green, dark brown
|
||||
// bg 3 (unused reserve): sky blue, yellow, orange, gold
|
||||
// The sprite sub-palette 0 (used by every `draw` statement — the
|
||||
// OAM attribute byte is always 0 in the current NEScript
|
||||
// runtime) carries the hero's red/peach/white body colours.
|
||||
// NOTE: the first byte of every sub-palette must match. The PPU
|
||||
// mirrors $3F10/$3F14/$3F18/$3F1C onto $3F00/$3F04/$3F08/$3F0C, so
|
||||
// writing 8 distinct "index 0" bytes ends up clobbering the
|
||||
// background universal colour with the last write. The canonical
|
||||
// fix — and what every NES game does — is to use the universal
|
||||
// background colour ($22 sky blue here) in all 8 slots. For sprite
|
||||
// palettes, index 0 is always transparent regardless of the stored
|
||||
// value, so this costs nothing visually.
|
||||
palette Main {
|
||||
colors: [
|
||||
// bg palette 0 — sky / clouds
|
||||
// 0 = sky blue (universal bg)
|
||||
// 1 = white
|
||||
// 2 = light gray
|
||||
// 3 = black
|
||||
0x22, 0x30, 0x10, 0x0F,
|
||||
// bg palette 1 — bricks, Q-blocks
|
||||
// 1 = red, 2 = peach, 3 = dark red
|
||||
0x22, 0x16, 0x27, 0x06,
|
||||
// bg palette 2 — grass, hills, bushes, dirt
|
||||
// 1 = light green
|
||||
// 2 = light brown
|
||||
// 3 = dark brown
|
||||
0x22, 0x1A, 0x17, 0x07,
|
||||
// bg palette 3 — unused but the mirroring still writes here
|
||||
0x22, 0x28, 0x27, 0x17,
|
||||
// sprite palette 0 — hero / enemy / coin
|
||||
// (OAM attribute is always 0 in the current runtime so
|
||||
// every sprite uses this one sub-palette)
|
||||
// 1 = red cap, 2 = peach skin, 3 = white
|
||||
0x22, 0x16, 0x27, 0x30,
|
||||
// sprite palette 1 — reserved
|
||||
0x22, 0x07, 0x17, 0x27,
|
||||
// sprite palette 2 — reserved
|
||||
0x22, 0x2A, 0x1A, 0x30,
|
||||
// sprite palette 3 — reserved
|
||||
0x22, 0x28, 0x18, 0x38
|
||||
]
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
sprite Tileset {
|
||||
chr: [
|
||||
// tile 1: Player head L
|
||||
0x3F, 0x7F, 0x70, 0x61, 0xCF, 0xCD, 0xC0, 0xC0, 0x00, 0x00, 0x0F, 0x1F, 0x3F, 0x3F, 0x3F, 0x3F,
|
||||
// tile 2: Player head R
|
||||
0xFC, 0xFE, 0x0E, 0x83, 0xF8, 0x4C, 0x00, 0x00, 0x00, 0x00, 0xF0, 0xFC, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
// tile 3: Player body L
|
||||
0x7F, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0xE0, 0x00, 0x10, 0x18, 0x00, 0x7F, 0x7F, 0x67, 0x07,
|
||||
// tile 4: Player body R
|
||||
0xFE, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x07, 0x00, 0x08, 0x18, 0x00, 0xFE, 0xFE, 0xE6, 0xE0,
|
||||
// tile 5: Enemy
|
||||
0x3C, 0x7E, 0x76, 0xFF, 0x99, 0x7E, 0xBD, 0x99, 0x00, 0x00, 0x18, 0x38, 0x7E, 0x00, 0x00, 0x00,
|
||||
// tile 6: Coin
|
||||
0x00, 0x00, 0x18, 0x24, 0x24, 0x18, 0x00, 0x00, 0x18, 0x3C, 0x7E, 0x7E, 0x7E, 0x7E, 0x3C, 0x18,
|
||||
// tile 7: Grass top
|
||||
0xFF, 0xBB, 0xFF, 0xFF, 0xFF, 0xFF, 0xBD, 0xFF, 0x00, 0x00, 0x00, 0x6A, 0xF7, 0xFF, 0xFF, 0xFF,
|
||||
// tile 8: Dirt
|
||||
0xFF, 0xBD, 0xFF, 0xF7, 0xFF, 0xBD, 0xF7, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
|
||||
// tile 9: Brick
|
||||
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x82, 0x82, 0x82, 0xFF, 0x10, 0x10, 0x10,
|
||||
// tile 10: Cloud L
|
||||
0x00, 0x1C, 0x3E, 0x7F, 0x7F, 0x1F, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x60, 0x3E, 0x07,
|
||||
// tile 11: Cloud R
|
||||
0x00, 0x70, 0xF8, 0xFE, 0xFE, 0xF8, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x7C, 0xE0,
|
||||
// tile 12: Hill
|
||||
0x00, 0x0C, 0x1E, 0x3F, 0x2F, 0x4B, 0x75, 0xC9, 0x00, 0x00, 0x00, 0x00, 0x10, 0x34, 0x0A, 0x36,
|
||||
// tile 13: Bush
|
||||
0x00, 0x18, 0x3C, 0x7E, 0xFF, 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x02, 0x02, 0x06, 0x06, 0x55,
|
||||
// tile 14: Q Block
|
||||
0xFF, 0xFF, 0xC3, 0xDB, 0xD3, 0xC3, 0xFF, 0xFF, 0xFF, 0x81, 0xBD, 0xBD, 0xBD, 0xBD, 0x81, 0xFF,
|
||||
// tile 15: Sky (blank)
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
|
||||
]
|
||||
}
|
||||
|
||||
// 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 ──────────────────────────────────────────────
|
||||
// Full-screen 32×30 nametable showing a static level view that
|
||||
// horizontal scrolling pans across. The attribute table splits
|
||||
// the screen into three palette regions (sky/blocks/ground) so
|
||||
// the grass and hills pick up the right colours even without
|
||||
// per-tile attribute churn.
|
||||
background Level {
|
||||
tiles: [
|
||||
// rows 0-4: clean sky
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
// row 5: clouds at x=4..5 and x=20..21
|
||||
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 10, 11, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
// row 6: cloud at x=12..13
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 10, 11, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
// rows 7-14: more clean sky
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
// row 15: brick/Q-block platform left, brick platform right
|
||||
15, 15, 15, 15, 15, 15, 9, 9, 14, 9, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 9, 9, 9, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
// rows 16-19: sky
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
// row 20: hills (3 on left, 4 on right)
|
||||
15, 15, 12, 12, 12, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 12, 12, 12, 12, 15, 15, 15, 15, 15, 15,
|
||||
// row 21: bushes above the grass line
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 13, 15, 15, 15,
|
||||
15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 13, 13, 15, 15, 15,
|
||||
// row 22: grass top (full width)
|
||||
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
|
||||
7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
|
||||
// rows 23-29: dirt with a few buried bricks for texture
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8,
|
||||
8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 9, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 9, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
|
||||
8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8
|
||||
]
|
||||
attributes: [
|
||||
// 8×8 attribute grid, one byte per 32×32-px metatile group.
|
||||
// Each byte packs 4 identical 2-bit quadrants selecting a
|
||||
// sub-palette (0..3):
|
||||
// 0x00 = sub-palette 0 (sky / clouds)
|
||||
// 0x55 = sub-palette 1 (bricks / Q-blocks)
|
||||
// 0xAA = sub-palette 2 (grass / hills / dirt)
|
||||
//
|
||||
// Row mapping: ay 0-2 (nt rows 0-11) = sky, ay 3 (nt rows
|
||||
// 12-15) = brick row, ay 4 (nt rows 16-19) = more sky, ay
|
||||
// 5-7 (nt rows 20-29) = ground.
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55, 0x55,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
|
||||
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA,
|
||||
0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA
|
||||
]
|
||||
}
|
||||
|
||||
// ── Audio ────────────────────────────────────────────────────
|
||||
|
||||
// Custom boingy jump sound — descending pitch arc on pulse 1.
|
||||
sfx Boing {
|
||||
duty: 2
|
||||
pitch: [0x30, 0x38, 0x40, 0x48, 0x50, 0x58, 0x60]
|
||||
volume: [12, 11, 10, 9, 7, 5, 2]
|
||||
}
|
||||
|
||||
// Custom coin ding — short rising blip.
|
||||
sfx Ding {
|
||||
duty: 2
|
||||
pitch: [0x20, 0x20, 0x20, 0x20]
|
||||
volume: [14, 12, 8, 3]
|
||||
}
|
||||
|
||||
// Custom looping underworld theme — playful four-bar loop on
|
||||
// pulse 2 that plays while the player is alive.
|
||||
music Theme {
|
||||
duty: 2
|
||||
volume: 9
|
||||
repeat: true
|
||||
notes: [
|
||||
37, 10, // C4
|
||||
41, 10, // E4
|
||||
44, 10, // G4
|
||||
49, 10, // C5
|
||||
44, 10, // G4
|
||||
41, 10, // E4
|
||||
39, 15, // D4
|
||||
0, 5, // rest
|
||||
36, 10, // B3
|
||||
41, 10, // E4
|
||||
44, 10, // G4
|
||||
49, 20, // C5
|
||||
0, 10 // rest
|
||||
]
|
||||
}
|
||||
|
||||
// ── 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue