1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-09 17:28:00 +00:00
nescript/examples/hud_demo.ne
Claude 854b61ea1e
docs + example: HUD demo and language-guide VRAM buffer section
Follow-up to 807c9c7 (the VRAM update buffer core). Adds the
realistic-HUD example the core was missing, plus a language-guide
section that explains when and how to use the three buffer
intrinsics.

**examples/hud_demo.ne**

A bouncing-ball playfield with a classic status bar across the
top:
- 5-cell lives indicator that ticks down once per second and
  resets at zero, drawn via `nt_fill_h` (plus a second
  `nt_fill_h` to erase the stale tail).
- Score counter at the right edge that bumps on every wall
  bounce, drawn via `nt_set`.
- One-shot `nt_attr` call on the first frame flipping the
  top-left metatile group to sub-palette 1 (the red HUD
  palette) so the UI chrome reads as distinct from the
  playfield.

The demo's point is the `last_score != score` / `last_lives !=
lives` shadow-compare pattern: on the ~58-of-60 frames where
nothing changed, the buffer stays empty and drain work is zero.
That's the whole reason the VRAM buffer exists — per-frame cost
scales with what moved, not with HUD complexity. Committed
`.nes` + pixel/audio goldens.

**docs/language-guide.md**

New "VRAM Update Buffer" section between "Hardware Intrinsics"
and "Inline Assembly". Covers:
- Why user code can't just poke `$2006` / `$2007` directly.
- The three intrinsics + their coordinate systems (cell, not
  pixel).
- The HUD pattern with a ready-to-paste code snippet and a
  pointer at `examples/hud_demo.ne`.
- A per-entry budget table + worked 1000-cycle drain example
  against the ~2273-cycle vblank budget.
- Known limits: horizontal-only, no overflow check,
  no coalescing — all already tracked under `future-work.md` §G.

**examples/README.md**

`vram_buffer_demo.ne` reframed as the minimal test-case exercise
it actually is, with a pointer at `hud_demo.ne` for the realistic
pattern. New table row for `hud_demo.ne`.

All 758 tests pass. Clippy clean. 48/48 emulator goldens match.
2026-04-18 21:34:44 +00:00

152 lines
5.3 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// HUD demo — the VRAM update buffer driving a classic status-bar
// layout above a scrolling playfield.
//
// The playfield shows a ball bouncing back and forth; every wall
// hit bumps a score counter that the HUD renders at the top of
// the screen. A "lives" indicator to the left ticks down
// periodically and resets, demonstrating both `nt_set` (for
// single-cell updates when state changes) and `nt_fill_h` (for
// repeatedly painting a multi-cell indicator bar). An `nt_attr`
// call at reset gives the HUD row a distinct colour palette so
// it reads as "UI chrome" instead of "gameplay background".
//
// The HUD never touches `$2006` / `$2007` directly — user code
// just appends records to the 256-byte ring at `$0400-$04FF`
// and the NMI handler drains them during vblank. `nt_attr` / `nt_set`
// / `nt_fill_h` each cost one buffer entry (4..19 bytes); the
// ~2273-cycle vblank budget drains ~200 single-cell writes, so a
// full HUD refresh fits comfortably.
//
// Only cells that *change* need a buffer entry: the demo tracks
// `last_score` and `last_lives` so the common "state didn't
// change this frame" path appends nothing. That's the whole
// point of the update buffer — per-frame cost scales with what
// actually changed, not with HUD complexity.
game "HUD Demo" {
mapper: NROM
mirroring: horizontal
}
palette GameColors {
universal: black
bg0: [dk_blue, blue, sky_blue] // playfield background
bg1: [dk_red, red, lt_red] // HUD row
bg2: [dk_green, green, lt_green]
bg3: [dk_gray, lt_gray, white]
sp0: [dk_blue, blue, sky_blue]
sp1: [red, orange, white]
sp2: [dk_teal, teal, lt_teal]
sp3: [dk_olive, olive, yellow]
}
background Playfield {
legend { ".": 0 }
// Fill the nametable with tile 0 so sprite-0 hit plumbing
// works and the HUD cells have opaque source pixels when we
// overwrite them. The map's three rows zero-pad to the
// full 30-row nametable automatically.
map: [
"................................",
"................................",
"................................"
]
}
// Ball position + velocity.
var bx: u8 = 64
var by: u8 = 100
var dx: u8 = 1 // 1 = moving right, 0 = moving left
var dy: u8 = 1 // 1 = moving down, 0 = moving up
// HUD state. `score` increments on each wall bounce; `lives`
// ticks down once a second and resets from 5 when it hits zero.
// The `last_*` shadows let us skip the HUD write when nothing
// has changed this frame.
var score: u8 = 0
var lives: u8 = 5
var last_score: u8 = 255 // 255 forces an initial paint on frame 0
var last_lives: u8 = 255
var life_tick: u8 = 0
var attr_set: u8 = 0 // 1 once the HUD attribute has been painted
on frame {
// ── One-shot: paint the HUD attribute byte on the first
// frame only. The top metatile group picks up sub-palette
// 1 (the red HUD palette) instead of sub-palette 0 (the
// blue playfield). `0b01010101` means all four 16×16
// quadrants of the metatile use sub-palette 1.
if attr_set == 0 {
nt_attr(0, 0, 0b01010101)
attr_set = 1
}
// ── Playfield: bounce the ball, count bounces as score. ──
if dx == 1 {
bx += 1
if bx >= 240 {
dx = 0
score += 1
}
} else {
bx -= 1
if bx == 16 {
dx = 1
score += 1
}
}
if dy == 1 {
by += 1
if by >= 200 { dy = 0 }
} else {
by -= 1
if by == 32 { dy = 1 }
}
draw Ball at: (bx, by)
// ── HUD: tick the lives timer. One "life" ticks every 60
// frames (~1 sec); at zero, reset to 5. ──
life_tick += 1
if life_tick >= 60 {
life_tick = 0
if lives == 0 {
lives = 5
} else {
lives -= 1
}
}
// ── HUD: rewrite only the cells whose backing state changed.
// When `score` ticks, update the single-digit cell at
// (28, 1) via `nt_set`. When `lives` ticks, repaint
// the whole lives bar via `nt_fill_h` (5 cells
// starting at column 2 of row 1, filled with tile 0
// for "alive"). A pre-flight shadow-compare keeps
// the buffer empty on the ~58 of 60 frames when
// nothing changed.
if score != last_score {
last_score = score
// Low nibble of score → tile index 0..15. A production
// HUD would use CHR-authored digit glyphs; this demo
// relies on the fact that whatever tiles happen to live
// at CHR indices 0..15 will render as *visible* changes
// frame-to-frame, making the update mechanism obvious.
var digit: u8 = score & 0x0F
nt_set(28, 1, digit)
}
if lives != last_lives {
last_lives = lives
// Fill `lives` cells at (2, 1) with the smiley tile, then
// clear the stale tail with a second fill of blank cells.
// `nt_fill_h` emits one buffer entry per call, so the
// two-step "draw, then erase" pattern costs two entries
// (~22 bytes) only on the frame the value changes.
nt_fill_h(2, 1, lives, 0)
if lives < 5 {
var blanks: u8 = 5 - lives
nt_fill_h(2 + lives, 1, blanks, 1)
}
}
}
start Main