mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
parser/lowering: declarative metasprites for multi-tile sprite groups
Multi-tile sprites used to require one hand-written `draw` per tile,
e.g. the four-call sequence in `examples/platformer.ne`'s
`draw_player()`. The new `metasprite Name { ... }` declaration
collects parallel `dx`/`dy`/`frame` arrays plus a reference to the
underlying sprite, and `draw Name at: (x, y)` expands to one OAM
slot per tile in the IR lowering — the codegen sees N regular
DrawSprite ops, so the runtime OAM cursor allocator picks them up
without any metasprite-specific awareness.
The metasprite's `frame:` array is interpreted *relative to the
underlying sprite's base tile*: index 0 means "the first tile this
sprite owns", which is the natural reading for a 16×16 hero whose
pixel art the asset resolver split into four consecutive tiles.
The lowering walks `program.sprites` to compute base tile indices
the same way `assets::resolve_sprites` would, then folds the base
into each frame entry before storing the metasprite info. Sprites
sourced from external `@chr(...)` / `@binary(...)` files whose
bytes aren't available at parse time fall back to a one-tile
assumption — those programs are rare and can declare metasprites
against pixel-art sprites instead.
The new `examples/metasprite_demo.ne` declares a 16×16 hero sprite
and arranges its four tiles into a metasprite, then sweeps the
hero across the screen so the harness captures it mid-motion.
The new keyword is added to the lexer/token list, and the parser
accepts `sprite:` (the otherwise-keyword) as a property name in
metasprite bodies so the natural spelling parses.
https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
This commit is contained in:
parent
9878b7d87d
commit
6b080316a4
17 changed files with 619 additions and 0 deletions
|
|
@ -91,6 +91,7 @@ start Main
|
|||
| [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` |
|
||||
| [`noise_triangle_sfx.ne`](examples/noise_triangle_sfx.ne) | Noise and triangle channel sfx via `channel: noise` / `channel: triangle` on `sfx` blocks |
|
||||
| [`sfx_pitch_envelope.ne`](examples/sfx_pitch_envelope.ne) | Per-frame pulse `pitch:` arrays — the audio tick walks the pitch envelope in lockstep with the volume envelope and writes `$4002` on every NMI for a frequency-sweeping siren tone |
|
||||
| [`metasprite_demo.ne`](examples/metasprite_demo.ne) | `metasprite Hero { sprite: ..., dx: [...], dy: [...], frame: [...] }` declarative multi-tile groups — `draw Hero at: (x, y)` expands to one OAM slot per tile so 16×16 sprites stop needing four hand-written `draw` statements |
|
||||
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds |
|
||||
|
||||
## Compiler Commands
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
|
|||
| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. |
|
||||
| `noise_triangle_sfx.ne` | `channel: noise`, `channel: triangle` on `sfx` blocks | Demonstrates the noise and triangle sfx channels. Declares one noise burst and one triangle bass note, plays each on a timer so the emulator harness captures both the pixel output and the APU state. |
|
||||
| `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_<name>` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. |
|
||||
| `metasprite_demo.ne` | declarative multi-tile sprites | A 16×16 hero sprite split into a `metasprite Hero { sprite: Hero16, dx: [...], dy: [...], frame: [...] }` declaration. `draw Hero at: (px, py)` then expands to one `DrawSprite` op per tile in the IR lowering, each with its dx/dy added to the user's anchor point and the frame offset by the underlying sprite's base tile. The codegen needs no metasprite-specific support — it sees N regular draws and the OAM cursor allocator handles the slots. |
|
||||
| `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). |
|
||||
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
|
||||
|
||||
|
|
|
|||
86
examples/metasprite_demo.ne
Normal file
86
examples/metasprite_demo.ne
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// Metasprite demo — declarative multi-tile sprite groups.
|
||||
//
|
||||
// The `metasprite` keyword bundles a list of (dx, dy, frame)
|
||||
// offsets into one named entity. `draw Hero at: (x, y)` then
|
||||
// expands to one OAM slot per tile, so user code stops having
|
||||
// to hand-write the four `draw Tileset at: (x+8, y+8) frame:
|
||||
// TILE_PLAYER_BR` calls that platformer.ne uses today. Each
|
||||
// tile inherits the OAM-cursor allocator from the runtime, so
|
||||
// metasprites compose with regular `draw` statements without
|
||||
// any extra wiring.
|
||||
//
|
||||
// Build: cargo run -- build examples/metasprite_demo.ne
|
||||
|
||||
game "Metasprite Demo" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
// 16×16 hero sprite drawn as ASCII pixel art. The parser splits
|
||||
// it into four 8×8 tiles in row-major order: tile 0 = top-left,
|
||||
// tile 1 = top-right, tile 2 = bottom-left, tile 3 = bottom-right.
|
||||
// We refer to those four tiles by base offset below.
|
||||
sprite Hero16 {
|
||||
pixels: [
|
||||
"..####....####..",
|
||||
".######..######.",
|
||||
"###@##....##@###",
|
||||
"###@##....##@###",
|
||||
"################",
|
||||
"################",
|
||||
".##############.",
|
||||
"..############..",
|
||||
"...##########...",
|
||||
"....########....",
|
||||
"....##....##....",
|
||||
"....##....##....",
|
||||
"....##....##....",
|
||||
"....##....##....",
|
||||
"...####..####...",
|
||||
"..######..######"
|
||||
]
|
||||
}
|
||||
|
||||
// Arrange Hero16's four tiles into a 16×16 metasprite. Each
|
||||
// row in the metasprite block is logically one OAM slot; the
|
||||
// `dx` / `dy` arrays carry the per-tile offset from the
|
||||
// metasprite's anchor point and `frame` carries the tile index.
|
||||
//
|
||||
// `sprite: Hero16` resolves to the base tile index reserved by
|
||||
// the asset resolver; the four entries below pick consecutive
|
||||
// tiles starting at that base — i.e. the four 8×8 tiles the
|
||||
// pixel-art block was sliced into.
|
||||
metasprite Hero {
|
||||
sprite: Hero16
|
||||
dx: [0, 8, 0, 8]
|
||||
dy: [0, 0, 8, 8]
|
||||
frame: [0, 1, 2, 3]
|
||||
}
|
||||
|
||||
var px: u8 = 120
|
||||
var py: u8 = 96
|
||||
var dir: u8 = 0 // 0 = right, 1 = left
|
||||
|
||||
on frame {
|
||||
// Bounce horizontally between two rails so the harness
|
||||
// captures movement at frame 180 regardless of which
|
||||
// direction we started in.
|
||||
if dir == 0 {
|
||||
px += 1
|
||||
if px == 200 {
|
||||
dir = 1
|
||||
}
|
||||
} else {
|
||||
px -= 1
|
||||
if px == 32 {
|
||||
dir = 0
|
||||
}
|
||||
}
|
||||
|
||||
// One `draw Hero` expands to four `draw Hero16` calls under
|
||||
// the hood. Compare with the four-line sequence in
|
||||
// platformer.ne's `draw_player()` — same OAM layout, much
|
||||
// less typing.
|
||||
draw Hero at: (px, py)
|
||||
}
|
||||
|
||||
start Main
|
||||
BIN
examples/metasprite_demo.nes
Normal file
BIN
examples/metasprite_demo.nes
Normal file
Binary file not shown.
|
|
@ -396,6 +396,68 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
|
||||
// Validate metasprite declarations: the parallel offset
|
||||
// arrays must all be the same length, the named sprite
|
||||
// must exist, and the metasprite name must be unique
|
||||
// (against other metasprites and against sprites — both
|
||||
// share the same `draw` lookup namespace).
|
||||
let mut seen_metasprites = HashSet::new();
|
||||
let sprite_names: HashSet<String> =
|
||||
program.sprites.iter().map(|s| s.name.clone()).collect();
|
||||
for ms in &program.metasprites {
|
||||
if !seen_metasprites.insert(ms.name.clone()) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0501,
|
||||
format!("duplicate metasprite '{}'", ms.name),
|
||||
ms.span,
|
||||
));
|
||||
}
|
||||
if sprite_names.contains(&ms.name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0501,
|
||||
format!(
|
||||
"metasprite '{}' shadows a sprite with the same name; pick a unique identifier",
|
||||
ms.name
|
||||
),
|
||||
ms.span,
|
||||
));
|
||||
}
|
||||
if !sprite_names.contains(&ms.sprite_name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"metasprite '{}' references unknown sprite '{}'",
|
||||
ms.name, ms.sprite_name
|
||||
),
|
||||
ms.span,
|
||||
));
|
||||
}
|
||||
if ms.dx.len() != ms.dy.len() || ms.dx.len() != ms.frame.len() {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"metasprite '{}' has mismatched array lengths: \
|
||||
dx={}, dy={}, frame={} — all three must be equal",
|
||||
ms.name,
|
||||
ms.dx.len(),
|
||||
ms.dy.len(),
|
||||
ms.frame.len()
|
||||
),
|
||||
ms.span,
|
||||
));
|
||||
}
|
||||
if ms.dx.is_empty() {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"metasprite '{}' is empty — declare at least one tile",
|
||||
ms.name
|
||||
),
|
||||
ms.span,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Register functions as symbols
|
||||
for fun in &program.functions {
|
||||
self.register_fun(fun);
|
||||
|
|
|
|||
|
|
@ -489,6 +489,127 @@ fn analyze_struct_with_unknown_inner_struct_errors() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_metasprite_ok() {
|
||||
let result = analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sprite Tile {
|
||||
pixels: [
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@"
|
||||
]
|
||||
}
|
||||
metasprite Hero {
|
||||
sprite: Tile
|
||||
dx: [0, 8]
|
||||
dy: [0, 0]
|
||||
frame: [0, 0]
|
||||
}
|
||||
on frame { draw Hero at: (10, 10) wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
// Sanity: the metasprite was kept around in the program.
|
||||
// (The analyzer doesn't move declarations into AnalysisResult,
|
||||
// so we only check that no errors were emitted; the
|
||||
// lowering test below validates the expansion path.)
|
||||
assert!(result.diagnostics.iter().all(|d| !d.is_error()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_metasprite_unknown_sprite_errors() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
metasprite Hero {
|
||||
sprite: NotASprite
|
||||
dx: [0]
|
||||
dy: [0]
|
||||
frame: [0]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0201),
|
||||
"metasprite referencing an unknown sprite should emit E0201, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_metasprite_mismatched_array_lengths_errors() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sprite Tile {
|
||||
pixels: [
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@"
|
||||
]
|
||||
}
|
||||
metasprite Hero {
|
||||
sprite: Tile
|
||||
dx: [0, 8, 0]
|
||||
dy: [0, 0]
|
||||
frame: [0, 1, 2]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0201),
|
||||
"mismatched dx/dy/frame lengths should emit E0201, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_metasprite_empty_errors() {
|
||||
let errors = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sprite Tile {
|
||||
pixels: [
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@"
|
||||
]
|
||||
}
|
||||
metasprite Hero {
|
||||
sprite: Tile
|
||||
dx: []
|
||||
dy: []
|
||||
frame: []
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
errors.contains(&ErrorCode::E0201),
|
||||
"empty metasprite should emit E0201, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_struct_with_array_of_structs_is_rejected() {
|
||||
// Arrays of structs aren't supported yet — the synthetic-
|
||||
|
|
|
|||
|
|
@ -770,6 +770,7 @@ mod tests {
|
|||
sprites: Vec::new(),
|
||||
palettes: Vec::new(),
|
||||
backgrounds: Vec::new(),
|
||||
metasprites: Vec::new(),
|
||||
sfx: Vec::new(),
|
||||
music: Vec::new(),
|
||||
banks: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -215,6 +215,7 @@ mod tests {
|
|||
sprites: vec![sprite],
|
||||
palettes: Vec::new(),
|
||||
backgrounds: Vec::new(),
|
||||
metasprites: Vec::new(),
|
||||
sfx: Vec::new(),
|
||||
music: Vec::new(),
|
||||
banks: Vec::new(),
|
||||
|
|
@ -293,6 +294,7 @@ mod tests {
|
|||
sprites: Vec::new(),
|
||||
palettes: Vec::new(),
|
||||
backgrounds: Vec::new(),
|
||||
metasprites: Vec::new(),
|
||||
sfx: Vec::new(),
|
||||
music: Vec::new(),
|
||||
banks: Vec::new(),
|
||||
|
|
|
|||
|
|
@ -46,6 +46,23 @@ struct LoweringContext {
|
|||
/// by binary-op, compare, and assignment lowering when they
|
||||
/// need to decide between `Add`/`Add16`, etc.
|
||||
wide_hi: HashMap<IrTemp, IrTemp>,
|
||||
/// Captured metasprite declarations keyed by name. When a
|
||||
/// `Statement::Draw` names a metasprite (rather than a flat
|
||||
/// sprite), the lowering expands it inline into one
|
||||
/// [`IrOp::DrawSprite`] per tile, with x/y offsets folded into
|
||||
/// the per-tile coordinates and the metasprite's `frame:`
|
||||
/// entry used as the literal frame index. Storing the lookup
|
||||
/// here keeps the per-statement lowering simple and avoids
|
||||
/// having to thread the program through every helper.
|
||||
metasprites: HashMap<String, MetaspriteInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct MetaspriteInfo {
|
||||
sprite_name: String,
|
||||
dx: Vec<u8>,
|
||||
dy: Vec<u8>,
|
||||
frame: Vec<u8>,
|
||||
}
|
||||
|
||||
struct LoopContext {
|
||||
|
|
@ -92,6 +109,7 @@ impl LoweringContext {
|
|||
state_names: Vec::new(),
|
||||
start_state: String::new(),
|
||||
wide_hi: HashMap::new(),
|
||||
metasprites: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,6 +287,52 @@ impl LoweringContext {
|
|||
self.state_names = program.states.iter().map(|s| s.name.clone()).collect();
|
||||
program.start_state.clone_into(&mut self.start_state);
|
||||
|
||||
// Capture metasprite declarations so the per-statement
|
||||
// Draw lowering can expand `draw Hero` into one
|
||||
// DrawSprite op per tile. The `frame:` array in a
|
||||
// metasprite is interpreted *relative to the underlying
|
||||
// sprite's base tile* — i.e. `frame: [0, 1, 2, 3]` on a
|
||||
// 16×16 sprite means "the four tiles this sprite owns".
|
||||
// Since the IR codegen's DrawSprite op takes an *absolute*
|
||||
// tile index whenever `frame` is set, we need to resolve
|
||||
// the per-sprite base tile here and rewrite the array
|
||||
// before storing it.
|
||||
//
|
||||
// Tile assignment mirrors `assets::resolve_sprites`: tile
|
||||
// index 0 is reserved for the runtime's default smiley,
|
||||
// user sprites start at 1, and each sprite consumes
|
||||
// `chr_bytes.len() / 16` tiles (rounded up). Sprites with
|
||||
// an external `@chr(...)` / `@binary(...)` source whose
|
||||
// bytes aren't available at parse time fall back to a
|
||||
// single-tile assumption — that's a regression for those
|
||||
// exotic sources but keeps the in-tree examples working.
|
||||
let mut sprite_base: HashMap<String, u8> = HashMap::new();
|
||||
let mut next_tile: u8 = 1;
|
||||
for sprite in &program.sprites {
|
||||
sprite_base.insert(sprite.name.clone(), next_tile);
|
||||
let tile_count = match &sprite.chr_source {
|
||||
crate::parser::ast::AssetSource::Inline(bytes) => {
|
||||
(bytes.len().div_ceil(16)).max(1) as u8
|
||||
}
|
||||
_ => 1,
|
||||
};
|
||||
next_tile = next_tile.saturating_add(tile_count);
|
||||
}
|
||||
for ms in &program.metasprites {
|
||||
let base = sprite_base.get(&ms.sprite_name).copied().unwrap_or(0);
|
||||
let resolved_frames: Vec<u8> =
|
||||
ms.frame.iter().map(|&f| base.saturating_add(f)).collect();
|
||||
self.metasprites.insert(
|
||||
ms.name.clone(),
|
||||
MetaspriteInfo {
|
||||
sprite_name: ms.sprite_name.clone(),
|
||||
dx: ms.dx.clone(),
|
||||
dy: ms.dy.clone(),
|
||||
frame: resolved_frames,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Register enum variants first so constants that reference
|
||||
// them (e.g. `const FIRST: u8 = VariantA`) can resolve.
|
||||
for e in &program.enums {
|
||||
|
|
@ -560,6 +624,47 @@ impl LoweringContext {
|
|||
self.start_block(&cont);
|
||||
}
|
||||
Statement::Draw(draw) => {
|
||||
if let Some(meta) = self.metasprites.get(&draw.sprite_name).cloned() {
|
||||
// Metasprite expansion: for each tile in the
|
||||
// declaration, emit one DrawSprite with x/y
|
||||
// offset by (dx[i], dy[i]) and frame = frame[i].
|
||||
// The IR codegen sees N independent draws so
|
||||
// the runtime OAM-cursor path picks them up
|
||||
// exactly like a hand-written sequence of
|
||||
// `draw` statements.
|
||||
//
|
||||
// The user's `frame:` argument is ignored when
|
||||
// drawing a metasprite — the per-tile frame
|
||||
// index comes from the declaration. The
|
||||
// analyzer doesn't currently flag this; future
|
||||
// work could warn on it.
|
||||
let base_x = self.lower_expr(&draw.x);
|
||||
let base_y = self.lower_expr(&draw.y);
|
||||
for ((dx_off, dy_off), tile) in
|
||||
meta.dx.iter().zip(&meta.dy).zip(&meta.frame)
|
||||
{
|
||||
let off_x = self.fresh_temp();
|
||||
self.emit(IrOp::LoadImm(off_x, *dx_off));
|
||||
let x_sum = self.fresh_temp();
|
||||
self.emit(IrOp::Add(x_sum, base_x, off_x));
|
||||
|
||||
let off_y = self.fresh_temp();
|
||||
self.emit(IrOp::LoadImm(off_y, *dy_off));
|
||||
let y_sum = self.fresh_temp();
|
||||
self.emit(IrOp::Add(y_sum, base_y, off_y));
|
||||
|
||||
let tile_imm = self.fresh_temp();
|
||||
self.emit(IrOp::LoadImm(tile_imm, *tile));
|
||||
|
||||
self.emit(IrOp::DrawSprite {
|
||||
sprite_name: meta.sprite_name.clone(),
|
||||
x: x_sum,
|
||||
y: y_sum,
|
||||
frame: Some(tile_imm),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
let x = self.lower_expr(&draw.x);
|
||||
let y = self.lower_expr(&draw.y);
|
||||
let frame = draw.frame.as_ref().map(|e| self.lower_expr(e));
|
||||
|
|
|
|||
|
|
@ -350,6 +350,69 @@ fn lower_debug_frame_overrun_count_emits_peek() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_metasprite_draw_expands_to_one_op_per_tile() {
|
||||
// `draw Hero at: (10, 20)` where Hero is a 4-tile metasprite
|
||||
// should lower to four `DrawSprite` ops, each with the
|
||||
// metasprite's underlying sprite name and one tile from the
|
||||
// declaration's `frame:` array (offset by the sprite's base
|
||||
// tile index — the runtime smiley occupies tile 0, so a
|
||||
// single-sprite program starts user tiles at 1).
|
||||
let ir = lower_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sprite Tile {
|
||||
pixels: [
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@"
|
||||
]
|
||||
}
|
||||
metasprite Hero {
|
||||
sprite: Tile
|
||||
dx: [0, 8, 0, 8]
|
||||
dy: [0, 0, 8, 8]
|
||||
frame: [0, 0, 0, 0]
|
||||
}
|
||||
on frame {
|
||||
draw Hero at: (10, 20)
|
||||
wait_frame
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
let frame_fn = ir
|
||||
.functions
|
||||
.iter()
|
||||
.find(|f| f.name.contains("frame"))
|
||||
.unwrap();
|
||||
let draws: Vec<_> = frame_fn
|
||||
.blocks
|
||||
.iter()
|
||||
.flat_map(|b| &b.ops)
|
||||
.filter_map(|op| match op {
|
||||
IrOp::DrawSprite { sprite_name, .. } => Some(sprite_name.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
draws.len(),
|
||||
4,
|
||||
"metasprite with 4 tiles should expand to 4 DrawSprite ops"
|
||||
);
|
||||
for name in &draws {
|
||||
assert_eq!(
|
||||
name, "Tile",
|
||||
"expanded ops should target the underlying sprite"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower_nested_struct_literal_init_expands_to_leaves() {
|
||||
// A `Hero { pos: Vec2 { x: 1, y: 2 }, hp: 100, inv: [3,4,5,6] }`
|
||||
|
|
|
|||
|
|
@ -462,6 +462,7 @@ impl<'a> Lexer<'a> {
|
|||
"start" => TokenKind::KwStart,
|
||||
"transition" => TokenKind::KwTransition,
|
||||
"sprite" => TokenKind::KwSprite,
|
||||
"metasprite" => TokenKind::KwMetasprite,
|
||||
"background" => TokenKind::KwBackground,
|
||||
"palette" => TokenKind::KwPalette,
|
||||
"sfx" => TokenKind::KwSfx,
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ pub enum TokenKind {
|
|||
KwStart,
|
||||
KwTransition,
|
||||
KwSprite,
|
||||
KwMetasprite,
|
||||
KwBackground,
|
||||
KwPalette,
|
||||
KwSfx,
|
||||
|
|
@ -175,6 +176,7 @@ impl std::fmt::Display for TokenKind {
|
|||
Self::KwStart => write!(f, "start"),
|
||||
Self::KwTransition => write!(f, "transition"),
|
||||
Self::KwSprite => write!(f, "sprite"),
|
||||
Self::KwMetasprite => write!(f, "metasprite"),
|
||||
Self::KwBackground => write!(f, "background"),
|
||||
Self::KwPalette => write!(f, "palette"),
|
||||
Self::KwSfx => write!(f, "sfx"),
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ pub struct Program {
|
|||
pub sprites: Vec<SpriteDecl>,
|
||||
pub palettes: Vec<PaletteDecl>,
|
||||
pub backgrounds: Vec<BackgroundDecl>,
|
||||
pub metasprites: Vec<MetaspriteDecl>,
|
||||
pub sfx: Vec<SfxDecl>,
|
||||
pub music: Vec<MusicDecl>,
|
||||
pub banks: Vec<BankDecl>,
|
||||
|
|
@ -19,6 +20,36 @@ pub struct Program {
|
|||
pub span: Span,
|
||||
}
|
||||
|
||||
/// `metasprite Name { sprite: Tileset, dx: [...], dy: [...], frame: [...] }`
|
||||
/// — a multi-tile sprite group authored as parallel offset arrays.
|
||||
/// `draw Name at: (x, y)` lowers to one `DrawSprite` per tile, with
|
||||
/// each tile's screen position computed as `(x + dx[i], y + dy[i])`
|
||||
/// and its tile index taken from `frame[i]`. The underlying
|
||||
/// `sprite:` field names a previously-declared sprite/tileset that
|
||||
/// owns the actual CHR data — metasprites only describe layout.
|
||||
///
|
||||
/// All three offset/frame arrays must have the same length, which
|
||||
/// becomes the metasprite's tile count. The lowering does the
|
||||
/// per-tile cursor bump through the existing OAM cursor path so a
|
||||
/// metasprite that draws four tiles consumes four OAM slots in the
|
||||
/// same order the user wrote them.
|
||||
///
|
||||
/// Today only u8 (unsigned) offsets are supported. Negative
|
||||
/// offsets aren't representable in the current `NesType::U8` array
|
||||
/// literals — see `docs/future-work.md`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MetaspriteDecl {
|
||||
pub name: String,
|
||||
/// Underlying CHR-bearing sprite/tileset whose tiles are
|
||||
/// indexed by this metasprite's `frame:` entries. Looked up
|
||||
/// in [`Program::sprites`] at analysis time.
|
||||
pub sprite_name: String,
|
||||
pub dx: Vec<u8>,
|
||||
pub dy: Vec<u8>,
|
||||
pub frame: Vec<u8>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
/// `enum Name { V1, V2, V3 }` — variants become u8 constants with
|
||||
/// values equal to their declaration order (0, 1, 2, ...). Variant
|
||||
/// names are global: they're flattened into the constants table so
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ impl Parser {
|
|||
let mut sprites = Vec::new();
|
||||
let mut palettes = Vec::new();
|
||||
let mut backgrounds = Vec::new();
|
||||
let mut metasprites = Vec::new();
|
||||
let mut sfx = Vec::new();
|
||||
let mut music = Vec::new();
|
||||
let mut banks = Vec::new();
|
||||
|
|
@ -165,6 +166,9 @@ impl Parser {
|
|||
TokenKind::KwSprite => {
|
||||
sprites.push(self.parse_sprite_decl()?);
|
||||
}
|
||||
TokenKind::KwMetasprite => {
|
||||
metasprites.push(self.parse_metasprite_decl()?);
|
||||
}
|
||||
TokenKind::KwPalette => {
|
||||
palettes.push(self.parse_palette_decl()?);
|
||||
}
|
||||
|
|
@ -253,6 +257,7 @@ impl Parser {
|
|||
sprites,
|
||||
palettes,
|
||||
backgrounds,
|
||||
metasprites,
|
||||
sfx,
|
||||
music,
|
||||
banks,
|
||||
|
|
@ -852,6 +857,104 @@ impl Parser {
|
|||
})
|
||||
}
|
||||
|
||||
/// Parse a `metasprite Name { sprite: ..., dx: [...], dy: [...],
|
||||
/// frame: [...] }` block. The body uses parallel byte arrays so
|
||||
/// the parser can reuse the existing [`Self::parse_byte_array`]
|
||||
/// helper for each one — the analyzer is responsible for
|
||||
/// asserting they're all the same length and that the named
|
||||
/// sprite exists.
|
||||
fn parse_metasprite_decl(&mut self) -> Result<MetaspriteDecl, Diagnostic> {
|
||||
let start = self.current_span();
|
||||
self.expect(&TokenKind::KwMetasprite)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
|
||||
let mut sprite_name: Option<String> = None;
|
||||
let mut dx: Option<Vec<u8>> = None;
|
||||
let mut dy: Option<Vec<u8>> = None;
|
||||
let mut frame: Option<Vec<u8>> = None;
|
||||
|
||||
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
|
||||
// Accept either a regular identifier or the `sprite`
|
||||
// keyword in the property-name position. We fall
|
||||
// through to the standard ident path for everything
|
||||
// else so misspellings still produce the usual
|
||||
// E0201 diagnostic.
|
||||
let (key, key_span) = if *self.peek() == TokenKind::KwSprite {
|
||||
let span = self.current_span();
|
||||
self.advance();
|
||||
("sprite".to_string(), span)
|
||||
} else {
|
||||
self.expect_ident()?
|
||||
};
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
match key.as_str() {
|
||||
"sprite" => {
|
||||
let (sname, _) = self.expect_ident()?;
|
||||
sprite_name = Some(sname);
|
||||
}
|
||||
"dx" => {
|
||||
dx = Some(self.parse_byte_array("dx")?);
|
||||
}
|
||||
"dy" => {
|
||||
dy = Some(self.parse_byte_array("dy")?);
|
||||
}
|
||||
"frame" => {
|
||||
frame = Some(self.parse_byte_array("frame")?);
|
||||
}
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("unknown metasprite property '{key}'"),
|
||||
key_span,
|
||||
));
|
||||
}
|
||||
}
|
||||
if *self.peek() == TokenKind::Comma {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
|
||||
let sprite_name = sprite_name.ok_or_else(|| {
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("metasprite '{name}' is missing the required 'sprite:' property"),
|
||||
start,
|
||||
)
|
||||
})?;
|
||||
let dx = dx.ok_or_else(|| {
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("metasprite '{name}' is missing the required 'dx:' array"),
|
||||
start,
|
||||
)
|
||||
})?;
|
||||
let dy = dy.ok_or_else(|| {
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("metasprite '{name}' is missing the required 'dy:' array"),
|
||||
start,
|
||||
)
|
||||
})?;
|
||||
let frame = frame.ok_or_else(|| {
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("metasprite '{name}' is missing the required 'frame:' array"),
|
||||
start,
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(MetaspriteDecl {
|
||||
name,
|
||||
sprite_name,
|
||||
dx,
|
||||
dy,
|
||||
frame,
|
||||
span: Span::new(start.file_id, start.start, self.current_span().end),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a pixel-art block of the form
|
||||
/// `[ "row0", "row1", ... ]` and lower it to CHR bytes.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -254,6 +254,45 @@ fn parse_draw_with_frame() {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_metasprite_decl() {
|
||||
// A metasprite collects parallel `dx` / `dy` / `frame`
|
||||
// arrays plus a reference to the underlying sprite. The
|
||||
// parser preserves them verbatim — array length validation
|
||||
// happens later in the analyzer.
|
||||
let src = r#"
|
||||
game "T" { mapper: NROM }
|
||||
sprite Tile {
|
||||
pixels: [
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@",
|
||||
"@@@@@@@@"
|
||||
]
|
||||
}
|
||||
metasprite Hero {
|
||||
sprite: Tile
|
||||
dx: [0, 8, 0, 8]
|
||||
dy: [0, 0, 8, 8]
|
||||
frame: [0, 1, 2, 3]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
assert_eq!(prog.metasprites.len(), 1);
|
||||
let ms = &prog.metasprites[0];
|
||||
assert_eq!(ms.name, "Hero");
|
||||
assert_eq!(ms.sprite_name, "Tile");
|
||||
assert_eq!(ms.dx, vec![0, 8, 0, 8]);
|
||||
assert_eq!(ms.dy, vec![0, 0, 8, 8]);
|
||||
assert_eq!(ms.frame, vec![0, 1, 2, 3]);
|
||||
}
|
||||
|
||||
// ── Expressions ──
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
1
tests/emulator/goldens/metasprite_demo.audio.hash
Normal file
1
tests/emulator/goldens/metasprite_demo.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
a82b6ff5 132084
|
||||
BIN
tests/emulator/goldens/metasprite_demo.png
Normal file
BIN
tests/emulator/goldens/metasprite_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 866 B |
Loading…
Add table
Add a link
Reference in a new issue