1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45:38 +00:00

feat(platformer): add stomp-or-die enemy collisions, live HUD, GameOver state

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
This commit is contained in:
Claude 2026-04-13 20:19:28 +00:00
parent 54910f2498
commit 169a481099
No known key found for this signature in database
7 changed files with 183 additions and 48 deletions

View file

@ -86,7 +86,7 @@ start Main
| [`structs_enums_for.ne`](examples/structs_enums_for.ne) | Structs, enums, `for` loops, struct literals |
| [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` |
| [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` |
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, enemies, coins, user-declared SFX + music, and a Title → Playing state machine with auto-play for the headless harness |
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds |
## Compiler Commands

Binary file not shown.

Before

Width:  |  Height:  |  Size: 438 KiB

After

Width:  |  Height:  |  Size: 446 KiB

Before After
Before After

View file

@ -28,7 +28,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `mmc1_banked.ne` | MMC1, banks, multiply | Banked mapper with software multiply |
| `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps |
| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. |
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, enemies, coin pickups, user-declared SFX + music, and a Title → Playing state machine with an autopilot so the headless harness still hits interesting gameplay at frame 180. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
## Emulator Controls

View file

@ -3,12 +3,13 @@
// 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.
// 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
@ -358,6 +359,12 @@ 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
@ -369,9 +376,13 @@ 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
// 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
@ -423,6 +434,54 @@ fun draw_player() {
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 {
@ -468,8 +527,11 @@ state Playing {
fall_vy = 0
camera_x = 0
frame_tick = 0
jump_timer = 0
anim_tick = 0
alive = 1
stomp_count = 0
auto_jumps = 0
start_music Theme
}
on frame {
@ -490,31 +552,40 @@ state Playing {
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.
// Always advance camera one px per frame (baseline scroll).
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).
// ── 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).
@ -529,24 +600,17 @@ state Playing {
}
}
// 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
}
}
}
// ── 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_player()
// Enemies — walking on the grass line. anim_tick drives
// a small vertical bob so they visibly move on the golden.
// 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
@ -554,9 +618,6 @@ state Playing {
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
@ -564,11 +625,85 @@ state Playing {
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
}
}
}

Binary file not shown.

View file

@ -1 +1 @@
a00949db 132084
e8b3ea0b 132084

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Before After
Before After