mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
Rewrites every example with non-trivial asset declarations to use
the pleasant QoL syntax introduced in the previous commit. Every
example still compiles to a byte-identical ROM (verified by a
temp-path diff before committing), so the committed `.nes` files
and the 23 emulator goldens are unchanged.
* platformer.ne — the centerpiece end-to-end demo:
- `palette Main` goes to grouped form with a shared
`universal: 0x22` (sky blue), one shared colour per
sub-palette, and named NES colours throughout; the
long-standing `$3F10` mirror-trap warning is now handled
by the parser and the manual pitfall comment is gone.
- `sprite Tileset` is 15 tiles of ASCII pixel art instead
of 240 bytes of inline hex.
- `background Level` uses a `legend { '.': 15, '#': 9, ... }`
block plus `map:` strings for the 32×30 nametable, and
`palette_map:` rows for the attribute table. The map
reads top-down like the rendered screen.
- SFX latch-once `pitch: 0x30` scalars + `envelope:` alias.
- `music Theme` uses note names + `tempo: 10` default.
* audio_demo.ne — scalar sfx pitches, `envelope:` alias, and a
note-name `C4, E4, G4, ...` music track.
* palette_and_background.ne — grouped CoolBlues / WarmReds
palettes with `universal: black` + named colours, plus
`legend` + `map:` tilemaps for the two backgrounds.
* sprites_and_palettes.ne — Arrow and Heart sprites rewritten
as `pixels:` ASCII art.
Along the way, two small parser extensions support the rewrites:
- `parse_pixel_art` now accepts `a/b/c` as aliases for `#/%/@`,
matching the vocabulary every NES editor (and our own
gen_platformer_tiles.rs generator) uses.
- `palette_map_to_attrs` allows up to 16 metatile rows (the
full attribute-table coverage, including the off-screen
bottom half) and auto-replicates row 14 → row 15 when only
15 rows are supplied so the visible bottom of the screen
gets consistent sub-palette assignments by default. The old
15-row cap couldn't match a hand-packed `0xAA` attribute
table for the last row; the platformer required this to
stay byte-identical.
`scripts/gen_platformer_tiles.rs` is updated to emit the new
syntax directly (pixel-art `pixels:` block + `legend`/`map:`/
`palette_map:` for the background), so regenerating the
platformer tiles stays a one-liner.
474 lib tests + 64 integration tests pass (3 new parser tests
for `palette_map:` 15/16/17 rows and the `abc` alias). All 23
emulator goldens still match pixel- and sample-for-sample.
https://claude.ai/code/session_01PzaSFj3VahDzxEYTKCESkz
575 lines
20 KiB
Text
575 lines
20 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, 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 ──────────────────────────────────────────────────
|
||
// 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
|
||
|
||
// ── 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
|