2026-04-11 23:34:35 +00:00
|
|
|
// Coin Cavern — a multi-state game demo for Milestone 2.
|
|
|
|
|
//
|
|
|
|
|
// Demonstrates: state machine, functions, if/else, while loops,
|
|
|
|
|
// constants, button input, transitions between states.
|
|
|
|
|
//
|
|
|
|
|
// Build: cargo run -- build examples/coin_cavern.ne
|
|
|
|
|
// Output: examples/coin_cavern.nes
|
|
|
|
|
//
|
|
|
|
|
// Note: In M2 sprites use the built-in CHR tile. Custom tile data
|
|
|
|
|
// and proper graphics come in M3 with the asset pipeline.
|
|
|
|
|
|
|
|
|
|
game "Coin Cavern" {
|
|
|
|
|
mapper: NROM
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Constants
|
|
|
|
|
const SPEED: u8 = 2
|
|
|
|
|
const GRAVITY: u8 = 1
|
|
|
|
|
const JUMP_FORCE: u8 = 4
|
|
|
|
|
const SCREEN_RIGHT: u8 = 240
|
|
|
|
|
const SCREEN_BOTTOM: u8 = 220
|
|
|
|
|
const COIN_X: u8 = 180
|
|
|
|
|
const COIN_Y: u8 = 100
|
|
|
|
|
|
|
|
|
|
// Global variables
|
|
|
|
|
var score: u8 = 0
|
|
|
|
|
|
|
|
|
|
// Helper function: clamp a value to screen bounds
|
|
|
|
|
fun clamp_x(val: u8) -> u8 {
|
|
|
|
|
if val > SCREEN_RIGHT {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
return val
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Title screen state
|
|
|
|
|
state Title {
|
|
|
|
|
on frame {
|
|
|
|
|
// Draw title sprite at center of screen
|
|
|
|
|
draw Logo at: (100, 100)
|
|
|
|
|
|
|
|
|
|
// Press start to play
|
|
|
|
|
if button.start {
|
|
|
|
|
transition Playing
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Main gameplay state
|
|
|
|
|
state Playing {
|
examples: move state-scoped globals to state-local in coin_cavern + platformer
Both examples declared their gameplay variables at the top level
even though every read and write happened inside one specific state.
That pattern hid the overlay feature from new users and kept the
state-local code path from being exercised outside the dedicated
`state_machine.ne` demo (which is how the "state-locals silently
drop their writes" bug survived so long).
`coin_cavern.ne`: the five Playing-only physics/position/inventory
vars (`player_x`, `player_y`, `player_vy`, `on_ground`,
`coins_left`) move onto Playing's state block. `score` stays
global because GameOver-era code could reasonably grow to read it.
The `on_enter` body loses its redundant resets — the declared
initializers on the state-locals re-run on every entry, so
retrying after `transition Title` comes back to a fresh state.
`platformer.ne`: player physics, camera, liveness, animation
phase, and the autopilot budget (`player_y`, `on_ground`,
`rise_count`, `fall_vy`, `camera_x`, `anim_tick`, `alive`,
`auto_jumps`) all move onto Playing. `frame_tick` and
`stomp_count` stay global — Title reads the former to
auto-advance, GameOver reads the latter to tally coins on the
death screen. The analyzer now overlays Title's `blink`,
Playing's eight physics vars, and GameOver's `linger` starting
at the same ZP byte (`$1A`), so the three scenes share a
9-byte window instead of each claiming their own slots.
Byte-level ROM bytes for both examples shift because variable
addresses moved. Video goldens stay pixel-identical (the harness
doesn't see Playing in coin_cavern, and the pre-transition
Title→Playing timing in platformer is preserved); the platformer
audio hash needed one more refresh because the now-slightly-shorter
reset prologue shifts APU writes within each frame.
https://claude.ai/code/session_015kvJu3iEFLSRJoShPBfm3X
2026-04-17 11:58:02 +00:00
|
|
|
// Physics and position live with the state: they're only
|
|
|
|
|
// meaningful while Playing is active, and the analyzer
|
|
|
|
|
// overlays them with the locals of the Title and GameOver
|
|
|
|
|
// states so the idle scenes don't reserve bytes they never
|
|
|
|
|
// touch. Initializers re-run on every entry, so dying and
|
|
|
|
|
// retrying starts the player back on the ground.
|
|
|
|
|
var player_x: u8 = 40
|
|
|
|
|
var player_y: u8 = 200
|
|
|
|
|
var player_vy: u8 = 0
|
|
|
|
|
var on_ground: u8 = 1
|
|
|
|
|
var coins_left: u8 = 3
|
|
|
|
|
|
2026-04-11 23:34:35 +00:00
|
|
|
on enter {
|
|
|
|
|
score = 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
on frame {
|
|
|
|
|
// Horizontal movement
|
|
|
|
|
if button.right {
|
|
|
|
|
player_x += SPEED
|
|
|
|
|
if player_x > SCREEN_RIGHT {
|
|
|
|
|
player_x = SCREEN_RIGHT
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if button.left {
|
|
|
|
|
if player_x >= SPEED {
|
|
|
|
|
player_x -= SPEED
|
|
|
|
|
} else {
|
|
|
|
|
player_x = 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Simple gravity
|
|
|
|
|
if on_ground == 0 {
|
|
|
|
|
player_y += player_vy
|
|
|
|
|
player_vy += GRAVITY
|
|
|
|
|
if player_y >= SCREEN_BOTTOM {
|
|
|
|
|
player_y = SCREEN_BOTTOM
|
|
|
|
|
on_ground = 1
|
|
|
|
|
player_vy = 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Jump
|
|
|
|
|
if button.a {
|
|
|
|
|
if on_ground == 1 {
|
|
|
|
|
on_ground = 0
|
|
|
|
|
player_vy = JUMP_FORCE
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check coin collection (simple distance check)
|
|
|
|
|
if player_x >= COIN_X {
|
|
|
|
|
if player_y >= COIN_Y {
|
|
|
|
|
score += 1
|
|
|
|
|
coins_left -= 1
|
|
|
|
|
if coins_left == 0 {
|
|
|
|
|
transition GameOver
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Draw player and coin
|
|
|
|
|
draw Player at: (player_x, player_y)
|
|
|
|
|
draw Coin at: (COIN_X, COIN_Y)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Game over state
|
|
|
|
|
state GameOver {
|
|
|
|
|
on frame {
|
|
|
|
|
draw Trophy at: (120, 100)
|
|
|
|
|
|
|
|
|
|
// Press start to restart
|
|
|
|
|
if button.start {
|
|
|
|
|
transition Title
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
start Title
|