mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
hud_demo: declare proper digit + heart CHR so the HUD is readable
The previous version of hud_demo passed `score & 0x0F` and tile
index `1` (= Heart) to nt_set / nt_fill_h, but the demo had no
Heart sprite declared and tile 1 in CHR was uninitialized garbage.
The result was a screen of blue smileys with a tiny red strip in
the corner — the buffer mechanism worked, but the visual gave no
sense that anything HUD-shaped was happening.
This commit makes the HUD actually look like a HUD:
- 12 sprite declarations (Bar, Heart, Digit0..9, Ball) that the
compiler lays into CHR at known tile indices in declaration
order. Tile-index constants (`BAR_TILE`, `HEART_TILE`,
`DIGIT_BASE`) match that order so the call sites can use names
instead of magic numbers.
- bg1 palette restructured to `[red, white, yellow]` so pixel-art
characters resolve to visible colours: `#` = red (background
fill), `%` = white (heart shape), `@` = yellow (digit strokes).
- Background pre-paints row 1 with the solid `Bar` (red) tile
via a `legend { "B": 1 }` entry, giving the HUD a uniform red
canvas for individual cell writes to land on.
- Eight `nt_attr` calls at startup paint the entire top metatile
row (4 rows × 32 cols) with sub-palette 1 so the HUD chrome
reads as visually distinct from the playfield.
The result at frame 180 is unmistakably HUD-shaped: a yellow-on-
red status bar at the top of the screen above blue playfield with
a yellow ball bouncing around. Per-frame cost still scales with
what changed — `last_score` / `last_lives` shadow-compares mean
the buffer stays empty on the ~58 of 60 frames where nothing
ticks.
Tests: 758 pass. Clippy clean. 48/48 emulator goldens match.
This commit is contained in:
parent
854b61ea1e
commit
a806bfd3bd
3 changed files with 243 additions and 62 deletions
|
|
@ -2,26 +2,25 @@
|
|||
// 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
|
||||
// hit bumps a score counter that the HUD renders at the right of
|
||||
// the status row. A "lives" indicator on 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".
|
||||
// repeatedly painting a multi-cell indicator). `nt_attr` calls at
|
||||
// startup paint the top row in a distinct palette so the HUD
|
||||
// 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.
|
||||
// User code never touches `$2006` / `$2007` directly — it just
|
||||
// appends records to the 256-byte ring at `$0400-$04FF` and the
|
||||
// NMI handler drains them during vblank. Only cells whose backing
|
||||
// state changed get a buffer entry: the demo tracks `last_score`
|
||||
// and `last_lives` so the common "state didn't change this frame"
|
||||
// path appends nothing.
|
||||
//
|
||||
// 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.
|
||||
// The visible NES output at native 256×240 is intentionally
|
||||
// readable rather than flashy — see the table-of-tiles layout
|
||||
// rather than a polished AAA HUD. Run the ROM in any emulator to
|
||||
// watch the lives countdown and score tick over time.
|
||||
|
||||
game "HUD Demo" {
|
||||
mapper: NROM
|
||||
|
|
@ -30,39 +29,227 @@ game "HUD Demo" {
|
|||
|
||||
palette GameColors {
|
||||
universal: black
|
||||
bg0: [dk_blue, blue, sky_blue] // playfield background
|
||||
bg1: [dk_red, red, lt_red] // HUD row
|
||||
bg0: [dk_blue, blue, sky_blue] // playfield tiles
|
||||
bg1: [red, white, yellow] // HUD palette
|
||||
bg2: [dk_green, green, lt_green]
|
||||
bg3: [dk_gray, lt_gray, white]
|
||||
sp0: [dk_blue, blue, sky_blue]
|
||||
sp0: [black, yellow, white] // ball sprite (yellow)
|
||||
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: [
|
||||
"................................",
|
||||
"................................",
|
||||
"................................"
|
||||
// ── HUD tile art. The compiler allocates sprite tiles in
|
||||
// declaration order starting at tile 1 (tile 0 is the built-in
|
||||
// smiley used by the playfield). The constants below match
|
||||
// that order so the call sites can use names instead of magic
|
||||
// numbers.
|
||||
//
|
||||
// `#` = palette index 1; with bg1 = [red, white, yellow] that's
|
||||
// red. `%` = index 2 = white. `@` = index 3 = yellow.
|
||||
|
||||
// Solid fill (everything index 1 = red) — the "HUD chrome"
|
||||
// background. Pre-painting row 1 with this tile gives the HUD
|
||||
// strip a uniform red look so individual cell writes (hearts,
|
||||
// digits) show up as obviously different content.
|
||||
sprite Bar {
|
||||
pixels: [
|
||||
"########",
|
||||
"########",
|
||||
"########",
|
||||
"########",
|
||||
"########",
|
||||
"########",
|
||||
"########",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
|
||||
// White heart-on-red — uses index 2 (white) for the heart shape,
|
||||
// index 1 (red) for the surrounding fill. Stands out crisply
|
||||
// against the all-red Bar tiles.
|
||||
sprite Heart {
|
||||
pixels: [
|
||||
"########",
|
||||
"#%%##%%#",
|
||||
"%%%%%%%%",
|
||||
"%%%%%%%%",
|
||||
"#%%%%%%#",
|
||||
"##%%%%##",
|
||||
"###%%###",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
|
||||
// Yellow digit glyphs — all use index 3 (yellow) for the strokes
|
||||
// and index 1 (red) for the surrounding fill. Big enough that the
|
||||
// digit is readable even at NES resolution.
|
||||
sprite Digit0 {
|
||||
pixels: [
|
||||
"########",
|
||||
"##@@@@##",
|
||||
"#@####@#",
|
||||
"#@####@#",
|
||||
"#@####@#",
|
||||
"#@####@#",
|
||||
"##@@@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit1 {
|
||||
pixels: [
|
||||
"########",
|
||||
"###@@###",
|
||||
"##@@@###",
|
||||
"###@@###",
|
||||
"###@@###",
|
||||
"###@@###",
|
||||
"##@@@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit2 {
|
||||
pixels: [
|
||||
"########",
|
||||
"##@@@@##",
|
||||
"#@####@#",
|
||||
"####@@##",
|
||||
"##@@####",
|
||||
"#@######",
|
||||
"#@@@@@@#",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit3 {
|
||||
pixels: [
|
||||
"########",
|
||||
"##@@@@##",
|
||||
"#@####@#",
|
||||
"####@@##",
|
||||
"######@#",
|
||||
"#@####@#",
|
||||
"##@@@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit4 {
|
||||
pixels: [
|
||||
"########",
|
||||
"####@@##",
|
||||
"###@@@##",
|
||||
"##@##@##",
|
||||
"#@@@@@@#",
|
||||
"####@@##",
|
||||
"####@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit5 {
|
||||
pixels: [
|
||||
"########",
|
||||
"#@@@@@@#",
|
||||
"#@######",
|
||||
"#@@@@@##",
|
||||
"######@#",
|
||||
"#@####@#",
|
||||
"##@@@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit6 {
|
||||
pixels: [
|
||||
"########",
|
||||
"##@@@@##",
|
||||
"#@####@#",
|
||||
"#@@@@@##",
|
||||
"#@####@#",
|
||||
"#@####@#",
|
||||
"##@@@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit7 {
|
||||
pixels: [
|
||||
"########",
|
||||
"#@@@@@@#",
|
||||
"######@#",
|
||||
"#####@##",
|
||||
"####@###",
|
||||
"###@####",
|
||||
"###@####",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit8 {
|
||||
pixels: [
|
||||
"########",
|
||||
"##@@@@##",
|
||||
"#@####@#",
|
||||
"##@@@@##",
|
||||
"#@####@#",
|
||||
"#@####@#",
|
||||
"##@@@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
sprite Digit9 {
|
||||
pixels: [
|
||||
"########",
|
||||
"##@@@@##",
|
||||
"#@####@#",
|
||||
"##@@@@@#",
|
||||
"######@#",
|
||||
"#@####@#",
|
||||
"##@@@@##",
|
||||
"########"
|
||||
]
|
||||
}
|
||||
|
||||
// Yellow ball for the playfield, uses sp0 sub-palette
|
||||
// (index 1 = yellow against universal black).
|
||||
sprite Ball {
|
||||
pixels: [
|
||||
"..####..",
|
||||
".######.",
|
||||
"########",
|
||||
"########",
|
||||
"########",
|
||||
"########",
|
||||
".######.",
|
||||
"..####.."
|
||||
]
|
||||
}
|
||||
|
||||
background Playfield {
|
||||
// Pre-paint row 1 (where the HUD lives) with the solid Bar
|
||||
// tile so single-cell writes (hearts and digits) stand out
|
||||
// crisply against the uniform red background. Row 0 keeps the
|
||||
// default smiley so the attribute-painted strip has visible
|
||||
// chrome above the HUD content. Rows 2+ are zero-padded by
|
||||
// the parser to fill the rest of the nametable with smileys
|
||||
// — that's the playfield.
|
||||
legend {
|
||||
".": 0 // smiley (default)
|
||||
"B": 1 // Bar — declared first sprite, lands at tile 1
|
||||
}
|
||||
map: [
|
||||
"................................", // row 0: smiley chrome
|
||||
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" // row 1: red Bar canvas for HUD
|
||||
]
|
||||
}
|
||||
|
||||
// Tile-index constants matching the sprite-declaration order.
|
||||
const BAR_TILE: u8 = 1
|
||||
const HEART_TILE: u8 = 2
|
||||
const DIGIT_BASE: u8 = 3 // Digit N is at tile DIGIT_BASE + N
|
||||
|
||||
// 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.
|
||||
// HUD state.
|
||||
var score: u8 = 0
|
||||
var lives: u8 = 5
|
||||
var last_score: u8 = 255 // 255 forces an initial paint on frame 0
|
||||
|
|
@ -71,14 +258,20 @@ 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.
|
||||
// ── One-shot: paint all 8 top-row attribute bytes so the
|
||||
// entire top four rows pick up sub-palette 1 (red HUD
|
||||
// palette). `0b01010101` means all four 16×16 sub-
|
||||
// quadrants of each metatile use sub-palette 1.
|
||||
if attr_set == 0 {
|
||||
nt_attr(0, 0, 0b01010101)
|
||||
attr_set = 1
|
||||
nt_attr(0, 0, 0b01010101)
|
||||
nt_attr(4, 0, 0b01010101)
|
||||
nt_attr(8, 0, 0b01010101)
|
||||
nt_attr(12, 0, 0b01010101)
|
||||
nt_attr(16, 0, 0b01010101)
|
||||
nt_attr(20, 0, 0b01010101)
|
||||
nt_attr(24, 0, 0b01010101)
|
||||
nt_attr(28, 0, 0b01010101)
|
||||
}
|
||||
|
||||
// ── Playfield: bounce the ball, count bounces as score. ──
|
||||
|
|
@ -87,12 +280,14 @@ on frame {
|
|||
if bx >= 240 {
|
||||
dx = 0
|
||||
score += 1
|
||||
if score >= 10 { score = 0 }
|
||||
}
|
||||
} else {
|
||||
bx -= 1
|
||||
if bx == 16 {
|
||||
dx = 1
|
||||
score += 1
|
||||
if score >= 10 { score = 0 }
|
||||
}
|
||||
}
|
||||
if dy == 1 {
|
||||
|
|
@ -104,8 +299,7 @@ on frame {
|
|||
}
|
||||
draw Ball at: (bx, by)
|
||||
|
||||
// ── HUD: tick the lives timer. One "life" ticks every 60
|
||||
// frames (~1 sec); at zero, reset to 5. ──
|
||||
// ── HUD: tick lives once per second; reset to 5 at zero. ──
|
||||
life_tick += 1
|
||||
if life_tick >= 60 {
|
||||
life_tick = 0
|
||||
|
|
@ -117,34 +311,21 @@ on frame {
|
|||
}
|
||||
|
||||
// ── 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.
|
||||
// `nt_set` for the score digit, `nt_fill_h` for the
|
||||
// lives bar (plus a second fill to erase the stale
|
||||
// tail with the Bar tile so the display "shrinks"
|
||||
// cleanly). The shadow-compare skips the write on the
|
||||
// ~58 of 60 frames where 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)
|
||||
nt_set(28, 1, DIGIT_BASE + score)
|
||||
}
|
||||
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)
|
||||
nt_fill_h(2, 1, lives, HEART_TILE)
|
||||
if lives < 5 {
|
||||
var blanks: u8 = 5 - lives
|
||||
nt_fill_h(2 + lives, 1, blanks, 1)
|
||||
nt_fill_h(2 + lives, 1, blanks, BAR_TILE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue