mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
The previous platformer example drew enemies but had almost no interaction with them: only enemy 1 had a stomp check, the stomp window was unreachable under the default +1-px-per-frame-plus-a- jump-every-40-frames autopilot, contact from any other angle was a silent no-op, and the header comment promised a "title → playing → game-over state machine" that didn't actually exist. The README demo gif and the committed golden both froze that state — a level the player could walk through indefinitely with no consequence. Flesh the enemy interaction model out into something real: - `resolve_enemy_hit(e_sx)`: one helper, called symmetrically for both enemies. Computes the player/enemy hitbox overlap (horizontal in `e_sx ∈ (72, 96)`, vertical in `player_y ∈ (152, 176)`) and branches three ways — falling onto the head is a stomp bounce (`rise_count = 6`, `fall_vy = 0`, `stomp_count += 1`, `play Boing`); overlap while `rise_count > 0` is a grace pass-through so the stomp bounce itself can't retrigger contact on the same enemy; anything else (walking into the side, standing on the ground against the enemy) is fatal — `alive = 0` and `play hit`. - New `GameOver` state: draws four enemy tiles across the middle of the screen plus a coin row sized to `stomp_count`, stops the music, lingers 60 frames then auto-retries, and also honours Start for an instant retry. - Proximity-based autopilot: pre-jump when an enemy is exactly 19 px ahead (`e1_sx == 99` or `e2_sx == 99`), capped at two jumps per life by `auto_jumps < AUTOPILOT_JUMPS`. Tuning: a JUMP_RISE=12, GRAVITY_CAP=4 jump lands the player's feet at enemy-head height exactly 21 frames after lift-off, by which point the autopilot camera has scrolled the enemy under the player. The first jump fires on Playing frame 1 and stomps enemy 1 on frame 22; the second fires on Playing frame 101 and stomps enemy 2 on frame 122. After that the autopilot is exhausted and the third enemy encounter (camera wraps back past enemy 1) is fatal — the golden harness now sees the full stomp, stomp, die, retry, stomp loop instead of a frozen walk. - Live HUD: up to four coin sprites in the top-left, one per stomp, rendered both during `Playing` and on the `GameOver` screen so the score is visible in the death frame. `Playing`'s player draw is now guarded by `if alive == 1` so the hero disappears on the fatal-contact frame and the enemy that killed them is visible underneath. Verified with a per-frame ZP trace through the patched puppeteer + jsnes harness: first stomp at emu frame 44 (camera_x=22), second at emu frame 144 (camera_x=122), death at emu frame 283 (camera_x=5 after a 256-px wrap), `Playing` restart at emu frame 343, third stomp at emu frame 365. All 22 emulator goldens still match after the update, and `docs/platformer.gif` regenerated from the new ROM now shows two clean stomps, a clean side-collision death, the GameOver screen, and the retry cycle all inside the 6-second demo window. Golden updates: - `tests/emulator/goldens/platformer.png` — the frame-180 capture now shows the hero walking forward with a two-coin HUD after both autopilot stomps (previously: a frozen bouncing hero). - `tests/emulator/goldens/platformer.audio.hash` — the track now includes two `Boing` stomp bounces, which shifts the hash. - `examples/platformer.nes` — rebuilt from the rewritten source. Also updates the platformer rows in `README.md` and `examples/README.md` to match the new gameplay. https://claude.ai/code/session_013Bi4H4YQ5or5HtMB4doUFi
710 lines
24 KiB
Text
710 lines
24 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
|
||
// coin HUD that tracks live score, 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: [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
|
||
|
||
// 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 ──────────────────────────────────────────────
|
||
|
||
// 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 anim_tick: u8 = 0 // visual animation phase
|
||
|
||
// Gameplay
|
||
var alive: u8 = 1 // 0 = dying/dead, 1 = playable
|
||
var stomp_count: u8 = 0 // successful enemy stomps this life
|
||
var auto_jumps: u8 = 0 // proximity pre-jumps used this life
|
||
|
||
// ── 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
|
||
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
|
||
anim_tick = 0
|
||
alive = 1
|
||
stomp_count = 0
|
||
auto_jumps = 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 ──────────────────────────────────────────
|
||
// 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
|
||
|
||
// Live HUD: draw one coin per stomp in the top-left. The
|
||
// background palette-1 region covers this strip so the
|
||
// sprite coins read correctly against the brick band.
|
||
if stomp_count >= 1 {
|
||
draw Tileset at: (16, 24) frame: TILE_COIN
|
||
}
|
||
if stomp_count >= 2 {
|
||
draw Tileset at: (28, 24) frame: TILE_COIN
|
||
}
|
||
if stomp_count >= 3 {
|
||
draw Tileset at: (40, 24) frame: TILE_COIN
|
||
}
|
||
if stomp_count >= 4 {
|
||
draw Tileset at: (52, 24) 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()
|
||
}
|
||
|
||
// ── 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)
|
||
|
||
// 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
|
||
stop_music
|
||
}
|
||
|
||
on frame {
|
||
linger += 1
|
||
|
||
// "GAME OVER" marker: a row of enemy tiles across the
|
||
// middle of the screen, plus a live readout of how many
|
||
// stomps the player racked up before dying (drawn as
|
||
// coins right underneath). 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
|
||
|
||
if stomp_count >= 1 {
|
||
draw Tileset at: (96, 128) frame: TILE_COIN
|
||
}
|
||
if stomp_count >= 2 {
|
||
draw Tileset at: (112, 128) frame: TILE_COIN
|
||
}
|
||
if stomp_count >= 3 {
|
||
draw Tileset at: (128, 128) frame: TILE_COIN
|
||
}
|
||
if stomp_count >= 4 {
|
||
draw Tileset at: (144, 128) frame: TILE_COIN
|
||
}
|
||
|
||
// Auto-retry after ~1 s so the headless harness sees the
|
||
// full loop. A human can hit Start to retry immediately.
|
||
if linger >= 60 {
|
||
transition Playing
|
||
}
|
||
if button.start {
|
||
transition Playing
|
||
}
|
||
}
|
||
}
|
||
|
||
start Title
|