mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
Upgrades the platformer's "live coin count" into a proper heads-up
display that stays pinned to the top of the viewport while the
nametable scrolls. Left side: coin icon + two-digit stomp tally.
Right side: red heart icon + single-digit lives counter. Both ride
through the GameOver screen without jumping position, so the death
banner reads as a continuation of the same run.
Wire-up: three new cross-state bits — score now accumulates across
lives, `lives` starts at 3 and decrements in `GameOver.on_enter`,
and the GameOver → Playing retry bounces to Title instead when the
last heart is spent (Title's `on_enter` refills both).
Tile pipeline: ten decimal digits + a heart glyph added to the
committed Tileset (generator source in `scripts/gen_platformer_tiles.rs`
kept in sync). Digits use `c` (white) so they read against the
sky; the heart uses `a` (red) to match the cap/brick palette.
Division workaround: the obvious `stomp_count / 10` / `% 10` pair
miscompiles near state transitions — the built ROM cycles
Title → Playing → Title once per blink period with Playing
surviving exactly one frame. Swapping both calls for repeated
`while r >= 10 { r -= 10 }` helpers fixes it. Documented as a
new entry in `docs/future-work.md` so the next person reaching
for `/` or `%` knows to check there first.
Goldens, docs/platformer.gif, and the top-level + examples README
entries all refreshed in the same commit.
617 lines
15 KiB
Rust
617 lines
15 KiB
Rust
//! One-shot generator for the CHR tiles and nametable used by
|
||
//! `examples/platformer.ne`.
|
||
//!
|
||
//! Run with `cargo run --bin gen_platformer_tiles`. The output is
|
||
//! intended to be pasted into `platformer.ne` under the
|
||
//! `sprite Tileset { pixels: [...] }` and
|
||
//! `background Level { legend { ... } map: [...] palette_map: [...] }`
|
||
//! blocks. Keeping the source of truth here (instead of
|
||
//! hand-maintained ASCII in the `.ne` file) ensures the tile art,
|
||
//! the nametable, and the named tile indices all stay in sync.
|
||
//!
|
||
//! Tiles are defined as 8×8 ASCII art where each character selects
|
||
//! one of 4 sub-palette slots:
|
||
//! '.' = colour 0 (sky / transparent for sprites)
|
||
//! 'a' = colour 1
|
||
//! 'b' = colour 2
|
||
//! 'c' = colour 3
|
||
//!
|
||
//! Both the generator *and* the `NEScript` parser accept `.abc` as
|
||
//! aliases for `.#%@`, so the output is ready to paste directly
|
||
//! into a `pixels:` block without translation.
|
||
|
||
/// A single 8×8 tile description: name + ASCII art.
|
||
struct Tile {
|
||
name: &'static str,
|
||
art: &'static str,
|
||
}
|
||
|
||
/// All tiles referenced by the platformer example. Order matters —
|
||
/// tile 0 is the default smiley reserved by the linker, so entry 0
|
||
/// here lands at CHR index 1, entry 1 at index 2, and so on.
|
||
///
|
||
/// Colour conventions (must line up with the palette + attribute
|
||
/// layout in `examples/platformer.ne`):
|
||
/// sprite sub-palette 0 (player / enemy / coin):
|
||
/// a = red/cap, b = peach/skin, c = white/highlight
|
||
/// bg sub-palette 0 (sky row): a = white, b = light gray, c = black
|
||
/// bg sub-palette 1 (block row): a = red, b = peach, c = dark red
|
||
/// bg sub-palette 2 (ground row): a = light green, b = light brown,
|
||
/// c = dark brown
|
||
const TILES: &[Tile] = &[
|
||
Tile {
|
||
name: "Player head L",
|
||
art: "\
|
||
..aaaaaa
|
||
.aaaaaaa
|
||
.aaabbbb
|
||
.aabbbbc
|
||
aabbcccc
|
||
aabbccbc
|
||
aabbbbbb
|
||
aabbbbbb",
|
||
},
|
||
Tile {
|
||
name: "Player head R",
|
||
art: "\
|
||
aaaaaa..
|
||
aaaaaaa.
|
||
bbbbaaa.
|
||
cbbbbbaa
|
||
cccccbbb
|
||
bcbbccbb
|
||
bbbbbbbb
|
||
bbbbbbbb",
|
||
},
|
||
Tile {
|
||
name: "Player body L",
|
||
art: "\
|
||
.aaaaaaa
|
||
aaacaaaa
|
||
aaaccaaa
|
||
aaaaaaaa
|
||
.bbbbbbb
|
||
.bbbbbbb
|
||
.bb..bbb
|
||
aaa..bbb",
|
||
},
|
||
Tile {
|
||
name: "Player body R",
|
||
art: "\
|
||
aaaaaaa.
|
||
aaaacaaa
|
||
aaaccaaa
|
||
aaaaaaaa
|
||
bbbbbbb.
|
||
bbbbbbb.
|
||
bbb..bb.
|
||
bbb..aaa",
|
||
},
|
||
Tile {
|
||
name: "Enemy",
|
||
art: "\
|
||
..aaaa..
|
||
.aaaaaa.
|
||
.aacbaa.
|
||
aacccaaa
|
||
abbccbba
|
||
.aaaaaa.
|
||
a.aaaa.a
|
||
a..aa..a",
|
||
},
|
||
Tile {
|
||
name: "Coin",
|
||
art: "\
|
||
...bb...
|
||
..bbbb..
|
||
.bbccbb.
|
||
.bcbbcb.
|
||
.bcbbcb.
|
||
.bbccbb.
|
||
..bbbb..
|
||
...bb...",
|
||
},
|
||
// Grass top (sub-palette 2: a=green, c=dark brown).
|
||
Tile {
|
||
name: "Grass top",
|
||
art: "\
|
||
aaaaaaaa
|
||
a.aaa.aa
|
||
aaaaaaaa
|
||
accacaca
|
||
ccccaccc
|
||
cccccccc
|
||
cbccccbc
|
||
cccccccc",
|
||
},
|
||
// Dirt (sub-palette 2: c=dark brown bulk, b=light brown speckles).
|
||
Tile {
|
||
name: "Dirt",
|
||
art: "\
|
||
cccccccc
|
||
cbccccbc
|
||
cccccccc
|
||
ccccbccc
|
||
cccccccc
|
||
cbccccbc
|
||
ccccbccc
|
||
cccccccc",
|
||
},
|
||
// Brick (sub-palette 1: a=red, c=dark red mortar).
|
||
Tile {
|
||
name: "Brick",
|
||
art: "\
|
||
cccccccc
|
||
caaaaaca
|
||
caaaaaca
|
||
caaaaaca
|
||
cccccccc
|
||
aaacaaaa
|
||
aaacaaaa
|
||
aaacaaaa",
|
||
},
|
||
// Cloud left (sub-palette 0: a=white, b=light gray shade).
|
||
Tile {
|
||
name: "Cloud L",
|
||
art: "\
|
||
........
|
||
...aaa..
|
||
..aaaaa.
|
||
.aaaaaaa
|
||
.aaaaaaa
|
||
.bbaaaaa
|
||
..bbbbba
|
||
.....bbb",
|
||
},
|
||
// Cloud right (sub-palette 0).
|
||
Tile {
|
||
name: "Cloud R",
|
||
art: "\
|
||
........
|
||
.aaa....
|
||
aaaaa...
|
||
aaaaaaa.
|
||
aaaaaaa.
|
||
aaaaabb.
|
||
abbbbb..
|
||
bbb.....",
|
||
},
|
||
// Hill (sub-palette 2: a=green, b=light brown shade).
|
||
Tile {
|
||
name: "Hill",
|
||
art: "\
|
||
........
|
||
....aa..
|
||
...aaaa.
|
||
..aaaaaa
|
||
..abaaaa
|
||
.abbabaa
|
||
.aaababa
|
||
aabbabba",
|
||
},
|
||
// Bush (sub-palette 2: a=green, c=dark brown outline).
|
||
Tile {
|
||
name: "Bush",
|
||
art: "\
|
||
........
|
||
...aa...
|
||
..aaaa..
|
||
.aaaaac.
|
||
aaaaaaca
|
||
aaaaacca
|
||
aaaaacca
|
||
acacacac",
|
||
},
|
||
// Q Block (sub-palette 1: a=red frame, b=peach face, c=dark red).
|
||
Tile {
|
||
name: "Q Block",
|
||
art: "\
|
||
cccccccc
|
||
caaaaaac
|
||
cabbbbac
|
||
cabccbac
|
||
cabcbbac
|
||
cabbbbac
|
||
caaaaaac
|
||
cccccccc",
|
||
},
|
||
// Sky (all transparent — renders as the universal bg colour).
|
||
Tile {
|
||
name: "Sky (blank)",
|
||
art: "\
|
||
........
|
||
........
|
||
........
|
||
........
|
||
........
|
||
........
|
||
........
|
||
........",
|
||
},
|
||
// ── HUD glyphs (sprite-only; palette sp0: a=red, c=white) ──
|
||
// Each digit is a 4-wide outline centred in an 8×8 cell and
|
||
// drawn in white ('c') so it reads crisply over the sky
|
||
// backdrop at the top of the playfield. The compiler treats
|
||
// '.' as transparent for sprites, so the sky shows through.
|
||
Tile {
|
||
name: "Digit 0",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
..c..c..
|
||
..c..c..
|
||
..c..c..
|
||
..c..c..
|
||
..cccc..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 1",
|
||
art: "\
|
||
........
|
||
...cc...
|
||
..ccc...
|
||
...cc...
|
||
...cc...
|
||
...cc...
|
||
..cccc..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 2",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
.....c..
|
||
....cc..
|
||
...cc...
|
||
..cc....
|
||
..cccc..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 3",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
.....c..
|
||
...ccc..
|
||
.....c..
|
||
.....c..
|
||
..cccc..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 4",
|
||
art: "\
|
||
........
|
||
..c..c..
|
||
..c..c..
|
||
..cccc..
|
||
.....c..
|
||
.....c..
|
||
.....c..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 5",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
..c.....
|
||
..cccc..
|
||
.....c..
|
||
..c..c..
|
||
..cccc..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 6",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
..c.....
|
||
..cccc..
|
||
..c..c..
|
||
..c..c..
|
||
..cccc..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 7",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
.....c..
|
||
....c...
|
||
...c....
|
||
..c.....
|
||
..c.....
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 8",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
..c..c..
|
||
..cccc..
|
||
..c..c..
|
||
..c..c..
|
||
..cccc..
|
||
........",
|
||
},
|
||
Tile {
|
||
name: "Digit 9",
|
||
art: "\
|
||
........
|
||
..cccc..
|
||
..c..c..
|
||
..cccc..
|
||
.....c..
|
||
.....c..
|
||
..cccc..
|
||
........",
|
||
},
|
||
// Small red heart for the lives readout. Uses 'a' (red) so the
|
||
// shape pops against the sky and matches the cap/brick red.
|
||
Tile {
|
||
name: "Heart",
|
||
art: "\
|
||
........
|
||
.aa..aa.
|
||
aaaaaaaa
|
||
aaaaaaaa
|
||
.aaaaaa.
|
||
..aaaa..
|
||
...aa...
|
||
........",
|
||
},
|
||
];
|
||
|
||
// ── Named CHR tile indices used by the nametable layout below ──
|
||
// (Player/enemy/coin tile indices are referenced by name only in
|
||
// the .ne file's `draw` statements, not by the nametable here, so
|
||
// the nametable-only constants live here.)
|
||
const GRASS: u8 = 7;
|
||
const DIRT: u8 = 8;
|
||
const BRICK: u8 = 9;
|
||
const CLOUD_L: u8 = 10;
|
||
const CLOUD_R: u8 = 11;
|
||
const HILL: u8 = 12;
|
||
const BUSH: u8 = 13;
|
||
const QBLOCK: u8 = 14;
|
||
const SKY: u8 = 15;
|
||
|
||
/// Validate that a tile's ASCII art is well-formed (8 rows × 8 cols,
|
||
/// only the legal `.abc` characters). Pixel→CHR encoding now happens
|
||
/// inside the `NEScript` parser when it sees the `pixels:` block, so
|
||
/// this generator's job is just to make sure the strings we paste
|
||
/// are syntactically valid.
|
||
fn validate_tile_art(name: &str, art: &str) {
|
||
let rows: Vec<&str> = art.lines().filter(|l| !l.is_empty()).collect();
|
||
assert_eq!(
|
||
rows.len(),
|
||
8,
|
||
"tile '{name}': expected 8 rows, got {}",
|
||
rows.len()
|
||
);
|
||
for (y, row) in rows.iter().enumerate() {
|
||
assert_eq!(
|
||
row.len(),
|
||
8,
|
||
"tile '{name}' row {y}: expected 8 cols, got {row:?}"
|
||
);
|
||
for ch in row.chars() {
|
||
assert!(
|
||
matches!(ch, '.' | 'a' | 'b' | 'c'),
|
||
"tile '{name}' row {y}: invalid character '{ch}'"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build a 32×30 nametable for a static Mario-ish level vista.
|
||
/// Horizontal scrolling pans this single nametable via `scroll()`,
|
||
/// so it only needs to look interesting at any X offset.
|
||
fn build_nametable() -> [u8; 960] {
|
||
let mut nt = [SKY; 960];
|
||
let set = |nt: &mut [u8; 960], y: usize, x: usize, t: u8| {
|
||
nt[y * 32 + x] = t;
|
||
};
|
||
|
||
// Row 5 & 6: two clouds at different X positions for parallax feel.
|
||
for &cx in &[4usize, 20] {
|
||
set(&mut nt, 5, cx, CLOUD_L);
|
||
set(&mut nt, 5, cx + 1, CLOUD_R);
|
||
}
|
||
set(&mut nt, 6, 12, CLOUD_L);
|
||
set(&mut nt, 6, 13, CLOUD_R);
|
||
|
||
// Row 15: a suspended brick platform with a Q-block in the middle.
|
||
for x in 6..=9 {
|
||
let t = if x == 8 { QBLOCK } else { BRICK };
|
||
set(&mut nt, 15, x, t);
|
||
}
|
||
for x in 18..=20 {
|
||
set(&mut nt, 15, x, BRICK);
|
||
}
|
||
|
||
// Row 20: hills sitting behind the grass line.
|
||
for x in 2..=4 {
|
||
set(&mut nt, 20, x, HILL);
|
||
}
|
||
for x in 22..=25 {
|
||
set(&mut nt, 20, x, HILL);
|
||
}
|
||
|
||
// Row 21: bushes on top of the grass.
|
||
for x in 10..=12 {
|
||
set(&mut nt, 21, x, BUSH);
|
||
}
|
||
for x in 27..=28 {
|
||
set(&mut nt, 21, x, BUSH);
|
||
}
|
||
|
||
// Row 22: grass top — a full horizontal line.
|
||
for x in 0..32 {
|
||
set(&mut nt, 22, x, GRASS);
|
||
}
|
||
|
||
// Row 23-29: dirt rows with a few buried bricks for texture.
|
||
for y in 23..30 {
|
||
for x in 0..32 {
|
||
set(&mut nt, y, x, DIRT);
|
||
}
|
||
}
|
||
for &(y, x) in &[(24usize, 5usize), (25, 11), (24, 18), (25, 24), (23, 30)] {
|
||
set(&mut nt, y, x, BRICK);
|
||
}
|
||
|
||
nt
|
||
}
|
||
|
||
/// Build a 64-byte attribute table that pairs every 2×2 metatile
|
||
/// with a background sub-palette.
|
||
///
|
||
/// The attribute table covers a 16×15 grid of 16×16 metatiles. Each
|
||
/// byte encodes 4 quadrants (top-left, top-right, bottom-left,
|
||
/// bottom-right) of a 32×32-pixel "super-metatile" with 2 bits
|
||
/// apiece (palette index 0..3). For this demo we simply pick a
|
||
/// sub-palette per screen row region:
|
||
/// - top region (sky): sub-palette 0
|
||
/// - mid region (hills/blocks): sub-palette 1
|
||
/// - ground region (grass/dirt): sub-palette 2
|
||
fn build_attributes() -> [u8; 64] {
|
||
let mut attr = [0u8; 64];
|
||
// The attribute table is 8×8 bytes. Row `ay` covers nametable
|
||
// rows 4*ay..4*ay+3. Palette layout:
|
||
// rows 0-11 (ay 0-2): sub-palette 0 (sky/clouds: whites)
|
||
// rows 12-15 (ay 3): sub-palette 1 (brick/Q-block row)
|
||
// rows 16-19 (ay 4): sub-palette 0 (more sky above the hills)
|
||
// rows 20-29 (ay 5-7): sub-palette 2 (grass/hills/bushes/dirt)
|
||
for ay in 0..8 {
|
||
let pal: u8 = match ay {
|
||
0..=2 => 0,
|
||
3 => 1,
|
||
4 => 0,
|
||
_ => 2, // 5, 6, 7
|
||
};
|
||
let quad = pal & 0b11;
|
||
let byte = quad | (quad << 2) | (quad << 4) | (quad << 6);
|
||
for ax in 0..8 {
|
||
attr[ay * 8 + ax] = byte;
|
||
}
|
||
}
|
||
attr
|
||
}
|
||
|
||
fn main() {
|
||
// ── CHR tiles as pixel-art strings ──────────────────────────
|
||
//
|
||
// `NEScript`'s `sprite Name { pixels: [...] }` accepts one string
|
||
// per pixel row; the parser splits the grid into 8×8 tiles in
|
||
// row-major reading order, so emitting each 8-row tile
|
||
// sequentially (stacked vertically, 1 tile wide × 15 tiles tall)
|
||
// produces CHR tile indices 1..15 in the same order as the
|
||
// TILES array.
|
||
println!("// ── CHR tiles (paste into sprite Tileset {{ pixels: [...] }}) ──");
|
||
println!(" pixels: [");
|
||
for (i, tile) in TILES.iter().enumerate() {
|
||
validate_tile_art(tile.name, tile.art);
|
||
let tile_idx = i + 1;
|
||
println!(" // tile {tile_idx}: {}", tile.name);
|
||
for row in tile.art.lines().filter(|l| !l.is_empty()) {
|
||
println!(" \"{row}\",");
|
||
}
|
||
}
|
||
println!(" ]\n");
|
||
|
||
// ── Background tilemap via legend + map form ────────────────
|
||
//
|
||
// Each distinct nametable tile gets one easy-to-read legend
|
||
// character. `.` is the most common (sky), so use it for the
|
||
// default fill to keep the `map:` strings tidy. The compiler
|
||
// validates every character — any typo in the rows below is
|
||
// caught by the parser rather than by a render bug at runtime.
|
||
println!("// ── Nametable (paste into background Level {{ legend/map/palette_map }}) ──");
|
||
println!(" legend {{");
|
||
println!(" \".\": 15 // sky (blank)");
|
||
println!(" \"<\": 10 // cloud left half");
|
||
println!(" \">\": 11 // cloud right half");
|
||
println!(" \"#\": 9 // brick");
|
||
println!(" \"Q\": 14 // question block");
|
||
println!(" \"^\": 12 // hill silhouette");
|
||
println!(" \"*\": 13 // bush");
|
||
println!(" \"=\": 7 // grass top");
|
||
println!(" \"%\": 8 // dirt");
|
||
println!(" }}");
|
||
println!();
|
||
println!(" map: [");
|
||
let nt = build_nametable();
|
||
assert_eq!(nt.len(), 960);
|
||
let legend = legend_char_for;
|
||
for row in 0..30 {
|
||
let mut s = String::from(" \"");
|
||
for col in 0..32 {
|
||
s.push(legend(nt[row * 32 + col]));
|
||
}
|
||
s.push_str("\",");
|
||
println!("{s}");
|
||
}
|
||
println!(" ]\n");
|
||
|
||
// ── Palette map (auto-packs the 64-byte attribute table) ────
|
||
//
|
||
// `palette_map:` is a 16-wide grid of sub-palette digits (one
|
||
// per 16×16 metatile). The parser packs pairs of rows into the
|
||
// 8×8 attribute table automatically, so we emit the metatile
|
||
// grid directly and skip the awkward `(br<<6)|(bl<<4)|(tr<<2)|tl`
|
||
// bit-packing the old raw-bytes form demanded.
|
||
//
|
||
// The attribute table covers 16 metatile rows (8 attr rows ×
|
||
// 2 each); the last row sits below the visible 240-scanline
|
||
// screen but the PPU still reads it, so we emit all 16 rows to
|
||
// match whatever the original hand-packed attribute byte was.
|
||
println!("// ── Attributes (paste into background Level {{ palette_map: [...] }}) ──");
|
||
println!(" palette_map: [");
|
||
let attr = build_attributes();
|
||
for my in 0..16 {
|
||
let mut s = String::from(" \"");
|
||
for mx in 0..16 {
|
||
// Recover the sub-palette index for metatile (mx, my)
|
||
// from the packed attribute table. Each byte covers a
|
||
// 2×2 metatile block at attr[(my/2)*8 + mx/2], with
|
||
// quadrants laid out as BR BL TR TL in the high bits.
|
||
let byte = attr[(my / 2) * 8 + mx / 2];
|
||
let quadrant = match (mx % 2, my % 2) {
|
||
(0, 0) => byte & 0b11,
|
||
(1, 0) => (byte >> 2) & 0b11,
|
||
(0, 1) => (byte >> 4) & 0b11,
|
||
_ => (byte >> 6) & 0b11,
|
||
};
|
||
s.push(char::from(b'0' + quadrant));
|
||
}
|
||
s.push_str("\",");
|
||
println!("{s}");
|
||
}
|
||
println!(" ]");
|
||
}
|
||
|
||
/// Map a CHR tile index back to its legend character in the
|
||
/// generated `map:` block. Must stay in sync with the `legend { ... }`
|
||
/// block printed by `main()`.
|
||
fn legend_char_for(tile: u8) -> char {
|
||
match tile {
|
||
15 => '.',
|
||
10 => '<',
|
||
11 => '>',
|
||
9 => '#',
|
||
14 => 'Q',
|
||
12 => '^',
|
||
13 => '*',
|
||
7 => '=',
|
||
8 => '%',
|
||
other => panic!("no legend char for tile {other}"),
|
||
}
|
||
}
|