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

platformer: sprite-based status bar with score + lives

Upgrades the platformer's "live coin count" into a proper heads-up
display that stays pinned to the top of the viewport while the
nametable scrolls. Left side: coin icon + two-digit stomp tally.
Right side: red heart icon + single-digit lives counter. Both ride
through the GameOver screen without jumping position, so the death
banner reads as a continuation of the same run.

Wire-up: three new cross-state bits — score now accumulates across
lives, `lives` starts at 3 and decrements in `GameOver.on_enter`,
and the GameOver → Playing retry bounces to Title instead when the
last heart is spent (Title's `on_enter` refills both).

Tile pipeline: ten decimal digits + a heart glyph added to the
committed Tileset (generator source in `scripts/gen_platformer_tiles.rs`
kept in sync). Digits use `c` (white) so they read against the
sky; the heart uses `a` (red) to match the cap/brick palette.

Division workaround: the obvious `stomp_count / 10` / `% 10` pair
miscompiles near state transitions — the built ROM cycles
Title → Playing → Title once per blink period with Playing
surviving exactly one frame. Swapping both calls for repeated
`while r >= 10 { r -= 10 }` helpers fixes it. Documented as a
new entry in `docs/future-work.md` so the next person reaching
for `/` or `%` knows to check there first.

Goldens, docs/platformer.gif, and the top-level + examples README
entries all refreshed in the same commit.
This commit is contained in:
Claude 2026-04-20 13:59:14 +00:00
parent 6b1cc98267
commit b83b944570
No known key found for this signature in database
9 changed files with 374 additions and 39 deletions

View file

@ -35,7 +35,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
| `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_<name>` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. |
| `metasprite_demo.ne` | declarative multi-tile sprites | A 16×16 hero sprite split into a `metasprite Hero { sprite: Hero16, dx: [...], dy: [...], frame: [...] }` declaration. `draw Hero at: (px, py)` then expands to one `DrawSprite` op per tile in the IR lowering, each with its dx/dy added to the user's anchor point and the frame offset by the underlying sprite's base tile. The codegen needs no metasprite-specific support — it sees N regular draws and the OAM cursor allocator handles the slots. |
| `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). |
| `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`. |
| `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, coin pickups, a sprite-based status bar pinned to the top of the viewport (coin icon + 2-digit score on the left, heart icon + lives counter on the right), cross-state life tracking that sends GameOver back to Title when the last heart is spent, 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`. |
| `sprite_flicker_demo.ne` | `cycle_sprites`, 8-per-scanline hardware limit | Twelve sprites packed onto the same 4-pixel band — two more than the NES's 8-sprites-per-scanline hardware budget. The W0109 analyzer warning fires at compile time, and a `cycle_sprites` call at the end of `on frame` rotates the OAM DMA offset one slot per frame so the PPU drops a *different* sprite each frame. The permanent-dropout failure mode becomes visible flicker, which the eye reconstructs across frames. The classic NES technique used by Gradius, Battletoads, and every shmup that ever existed. |
| `war.ne` | **production-quality card game**, multi-file source layout | A complete port of the card game War, split across `examples/war/*.ne` files and pulled in via `include` directives. Title screen with a 0/1/2-player menu (cursor sprite, blinking PRESS A, brisk 4/4 march on pulse 2), a 50-frame deal animation, a deep `Playing` state with an inner phase machine (`P_WAIT_A`/`P_FLY_A`/.../`P_WAR_BANNER`/`P_WAR_BURY`/`P_CHECK`), card-conserving queue-based decks built on a 200-iteration random-swap shuffle, a "WAR!" tie-break that buries 3+1 face-down cards per player and plays a noise-channel thump per bury, and a victory screen with the builtin fanfare. The first NEScript example to use a top-level file as a thin shell that `include`s ~12 component files; building it surfaced seven compiler bugs across the analyzer, IR lowerer, and codegen that were all fixed on the same branch (see `git log` for details). |
| `pong.ne` | **production-quality Pong**, powerups, multi-ball, multi-file | A complete Pong game split across `examples/pong/*.ne`. CPU VS CPU / 1 PLAYER / 2 PLAYERS title menu with brisk pulse-2 title march and autopilot, smooth ball physics with wall and paddle bouncing, CPU AI that tracks the ball with a reaction lag and dead zone, three powerup types (LONG paddle for 5 hits, FAST ball on next hit, MULTI-ball on next hit spawning 3 balls) that bounce around the field and are caught by paddle AABB overlap, multi-ball scoring (each ball scores a point, round continues until last ball exits), inner phase machine (`P_SERVE`/`P_PLAY`/`P_POINT`), and a "PLAYER N WINS" victory screen with the builtin fanfare. First-to-7 wins. |

View file

@ -4,12 +4,17 @@
// 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.
// 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
@ -212,6 +217,105 @@ sprite Tileset {
"........",
"........",
"........",
"........",
// 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...",
"........"
]
}
@ -223,6 +327,10 @@ const TILE_PLAYER_BL: u8 = 3
const TILE_PLAYER_BR: u8 = 4
const TILE_ENEMY: u8 = 5
const TILE_COIN: u8 = 6
// HUD glyphs (sprite-only — not referenced by the nametable). The
// digit tiles are contiguous so `TILE_DIGIT_0 + n` picks digit `n`.
const TILE_DIGIT_0: u8 = 16
const TILE_HEART: u8 = 26
// ── Background ──────────────────────────────────────────────
// The 32×30 nametable is authored as ASCII art with a `legend`
@ -378,6 +486,51 @@ const AUTOPILOT_JUMPS: u8 = 2
// 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 helpers ─────────────────────────────────────────────
// Paint the four-sprite status bar at the very top of the screen:
// a coin icon followed by a two-digit stomp tally on the left, and
// a heart icon followed by a single-digit lives counter on the
// right. Everything is drawn as OAM sprites (never the nametable)
// so the HUD stays pinned while the background scrolls under it.
// y = 16 is above the brick row, so the white digits read cleanly
// against the universal-sky backdrop.
// Split a 0..99 count into its tens and ones decimal digits via
// repeated subtraction — the runtime's software divide has a known
// issue that scribbles over state-local memory (tracked in
// docs/future-work.md §G), and the HUD is hot enough that the
// miscompile sends the state machine spinning. Manual repeated
// subtraction is two dozen cycles for the worst case of a two-digit
// score, which is cheap for a once-per-frame call.
fun tens_digit(n: u8) -> u8 {
var t: u8 = 0
var r: u8 = n
while r >= 10 {
r -= 10
t += 1
}
return t
}
fun ones_digit(n: u8) -> u8 {
var r: u8 = n
while r >= 10 {
r -= 10
}
return r
}
fun draw_hud() {
// Score side (left): coin + tens + ones.
draw Tileset at: (16, 16) frame: TILE_COIN
draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + tens_digit(stomp_count)
draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + ones_digit(stomp_count)
// Lives side (right): heart + digit.
draw Tileset at: (208, 16) frame: TILE_HEART
draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives
}
// ── Helper functions ────────────────────────────────────────
@ -486,6 +639,11 @@ state Title {
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
}
@ -628,21 +786,10 @@ 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
}
// Status bar (score tally on the left, lives on the right).
// Drawn as sprites so it stays pinned while the nametable
// scrolls under it.
draw_hud()
// Player is only drawn while alive — on the dying frame
// we show the scene without the hero on top of the enemy
@ -670,6 +817,12 @@ state GameOver {
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
}
@ -686,26 +839,33 @@ state GameOver {
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
}
// Carry the status bar through the death screen so the
// final score and the remaining heart total are visible at
// the exact same position as in Playing — no awkward jump.
draw Tileset at: (16, 16) frame: TILE_COIN
draw Tileset at: (28, 16) frame: TILE_DIGIT_0 + tens_digit(stomp_count)
draw Tileset at: (36, 16) frame: TILE_DIGIT_0 + ones_digit(stomp_count)
draw Tileset at: (208, 16) frame: TILE_HEART
draw Tileset at: (224, 16) frame: TILE_DIGIT_0 + lives
// Auto-retry after ~1 s so the headless harness sees the
// full loop. A human can hit Start to retry immediately.
// 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 {
transition Playing
if lives == 0 {
transition Title
} else {
transition Playing
}
}
if button.start {
transition Playing
if lives == 0 {
transition Title
} else {
transition Playing
}
}
}
}

Binary file not shown.