1
0
Fork 0
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:
Claude 2026-04-15 03:13:30 +00:00
parent 9878b7d87d
commit 6b080316a4
No known key found for this signature in database
17 changed files with 619 additions and 0 deletions

View file

@ -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

View file

@ -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`. |

View 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

Binary file not shown.

View file

@ -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);

View file

@ -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-

View file

@ -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(),

View file

@ -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(),

View file

@ -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));

View file

@ -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] }`

View file

@ -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,

View file

@ -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"),

View file

@ -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

View file

@ -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.
///

View file

@ -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]

View file

@ -0,0 +1 @@
a82b6ff5 132084

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 B