Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/book/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
- [Animation](format/animation.md)
- [Variant](format/variant.md)
- [Composition](format/composition.md)
- [Particle Systems](format/particle.md)
- [Transforms](format/transforms.md)
- [Includes](format/includes.md)

Expand Down
2 changes: 2 additions & 0 deletions docs/book/src/cli/show.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,6 @@ Most modern terminals support this, including:
## See Also

- [render](render.md) - Render to image files
- [mask](../format/regions.md) - Inspect a sprite as a text token grid (the
non-visual complement to `show`'s color preview)
- [explain](explain.md) - Explain objects in human-readable format
79 changes: 79 additions & 0 deletions docs/book/src/format/particle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Particle Systems

A particle system emits copies of a sprite with randomized, physically-simulated
motion — sparks, embers, dust, rain, snow. The simulation is **seeded and
deterministic**: the same definition always produces the same frames, so a
particle effect can be *baked* to a fixed frame-set and shipped like any other
animation.

## Basic Syntax

```json5
{
type: "particle",
name: "embers",
sprite: "ember", // the sprite emitted as each particle
emitter: {
rate: 1.5, // particles spawned per frame (fractional accumulates)
lifetime: [6, 10], // frames each particle lives, [min, max]
velocity: { x: [-0.4, 0.4], y: [-1.2, -0.6] }, // initial velocity range
gravity: -0.02, // per-frame acceleration on vy (negative = rises)
fade: true, // fade alpha to zero over the particle's lifetime
rotation: [0, 0], // rotation range in degrees (optional)
seed: 7, // RNG seed — fix it for reproducible bakes
},
}
```

## Fields

| Field | Required | Description |
|-------|----------|-------------|
| `type` | Yes | Must be `"particle"` |
| `name` | Yes | Unique identifier (and the base name of the baked frame-set) |
| `sprite` | Yes | Name of the sprite to emit as each particle |
| `emitter` | Yes | Emitter configuration (below) |

### Emitter

| Field | Default | Description |
|-------|---------|-------------|
| `rate` | `1.0` | Particles emitted per frame; fractional rates accumulate across frames |
| `lifetime` | `[10, 20]` | Per-particle lifetime in frames, `[min, max]` |
| `velocity` | none | Initial velocity range `{ x: [min, max], y: [min, max] }` |
| `gravity` | `0` | Acceleration added to `vy` each frame (negative rises, positive falls) |
| `fade` | `false` | If true, alpha fades linearly to zero over the lifetime |
| `rotation` | `[0, 0]` | Rotation range in degrees |
| `seed` | `42` | RNG seed — **set this** so bakes are reproducible |

## Baking to Frames

Particle systems are runtime constructs, so `pxl render` does **not** draw them
statically by default. Pass `--frames N` to simulate the emitter and bake `N`
frames:

```bash
# A numbered PNG per frame: embers_00.png … embers_11.png
pxl render embers.pxl --frames 12 --canvas 16x32 --origin 8,31 -o frames/

# A single animated GIF instead
pxl render embers.pxl --frames 12 --canvas 16x32 --gif --fps 12 -o embers.gif
```

| Flag | Default | Description |
|------|---------|-------------|
| `--frames N` | — | Bake `N` frames (required to render a particle system) |
| `--canvas WxH` | `32x32` | Output canvas size |
| `--origin X,Y` | bottom-center | Emitter origin on the canvas |
| `--gif` | off | Emit one animated GIF instead of numbered PNGs |
| `--fps N` | `10` | GIF playback rate |
| `--sprite NAME` | all | Bake only the named particle system |

The PNG output follows the frame-set naming convention `{name}_NN.png`, so a
baked particle drops straight into an [animation](animation.md) `frames` array or
any pipeline that consumes numbered frames. Because the simulation is seeded,
re-baking the same file reproduces byte-identical frames — safe to commit and
diff.

> **Tip:** the emitted `sprite` is a normal sprite, so it can itself use
> [`extends`](sprite.md#region-inheritance-extends), transforms, or any palette.
14 changes: 10 additions & 4 deletions docs/book/src/format/regions.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,15 +289,21 @@ grass: {

### Auto-Outline

Generate outline around a region.
Generate a silhouette outline around another region. The source region is
dilated outward (8-connected, so convex corners are filled) and the source is
subtracted, leaving a band `thickness` pixels wide that surrounds it. Give the
outline a lower `z` than its source so the source draws on top.

```json5
outline: {
"auto-outline": "body",
thickness: 1
regions: {
outline: { "auto-outline": "body", thickness: 1, z: 0 },
body: { rect: [3, 3, 4, 4], z: 1 },
}
```

`thickness` defaults to `1`. The source must be defined and non-empty; if it is
missing the region resolves to zero pixels and the validator flags it.

### Auto-Shadow

Generate drop shadow.
Expand Down
100 changes: 100 additions & 0 deletions docs/book/src/format/sprite.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,106 @@ See [Transforms](transforms.md#op-style-transforms-derived-sprites) for the full

> **Note:** For animated transforms (in keyframes), use CSS transform strings instead. See [Animation](animation.md).

## Region Inheritance (`extends`)

`extends` builds a sprite from another sprite's regions, then patches them
key-by-key. It is the right tool for **animation frame-sets**, where most
regions are identical across frames and only a few change — instead of copying
every region into every frame (and risking a frame-tear when one copy drifts),
each frame inherits the shared regions and overrides only what moves.

```json5
// Base frame: defines the full sprite.
{
type: "sprite",
name: "fire_a",
size: [16, 32],
palette: "fire",
regions: {
ring: { rect: [3, 26, 10, 4], z: 0 }, // stone ring — shared by every frame
flame: { rect: [7, 14, 2, 12], z: 1 },
},
}

// Next frame: inherits `ring` and the palette; overrides only `flame`.
{
type: "sprite",
name: "fire_b",
extends: "fire_a",
regions: {
flame: { rect: [8, 12, 2, 14], z: 1 },
},
}
```

### Fields

| Field | Description |
|-------|-------------|
| `extends` | Name of the base sprite to inherit regions, palette, and size from |
| `regions` | Region patches: each key **replaces** the inherited region of that name (or **adds** a new one if the key is not inherited) |
| `remove` | List of inherited region keys to **delete** |

### Semantics

- **Override** — a key in `regions` that the base also defines replaces the
whole region definition. Its `z` and `role` come from the override, not the
inherited region, so an override is free to change its own z-order. (Other
regions keep theirs; nothing else re-stacks.)
- **Add** — a key not present in the base is simply inserted at its declared `z`.
- **Remove** — `remove: ["key"]` deletes an inherited region. Removing a key the
base doesn't define is a warning, not an error.
- **Palette** — the base's resolved palette is inherited. You may omit `palette`
entirely (it is required on every *non-extending* sprite); if you do declare
one, its colors override inherited tokens key-by-key, exactly like a
[variant](variant.md).
- **Size** — inherited from the base unless the extending sprite declares its own.
- **Chaining** — `extends` may point at another extending sprite; the chain
resolves base-first, so the deepest override of a key wins.

### `extends` vs. `source`

Both inherit from another sprite, but they operate at different levels:

| | `source` | `extends` |
|---|----------|-----------|
| Inherits | The base sprite's **rendered image** | The base sprite's **region map** |
| Can change individual regions? | No — only whole-image `transform`s | Yes — override/add/remove by key |
| Use for | Mirrored / rotated / filtered variants | Animation frames that share most regions |

Inspect the resolved result of an extending sprite the same way as any sprite —
`pxl mask <file> --sprite fire_b` shows the merged token grid, and
`pxl render --sprite fire_b` renders the inherited + overridden regions together.

## Group Translation (`translate`)

Many animation cycles — a walk bob, a shiver, a float — are *only* a small
offset of a base frame. `translate` shifts a named set of regions by one offset,
so the whole move is a single line instead of re-deriving every member region's
coordinates (and risking a frame-tear when one is off by a pixel).

```json5
// Passing frame of a walk cycle: inherit the contact frame, bob the upper
// body up one pixel. The legs stay planted.
{
type: "sprite",
name: "walk_pass",
extends: "walk_contact",
translate: { by: [0, -1], regions: ["torso", "head", "arm_l", "arm_r"] },
}
```

| Field | Description |
|-------|-------------|
| `by` | Offset `[dx, dy]` in pixels (negative `dy` moves up) |
| `regions` | Region keys to shift. **Omit to shift every region** in the sprite. |

`translate` is applied after `extends` inheritance and region merging, and
composes with it: a frame can inherit a base, override a region, *and* shift a
group, all at once. It expands into a per-region `translate` offset (a region
may also carry its own `translate` to shift just itself); the two compose
additively.

## Complete Example

```json5
Expand Down
26 changes: 26 additions & 0 deletions src/cli/explain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,32 @@ pub fn run_diff(file_a: &PathBuf, file_b: &PathBuf, sprite: Option<&str>, json:
});
}

if let Some(ref note) = diff.presence_change {
obj["presence_change"] = serde_json::json!(note);
}

if !diff.region_changes.is_empty() {
let region_changes: Vec<_> = diff
.region_changes
.iter()
.map(|c| match c {
crate::diff::RegionChange::Added { token } => {
serde_json::json!({"type": "added", "token": token})
}
crate::diff::RegionChange::Removed { token } => {
serde_json::json!({"type": "removed", "token": token})
}
crate::diff::RegionChange::Shifted { token, dx, dy } => {
serde_json::json!({"type": "shifted", "token": token, "dx": dx, "dy": dy})
}
crate::diff::RegionChange::Changed { token, differing } => {
serde_json::json!({"type": "changed", "token": token, "differing": differing})
}
})
.collect();
obj["region_changes"] = serde_json::json!(region_changes);
}

if !diff.palette_changes.is_empty() {
let palette_changes: Vec<_> = diff
.palette_changes
Expand Down
5 changes: 3 additions & 2 deletions src/cli/mask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,11 @@ pub fn run_mask(
}
};

let sprite = pipeline.sprite().unwrap();
// Resolve `extends`/`source` inheritance so queries see the rendered sprite.
let sprite = pipeline.resolved_sprite().unwrap();

// Extract token grid
let grid = match TokenGrid::from_sprite(sprite) {
let grid = match TokenGrid::from_sprite(&sprite) {
Ok(g) => g,
Err(e) => {
eprintln!("Error: {}", e);
Expand Down
25 changes: 25 additions & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,23 @@ pub enum Commands {
/// Disable project context detection (skip pxl.toml lookup)
#[arg(long)]
no_project: bool,

/// Bake a particle system to this many frames (PNG per frame, or a GIF
/// with --gif). Selects the particle by --sprite, or bakes all.
#[arg(long)]
frames: Option<u32>,

/// Canvas size for particle baking, "WxH" (default: 32x32)
#[arg(long)]
canvas: Option<String>,

/// Emitter origin for particle baking, "X,Y" (default: bottom-center)
#[arg(long)]
origin: Option<String>,

/// Frames per second for a baked particle GIF (default: 10)
#[arg(long)]
fps: Option<u32>,
},
/// Import a PNG image and convert to Pixelsrc format
Import {
Expand Down Expand Up @@ -605,6 +622,10 @@ pub fn run() -> ExitCode {
no_semantic_aa,
gradient_shadows,
no_project,
frames,
canvas,
origin,
fps,
} => render::run_render(
&input,
output.as_deref(),
Expand All @@ -627,6 +648,10 @@ pub fn run() -> ExitCode {
no_semantic_aa,
gradient_shadows,
no_project,
frames,
canvas.as_deref(),
origin.as_deref(),
fps,
),
Commands::Import {
input,
Expand Down
Loading
Loading