mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
examples: add 5 new programs covering match/loop/logic/bitwise/scanline
Fills the biggest feature-coverage gaps in the existing example set:
- match_demo.ne — match statement over a Screen enum,
driving a title / playing / paused /
game-over flow with a debounced controller.
- loop_break_continue.ne — `loop { ... }` with `break` and `continue`,
scanning an enemy array for the first hit.
- logic_ops.ne — keyword-based `and` / `or` / `not` gating
movement and scoring on alive/paused flags.
- bitwise_ops.ne — packed status-byte flags with `&` / `|` /
`^` / `>>` plus a health-bar render loop.
- scanline_split.ne — MMC3 `on scanline(120)` handler rewriting
the scroll register mid-frame for a
classic status-bar split.
All 14 examples (9 existing + 5 new) pass the jsnes smoke test
(`14/14 ROMs rendered successfully`) and still pass `cargo fmt`,
`cargo clippy -D warnings`, and `cargo test`.
Known limitations surfaced while authoring these examples, to be
fixed in follow-up commits:
1. Array-literal global initializers (`var xs: u8[4] = [1,2,3,4]`)
are silently dropped by `lower_program` — `eval_const` returns
None for `Expr::ArrayLiteral` and no synthetic per-element
init code is emitted. Affects `arrays_and_functions`,
`structs_enums_for`, `loop_break_continue`, and any future
array-using example. Arrays effectively boot at all-zero.
2. `draw` inside a loop body reuses one static OAM slot —
`next_oam_slot` increments at IR-codegen time rather than at
runtime, so N iterations all write to the same 4-byte OAM
entry. Affects `arrays_and_functions`, `structs_enums_for`,
`bitwise_ops` (health pips), and any loop that wants to
render per-iteration sprites.
Both bugs are latent and didn't surface until I tried to write
examples that exercise the relevant features — the existing
integration tests only check iNES header structure, and the
jsnes smoke test's "at least one sprite rendered" bar is
satisfied by one sprite even when several were intended.
https://claude.ai/code/session_014Z5y3Q9krLcAxYpZQJhZ5V
This commit is contained in:
parent
24db4e6cda
commit
1525922faa
10 changed files with 423 additions and 0 deletions
98
examples/bitwise_ops.ne
Normal file
98
examples/bitwise_ops.ne
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// Bitwise Ops — demonstrates shift and mask operators on a packed
|
||||
// "status" byte that holds several small flags.
|
||||
//
|
||||
// The byte layout:
|
||||
// bit 7 — alive
|
||||
// bit 6 — shield
|
||||
// bit 5 — powered
|
||||
// bit 4 — invulnerable
|
||||
// bits 0..3 — health (0..15)
|
||||
//
|
||||
// The player toggles each high-bit flag with the face buttons and
|
||||
// heals/damages the low nibble with the d-pad. The drawing code
|
||||
// reads each flag with an `and` mask after shifting the status byte
|
||||
// into place — a typical pattern on the 6502.
|
||||
//
|
||||
// Build: cargo run -- build examples/bitwise_ops.ne
|
||||
|
||||
game "Bitwise Ops" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
const ALIVE: u8 = 0x80
|
||||
const SHIELD: u8 = 0x40
|
||||
const POWERED: u8 = 0x20
|
||||
const INVULN: u8 = 0x10
|
||||
const HEALTH_MASK: u8 = 0x0F
|
||||
|
||||
var status: u8 = 0x8F // alive + full health
|
||||
var debounce: u8 = 0
|
||||
|
||||
on frame {
|
||||
if debounce > 0 {
|
||||
debounce -= 1
|
||||
}
|
||||
|
||||
// Toggle each flag on a different face button. `^` is XOR,
|
||||
// which flips exactly the masked bit.
|
||||
if button.a and debounce == 0 {
|
||||
status = status ^ SHIELD
|
||||
debounce = 15
|
||||
}
|
||||
if button.b and debounce == 0 {
|
||||
status = status ^ POWERED
|
||||
debounce = 15
|
||||
}
|
||||
if button.start and debounce == 0 {
|
||||
status = status ^ INVULN
|
||||
debounce = 15
|
||||
}
|
||||
if button.select and debounce == 0 {
|
||||
status = status ^ ALIVE
|
||||
debounce = 15
|
||||
}
|
||||
|
||||
// D-pad tweaks the low-nibble health counter. Clear the low
|
||||
// nibble, compute new health, then OR it back in — the
|
||||
// canonical "update a bitfield" pattern.
|
||||
var health: u8 = status & HEALTH_MASK
|
||||
if button.right and debounce == 0 {
|
||||
if health < 15 {
|
||||
health += 1
|
||||
}
|
||||
debounce = 8
|
||||
}
|
||||
if button.left and debounce == 0 {
|
||||
if health > 0 {
|
||||
health -= 1
|
||||
}
|
||||
debounce = 8
|
||||
}
|
||||
status = (status & 0xF0) | health
|
||||
|
||||
// Read back each flag with a mask + nonzero test.
|
||||
if (status & ALIVE) != 0 {
|
||||
draw Player at: (120, 120)
|
||||
}
|
||||
if (status & SHIELD) != 0 {
|
||||
draw Ring at: (120, 120)
|
||||
}
|
||||
if (status & POWERED) != 0 {
|
||||
draw Spark at: (136, 120)
|
||||
}
|
||||
if (status & INVULN) != 0 {
|
||||
draw Spark at: (104, 120)
|
||||
}
|
||||
|
||||
// Draw a health bar by shifting the low nibble into a loop
|
||||
// counter. `>>` divides by 2^n on the 6502 with a shift, which
|
||||
// the optimizer also emits for `/ 2`.
|
||||
var bars: u8 = health >> 1 // 0..7 bars
|
||||
var i: u8 = 0
|
||||
while i < bars {
|
||||
draw Pip at: (40 + i * 10, 200)
|
||||
i += 1
|
||||
}
|
||||
}
|
||||
|
||||
start Main
|
||||
80
examples/logic_ops.ne
Normal file
80
examples/logic_ops.ne
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// Logic Ops — demonstrates keyword-based boolean operators
|
||||
// (`and`, `or`, `not`) along with comparison operators.
|
||||
//
|
||||
// The player sprite is drawn only when it's "alive AND unpaused",
|
||||
// the score counter freezes when paused OR dead, and movement is
|
||||
// allowed when NOT paused.
|
||||
//
|
||||
// Build: cargo run -- build examples/logic_ops.ne
|
||||
|
||||
game "Logic Ops" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
var alive: bool = true
|
||||
var paused: bool = false
|
||||
var px: u8 = 100
|
||||
var py: u8 = 120
|
||||
var score: u8 = 0
|
||||
var tick: u8 = 0
|
||||
var debounce: u8 = 0
|
||||
|
||||
on frame {
|
||||
if debounce > 0 {
|
||||
debounce -= 1
|
||||
}
|
||||
|
||||
// Start toggles pause; B kills the player (both with debounce).
|
||||
if button.start and debounce == 0 {
|
||||
if paused {
|
||||
paused = false
|
||||
} else {
|
||||
paused = true
|
||||
}
|
||||
debounce = 20
|
||||
}
|
||||
if button.b and debounce == 0 {
|
||||
alive = false
|
||||
debounce = 20
|
||||
}
|
||||
if button.a and debounce == 0 {
|
||||
alive = true
|
||||
debounce = 20
|
||||
}
|
||||
|
||||
// Movement is only allowed when alive AND not paused.
|
||||
if alive and (not paused) {
|
||||
if button.right { px += 1 }
|
||||
if button.left { px -= 1 }
|
||||
if button.up { py -= 1 }
|
||||
if button.down { py += 1 }
|
||||
}
|
||||
|
||||
// Score ticks up 1/frame unless paused or dead. Using OR to
|
||||
// short-circuit when either flag blocks scoring.
|
||||
tick += 1
|
||||
if paused or (not alive) {
|
||||
// frozen
|
||||
} else {
|
||||
if tick >= 30 {
|
||||
tick = 0
|
||||
if score < 240 {
|
||||
score += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only draw the player when alive and unpaused.
|
||||
if alive and (not paused) {
|
||||
draw Player at: (px, py)
|
||||
}
|
||||
|
||||
// A simple score bar: draw one marker per 30 score points so the
|
||||
// effect of the pause/dead gating is visible without text.
|
||||
if score >= 30 { draw Pip at: (20, 20) }
|
||||
if score >= 60 { draw Pip at: (30, 20) }
|
||||
if score >= 90 { draw Pip at: (40, 20) }
|
||||
if score >= 120 { draw Pip at: (50, 20) }
|
||||
}
|
||||
|
||||
start Main
|
||||
108
examples/loop_break_continue.ne
Normal file
108
examples/loop_break_continue.ne
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Loop / Break / Continue — demonstrates `loop`, `break`, and
|
||||
// `continue` via a linear scan that finds the first "hazard" the
|
||||
// player is overlapping.
|
||||
//
|
||||
// NEScript's sprite allocator assigns a static OAM slot per `draw`
|
||||
// statement at compile time, so a `draw` inside a loop body would
|
||||
// reuse the same slot on every iteration. We instead use the loop
|
||||
// to COMPUTE state — "is the player touching a hazard, and which
|
||||
// one?" — and then fold that result into how (and whether) the
|
||||
// single player sprite is drawn. The hazards themselves get one
|
||||
// unrolled `draw` per slot so each hazard gets its own OAM entry.
|
||||
//
|
||||
// Features shown:
|
||||
// - `loop { ... }` as an infinite loop
|
||||
// - `break` to exit on first match
|
||||
// - `continue` to skip inactive slots
|
||||
// - array indexing with a loop-carried variable
|
||||
//
|
||||
// Build: cargo run -- build examples/loop_break_continue.ne
|
||||
|
||||
game "Loop Demo" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
const NUM_HAZARDS: u8 = 4
|
||||
const HIT_RADIUS: u8 = 12
|
||||
|
||||
// Four hazard slots. `active` is 1 if the slot is live.
|
||||
var active: u8[4] = [1, 1, 0, 1]
|
||||
var hazard_x: u8[4] = [40, 96, 160, 200]
|
||||
var hazard_y: u8[4] = [60, 140, 100, 180]
|
||||
|
||||
var px: u8 = 120
|
||||
var py: u8 = 120
|
||||
|
||||
// Search results, written by the loop each frame.
|
||||
var hit: u8 = 0 // 1 if the player is touching any hazard
|
||||
var hit_idx: u8 = 0 // which slot was hit (only meaningful if hit == 1)
|
||||
|
||||
// Small absolute-difference helper. Saves lines at each call site.
|
||||
inline fun abs_diff(a: u8, b: u8) -> u8 {
|
||||
if a > b {
|
||||
return a - b
|
||||
}
|
||||
return b - a
|
||||
}
|
||||
|
||||
on frame {
|
||||
// Player movement.
|
||||
if button.right { px += 1 }
|
||||
if button.left { px -= 1 }
|
||||
if button.down { py += 1 }
|
||||
if button.up { py -= 1 }
|
||||
|
||||
// Linear scan for the first active hazard the player is
|
||||
// touching. The loop exits via `break` on the first hit, or
|
||||
// by running i past the end.
|
||||
hit = 0
|
||||
var i: u8 = 0
|
||||
loop {
|
||||
if i >= NUM_HAZARDS {
|
||||
break
|
||||
}
|
||||
if active[i] == 0 {
|
||||
// Skip inactive slots — this is where `continue`
|
||||
// shines: the increment and re-test happen at the top.
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
var dx: u8 = abs_diff(px, hazard_x[i])
|
||||
var dy: u8 = abs_diff(py, hazard_y[i])
|
||||
if dx < HIT_RADIUS {
|
||||
if dy < HIT_RADIUS {
|
||||
hit = 1
|
||||
hit_idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
|
||||
// One draw per hazard slot — unrolled so each sprite gets its
|
||||
// own static OAM entry. Inactive slots draw off-screen ($FE)
|
||||
// so they don't flicker the visible ones.
|
||||
if active[0] == 1 {
|
||||
draw Hazard at: (hazard_x[0], hazard_y[0])
|
||||
}
|
||||
if active[1] == 1 {
|
||||
draw Hazard at: (hazard_x[1], hazard_y[1])
|
||||
}
|
||||
if active[2] == 1 {
|
||||
draw Hazard at: (hazard_x[2], hazard_y[2])
|
||||
}
|
||||
if active[3] == 1 {
|
||||
draw Hazard at: (hazard_x[3], hazard_y[3])
|
||||
}
|
||||
|
||||
// The player sprite reflects the collision state: when hit,
|
||||
// draw a "bang" marker above the player head. The `hit_idx`
|
||||
// is unused visually but proves the loop ran to completion.
|
||||
draw Player at: (px, py)
|
||||
if hit == 1 {
|
||||
draw Spark at: (px, py - 8)
|
||||
}
|
||||
}
|
||||
|
||||
start Main
|
||||
76
examples/match_demo.ne
Normal file
76
examples/match_demo.ne
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Match Demo — demonstrates the `match` statement.
|
||||
//
|
||||
// Match dispatches on a value against a sequence of patterns. Each
|
||||
// arm runs only when its pattern compares equal to the scrutinee,
|
||||
// and the underscore arm catches everything else. Here we drive a
|
||||
// tiny title/playing/paused/game-over menu purely through a match
|
||||
// on a u8 screen mode, cycled by the d-pad buttons.
|
||||
//
|
||||
// Build: cargo run -- build examples/match_demo.ne
|
||||
|
||||
game "Match Demo" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
enum Screen { Title, Playing, Paused, GameOver }
|
||||
|
||||
var screen: u8 = Title
|
||||
var x: u8 = 120
|
||||
var y: u8 = 112
|
||||
var debounce: u8 = 0
|
||||
|
||||
on frame {
|
||||
// Debounce so a held button doesn't spam transitions each frame.
|
||||
if debounce > 0 {
|
||||
debounce -= 1
|
||||
}
|
||||
|
||||
match screen {
|
||||
Title => {
|
||||
// Blink the pointer by alternating its position.
|
||||
draw Arrow at: (80, 112)
|
||||
if button.start and debounce == 0 {
|
||||
screen = Playing
|
||||
debounce = 20
|
||||
}
|
||||
}
|
||||
Playing => {
|
||||
if button.right { x += 2 }
|
||||
if button.left { x -= 2 }
|
||||
if button.down { y += 2 }
|
||||
if button.up { y -= 2 }
|
||||
draw Player at: (x, y)
|
||||
|
||||
if button.select and debounce == 0 {
|
||||
screen = Paused
|
||||
debounce = 20
|
||||
}
|
||||
if button.b and debounce == 0 {
|
||||
screen = GameOver
|
||||
debounce = 20
|
||||
}
|
||||
}
|
||||
Paused => {
|
||||
// Freeze the player where it was and show a pause marker.
|
||||
draw Player at: (x, y)
|
||||
draw Arrow at: (120, 80)
|
||||
if button.select and debounce == 0 {
|
||||
screen = Playing
|
||||
debounce = 20
|
||||
}
|
||||
}
|
||||
GameOver => {
|
||||
draw Skull at: (120, 112)
|
||||
if button.a and debounce == 0 {
|
||||
screen = Title
|
||||
debounce = 20
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Defensive: an unexpected value lands back on the title.
|
||||
screen = Title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
start Main
|
||||
61
examples/scanline_split.ne
Normal file
61
examples/scanline_split.ne
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// Scanline Split — demonstrates MMC3's per-scanline IRQ via an
|
||||
// `on scanline(N)` handler. The handler runs at PPU scanline 120,
|
||||
// roughly mid-screen, and changes the horizontal scroll register
|
||||
// so the top half and bottom half appear to scroll independently.
|
||||
// This is the classic "status bar + parallax" trick used in many
|
||||
// NES games.
|
||||
//
|
||||
// Build: cargo run -- build examples/scanline_split.ne
|
||||
|
||||
game "Scanline Split" {
|
||||
mapper: MMC3
|
||||
mirroring: horizontal
|
||||
}
|
||||
|
||||
var top_scroll: u8 = 0
|
||||
var bottom_scroll: u8 = 0
|
||||
var px: u8 = 120
|
||||
var py: u8 = 160
|
||||
|
||||
state Main {
|
||||
on enter {
|
||||
top_scroll = 0
|
||||
bottom_scroll = 0
|
||||
px = 120
|
||||
py = 160
|
||||
}
|
||||
|
||||
on frame {
|
||||
// Drift the top layer left and the bottom layer right so
|
||||
// the split is easy to see when the example runs.
|
||||
top_scroll += 1
|
||||
bottom_scroll -= 1
|
||||
|
||||
// Player on the bottom half.
|
||||
if button.right { px += 2 }
|
||||
if button.left { px -= 2 }
|
||||
if button.up { py -= 2 }
|
||||
if button.down { py += 2 }
|
||||
|
||||
// Top-half banner.
|
||||
draw Banner at: (40, 24)
|
||||
|
||||
// Bottom-half player.
|
||||
draw Player at: (px, py)
|
||||
|
||||
// Set scroll for the TOP half. The scanline handler will
|
||||
// overwrite this partway through the frame for the bottom
|
||||
// half. Without a handler call the PPU would use this one
|
||||
// value for the entire visible screen.
|
||||
scroll(top_scroll, 0)
|
||||
}
|
||||
|
||||
// Fires ~halfway down the visible screen. MMC3's scanline IRQ
|
||||
// counts rendered lines and fires when the reload value
|
||||
// elapses; the compiler emits the reload + acknowledge glue.
|
||||
on scanline(120) {
|
||||
scroll(bottom_scroll, 0)
|
||||
}
|
||||
}
|
||||
|
||||
start Main
|
||||
BIN
tests/emulator/screenshots/bitwise_ops.png
Normal file
BIN
tests/emulator/screenshots/bitwise_ops.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 884 B |
BIN
tests/emulator/screenshots/logic_ops.png
Normal file
BIN
tests/emulator/screenshots/logic_ops.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 816 B |
BIN
tests/emulator/screenshots/loop_break_continue.png
Normal file
BIN
tests/emulator/screenshots/loop_break_continue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 816 B |
BIN
tests/emulator/screenshots/match_demo.png
Normal file
BIN
tests/emulator/screenshots/match_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 807 B |
BIN
tests/emulator/screenshots/scanline_split.png
Normal file
BIN
tests/emulator/screenshots/scanline_split.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 884 B |
Loading…
Add table
Add a link
Reference in a new issue