From 612c30a5389106b631def16a6a223e082b1697d6 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 28 Oct 2026 20:58:59 +0900 Subject: [PATCH 1/3] Pixel fixer: several animations on one sheet Each row of frames is one animation by default (rows aligned by their own top and bottom; a row across two side-by-side blocks is split where they meet when both parts match the blocks' frame counts). Sheet.animations holds each animation's name, frames, include, fps, and loop (moved off the sheet), kept consistent by withFrames, insertFrame, removeFrame, splitAnimation, joinAnimation, renameAnimation, and setAnimation. Each animation is laid out on its own canvas; the atlas stacks exported animations by row and the JSON names them (animations, frameTags, per-animation fps). Page: an Animations panel (In/Out, choose to play, rename, speed, loop, Split, Join with next), boxes tinted by animation, GIF of the chosen one, frames zip with a folder per animation. CLI sheet: --animations, --only, a GIF and a frames folder per animation, animations in the report. Recipes without animations read as one animation of every frame. Co-Authored-By: Claude Opus 5.5 --- .changeset/pixel-sheet-animations.md | 10 + packages/core/skills/ritmo-pixel-art/SKILL.md | 2 +- packages/tools/README.md | 47 ++- packages/tools/src/pixel/cli/commands.ts | 120 ++++-- packages/tools/src/pixel/core/animation.ts | 86 +++-- packages/tools/src/pixel/core/preview.ts | 65 ++-- packages/tools/src/pixel/core/recipe.ts | 43 ++- packages/tools/src/pixel/core/report.ts | 71 ++-- packages/tools/src/pixel/core/sheet.ts | 194 +++++++++- packages/tools/src/pixel/core/spritesheet.ts | 160 ++++---- packages/tools/src/pixel/ui/main.ts | 342 ++++++++++++------ packages/tools/src/pixel/ui/viewer.ts | 10 +- packages/tools/test/agent.test.ts | 36 +- packages/tools/test/animation.test.ts | 166 +++++++-- 14 files changed, 998 insertions(+), 354 deletions(-) create mode 100644 .changeset/pixel-sheet-animations.md diff --git a/.changeset/pixel-sheet-animations.md b/.changeset/pixel-sheet-animations.md new file mode 100644 index 00000000..f1463f97 --- /dev/null +++ b/.changeset/pixel-sheet-animations.md @@ -0,0 +1,10 @@ +--- +"@typeonce/ritmo-tools": minor +"@typeonce/ritmo": patch +--- + +`ritmo-tools pixel`: several animations on one sheet. Each row of frames is one animation by default (`animation-1`, ... in reading order; where two blocks sit side by side, a row across both is split where they meet). The page's **Animations** panel chooses which are exported (**In**/**Out**), plays, renames, and sets each one's speed and loop, and **Split** / **Join with next** regroup them. Each animation gets its own canvas. The sheet PNG holds every exported animation, each from a new row; its JSON names each one under `animations` and `frameTags`, frames are named ` 0`, ..., and `meta.fps` is a map from animation to frames per second. `pixel sheet` writes a GIF per animation (`..gif`) and frames under `.frames//`, lists the animations in its report (`animation.animations`), and takes `--animations "walk=1-4;explode=5-14"` and `--only walk,explode`. + +Breaking, for recipes and code reading the sheet: `sheet.fps` and `sheet.loop` moved onto each animation (`sheet.animations[i].fps`, `.loop`). A recipe without `animations` still reads, as one animation of every frame at its `fps` and `loop`. The report's `animation.fps` and `animation.loop` are now per animation in `animation.animations`, and `output` is the size of the sheet written. + +The `ritmo-pixel-art` skill says how to name and pick animations with `--animations` and `--only`. diff --git a/packages/core/skills/ritmo-pixel-art/SKILL.md b/packages/core/skills/ritmo-pixel-art/SKILL.md index 0063ad5e..2809c383 100644 --- a/packages/core/skills/ritmo-pixel-art/SKILL.md +++ b/packages/core/skills/ritmo-pixel-art/SKILL.md @@ -24,7 +24,7 @@ A `pixel.config.json` at the game's root (`pixel init -p ` writes one) - **Drawing a sprite with code.** Write a sprite source (below), then `pixel render sprite.txt --scale 8`: it writes `.png`, `@8x.png`, `.svg`, and `.preview.png`, and lints the art. Fix every error in the source, never in the PNG. Look at the preview: whether it reads well is yours to judge. - **A picture from an image generator.** `pixel check pic.png` first. `fixable` → `pixel fix pic.png` writes the true-size `.png`, `.svg`, a JSON map, a `.sprite.txt` (to edit by text), its `.recipe.json`, and a `.preview.png` (the source and its grid beside the fix). Add `--colors N` when there is no palette, `--remove-background` for a sprite, `--cleanup 1` against noise specks. -- **An animation sheet from an image generator** (frames side by side in boxes, often labelled). `pixel sheet sheet.png --colors 16` clears the background and labels, finds the frames, fixes them on one grid and palette, lines them up (by default on the ground: sideways drift removed, hops kept; `--anchor feet` pins the feet to one row), and writes the sheet `.png` and its `.json` (Pixi, Phaser, Aseprite), a `.gif`, `.frames/`, the recipe, and a `.preview.png`. Open the preview: each frame's box must hold the whole frame. Pass `--boxes "x,y,w,h;..."` when one is cut, `--skip 3` to leave a bad frame out, `--anchor center` for things that float, `--fps` and `--pingpong` for the timing, and `--columns N` for rows. Look at the `.gif` before using it. +- **An animation sheet from an image generator** (frames side by side in boxes, often labelled). `pixel sheet sheet.png --colors 16` clears the background and labels, finds the frames, fixes them on one grid and palette, lines them up (by default on the ground: sideways drift removed, hops kept; `--anchor feet` pins the feet to one row), and writes the sheet `.png` and its `.json` (Pixi, Phaser, Aseprite), a `.gif` per animation, `.frames/`, the recipe, and a `.preview.png`. Each row of frames is one animation (`animation-1`, ... in reading order; `animation.animations` in the report lists each one's frame numbers): name them and keep the ones the game uses with `--animations "walk-down=1-4;throw-down=5-8;explode=29-38"` (every frame in one; a row that is not one animation is split or joined here) and `--only walk-down,explode`, then play them by name from the JSON's `animations`. Open the preview: each frame's box must hold the whole frame, and each animation's boxes share a color. Pass `--boxes "x,y,w,h;..."` when one is cut, `--skip 3` to leave a bad frame out, `--anchor center` for things that float, `--fps` and `--pingpong` for the timing, and `--columns N` for rows. Look at each `.gif` before using it. - **A reference image (a drawing, a render, a photo) to turn into game art.** `pixel pixelate ref.png -w 32` cuts it into 32 pixels across on the project's palette (or `--colors N` proposed from the picture). Pixelating loses detail by design: compare widths by their previews, keep the smallest one that still reads, then touch up the `.sprite.txt` and `render` it. - **Checking assets before they ship.** `pixel lint` each PNG at its true size (exit 0 for all of them), or `pixel check` for pictures that may be upscaled. `pixel compare a.png b.png` says which of two versions is better and why. diff --git a/packages/tools/README.md b/packages/tools/README.md index d909b468..23579869 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -171,7 +171,8 @@ step. Opening another picture starts the history over. ### Animations An image generator asked for an animation draws a sheet: the frames side by -side (or in rows), each in a box, over a dark background with a painted grid, +side (or in rows, often one animation per row: walking each way, throwing, +exploding), each in a box, over a dark background with a painted grid, often with a label under each frame. Open it like any picture: when the fixer finds several boxes, most of them holding about as much art each (a few faint ones, like the last smoke of an explosion, do not count against @@ -191,9 +192,19 @@ it is. how noisy the background is. The **Sheet** view shows the source with every frame's box, numbered. Choose **Boxes** (F) to drag a box, resize it by its handles, or draw a new one on empty space when something was cut out; Delete removes the - selected box. **Frames** on the right lists every frame: **In**/**Out** - leaves one out of the animation without losing its box, and **Find again** - starts over (boxes changed by hand are dropped). + selected box. Each box is tinted with its animation's color. +- **Animations.** Each row of frames is one animation, named + `animation-1`, `animation-2`, ... in reading order; where two blocks sit + side by side (walking on the left, throwing on the right), a row across + both is split where they meet, when its two parts hold as many frames as + the blocks' rows. **Animations** on the right lists them: **In**/**Out** + chooses which are exported (the others keep their frames and settings); + choose one to play it, rename it (the name a game plays it by), and set its + speed and loop. Its frames are listed under it: **In**/**Out** leaves one + out without losing its box. **Split** cuts the animation in two before the + chosen frame, and **Join with next** puts it together with the next one, + for a sheet whose rows are not its animations. **Find again** starts over + (boxes and animations changed by hand are dropped). - **Grid and palette.** Every frame is read on one cell size (found from all the frames together, or typed), each at its own offset, and fixed with one recipe: the palette, matching, and cleanup are the picture's settings, and @@ -204,19 +215,23 @@ it is. height it was drawn, so a hop or a bob stays; **Feet** pins the body's lowest row to one spot (hops are flattened); **Center** pins the body's middle, for things that float; **Box** places frames as cut. Arrow keys nudge the selected frame by a pixel - (Shift: 4). **Margin** sets the empty pixels around the frames. -- **Playing.** The **Play** view shows the animation running, with the grid + (Shift: 4). Each animation gets its own canvas, the smallest that holds + its frames; **Margin** sets the empty pixels around them. +- **Playing.** The **Play** view shows the chosen animation running, with the grid if you want it; Space plays and pauses, `,` and `.` step a frame, and the bar under it sets the frame rate, **Loop** or **Ping-pong**, and **Onion** (the previous frame, faint, under the current one). **Frames** shows them all side by side; click one to select it. -- **Export.** A **Sheet PNG** with every frame in one row or in rows of - **Per row** frames, with **Spacing** between them, at any whole scale; its +- **Export.** A **Sheet PNG** with every exported animation, each from a + new row, its frames in one row or in rows of **Per row** frames, with + **Spacing** between frames and animations, at any whole scale; its **JSON** in the hash format Pixi (`Spritesheet`), Phaser (`atlas`), and - Aseprite share, with each frame's rectangle, duration, and anchor, the - animation's frame list, and Aseprite's `frameTags`; a **GIF**; **Frames - .zip** (one PNG per frame); the **Recipe** (frames, anchor, frame rate, and - loop included); and the palette. + Aseprite share, with each frame's rectangle, duration, and anchor (frames + are named ` 0`, ` 1`, ...), each animation's frame + list under `animations`, and Aseprite's `frameTags`; a **GIF** of the + chosen animation; **Frames .zip** (one PNG per frame, a folder per + animation); the **Recipe** (frames, animations, and anchor included); and + the palette. Hand edits (**Erase**, **Paint**) and **Size** are for single pictures and are not offered for animations. @@ -258,7 +273,7 @@ the source), 4 needs a person (the grid is unsure: look at the preview). | --- | --- | | `check ` | Clean pixel art, fixable with confidence, or needs a person? Writes nothing. | | `fix ` | Fixes it, writing `.png` (1x), `@Nx.png` (`--scale N`), `.svg`, `.json` map, `.sprite.txt`, `.recipe.json`, and `.preview.png`; `--project` also writes `.project.png`. | -| `sheet ` | Fixes an animation sheet: `.png` (the frames in a strip, or `--columns N` per row), its `.json`, a `.gif`, `.frames/` (one PNG each), `.recipe.json`, and `.preview.png` (the boxes found over the source, and the fixed frames). | +| `sheet ` | Fixes an animation sheet: `.png` (each animation's frames in a strip from a new row, or `--columns N` per row), its `.json`, a `..gif` each, `.frames//` (one PNG per frame), `.recipe.json`, and `.preview.png` (the boxes found over the source, and each animation's fixed frames). The report lists the animations with their frame numbers. | | `pixelate -w N` | Cuts a picture that is not pixel art into N pixels across, on the palette (or `--colors N` proposed from it). | | `render ` | Renders a sprite source or JSON map to PNG and SVG, and lints it. | | `lint ` | The project's rules on the pixels as they are. | @@ -271,8 +286,10 @@ the source), 4 needs a person (the grid is unsure: look at the preview). (`--palette`, `--cell 8x8 --offset 2,3`, `--follow`, `--cleanup`, `--remove-background`, `--crop`, `--dither`, `--metric`, `--shrink-to`), over a `--recipe` file (or the recipe a project PNG holds), over the config. `ritmo-tools pixel --help` lists them. `sheet` takes them too, plus -`--boxes`, `--skip`, `--anchor`, `--fps`, `--pingpong`, `--columns`, and -`--spacing`. Every command reads PNG and JPEG. +`--boxes`, `--skip`, `--animations` (which frames make which animation: +`"walk=1-4;throw=5-8;explode=9-18"`, every frame in one), `--only` (the +animations to write, by name: `"walk,explode"`), `--anchor`, `--fps`, +`--pingpong`, `--columns`, and `--spacing`. Every command reads PNG and JPEG. **`pixel.config.json`**, found from the working directory up (or `--config`), holds a project's rules: `palette` (a preset's name, a list of diff --git a/packages/tools/src/pixel/cli/commands.ts b/packages/tools/src/pixel/cli/commands.ts index c9dca4e0..94c7fcb2 100644 --- a/packages/tools/src/pixel/cli/commands.ts +++ b/packages/tools/src/pixel/cli/commands.ts @@ -17,11 +17,11 @@ import { toJson, toRgba, toSvg } from "../core/export.ts" import type { LintRules } from "../core/lint.ts" import { presets, proposePalette, type ProposeMethod } from "../core/palette.ts" import { runRecipe, type Outcome } from "../core/pipeline.ts" -import { detectFrames, proposeForSheet, runSheet, type AnimationCache } from "../core/animation.ts" +import { atlasClips, detectFrames, proposeForSheet, runSheet, type AnimationCache } from "../core/animation.ts" import { toGif } from "../core/gif.ts" import { fitScale, previewArt, previewFix, previewSheet } from "../core/preview.ts" -import { defaultSheet, playOrder, type Sheet } from "../core/sheet.ts" -import { sheetArt, spritesheetJson } from "../core/spritesheet.ts" +import { animationOf, defaultSheet, isAnimationName, playOrder, withFrames, type Sheet, type SheetAnimation } from "../core/sheet.ts" +import { atlasOf, spritesheetJson } from "../core/spritesheet.ts" import { encodeRecipe, type GridChoice, type Recipe } from "../core/recipe.ts" import { checkImage, compareReports, reportOfArt, reportOfFix, reportOfSheet, type Report, type Verdict } from "../core/report.ts" import { artOfImage, formatSprite } from "../core/sprite.ts" @@ -342,6 +342,37 @@ const parseBoxes = (text: string): Effect.Effect => return Effect.succeed(boxes.map(([x, y, width, height]) => ({ box: { x: x!, y: y!, width: width!, height: height! }, include: true, nudge: { x: 0, y: 0 } }))) } +/** Reads "walk=1-4;throw=5,6,7;9-12" as animations: every frame (numbered from 1) in exactly one. */ +const parseAnimations = (text: string, count: number): Effect.Effect, CliFailure> => { + const fail = (message: string) => Effect.fail(new CliFailure({ message })) + const parts = text.split(";").map((part) => part.trim()).filter((part) => part !== "") + const seen = new Set() + const animations: Array = [] + for (const [a, part] of parts.entries()) { + const named = /^([^=]+)=(.+)$/.exec(part) + const name = named === null ? `animation-${a + 1}` : named[1]!.trim() + if (!isAnimationName(name)) return fail(`"${name}" cannot name an animation: use letters, digits, "-", "_", and "."`) + if (animations.some((animation) => animation.name === name)) return fail(`two animations are named "${name}"`) + const frames: Array = [] + for (const range of (named === null ? part : named[2]!).split(",")) { + const bounds = /^\s*(\d+)\s*(?:-\s*(\d+)\s*)?$/.exec(range) + if (bounds === null) return fail(`"${range.trim()}" is not a frame number or a range like "1-4"`) + const from = Number(bounds[1]) + const to = bounds[2] === undefined ? from : Number(bounds[2]) + for (let n = from; from <= to ? n <= to : n >= to; n += from <= to ? 1 : -1) { + if (n < 1 || n > count) return fail(`frame ${n} is not on the sheet: its frames are 1 to ${count}`) + if (seen.has(n)) return fail(`frame ${n} is in more than one animation`) + seen.add(n) + frames.push(n - 1) + } + } + animations.push(animationOf(name, frames)) + } + const missing = Array.from({ length: count }, (_, i) => i + 1).filter((n) => !seen.has(n)) + if (missing.length > 0) return fail(`frames ${missing.join(", ")} are in no animation: every frame must be in one`) + return Effect.succeed(animations) +} + const sheetCommand = Command.make( "sheet", { @@ -352,13 +383,20 @@ const sheetCommand = Command.make( colors: Flag.Int("colors").pipe(Flag.withDescription("Without a palette, how many colors to propose from all the frames together."), Flag.withDefault(16)), cell: Flag.String("cell").pipe(Flag.withDescription('The cell size in source pixels, "11.5", instead of finding it (each frame finds its own offset).'), Flag.optional), boxes: Flag.String("boxes").pipe(Flag.withDescription('The frames\' boxes, "x,y,w,h;x,y,w,h" in source pixels, instead of finding them.'), Flag.optional), - skip: Flag.String("skip").pipe(Flag.withDescription('Frames to leave out of the animation, by number from 1: "1,6".'), Flag.optional), + skip: Flag.String("skip").pipe(Flag.withDescription('Frames to leave out of their animation, by number from 1: "1,6".'), Flag.optional), + animations: Flag.String("animations").pipe( + Flag.withDescription( + 'How the frames make animations, by frame number from 1: "walk=1-4;throw=5-8;explode=9-18" (a name is optional: "1-4;5-8"). Every frame must be in one. Default: one per row of frames, as the report lists them.' + ), + Flag.optional + ), + only: Flag.String("only").pipe(Flag.withDescription('The animations to write, by name: "walk,explode" (the others stay in the recipe, left out).'), Flag.optional), keepBackground: Flag.Boolean("keep-background").pipe(Flag.withDefault(false), Flag.withDescription("Keep the sheet's background (by default it is cleared, with any painted grid and labels).")), anchor: Flag.Literals("anchor", ["ground", "feet", "center", "box"]).pipe(Flag.withDescription("What lines frames up: the ground (default: each frame at its height as drawn, hops kept, sideways drift removed), the feet (pinned to one row), the body's center, or nothing (frames as in their boxes)."), Flag.optional), - fps: Flag.Finite("fps").pipe(Flag.withDescription("Frames per second (default 8)."), Flag.optional), - pingpong: Flag.Boolean("pingpong").pipe(Flag.withDefault(false), Flag.withDescription("Play there and back instead of looping from the start.")), - columns: Flag.Int("columns").pipe(Flag.withDescription("Frames per row on the sheet written (default: one row)."), Flag.optional), - spacing: Flag.Int("spacing").pipe(Flag.withDescription("Empty pixels between frames on the sheet written."), Flag.withDefault(0)), + fps: Flag.Finite("fps").pipe(Flag.withDescription("Frames per second, for every animation (default 8)."), Flag.optional), + pingpong: Flag.Boolean("pingpong").pipe(Flag.withDefault(false), Flag.withDescription("Every animation plays there and back instead of looping from the start.")), + columns: Flag.Int("columns").pipe(Flag.withDescription("Frames per row on the sheet written; each animation starts a new row (default: one row per animation)."), Flag.optional), + spacing: Flag.Int("spacing").pipe(Flag.withDescription("Empty pixels between frames, and between animations, on the sheet written."), Flag.withDefault(0)), out, scale }, @@ -372,19 +410,34 @@ const sheetCommand = Command.make( const image = yield* readPicture(flags.input) const cache: AnimationCache = {} const start = recipe.sheet ?? defaultSheet - let frames = Option.isSome(flags.boxes) ? yield* parseBoxes(flags.boxes.value) : start.frames.length > 0 ? start.frames : detectFrames(image, recipe, cache) + let sheet: Sheet = Option.isSome(flags.boxes) + ? withFrames(start, yield* parseBoxes(flags.boxes.value)) + : start.frames.length > 0 + ? start + : withFrames(start, detectFrames(image, recipe, cache)) + if (sheet.frames.length === 0) return yield* new CliFailure({ message: "no frames found in the sheet: give them with --boxes" }) if (Option.isSome(flags.skip)) { const skipped = new Set(flags.skip.value.split(",").map((part) => Number(part.trim()) - 1)) - frames = frames.map((frame, i) => ({ ...frame, include: !skipped.has(i) })) + sheet = { ...sheet, frames: sheet.frames.map((frame, i) => ({ ...frame, include: !skipped.has(i) })) } } - const sheet: Sheet = { - ...start, - frames, - anchor: Option.getOrElse(flags.anchor, () => start.anchor), - fps: Option.getOrElse(flags.fps, () => start.fps), - loop: flags.pingpong ? "pingpong" : start.loop + if (Option.isSome(flags.animations)) sheet = { ...sheet, animations: yield* parseAnimations(flags.animations.value, sheet.frames.length) } + if (Option.isSome(flags.only)) { + const wanted = flags.only.value.split(",").map((part) => part.trim()).filter((part) => part !== "") + const unknown = wanted.filter((name) => !sheet.animations.some((animation) => animation.name === name)) + if (unknown.length > 0) { + return yield* new CliFailure({ message: `no animation named ${unknown.join(", ")}: the sheet has ${sheet.animations.map((animation) => animation.name).join(", ")}` }) + } + sheet = { ...sheet, animations: sheet.animations.map((animation) => ({ ...animation, include: wanted.includes(animation.name) })) } + } + sheet = { + ...sheet, + anchor: Option.getOrElse(flags.anchor, () => sheet.anchor), + animations: sheet.animations.map((animation) => ({ + ...animation, + fps: Option.getOrElse(flags.fps, () => animation.fps), + loop: flags.pingpong ? "pingpong" : animation.loop + })) } - if (sheet.frames.length === 0) return yield* new CliFailure({ message: "no frames found in the sheet: give them with --boxes" }) recipe = { ...recipe, sheet } if (recipe.palette === undefined && flags.colors > 0) { const proposed = proposeForSheet(image, recipe, sheet, flags.colors, "dominant", cache).map((proposal) => proposal.color) @@ -395,34 +448,37 @@ const sheetCommand = Command.make( const base = yield* outputBase(flags.input, Option.getOrUndefined(flags.out), "sheet") const path = yield* Path.Path const name = path.basename(base) - const arts = outcome.placed.map((frame) => frame.art) - const layout = { columns: Option.getOrElse(flags.columns, () => arts.length), spacing: flags.spacing } - const art = sheetArt(arts, layout) - const anchor = outcome.placed[0]?.anchor ?? { x: 0, y: 0 } - const info = (image: string, scale: number) => - spritesheetJson({ name, image, frameWidth: outcome.width, frameHeight: outcome.height, count: arts.length, layout, scale, fps: sheet.fps, loop: sheet.loop, anchor }) + const clips = atlasClips(outcome, sheet) + const layout = { columns: Option.getOrElse(flags.columns, () => Number.MAX_SAFE_INTEGER), spacing: flags.spacing } + const atlas = atlasOf(clips, layout) + const info = (image: string, scale: number) => spritesheetJson({ image, clips, atlas, scale }) const files: Record> = {} - if (arts.length > 0) { - files.png = yield* writePng(`${base}.png`, toRgba(art)) + if (clips.length > 0) { + files.png = yield* writePng(`${base}.png`, toRgba(atlas.art)) files.json = yield* writeText(`${base}.json`, info(`${name}.png`, 1)) if (flags.scale > 1) { - files.scaled = yield* writePng(`${base}@${flags.scale}x.png`, toRgba(art, flags.scale)) + files.scaled = yield* writePng(`${base}@${flags.scale}x.png`, toRgba(atlas.art, flags.scale)) files.scaledJson = yield* writeText(`${base}@${flags.scale}x.json`, info(`${name}@${flags.scale}x.png`, flags.scale)) } - const order = playOrder(arts.length, sheet.loop).map((i) => arts[i]!) - files.gif = yield* writeBytes(`${base}.gif`, toGif(order, { scale: Math.max(flags.scale, fitScale(arts[0]!, 256)), delay: 1000 / sheet.fps })) + // One GIF and one folder of frames per animation. + const gifs: Array = [] const written: Array = [] - for (const [i, frame] of arts.entries()) written.push(yield* writePng(path.join(`${base}.frames`, `${name}-${i}.png`), toRgba(frame, flags.scale))) + for (const clip of clips) { + const order = playOrder(clip.frames.length, clip.loop).map((i) => clip.frames[i]!) + gifs.push(yield* writeBytes(`${base}.${clip.name}.gif`, toGif(order, { scale: Math.max(flags.scale, fitScale(clip.frames[0]!, 256)), delay: 1000 / clip.fps }))) + for (const [i, frame] of clip.frames.entries()) written.push(yield* writePng(path.join(`${base}.frames`, clip.name, `${i}.png`), toRgba(frame, flags.scale))) + } + files.gifs = gifs files.frames = written } files.recipe = yield* writeText(`${base}.recipe.json`, encodeRecipe(recipe)) + const groupOf = new Map(sheet.animations.flatMap((animation, a) => animation.frames.map((i) => [i, a] as const))) files.preview = yield* writePng( `${base}.preview.png`, previewSheet( image, - sheet.frames.map((frame) => ({ ...frame.box, include: frame.include })), - arts, - sheet.anchor === "feet" || sheet.anchor === "ground" ? anchor.y : undefined + sheet.frames.map((frame, i) => ({ ...frame.box, include: frame.include && sheet.animations[groupOf.get(i) ?? 0]?.include === true, group: groupOf.get(i) ?? 0 })), + clips.map((clip) => ({ frames: clip.frames, anchorY: sheet.anchor === "feet" || sheet.anchor === "ground" ? clip.anchor.y : undefined })) ) ) yield* printJson({ file: flags.input, ...storedNote(resolved.stored), ...report, files }) diff --git a/packages/tools/src/pixel/core/animation.ts b/packages/tools/src/pixel/core/animation.ts index c9040cad..26d52710 100644 --- a/packages/tools/src/pixel/core/animation.ts +++ b/packages/tools/src/pixel/core/animation.ts @@ -15,6 +15,7 @@ import { detectGrid, fitOffsets, type Detection, type GridSpec, type RgbaImage } import { lostColors, type LostColor } from "./lost.ts" import { runWithSpec, type Outcome, type PipelineCache } from "./pipeline.ts" import type { Recipe } from "./recipe.ts" +import type { AtlasClip } from "./spritesheet.ts" import { anchorOf, clearSheet, @@ -63,6 +64,19 @@ export interface FixedFrame { readonly anchor: Point | undefined } +/** One animation of the sheet, its frames on one canvas with their anchors on one spot. */ +export interface ClipOutcome { + /** Its position in the sheet's animations. */ + readonly animation: number + readonly name: string + /** Whether it is exported. */ + readonly include: boolean + /** Its included frames, in the order they play. */ + readonly placed: ReadonlyArray + readonly width: number + readonly height: number +} + export interface AnimationOutcome { /** The background colors cleared from the sheet (empty when it is not cleared). */ readonly backgroundFound: ReadonlyArray @@ -74,11 +88,9 @@ export interface AnimationOutcome { readonly confidence: number | undefined /** Every frame, included or not. */ readonly frames: ReadonlyArray - /** The included frames, in order, on one canvas. */ - readonly placed: ReadonlyArray - readonly width: number - readonly height: number - /** Colors the palette has no match for, across the included frames. */ + /** Every animation, exported or not, in the sheet's order. */ + readonly clips: ReadonlyArray + /** Colors the palette has no match for, across the included frames of the exported animations. */ readonly lost: ReadonlyArray } @@ -199,7 +211,35 @@ export const runSheet = (image: RgbaImage, recipe: Recipe, sheet: Sheet, cache: const shared = sharePalette(fixed.map((frame) => frame.outcome.art)) const frames: Array = fixed.map((frame, i) => ({ ...frame, art: shared[i]!, anchor: anchorOf(shared[i]!, sheet.anchor) })) - const included = frames.filter((frame) => sheet.frames[frame.index]!.include) + const clips = sheet.animations.map((animation, a): ClipOutcome => { + const included = animation.frames.flatMap((i) => { + const fixedFrame = frames[i] + return sheet.frames[i]?.include === true && fixedFrame !== undefined ? [fixedFrame] : [] + }) + const laid = placeFrames(included, sheet, cell) + return { animation: a, name: animation.name, include: animation.include, placed: laid.frames.map((placed, i) => ({ ...placed, index: included[i]!.index })), width: laid.width, height: laid.height } + }) + const exported = new Set(sheet.animations.flatMap((animation) => (animation.include ? animation.frames : []))) + const lost = + recipe.palette === undefined + ? [] + : frames + .filter((frame) => exported.has(frame.index) && sheet.frames[frame.index]!.include) + .flatMap((frame) => lostColors(frame.outcome.samples, frame.outcome.art, recipe.lostThreshold).map((entry) => ({ ...entry, frame: frame.index }))) + return { + backgroundFound: colors, + cleared, + cell, + confidence: given !== undefined ? undefined : found?.confidence, + frames, + clips, + lost + } +} + +/** One animation's frames on one canvas, lined up by the sheet's anchor. */ +const placeFrames = (included: ReadonlyArray, sheet: Sheet, cell: { readonly width: number; readonly height: number }) => { + if (included.length === 0) return { frames: [], width: 0, height: 0 } // On the ground, each frame keeps its height as drawn on the sheet (a hop stays a hop): its // top, in cells, above the bottom of its row of boxes; the lowest feet of all set the ground. const drawn = included.map((frame) => { @@ -215,35 +255,20 @@ export const runSheet = (image: RgbaImage, recipe: Recipe, sheet: Sheet, cache: if (sheet.anchor === "ground") return { x: nudge.x - frame.anchor.x, y: nudge.y + drawn[i]! - ground } return { x: nudge.x - frame.anchor.x, y: nudge.y - frame.anchor.y } }) - const laid = layOut( + return layOut( included.map((frame) => frame.art), offsets, sheet.padding ) - const lost = - recipe.palette === undefined - ? [] - : included.flatMap((frame) => lostColors(frame.outcome.samples, frame.outcome.art, recipe.lostThreshold).map((entry) => ({ ...entry, frame: frame.index }))) - return { - backgroundFound: colors, - cleared, - cell, - confidence: given !== undefined ? undefined : found?.confidence, - frames, - placed: laid.frames.map((placed, i) => ({ ...placed, index: included[i]!.index })), - width: laid.width, - height: laid.height, - lost - } } /** Whether two boxes share most of their height: frames on one row of the sheet. */ const overlapsVertically = (a: FrameBox, b: FrameBox): boolean => Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) >= 0.5 * Math.min(a.height, b.height) -/** Every opaque cell color of every included frame, for proposing one palette for the whole animation. */ +/** Every opaque cell color of every included frame of the exported animations, for proposing one palette for them all. */ export const sheetColors = (outcome: AnimationOutcome): Array => - outcome.placed.flatMap((frame) => { + exportedClips(outcome).flatMap((clip) => clip.placed).flatMap((frame) => { const colors: Array = [] frame.art.indices.forEach((index) => { if (index !== TRANSPARENT) colors.push(frame.art.palette[index]!) @@ -260,3 +285,16 @@ export const proposeForSheet = ( method: ProposeMethod, cache: AnimationCache = {} ): Array => proposePalette(sheetColors(runSheet(image, { ...recipe, palette: undefined }, sheet, cache)), count, method) + +/** The animations that are exported, with at least one frame. */ +export const exportedClips = (outcome: AnimationOutcome): Array => outcome.clips.filter((clip) => clip.include && clip.placed.length > 0) + +/** The exported animations as `atlasOf` and `spritesheetJson` take them: their frames, anchors, and how they play. */ +export const atlasClips = (outcome: AnimationOutcome, sheet: Sheet): Array => + exportedClips(outcome).map((clip) => ({ + name: clip.name, + frames: clip.placed.map((placed) => placed.art), + anchor: clip.placed[0]!.anchor, + fps: sheet.animations[clip.animation]!.fps, + loop: sheet.animations[clip.animation]!.loop + })) diff --git a/packages/tools/src/pixel/core/preview.ts b/packages/tools/src/pixel/core/preview.ts index 6cac73bc..86390d47 100644 --- a/packages/tools/src/pixel/core/preview.ts +++ b/packages/tools/src/pixel/core/preview.ts @@ -120,31 +120,38 @@ export const previewFix = (source: RgbaImage, grid: Grid, art: PixelArt, height } /** The colors frame boxes are outlined in, in turn, so neighbouring boxes tell apart. */ +/** One color per animation, in turn, so neighbouring animations' boxes differ. */ const BOX_COLORS = [ [0, 220, 255], - [255, 200, 0] + [255, 200, 0], + [120, 255, 120], + [255, 110, 200] ] as const /** * An animation sheet to look at: the source on top with each frame's box - * outlined, and under it every fixed frame enlarged, side by side on one - * canvas size, with a line through the anchor so a frame that jumps shows. + * outlined in its animation's color, and under it each animation's fixed + * frames enlarged, side by side on one row, with a line through the anchor + * so a frame that jumps shows. */ export const previewSheet = ( source: RgbaImage, - boxes: ReadonlyArray<{ readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly include: boolean }>, - frames: ReadonlyArray, - anchorY: number | undefined, + boxes: ReadonlyArray<{ readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly include: boolean; readonly group: number }>, + clips: ReadonlyArray<{ readonly frames: ReadonlyArray; readonly anchorY: number | undefined }>, target = 1024 ): RgbaImage => { - const first = frames[0] - const frameScale = first === undefined ? 1 : Math.max(1, Math.floor((target - GAP * (frames.length - 1)) / Math.max(1, frames.length * first.width))) - const rowWidth = first === undefined ? 0 : frames.length * first.width * frameScale + GAP * (frames.length - 1) - const rowHeight = first === undefined ? 0 : first.height * frameScale - const width = Math.max(rowWidth, Math.min(target, source.width)) + const rows = clips + .filter((clip) => clip.frames.length > 0) + .map((clip) => { + const first = clip.frames[0]! + const n = clip.frames.length + const frameScale = Math.max(1, Math.floor((target - GAP * (n - 1)) / Math.max(1, n * first.width))) + return { ...clip, frameScale, width: n * first.width * frameScale + GAP * (n - 1), height: first.height * frameScale } + }) + const width = Math.max(Math.min(target, source.width), ...rows.map((row) => row.width)) const scale = width / source.width const top = Math.max(1, Math.round(source.height * scale)) - const height = top + (first === undefined ? 0 : GAP * 2 + rowHeight) + const height = top + rows.reduce((sum, row) => sum + GAP * 2 + row.height, 0) const data = new Uint8ClampedArray(width * height * 4) for (let i = 0; i < width * height; i++) { data[i * 4] = BACKDROP[0] @@ -165,34 +172,40 @@ export const previewSheet = ( data[i + 2] = source.data[s + 2]! * alpha + shade * (1 - alpha) } } - boxes.forEach((box, b) => { - const [r, g, bl] = BOX_COLORS[b % BOX_COLORS.length]! + boxes.forEach((box) => { + const [r, g, bl] = BOX_COLORS[box.group % BOX_COLORS.length]! const color = [r, g, bl, box.include ? 255 : 110] as const const x0 = Math.max(0, Math.round(box.x * scale)) const y0 = Math.max(0, Math.round(box.y * scale)) const x1 = Math.min(width - 1, Math.round((box.x + box.width) * scale) - 1) const y1 = Math.min(top - 1, Math.round((box.y + box.height) * scale) - 1) - for (let t = 0; t < 2; t++) { - for (let x = x0; x <= x1; x++) { + // Inset by a pixel, so two boxes that touch both show. + for (let t = 1; t < 3; t++) { + for (let x = x0 + t; x <= x1 - t; x++) { blend(data, ((y0 + t) * width + x) * 4, color) blend(data, ((y1 - t) * width + x) * 4, color) } - for (let y = y0; y <= y1; y++) { + for (let y = y0 + t; y <= y1 - t; y++) { blend(data, (y * width + x0 + t) * 4, color) blend(data, (y * width + x1 - t) * 4, color) } } }) - frames.forEach((art, f) => { - const picture = previewArt(art, frameScale) - const left = f * (art.width * frameScale + GAP) - for (let y = 0; y < picture.height; y++) { - data.set(picture.data.subarray(y * picture.width * 4, (y + 1) * picture.width * 4), ((top + GAP + y) * width + left) * 4) + let at = top + for (const row of rows) { + at += GAP + row.frames.forEach((art, f) => { + const picture = previewArt(art, row.frameScale) + const left = f * (art.width * row.frameScale + GAP) + for (let y = 0; y < picture.height; y++) { + data.set(picture.data.subarray(y * picture.width * 4, (y + 1) * picture.width * 4), ((at + y) * width + left) * 4) + } + }) + if (row.anchorY !== undefined) { + const y = at + Math.min(row.height - 1, (row.anchorY + 1) * row.frameScale - 1) + for (let x = 0; x < row.width; x++) blend(data, (y * width + x) * 4, [255, 64, 160, 200]) } - }) - if (anchorY !== undefined && first !== undefined) { - const y = top + GAP + Math.min(rowHeight - 1, (anchorY + 1) * frameScale - 1) - for (let x = 0; x < rowWidth; x++) blend(data, (y * width + x) * 4, [255, 64, 160, 200]) + at += row.height + GAP } return { width, height, data } } diff --git a/packages/tools/src/pixel/core/recipe.ts b/packages/tools/src/pixel/core/recipe.ts index 0ef1847c..bd49cd85 100644 --- a/packages/tools/src/pixel/core/recipe.ts +++ b/packages/tools/src/pixel/core/recipe.ts @@ -13,7 +13,7 @@ import { defaultSampleOptions, type Dither, type SampleOptions } from "./fix.ts" import type { GridSpec } from "./grid.ts" import { presets } from "./palette.ts" import type { ReduceMode } from "./reduce.ts" -import { anchors, defaultSheet, type Sheet, type SheetFrame } from "./sheet.ts" +import { anchors, animationOf, DEFAULT_FPS, defaultSheet, isAnimationName, type Sheet, type SheetAnimation, type SheetFrame } from "./sheet.ts" /** * Where the grid comes from: found in the picture ("auto"), given as a cell @@ -265,13 +265,46 @@ export const decodeRecipe = (text: string): Decoded => { } ] }) - const fps = number(value.fps, "sheet.fps", defaultSheet.fps) - if (!(fps > 0)) errors.push("sheet.fps must be more than 0") + const playing = (entry: Record, where: string): Pick => { + const fps = number(entry.fps, `${where}.fps`, DEFAULT_FPS) + if (!(fps > 0)) errors.push(`${where}.fps must be more than 0`) + return { fps, loop: oneOf(entry.loop, `${where}.loop`, ["forward", "pingpong"], "forward") } + } + // A recipe from before sheets held animations: every frame is one animation, played as the sheet said. + const animations = ((): Array => { + if (value.animations === undefined) { + const played = playing(value, "sheet") + return read.length === 0 ? [] : [{ ...animationOf("animation-1", read.map((_, i) => i)), ...played }] + } + if (!Array.isArray(value.animations)) { + errors.push("sheet.animations must be a list") + return [] + } + const seen = new Set() + const names = new Set() + const list = value.animations.flatMap((entry, a): Array => { + const where = `sheet.animations[${a}]` + if (!isRecord(entry) || typeof entry.name !== "string" || !isAnimationName(entry.name) || !Array.isArray(entry.frames)) { + errors.push(`${where} needs a name (letters, digits, "-", "_", ".") and a list of frames`) + return [] + } + if (names.has(entry.name)) errors.push(`${where}: another animation is named "${entry.name}"`) + names.add(entry.name) + const frames = entry.frames.filter((i): i is number => Number.isInteger(i) && i >= 0 && i < read.length) + if (frames.length !== entry.frames.length || frames.length === 0) errors.push(`${where}.frames must be frame numbers from 0 to ${read.length - 1}, at least one`) + for (const i of frames) { + if (seen.has(i)) errors.push(`frame ${i} is in more than one animation`) + seen.add(i) + } + return [{ name: entry.name, frames, include: boolean(entry.include, `${where}.include`, true), ...playing(entry, where) }] + }) + if (seen.size !== read.length) errors.push(`every frame must be in an animation (${read.length - seen.size} are not)`) + return list + })() return { frames: read, + animations, anchor: oneOf(value.anchor, "sheet.anchor", anchors.map((entry) => entry.id), defaultSheet.anchor), - fps, - loop: oneOf(value.loop, "sheet.loop", ["forward", "pingpong"], defaultSheet.loop), padding: Math.max(0, Math.round(number(value.padding, "sheet.padding", defaultSheet.padding))) } })() diff --git a/packages/tools/src/pixel/core/report.ts b/packages/tools/src/pixel/core/report.ts index 544b060e..c281024d 100644 --- a/packages/tools/src/pixel/core/report.ts +++ b/packages/tools/src/pixel/core/report.ts @@ -8,14 +8,14 @@ * measure; it rarely fools both, and when either is low the answer is * "needs-person", never a confident bad fix. */ -import type { AnimationOutcome } from "./animation.ts" +import { atlasClips, type AnimationOutcome } from "./animation.ts" import { pack, toHex, toOklab, unpack, type Lab } from "./color.ts" import { medianColor, usedColors, type CellSamples, type PixelArt } from "./fix.ts" import type { GridSpec, RgbaImage } from "./grid.ts" import { lintArt, type Issue, type LintRules } from "./lint.ts" import { runRecipe, type Outcome } from "./pipeline.ts" import type { Recipe } from "./recipe.ts" -import { sheetArt } from "./spritesheet.ts" +import { atlasOf } from "./spritesheet.ts" import { defaultSheet } from "./sheet.ts" import { artOfImage } from "./sprite.ts" @@ -43,10 +43,19 @@ export interface Report { /** For an animation sheet: its frames, where they are in the source, and how sure each frame's own grid was. */ readonly animation?: { readonly frames: number + /** Frames in the exported animations. */ readonly included: number - readonly fps: number - readonly loop: string readonly anchor: string + /** Every animation on the sheet: its frames (numbered from 1, in play order), whether it is exported, and its size. */ + readonly animations: ReadonlyArray<{ + readonly name: string + readonly frames: ReadonlyArray + readonly include: boolean + readonly fps: number + readonly loop: string + readonly width: number + readonly height: number + }> readonly boxes: ReadonlyArray<{ readonly frame: number readonly x: number @@ -272,21 +281,31 @@ const OTHER_SCALE = 0.05 * when the grid is unsure or a frame came out empty. */ export const reportOfSheet = (image: RgbaImage, recipe: Recipe, outcome: AnimationOutcome, rules: LintRules = {}): Report => { - const sheet = recipe.sheet - const placed = outcome.placed.map((frame) => frame.art) - const strip = sheetArt(placed, { columns: placed.length, spacing: 1 }) + const sheet = recipe.sheet ?? defaultSheet + const clips = atlasClips(outcome, sheet) + const placed = clips.flatMap((clip) => clip.frames) + const strip = atlasOf(clips, { columns: Number.MAX_SAFE_INTEGER, spacing: 1 }).art const issues: Array = lintArt(strip, { ...rules, sizes: undefined, palette: rules.palette ?? recipe.palette?.colors }).filter( // Sparks and dust around a body are single pixels on purpose: stray pixels are judged on single pictures only. (issue) => issue.rule !== "stray-pixel" ) - const first = placed[0] - if (first !== undefined && rules.sizes !== undefined && rules.sizes.length > 0) { - issues.unshift(...lintArt(first, { sizes: rules.sizes }).filter((issue) => issue.rule === "size")) + if (rules.sizes !== undefined && rules.sizes.length > 0) { + for (const clip of clips) { + issues.unshift( + ...lintArt(clip.frames[0]!, { sizes: rules.sizes }) + .filter((issue) => issue.rule === "size") + .map((issue) => ({ ...issue, message: `${clip.name}: ${issue.message}` })) + ) + } + } + if (clips.length === 0) { + issues.push({ rule: "one-frame", severity: "warning", message: "No animation is exported: check the frames found, add boxes by hand, or put an animation back in", count: 0, at: [] }) } - if (outcome.placed.length < 2) { - issues.push({ rule: "one-frame", severity: "warning", message: `${outcome.placed.length} frame${outcome.placed.length === 1 ? "" : "s"} in the animation: check the frames found, or add boxes by hand`, count: 0, at: [] }) + for (const clip of clips.filter((entry) => entry.frames.length === 1)) { + issues.push({ rule: "one-frame", severity: "warning", message: `${clip.name} has 1 frame: check its frames, or join it to the animation it belongs to`, count: 0, at: [] }) } - const empty = outcome.frames.filter((frame) => sheet?.frames[frame.index]?.include === true && !frame.art.indices.some((index) => index >= 0)) + const exported = new Set(sheet.animations.flatMap((animation) => (animation.include ? animation.frames : []))) + const empty = outcome.frames.filter((frame) => exported.has(frame.index) && sheet.frames[frame.index]?.include === true && !frame.art.indices.some((index) => index >= 0)) for (const frame of empty) { issues.push({ rule: "empty-frame", severity: "error", message: `Frame ${frame.index + 1} has nothing in it: move its box onto the art, or leave it out`, count: 0, at: [] }) } @@ -309,14 +328,14 @@ export const reportOfSheet = (image: RgbaImage, recipe: Recipe, outcome: Animati const sure = confidence === undefined || confidence >= CONFIDENT.confidence const verdict: Verdict = sure && errors(issues) === 0 ? "fixable" : "needs-person" const colors = usedColors(strip).length - const count = outcome.placed.length - const frameSize = `${outcome.width}×${outcome.height}` + const count = placed.length + const what = `${clips.length} animation${clips.length === 1 ? "" : "s"} of ${count} frame${count === 1 ? "" : "s"}` const summary = verdict === "fixable" - ? `Animation of ${count} frame${count === 1 ? "" : "s"}, ${frameSize} each, ${colors} colors (cells ${round(size)} px${confidence === undefined ? "" : `, confidence ${Math.round(confidence * 100)}%`}).` + ? `${what}, ${colors} colors (cells ${round(size)} px${confidence === undefined ? "" : `, confidence ${Math.round(confidence * 100)}%`}).` : !sure ? `Unsure of the grid (confidence ${Math.round((confidence ?? 0) * 100)}%): look at the preview, or set the cell size by hand.` - : `Animation of ${count} frames, but rules are still broken: see issues.` + : `${what}, but rules are still broken: see issues.` return { verdict, summary, @@ -330,17 +349,25 @@ export const reportOfSheet = (image: RgbaImage, recipe: Recipe, outcome: Animati ...(confidence === undefined ? {} : { confidence: round(confidence) }), fit: round(outcome.frames.length === 0 ? 0 : outcome.frames.reduce((sum, frame) => sum + gridFit(frame.outcome.samples), 0) / outcome.frames.length) }, - output: { width: outcome.width, height: outcome.height, colors }, + // The sheet written: every exported animation, each from a new row of frames. + output: { width: strip.width, height: strip.height, colors }, animation: { frames: outcome.frames.length, included: count, - fps: sheet?.fps ?? 0, - loop: sheet?.loop ?? "forward", - anchor: sheet?.anchor ?? defaultSheet.anchor, + anchor: sheet.anchor, + animations: sheet.animations.map((animation, a) => ({ + name: animation.name, + frames: animation.frames.map((i) => i + 1), + include: animation.include, + fps: animation.fps, + loop: animation.loop, + width: outcome.clips[a]?.width ?? 0, + height: outcome.clips[a]?.height ?? 0 + })), boxes: outcome.frames.map((frame) => ({ frame: frame.index + 1, ...frame.box, - include: sheet?.frames[frame.index]?.include ?? true, + include: sheet.frames[frame.index]?.include ?? true, ...(frame.detection === undefined ? {} : { cell: round((frame.detection.spec.cellWidth + frame.detection.spec.cellHeight) / 2), confidence: round(frame.detection.confidence) }) })) }, diff --git a/packages/tools/src/pixel/core/sheet.ts b/packages/tools/src/pixel/core/sheet.ts index 42e4fc44..281dfb82 100644 --- a/packages/tools/src/pixel/core/sheet.ts +++ b/packages/tools/src/pixel/core/sheet.ts @@ -52,21 +52,201 @@ export interface SheetFrame { readonly nudge: { readonly x: number; readonly y: number } } -/** Everything that makes a sheet into an animation, beyond the recipe every frame shares. */ -export interface Sheet { - readonly frames: ReadonlyArray - readonly anchor: Anchor +/** + * One animation on a sheet: a name a game plays it by, its frames (indices + * into the sheet's frames, in the order they play), whether it is exported, + * and how it plays. + */ +export interface SheetAnimation { + readonly name: string + readonly frames: ReadonlyArray + readonly include: boolean /** Frames per second. */ readonly fps: number readonly loop: Loop - /** Empty pixels kept around the frames, on every side. */ +} + +/** + * Everything that makes a sheet into animations, beyond the recipe every + * frame shares. Every frame belongs to exactly one animation: change frames + * and animations with `withFrames`, `insertFrame`, `removeFrame`, + * `splitAnimation`, `joinAnimation`, and `renameAnimation`, which keep that so. + */ +export interface Sheet { + readonly frames: ReadonlyArray + readonly animations: ReadonlyArray + readonly anchor: Anchor + /** Empty pixels kept around each animation's frames, on every side. */ readonly padding: number } -export const defaultSheet: Sheet = { frames: [], anchor: "ground", fps: 8, loop: "forward", padding: 1 } +export const DEFAULT_FPS = 8 + +export const defaultSheet: Sheet = { frames: [], animations: [], anchor: "ground", padding: 1 } export const frameOf = (box: FrameBox): SheetFrame => ({ box, include: true, nudge: { x: 0, y: 0 } }) +/** An animation of these frames, exported, at the default speed, looping. */ +export const animationOf = (name: string, frames: ReadonlyArray): SheetAnimation => ({ name, frames, include: true, fps: DEFAULT_FPS, loop: "forward" }) + +/** Two boxes on one row: their middles at about one height and their heights alike (a quarter of the smaller apart). */ +const sameRow = (a: FrameBox, b: FrameBox): boolean => { + const least = Math.min(a.height, b.height) + return Math.abs(a.y + a.height / 2 - (b.y + b.height / 2)) <= 0.25 * least && Math.max(a.height, b.height) <= 1.5 * least +} + +/** Boxes in rows: each row's indices left to right, the rows top to bottom (a row level with another, left first). */ +const rowsOf = (boxes: ReadonlyArray): Array> => { + const rows: Array> = [] + boxes.forEach((box, i) => { + const row = rows.find((other) => sameRow(boxes[other[0]!]!, box)) + if (row === undefined) rows.push([i]) + else row.push(i) + }) + for (const row of rows) row.sort((a, b) => boxes[a]!.x - boxes[b]!.x) + const top = (row: ReadonlyArray): FrameBox => boxes[row[0]!]! + return rows.sort((a, b) => { + const first = top(a) + const second = top(b) + return Math.abs(first.y - second.y) <= 0.25 * Math.min(first.height, second.height) ? first.x - second.x : first.y - second.y + }) +} + +/** Boxes in reading order: row by row, left to right, a row level with another after the one on its left. */ +export const readingOrder = (boxes: ReadonlyArray): Array => rowsOf(boxes).flatMap((row) => row.map((i) => boxes[i]!)) + +/** + * The animations a sheet's frames make, by default: one per row. Image + * generators often put two blocks side by side (walking on the left, + * throwing on the right), and where their rows do not line up the blocks + * show; a row across both is split where the blocks meet, when its two + * parts hold as many frames as the blocks' rows (a row of a different count, + * like an explosion under both, stays whole). Named "animation-1", ... in + * reading order. + */ +export const groupFrames = (boxes: ReadonlyArray): Array => { + const rows = rowsOf(boxes) + const left = (row: ReadonlyArray): number => Math.min(...row.map((i) => boxes[i]!.x)) + const right = (row: ReadonlyArray): number => Math.max(...row.map((i) => boxes[i]!.x + boxes[i]!.width)) + const span = (row: ReadonlyArray): { top: number; bottom: number } => ({ + top: Math.min(...row.map((i) => boxes[i]!.y)), + bottom: Math.max(...row.map((i) => boxes[i]!.y + boxes[i]!.height)) + }) + const widths = boxes.map((box) => box.width).sort((a, b) => a - b) + const reach = 0.5 * (widths[Math.floor(widths.length / 2)] ?? 0) + // Where two rows sit side by side: the blocks' meeting line, and how many frames each block's rows hold. + const seams: Array<{ readonly x: number; readonly before: number; readonly after: number }> = [] + for (const a of rows) { + for (const b of rows) { + if (a === b) continue + const sa = span(a) + const sb = span(b) + const overlap = Math.min(sa.bottom, sb.bottom) - Math.max(sa.top, sb.top) + if (overlap > 0.3 * Math.min(sa.bottom - sa.top, sb.bottom - sb.top) && right(a) <= left(b) + reach) { + seams.push({ x: (right(a) + left(b)) / 2, before: a.length, after: b.length }) + } + } + } + const parts = rows.flatMap((row): Array> => { + for (const seam of seams) { + const at = row.findIndex((i) => boxes[i]!.x + boxes[i]!.width / 2 > seam.x) + if (at !== seam.before || row.length - at !== seam.after) continue + const edge = boxes[row[at]!]!.x + if (Math.abs(edge - seam.x) <= reach) return [row.slice(0, at), row.slice(at)] + } + return [row] + }) + return parts.map((frames, i) => animationOf(`animation-${i + 1}`, frames)) +} + +/** The sheet with these frames, grouped into animations by `groupFrames` (anchor and margin kept). */ +export const withFrames = (sheet: Sheet, frames: ReadonlyArray): Sheet => ({ + ...sheet, + frames, + animations: groupFrames(frames.map((frame) => frame.box)) +}) + +/** A name no other animation has: `name`, or `name-2`, `name-3`, ... */ +const freeName = (animations: ReadonlyArray, name: string, skip = -1): string => { + const taken = new Set(animations.filter((_, i) => i !== skip).map((animation) => animation.name)) + if (!taken.has(name)) return name + let n = 2 + while (taken.has(`${name}-${n}`)) n++ + return `${name}-${n}` +} + +/** + * The sheet with a frame added at `index` (later frames move up one), in + * the animation of the frame before it, right after that frame; the first + * frame of all joins the first animation, and a sheet with none gets one. + */ +export const insertFrame = (sheet: Sheet, index: number, frame: SheetFrame): Sheet => { + const frames = [...sheet.frames] + frames.splice(index, 0, frame) + const shifted = sheet.animations.map((animation) => ({ ...animation, frames: animation.frames.map((i) => (i >= index ? i + 1 : i)) })) + if (shifted.length === 0) return { ...sheet, frames, animations: [animationOf("animation-1", [index])] } + const before = index - 1 + const owner = shifted.findIndex((animation) => animation.frames.includes(before)) + const animations = shifted.map((animation, a) => { + if (owner < 0 ? a !== 0 : a !== owner) return animation + const list = [...animation.frames] + list.splice(owner < 0 ? 0 : list.indexOf(before) + 1, 0, index) + return { ...animation, frames: list } + }) + return { ...sheet, frames, animations } +} + +/** The sheet without the frame at `index` (later frames move down one); an animation left with no frames is dropped. */ +export const removeFrame = (sheet: Sheet, index: number): Sheet => ({ + ...sheet, + frames: sheet.frames.filter((_, i) => i !== index), + animations: sheet.animations + .map((animation) => ({ ...animation, frames: animation.frames.filter((i) => i !== index).map((i) => (i > index ? i - 1 : i)) })) + .filter((animation) => animation.frames.length > 0) +}) + +/** The animation at `animation` cut in two before its frame at position `at` (1 or more): the second part is a new animation after it. */ +export const splitAnimation = (sheet: Sheet, animation: number, at: number): Sheet => { + const current = sheet.animations[animation] + if (current === undefined || at < 1 || at >= current.frames.length) return sheet + const animations = [...sheet.animations] + const second = { ...current, frames: current.frames.slice(at), name: "" } + animations.splice(animation, 1, { ...current, frames: current.frames.slice(0, at) }, { ...second, name: freeName(animations, current.name) }) + return { ...sheet, animations } +} + +/** The animation at `animation` and the one after it as one, keeping the first one's name and settings. */ +export const joinAnimation = (sheet: Sheet, animation: number): Sheet => { + const current = sheet.animations[animation] + const next = sheet.animations[animation + 1] + if (current === undefined || next === undefined) return sheet + const animations = [...sheet.animations] + animations.splice(animation, 2, { ...current, frames: [...current.frames, ...next.frames] }) + return { ...sheet, animations } +} + +/** A name an animation can take: letters, digits, "-", "_", and ".", as games look it up. */ +export const isAnimationName = (name: string): boolean => /^[A-Za-z0-9_.-]+$/.test(name) + +/** The animation renamed; a name that is taken gets a number, and a name that is not `isAnimationName` leaves it as it is. */ +export const renameAnimation = (sheet: Sheet, animation: number, name: string): Sheet => { + const current = sheet.animations[animation] + const wanted = name.trim().replace(/\s+/g, "-") + if (current === undefined || !isAnimationName(wanted)) return sheet + const animations = [...sheet.animations] + animations[animation] = { ...current, name: freeName(sheet.animations, wanted, animation) } + return { ...sheet, animations } +} + +/** The animation with some of its settings changed (whether it is exported, its speed, how it loops). */ +export const setAnimation = (sheet: Sheet, animation: number, change: Partial>): Sheet => { + const current = sheet.animations[animation] + if (current === undefined) return sheet + const animations = [...sheet.animations] + animations[animation] = { ...current, ...change, fps: Math.min(60, Math.max(0.5, change.fps ?? current.fps)) } + return { ...sheet, animations } +} + // --------------------------------------------------------------------------- // Clearing the background @@ -368,7 +548,7 @@ export const findFrames = (image: RgbaImage): Array => { const inner = framesIn(image, box, usual, []) return inner.length > 1 ? inner.flatMap((part) => again(part, depth + 1)) : [box] } - return top.flatMap((box) => again(box, 0)) + return readingOrder(top.flatMap((box) => again(box, 0))) } /** How many times a box is cut into rows again: blocks within blocks are rare. */ diff --git a/packages/tools/src/pixel/core/spritesheet.ts b/packages/tools/src/pixel/core/spritesheet.ts index 68e23b24..dc7e4bf0 100644 --- a/packages/tools/src/pixel/core/spritesheet.ts +++ b/packages/tools/src/pixel/core/spritesheet.ts @@ -1,118 +1,138 @@ /** - * Writing an animation out the ways games read one: every frame on one - * picture (a strip, or a grid of rows), with a JSON file saying where each - * frame is, how long it shows, and where its anchor is. + * Writing animations out the ways games read them: every frame of every + * animation on one picture (each animation from a new row, its frames in + * one row or in rows of a few), with a JSON file saying where each frame + * is, how long it shows, and where its anchor is. * * The JSON is the "hash" spritesheet format that Pixi's `Spritesheet`, * Phaser's `atlas`, and Aseprite's export share (`frames`, `meta`), with - * Pixi's `animations` and Aseprite's `frameTags` for the clip, and each - * frame's `anchor` (where it stands, 0 to 1 across the frame) for engines - * that place sprites by it. + * Pixi's `animations` and Aseprite's `frameTags` naming each animation's + * frames, and each frame's `anchor` (where it stands, 0 to 1 across the + * frame) for engines that place sprites by it. */ import { TRANSPARENT, type PixelArt } from "./fix.ts" -import type { Point, Loop } from "./sheet.ts" +import type { Loop, Point } from "./sheet.ts" -/** How frames are laid out on one picture: one row, or rows of `columns` frames. */ +/** How each animation's frames are laid out: one row, or rows of `columns` frames. */ export interface SheetLayout { - /** Frames per row; the frame count (or more) makes a strip. */ + /** Frames per row; an animation's frame count (or more) makes a strip. */ readonly columns: number - /** Empty pixels between frames (and none around them). */ + /** Empty pixels between frames, and between animations (none around them). */ readonly spacing: number } -/** Frames per row and rows for a layout. */ -export const gridOf = (count: number, layout: SheetLayout): { readonly columns: number; readonly rows: number } => { - const columns = Math.max(1, Math.min(count, Math.round(layout.columns))) - return { columns, rows: Math.max(1, Math.ceil(count / columns)) } +/** One animation to write: its frames (one size, one palette with the others), its name, and how it plays. */ +export interface AtlasClip { + readonly name: string + readonly frames: ReadonlyArray + /** Where every frame's anchor is, in frame pixels at 1x (one spot for all its frames). */ + readonly anchor: Point + readonly fps: number + readonly loop: Loop } -/** Where a frame sits on the sheet, in the sheet's pixels at 1x. */ -export const frameRect = (i: number, width: number, height: number, count: number, layout: SheetLayout): { x: number; y: number; w: number; h: number } => { - const { columns } = gridOf(count, layout) - const spacing = Math.max(0, Math.round(layout.spacing)) - return { x: (i % columns) * (width + spacing), y: Math.floor(i / columns) * (height + spacing), w: width, h: height } +/** Where one frame is on the picture, in its pixels at 1x. */ +export interface AtlasRect { + readonly clip: number + readonly frame: number + readonly x: number + readonly y: number + readonly w: number + readonly h: number } -/** Every frame on one picture, laid out as asked. Frames must share one size and one palette. */ -export const sheetArt = (frames: ReadonlyArray, layout: SheetLayout): PixelArt => { - const first = frames[0] - if (first === undefined) return { width: 0, height: 0, palette: [], indices: new Int32Array() } - const { columns, rows } = gridOf(frames.length, layout) +/** Every frame on one picture, and where each one is. */ +export interface Atlas { + readonly art: PixelArt + readonly rects: ReadonlyArray +} + +/** + * Lays every animation's frames out on one picture: each animation from a + * new row, its frames in rows of `columns`. Frames must share one palette. + */ +export const atlasOf = (clips: ReadonlyArray, layout: SheetLayout): Atlas => { const spacing = Math.max(0, Math.round(layout.spacing)) - const width = columns * first.width + (columns - 1) * spacing - const height = rows * first.height + (rows - 1) * spacing + const rects: Array = [] + let top = 0 + let width = 0 + clips.forEach((clip, c) => { + const first = clip.frames[0] + if (first === undefined) return + const columns = Math.max(1, Math.min(clip.frames.length, Math.round(layout.columns))) + clip.frames.forEach((_, f) => { + rects.push({ clip: c, frame: f, x: (f % columns) * (first.width + spacing), y: top + Math.floor(f / columns) * (first.height + spacing), w: first.width, h: first.height }) + }) + const rows = Math.ceil(clip.frames.length / columns) + width = Math.max(width, columns * first.width + (columns - 1) * spacing) + top += rows * first.height + (rows - 1) * spacing + spacing + }) + const height = Math.max(0, top - spacing) + const palette = clips.find((clip) => clip.frames.length > 0)?.frames[0]?.palette ?? [] const indices = new Int32Array(width * height).fill(TRANSPARENT) - frames.forEach((art, i) => { - const at = frameRect(i, first.width, first.height, frames.length, layout) + for (const rect of rects) { + const art = clips[rect.clip]!.frames[rect.frame]! for (let y = 0; y < art.height; y++) { - indices.set(art.indices.subarray(y * art.width, (y + 1) * art.width), (at.y + y) * width + at.x) + indices.set(art.indices.subarray(y * art.width, (y + 1) * art.width), (rect.y + y) * width + rect.x) } - }) - return { width, height, palette: first.palette, indices } + } + return { art: { width, height, palette, indices }, rects } } export interface SpritesheetInfo { - /** The animation's name: frames are named " 0", " 1", ... */ - readonly name: string /** The sheet picture's file name, as the JSON refers to it. */ readonly image: string - readonly frameWidth: number - readonly frameHeight: number - readonly count: number - readonly layout: SheetLayout + readonly clips: ReadonlyArray + /** Where the frames are, from `atlasOf` on the same clips. */ + readonly atlas: Atlas /** Each pixel of the picture written this many times. */ readonly scale: number - readonly fps: number - readonly loop: Loop - /** Where every frame's anchor is, in frame pixels at 1x (one spot for all, as laid out). */ - readonly anchor: Point } const round = (value: number): number => Math.round(value * 10000) / 10000 -/** The spritesheet's JSON (see the module's comment for who reads it). */ +/** The spritesheet's JSON (see the module's comment for who reads it). Frames are named " 0", " 1", ... */ export const spritesheetJson = (info: SpritesheetInfo): string => { const scale = Math.max(1, Math.floor(info.scale)) - const duration = Math.round(1000 / Math.max(0.1, info.fps)) - const names = Array.from({ length: info.count }, (_, i) => `${info.name} ${i}`) - const anchor = { - x: round((info.anchor.x + 0.5) / info.frameWidth), - y: round((info.anchor.y + 1) / info.frameHeight) - } const frames: Record = {} - names.forEach((name, i) => { - const rect = frameRect(i, info.frameWidth, info.frameHeight, info.count, info.layout) - const frame = { x: rect.x * scale, y: rect.y * scale, w: rect.w * scale, h: rect.h * scale } - frames[name] = { - frame, - rotated: false, - trimmed: false, - spriteSourceSize: { x: 0, y: 0, w: frame.w, h: frame.h }, - sourceSize: { w: frame.w, h: frame.h }, - duration, - anchor - } + const animations: Record> = {} + const frameTags: Array<{ name: string; from: number; to: number; direction: Loop }> = [] + info.clips.forEach((clip, c) => { + const rects = info.atlas.rects.filter((rect) => rect.clip === c) + const first = rects[0] + if (first === undefined) return + const duration = Math.round(1000 / Math.max(0.1, clip.fps)) + const anchor = { x: round((clip.anchor.x + 0.5) / first.w), y: round((clip.anchor.y + 1) / first.h) } + const names = rects.map((rect) => `${clip.name} ${rect.frame}`) + frameTags.push({ name: clip.name, from: Object.keys(frames).length, to: Object.keys(frames).length + names.length - 1, direction: clip.loop }) + rects.forEach((rect, i) => { + const frame = { x: rect.x * scale, y: rect.y * scale, w: rect.w * scale, h: rect.h * scale } + frames[names[i]!] = { + frame, + rotated: false, + trimmed: false, + spriteSourceSize: { x: 0, y: 0, w: frame.w, h: frame.h }, + sourceSize: { w: frame.w, h: frame.h }, + duration, + anchor + } + }) + animations[clip.name] = names }) - const { columns, rows } = gridOf(info.count, info.layout) - const spacing = Math.max(0, Math.round(info.layout.spacing)) - const size = { - w: (columns * info.frameWidth + (columns - 1) * spacing) * scale, - h: (rows * info.frameHeight + (rows - 1) * spacing) * scale - } return ( JSON.stringify( { frames, - animations: { [info.name]: names }, + animations, meta: { app: "https://ritmo.typeonce.dev/tools/pixel/", version: "1", image: info.image, format: "RGBA8888", - size, + size: { w: info.atlas.art.width * scale, h: info.atlas.art.height * scale }, scale: "1", - frameTags: [{ name: info.name, from: 0, to: info.count - 1, direction: info.loop }], - fps: info.fps + frameTags, + fps: Object.fromEntries(info.clips.map((clip) => [clip.name, clip.fps])) } }, null, diff --git a/packages/tools/src/pixel/ui/main.ts b/packages/tools/src/pixel/ui/main.ts index 3472e05d..b5611442 100644 --- a/packages/tools/src/pixel/ui/main.ts +++ b/packages/tools/src/pixel/ui/main.ts @@ -55,10 +55,26 @@ import { describeDetection, gridKeyOf, resolveGrid, runWithSpec, type PipelineCa import { isPng, readProjectRecipe, withProjectRecipe } from "../core/project.ts" import { decodeRecipe, encodeRecipe, presetPalette, type Recipe } from "../core/recipe.ts" import { isNative, reportOfArt, reportOfFix, reportOfSheet, type Report } from "../core/report.ts" -import { detectFrames, isAnimationSheet, proposeForSheet, runSheet, type AnimationCache, type AnimationOutcome } from "../core/animation.ts" +import { atlasClips, detectFrames, exportedClips, isAnimationSheet, proposeForSheet, runSheet, type AnimationCache, type AnimationOutcome, type ClipOutcome } from "../core/animation.ts" import { toGif } from "../core/gif.ts" -import { anchors, defaultSheet, frameOf, playOrder, type Sheet } from "../core/sheet.ts" -import { sheetArt, spritesheetJson, type SheetLayout } from "../core/spritesheet.ts" +import { + anchors, + DEFAULT_FPS, + defaultSheet, + frameOf, + insertFrame, + isAnimationName, + joinAnimation, + playOrder, + removeFrame as withoutFrame, + renameAnimation, + setAnimation, + splitAnimation, + withFrames, + type Sheet, + type SheetAnimation +} from "../core/sheet.ts" +import { atlasOf, spritesheetJson, type SheetLayout } from "../core/spritesheet.ts" import { toZip } from "../core/zip.ts" import { formatSprite, parseJsonMap, parseSprite } from "../core/sprite.ts" import { reduceModes, type ReduceMode } from "../core/reduce.ts" @@ -101,8 +117,10 @@ interface State { source: { readonly image: RgbaImage; readonly canvas: HTMLCanvasElement; readonly name: string; readonly png?: Uint8Array | undefined } | undefined /** Fixed as one picture, or as an animation of the frames on it. */ kind: "picture" | "animation" - /** The animation's frames and how they play, when `kind` is "animation". */ + /** The sheet's frames and the animations they make, when `kind` is "animation". */ sheet: Sheet + /** The animation chosen in the Animations panel: what plays, what Frames shows, and what the GIF holds. */ + selectedAnimation: number /** The animation's cell size typed by hand; undefined finds it in the frames. */ sheetCell: number | undefined animationView: AnimationView @@ -113,7 +131,7 @@ interface State { onion: boolean /** The sheet as it is, or with its background cleared, in the sheet view. */ sheetShows: "cleared" | "original" - /** How the exported sheet lays its frames out. */ + /** How the exported sheet lays each animation's frames out. */ exportLayout: { kind: "strip" | "grid"; columns: number; spacing: number } spec: GridSpec linked: boolean @@ -172,6 +190,7 @@ const state: State = { source: undefined, kind: "picture", sheet: defaultSheet, + selectedAnimation: 0, sheetCell: undefined, animationView: "play", selectedFrame: undefined, @@ -362,10 +381,7 @@ const recompute = (): void => { painted = [] backgroundFound = [...animation.backgroundFound] result = undefined - exported = sheetArt( - animation.placed.map((frame) => frame.art), - exportLayout() - ) + exported = atlasOf(atlasClips(animation, state.sheet), exportLayout()).art report = reportOfSheet(source.image, recipe, animation, rulesOf()) return } @@ -425,6 +441,7 @@ const setSource = (image: RgbaImage, canvas: HTMLCanvasElement, name: string, sp state.proposals = [] state.kind = "picture" state.sheet = defaultSheet + state.selectedAnimation = 0 state.selectedFrame = undefined if (state.tool === "frames") state.tool = "move" cache = {} @@ -461,14 +478,16 @@ const openAsAnimation = (): boolean => { if (!isAnimationSheet(source.image, recipe, frames, animationCache)) return false state.kind = "animation" state.removeBackground = true - state.sheet = { ...defaultSheet, frames } + state.sheet = withFrames(defaultSheet, frames) + state.selectedAnimation = 0 state.sheetCell = undefined state.animationView = "play" state.playing = true frame = 0 state.keepCount = 16 propose() - state.message = `Found ${frames.length} frames: opened as an animation. Choose Picture in the top bar to fix it as one picture.` + const count = state.sheet.animations.length + state.message = `Found ${frames.length} frames${count > 1 ? ` in ${count} animations (one per row)` : ""}: opened as ${count > 1 ? "animations" : "an animation"}. Choose Picture in the top bar to fix it as one picture.` return true } @@ -479,7 +498,8 @@ const setKind = (kind: State["kind"]): void => { if (kind === "animation" && state.sheet.frames.length === 0) { const found = detectFrames(source.image, { ...recipeOf(), removeBackground: true }, animationCache) // Nothing found: one box around the whole picture, to move and add to by hand. - state.sheet = { ...state.sheet, frames: found.length > 0 ? found : [frameOf({ x: 0, y: 0, width: source.image.width, height: source.image.height })] } + state.sheet = withFrames(state.sheet, found.length > 0 ? found : [frameOf({ x: 0, y: 0, width: source.image.width, height: source.image.height })]) + state.selectedAnimation = 0 state.removeBackground = true } state.kind = kind @@ -602,6 +622,7 @@ const applyRecipe = (recipe: Recipe, name: string, message = `Applied ${name}.`) if (recipe.sheet !== undefined) { state.kind = "animation" state.sheet = recipe.sheet + state.selectedAnimation = 0 state.sheetCell = recipe.grid.kind === "cells" ? recipe.grid.spec.cellWidth : undefined state.selectedFrame = undefined if (state.tool !== "move" && state.tool !== "frames") state.tool = "move" @@ -688,6 +709,9 @@ const viewer = makeViewer(canvas, { viewChanged: () => renderZoom(), selectBox: (index) => { state.selectedFrame = index + // Its animation is the one chosen, so the panel shows the frame. + const owner = index === undefined ? -1 : state.sheet.animations.findIndex((entry) => entry.frames.includes(index)) + if (owner >= 0) state.selectedAnimation = owner update() }, changeBox: (index, box) => setFrameBox(index, box), @@ -726,12 +750,11 @@ const viewer = makeViewer(canvas, { const playerCanvas = h("canvas", { class: "view", attrs: { "aria-label": "Animation view", hidden: true } }) const player = makePlayer(playerCanvas, { choose: (index) => { - const placed = animation?.placed[index] + const placed = selectedClip()?.placed[index] if (placed === undefined) return state.selectedFrame = placed.index state.playing = false - const order = playOrder(animation!.placed.length, state.sheet.loop) - frame = Math.max(0, order.indexOf(index)) + frame = Math.max(0, playing().indexOf(index)) update() }, viewChanged: () => renderZoom() @@ -1692,10 +1715,31 @@ const introPanel = (): HTMLElement => // --------------------------------------------------------------------------- // Animation: frames, playback, and the frames' panel -/** The animation's frames on the canvas, in the order they play, as positions in `animation.placed`. */ -const playing = (): Array => playOrder(animation?.placed.length ?? 0, state.sheet.loop) +/** The animation chosen in the Animations panel, fixed. */ +const selectedClip = (): ClipOutcome | undefined => animation?.clips[state.selectedAnimation] + +/** How the chosen animation plays. */ +const selectedSettings = (): SheetAnimation | undefined => state.sheet.animations[state.selectedAnimation] + +/** The chosen animation's frames on its canvas, in the order they play, as positions in its `placed`. */ +const playing = (): Array => playOrder(selectedClip()?.placed.length ?? 0, selectedSettings()?.loop ?? "forward") + +/** Chooses an animation: it plays from its first frame. */ +const chooseAnimation = (index: number): void => { + state.selectedAnimation = index + state.selectedFrame = undefined + frame = 0 + update() +} -/** The frame on screen, as a position in `animation.placed`. */ +/** The sheet's animations, changed with `change` (a function from `../core/sheet.ts`). */ +const setSheet = (change: (sheet: Sheet) => Sheet): void => { + state.sheet = change(state.sheet) + state.selectedAnimation = Math.min(state.selectedAnimation, Math.max(0, state.sheet.animations.length - 1)) + update() +} + +/** The frame on screen, as a position in the chosen animation's `placed`. */ const shownFrame = (): number => { const order = playing() return order.length === 0 ? 0 : order[((frame % order.length) + order.length) % order.length]! @@ -1725,12 +1769,12 @@ const addFrame = (box: Box): void => { const index = at < 0 ? state.sheet.frames.length : at state.selectedFrame = index state.message = `Frame ${index + 1} added.` - setFrames((frames) => frames.splice(index, 0, frameOf({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }))) + setSheet((sheet) => insertFrame(sheet, index, frameOf({ x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) }))) } const removeFrame = (index: number): void => { state.selectedFrame = undefined - setFrames((frames) => frames.splice(index, 1)) + setSheet((sheet) => withoutFrame(sheet, index)) } const toggleFrame = (index: number): void => @@ -1753,14 +1797,15 @@ const findFramesAgain = (): void => { const found = detectFrames(source.image, { ...recipeOf(), removeBackground: true }, animationCache) state.selectedFrame = undefined state.message = found.length === 0 ? "No frames found: draw boxes with the Boxes tool." : `Found ${found.length} frame${found.length === 1 ? "" : "s"}.` - setFrames((frames) => frames.splice(0, frames.length, ...found)) + state.selectedAnimation = 0 + setSheet((sheet) => withFrames(sheet, found)) } /** Steps the animation by `by` frames, stopping playback. */ const step = (by: number): void => { state.playing = false frame += by - const placed = animation?.placed[shownFrame()] + const placed = selectedClip()?.placed[shownFrame()] if (placed !== undefined) state.selectedFrame = placed.index update() } @@ -1776,7 +1821,7 @@ const tick = (now: number): void => { lastTick = undefined return } - const every = 1000 / Math.max(0.5, state.sheet.fps) + const every = 1000 / Math.max(0.5, selectedSettings()?.fps ?? DEFAULT_FPS) if (lastTick === undefined) lastTick = now else if (now - lastTick >= every) { // A slow frame skips ahead instead of slowing the animation down. @@ -1800,13 +1845,12 @@ const counter = h("span", { class: "frame-counter" }) const renderCounter = (): void => { const count = playing().length - const placed = animation?.placed[shownFrame()] + const placed = selectedClip()?.placed[shownFrame()] counter.textContent = count === 0 ? "No frames" : `Frame ${placed === undefined ? "" : placed.index + 1} · ${(frame % count + count) % count + 1}/${count}` } const renderPlayer = (): void => { - const outcome = animation - if (outcome === undefined) return + const outcome = selectedClip() ?? { placed: [] } const order = playing() const position = order.length === 0 ? 0 : ((frame % order.length) + order.length) % order.length const chosen = state.selectedFrame === undefined ? -1 : outcome.placed.findIndex((placed) => placed.index === state.selectedFrame) @@ -1839,21 +1883,21 @@ const renderPlayback = (): void => { h( "label", { class: "fps", title: "Frames per second" }, - numberInput(state.sheet.fps, (value) => { - state.sheet = { ...state.sheet, fps: Math.min(60, Math.max(0.5, value)) } - update() - }, { min: 0.5, max: 60, step: 1, label: "Frames per second", suffix: "fps" }) + numberInput(selectedSettings()?.fps ?? DEFAULT_FPS, (value) => setSheet((sheet) => setAnimation(sheet, state.selectedAnimation, { fps: value })), { + min: 0.5, + max: 60, + step: 1, + label: "Frames per second", + suffix: "fps" + }) ), segmented( - state.sheet.loop, + selectedSettings()?.loop ?? "forward", [ { id: "forward", label: "Loop", title: "Plays start to end, then again" }, { id: "pingpong", label: "Ping-pong", title: "Plays there and back" } ], - (loop) => { - state.sheet = { ...state.sheet, loop } - update() - }, + (loop) => setSheet((sheet) => setAnimation(sheet, state.selectedAnimation, { loop })), { label: "Loop" } ), segmented( @@ -1868,54 +1912,136 @@ const renderPlayback = (): void => { ) } +/** A row of a list in the Animations panel: a number, a text, and an In/Out switch. */ +const listRow = (options: { + readonly number: number + readonly text: string + readonly on: boolean + readonly include: boolean + readonly choose: () => void + readonly toggle: () => void + readonly toggleTitle: string +}): HTMLElement => + h( + "div", + { class: `frame-row${options.on ? " on" : ""}${options.include ? "" : " out"}`, on: { click: options.choose } }, + h("span", { class: "frame-number" }, String(options.number)), + h("span", { class: "list-text" }, options.text), + button(options.include ? "In" : "Out", (event) => { + event.stopPropagation() + options.toggle() + }, { variant: "ghost", small: true, title: options.toggleTitle }) + ) + +/** " · W × H" for an animation's canvas, or nothing when it has no frames in it. */ +const clipSize = (clip: ClipOutcome | undefined): string => (clip === undefined || clip.placed.length === 0 ? "" : ` · ${clip.width} × ${clip.height}`) + +/** The sheet's animations: which are exported, their names and speed, and the frames of the chosen one. */ const framesPanel = (): HTMLElement | false => { const outcome = animation if (outcome === undefined) return false - const frames = state.sheet.frames - const included = frames.filter((entry) => entry.include).length + const { frames, animations } = state.sheet + const chosen = animations[state.selectedAnimation] + const clip = selectedClip() + const exportedCount = animations.filter((entry) => entry.include).length const selected = state.selectedFrame === undefined ? undefined : frames[state.selectedFrame] + // Where the chosen frame is in the chosen animation: a split goes before it. + const at = chosen === undefined || state.selectedFrame === undefined ? -1 : chosen.frames.indexOf(state.selectedFrame) return sectionOf( "frames", - "Frames", + "Animations", { - action: button("Find again", findFramesAgain, { variant: "ghost", small: true, icon: "wand", title: "Find the frames again (drops boxes changed by hand)" }), + action: button("Find again", findFramesAgain, { variant: "ghost", small: true, icon: "wand", title: "Find the frames again, one animation per row (drops boxes and animations changed by hand)" }), help: - "Each box on the sheet is one frame, fixed with the same settings. Choose Boxes in the toolbar over the sheet to move a box, drag its edges, or draw a new one on empty space; a box left out keeps its place but is not in the animation. Frames are lined up by the anchor: Ground keeps each frame at the height it was drawn (hops stay), Feet pins the feet to one row. Nudge a frame by a pixel with the arrow keys when it looks off." + "Each box on the sheet is one frame, and each row of frames is one animation, named so a game can play it by name. In or Out chooses which animations are exported; the others keep their frames and settings. Choose one to play it, rename it, and set its speed. Split cuts the chosen animation in two before the chosen frame; Join puts it together with the next one. Choose Boxes in the toolbar over the sheet to move a box, drag its edges, or draw a new one. Frames are lined up by the anchor: Ground keeps each frame at the height it was drawn (hops stay), Feet pins the feet to one row. Nudge a frame by a pixel with the arrow keys when it looks off." }, callout( - included >= 2 ? "good" : "warn", - `${frames.length} frame${frames.length === 1 ? "" : "s"} on the sheet, ${included} in the animation, ${outcome.width} × ${outcome.height} each.` + exportedCount > 0 ? "good" : "warn", + `${animations.length} animation${animations.length === 1 ? "" : "s"}, ${exportedCount} exported, from ${frames.length} frame${frames.length === 1 ? "" : "s"} on the sheet.` ), h( "div", { class: "frame-list" }, - frames.map((entry, i) => { - const nudged = entry.nudge.x !== 0 || entry.nudge.y !== 0 - return h( + animations.map((entry, a) => + listRow({ + number: a + 1, + text: `${entry.name} · ${entry.frames.length} frame${entry.frames.length === 1 ? "" : "s"}${clipSize(outcome.clips[a])}`, + on: a === state.selectedAnimation, + include: entry.include, + choose: () => chooseAnimation(a), + toggle: () => setSheet((sheet) => setAnimation(sheet, a, { include: !entry.include })), + toggleTitle: entry.include ? "Exported: click to leave it out" : "Left out: click to export it" + }) + ) + ), + chosen !== undefined && + group( + chosen.name, + prop( + "Name", + textInput(chosen.name, (value) => { + if (!isAnimationName(value.trim().replace(/\s+/g, "-"))) state.message = `"${value}" cannot name an animation: use letters, digits, "-", "_", and ".".` + setSheet((sheet) => renameAnimation(sheet, state.selectedAnimation, value)) + }, { label: "Animation name" }), + "What a game plays it by: the JSON's animations and frame tags use it." + ), + prop( + "Speed", + numberInput(chosen.fps, (value) => setSheet((sheet) => setAnimation(sheet, state.selectedAnimation, { fps: value })), { min: 0.5, max: 60, step: 1, suffix: "fps", label: "Speed" }), + "Frames per second, saved in the JSON and the GIF." + ), + prop( + "Loop", + segmented( + chosen.loop, + [ + { id: "forward", label: "Loop" }, + { id: "pingpong", label: "Ping-pong" } + ], + (loop) => setSheet((sheet) => setAnimation(sheet, state.selectedAnimation, { loop })), + { label: "Loop", class: "fill" } + ) + ), + h( "div", - { - class: `frame-row${state.selectedFrame === i ? " on" : ""}${entry.include ? "" : " out"}`, - on: { - click: () => { + { class: "frame-list" }, + chosen.frames.map((i) => { + const entry = frames[i]! + const nudged = entry.nudge.x !== 0 || entry.nudge.y !== 0 + return listRow({ + number: i + 1, + text: `${entry.box.width} × ${entry.box.height} px${nudged ? ` · moved ${entry.nudge.x}, ${entry.nudge.y}` : ""}`, + on: state.selectedFrame === i, + include: entry.include, + choose: () => { state.selectedFrame = i - const position = outcome.placed.findIndex((placed) => placed.index === i) + const position = clip?.placed.findIndex((placed) => placed.index === i) ?? -1 if (position >= 0) { state.playing = false frame = Math.max(0, playing().indexOf(position)) } update() - } - } - }, - h("span", { class: "frame-number" }, String(i + 1)), - h("span", { class: "list-text" }, `${entry.box.width} × ${entry.box.height} px${nudged ? ` · moved ${entry.nudge.x}, ${entry.nudge.y}` : ""}`), - button(entry.include ? "In" : "Out", (event) => { - event.stopPropagation() - toggleFrame(i) - }, { variant: "ghost", small: true, title: entry.include ? "In the animation: click to leave it out" : "Left out: click to put it back" }) + }, + toggle: () => toggleFrame(i), + toggleTitle: entry.include ? "In the animation: click to leave it out" : "Left out: click to put it back" + }) + }) + ), + h( + "div", + { class: "inline-action" }, + button(at > 0 ? `Split before frame ${state.selectedFrame! + 1}` : "Split", () => { + state.selectedFrame = undefined + setSheet((sheet) => splitAnimation(sheet, state.selectedAnimation, at)) + }, { variant: "ghost", small: true, disabled: at < 1, title: "Choose a frame after the first to cut the animation in two before it" }), + button("Join with next", () => setSheet((sheet) => joinAnimation(sheet, state.selectedAnimation)), { + variant: "ghost", + small: true, + disabled: state.selectedAnimation >= animations.length - 1, + title: "Puts the next animation's frames at the end of this one" + }) ) - }) - ), + ), selected !== undefined && h( "div", @@ -1925,42 +2051,23 @@ const framesPanel = (): HTMLElement | false => { button("Reset", () => nudgeFrame(state.selectedFrame!, -selected.nudge.x, -selected.nudge.y), { variant: "ghost", small: true }), button("Remove", () => removeFrame(state.selectedFrame!), { variant: "ghost", small: true, icon: "trash", title: "Remove this frame's box (Delete)" }) ), - prop( - "Anchor", - segmented(state.sheet.anchor, anchors.map((entry) => ({ id: entry.id, label: entry.label, title: entry.hint })), (anchor) => { - state.sheet = { ...state.sheet, anchor } - update() - }, { label: "Anchor", class: "fill" }), - anchors.find((entry) => entry.id === state.sheet.anchor)!.hint - ), - prop( - "Margin", - numberInput(state.sheet.padding, (value) => { - state.sheet = { ...state.sheet, padding: Math.max(0, Math.round(value)) } - update() - }, { min: 0, max: 64, step: 1, suffix: "px", label: "Margin" }), - "Empty pixels kept around the frames, on every side." - ), group( - "Playback", - prop("Speed", numberInput(state.sheet.fps, (value) => { - state.sheet = { ...state.sheet, fps: Math.min(60, Math.max(0.5, value)) } - update() - }, { min: 0.5, max: 60, step: 1, suffix: "fps", label: "Speed" }), "Frames per second, saved in the JSON and the GIF."), + "Lining up", prop( - "Loop", - segmented( - state.sheet.loop, - [ - { id: "forward", label: "Loop" }, - { id: "pingpong", label: "Ping-pong" } - ], - (loop) => { - state.sheet = { ...state.sheet, loop } - update() - }, - { label: "Loop", class: "fill" } - ) + "Anchor", + segmented(state.sheet.anchor, anchors.map((entry) => ({ id: entry.id, label: entry.label, title: entry.hint })), (anchor) => { + state.sheet = { ...state.sheet, anchor } + update() + }, { label: "Anchor", class: "fill" }), + anchors.find((entry) => entry.id === state.sheet.anchor)!.hint + ), + prop( + "Margin", + numberInput(state.sheet.padding, (value) => { + state.sheet = { ...state.sheet, padding: Math.max(0, Math.round(value)) } + update() + }, { min: 0, max: 64, step: 1, suffix: "px", label: "Margin" }), + "Empty pixels kept around each animation's frames, on every side." ) ) ) @@ -2049,7 +2156,7 @@ const sheetBackgroundPanel = (): HTMLElement => ) const exportLayout = (): SheetLayout => ({ - columns: state.exportLayout.kind === "strip" ? Math.max(1, animation?.placed.length ?? 1) : Math.max(1, state.exportLayout.columns), + columns: state.exportLayout.kind === "strip" ? Number.MAX_SAFE_INTEGER : Math.max(1, state.exportLayout.columns), spacing: state.exportLayout.spacing }) @@ -2058,23 +2165,24 @@ const bytesOf = async (blob: Blob): Promise => new Uint8Array(await const animationExportPanel = (): HTMLElement | false => { const outcome = animation const art = exported - if (outcome === undefined || art === undefined || outcome.placed.length === 0) return false + const clips = outcome === undefined ? [] : atlasClips(outcome, state.sheet) + if (outcome === undefined || art === undefined || clips.length === 0) return false const name = state.source === undefined ? "animation" : baseName(state.source.name) drawPreview() const scale = state.exportScale const layout = exportLayout() - const frames = outcome.placed.map((placed) => placed.art) - const anchor = outcome.placed[0]!.anchor + const frameCount = clips.reduce((sum, clip) => sum + clip.frames.length, 0) + // The GIF is the animation playing; when it is left out, the first one exported. + const playingClip = clips.find((clip) => clip.name === selectedSettings()?.name) ?? clips[0]! const sheetName = scale === 1 ? `${name}-sheet.png` : `${name}-sheet-${scale}x.png` - const json = (): string => - spritesheetJson({ name, image: sheetName, frameWidth: outcome.width, frameHeight: outcome.height, count: frames.length, layout, scale, fps: state.sheet.fps, loop: state.sheet.loop, anchor }) + const json = (): string => spritesheetJson({ image: sheetName, clips, atlas: atlasOf(clips, layout), scale }) const paletteOf = (): Palette => ({ name: state.paletteName === ORIGINAL ? name : state.paletteName, colors: usedColors(art).map((entry) => entry.color) }) return sectionOf( "export", "Export", { - action: h("span", { class: "meta" }, `${frames.length} × ${outcome.width} × ${outcome.height} · ${usedColors(art).length} colors`), - help: "The sheet holds every frame on one picture, as a strip or in rows, with a JSON file saying where each frame is, how long it shows, and where it stands (its anchor): Pixi's Spritesheet, Phaser's atlas, and Aseprite's JSON read it. The GIF plays the animation to share it. Frames gives each frame as its own PNG, in a ZIP. The recipe holds every setting and every box: open it here again, or run it with pixel sheet --recipe." + action: h("span", { class: "meta" }, `${clips.length} animation${clips.length === 1 ? "" : "s"} · ${frameCount} frames · ${usedColors(art).length} colors`), + help: "The sheet holds every exported animation on one picture, each from a new row, its frames in one row or in rows of a few, with a JSON file naming each animation and saying where each frame is, how long it shows, and where it stands (its anchor): Pixi's Spritesheet, Phaser's atlas, and Aseprite's JSON read it. The GIF plays the chosen animation to share it. Frames gives each frame as its own PNG, in a ZIP with a folder per animation. The recipe holds every setting, every box, and every animation: open it here again, or run it with pixel sheet --recipe." }, h("div", { class: "preview-box" }, preview), prop( @@ -2091,7 +2199,7 @@ const animationExportPanel = (): HTMLElement | false => { }, { label: "Layout", class: "fill" } ), - "One row of frames, or rows of a few frames each." + "Each animation in one row of frames, or in rows of a few frames each." ), state.exportLayout.kind === "grid" && prop("Per row", numberInput(state.exportLayout.columns, (value) => { @@ -2101,7 +2209,7 @@ const animationExportPanel = (): HTMLElement | false => { prop("Spacing", numberInput(state.exportLayout.spacing, (value) => { state.exportLayout = { ...state.exportLayout, spacing: Math.max(0, Math.round(value)) } update() - }, { min: 0, step: 1, suffix: "px", label: "Spacing" }), "Empty pixels between frames on the sheet."), + }, { min: 0, step: 1, suffix: "px", label: "Spacing" }), "Empty pixels between frames, and between animations, on the sheet."), prop( "Scale", selectInput( @@ -2121,7 +2229,7 @@ const animationExportPanel = (): HTMLElement | false => { button("JSON", () => download(`${name}-sheet${scale === 1 ? "" : `-${scale}x`}.json`, json(), "application/json"), { variant: "secondary", icon: "download", - title: "Where each frame is on the sheet PNG at this scale, its duration, and its anchor" + title: "Each animation's frames by name, where each frame is on the sheet PNG at this scale, its duration, and its anchor" }) ), group( @@ -2130,14 +2238,15 @@ const animationExportPanel = (): HTMLElement | false => { "div", { class: "export-grid" }, button("GIF", () => { - const order = playOrder(frames.length, state.sheet.loop).map((i) => frames[i]!) - download(`${name}.gif`, new Blob([toGif(order, { scale: Math.max(scale, 1), delay: 1000 / state.sheet.fps })], { type: "image/gif" })) - }, { variant: "secondary", small: true, title: "The animation playing, at this scale, looping" }), + const order = playOrder(playingClip.frames.length, playingClip.loop).map((i) => playingClip.frames[i]!) + download(`${name}-${playingClip.name}.gif`, new Blob([toGif(order, { scale: Math.max(scale, 1), delay: 1000 / playingClip.fps })], { type: "image/gif" })) + }, { variant: "secondary", small: true, title: `${playingClip.name}, the animation playing, at this scale, looping` }), button("Frames .zip", () => { - void Promise.all(frames.map(async (frameArt, i) => ({ name: `${name}-${i}.png`, bytes: await bytesOf(await pngOf(frameArt, scale)) }))).then((entries) => + const files = clips.flatMap((clip) => clip.frames.map((frameArt, i) => ({ name: `${clip.name}/${i}.png`, art: frameArt }))) + void Promise.all(files.map(async (file) => ({ name: file.name, bytes: await bytesOf(await pngOf(file.art, scale)) }))).then((entries) => download(`${name}-frames.zip`, new Blob([toZip(entries)], { type: "application/zip" })) ) - }, { variant: "secondary", small: true, title: "Each frame as its own PNG, at this scale" }), + }, { variant: "secondary", small: true, title: "Each frame as its own PNG, at this scale, in a folder per animation" }), button("Recipe", () => download(`${name}.recipe.json`, encodeRecipe(animationRecipe()), "application/json"), { variant: "secondary", small: true, @@ -2213,7 +2322,7 @@ const renderTopbar = (): void => { "span", { class: "file-meta" }, state.kind === "animation" && animation !== undefined - ? `${source.image.width} × ${source.image.height} → ${animation.placed.length} × ${animation.width} × ${animation.height}` + ? `${source.image.width} × ${source.image.height} → ${exportedClips(animation).length} animation${exportedClips(animation).length === 1 ? "" : "s"}` : `${source.image.width} × ${source.image.height}${out === undefined ? "" : ` → ${out.width} × ${out.height}`}` ) ), @@ -2276,7 +2385,7 @@ const renderTopbar = (): void => { h("span", { class: "divider" }), button("Demo", openDemo, { variant: "ghost", class: "hide-narrow", title: "Open the demo picture" }), button("Open", () => fileInput.click(), { variant: "secondary", icon: "open", title: "Open a picture, project PNG, sprite source, recipe, or pixel.config.json" }), - button("Export", () => reveal("export", () => update()), { variant: "primary", icon: "download", disabled: result === undefined && (animation?.placed.length ?? 0) === 0 }) + button("Export", () => reveal("export", () => update()), { variant: "primary", icon: "download", disabled: result === undefined && (animation === undefined || exportedClips(animation).length === 0) }) ) ) } @@ -2430,7 +2539,7 @@ window.addEventListener("keydown", (event) => { if (event.altKey || state.source === undefined) return if (state.kind === "animation") { // Space plays, comma and period step, arrows move the chosen (or shown) frame by a pixel, Delete removes a box. - const target = state.selectedFrame ?? (state.animationView === "play" ? animation?.placed[shownFrame()]?.index : undefined) + const target = state.selectedFrame ?? (state.animationView === "play" ? selectedClip()?.placed[shownFrame()]?.index : undefined) const arrows: Record = { arrowleft: [-1, 0], arrowright: [1, 0], arrowup: [0, -1], arrowdown: [0, 1] } const arrow = arrows[key] const keys: Record void> = { @@ -2542,7 +2651,10 @@ const render = (rebuildPanels: boolean): void => { highlight: [], marked: [], painted: [], - boxes: state.sheet.frames.map((entry) => ({ ...entry.box, include: entry.include })), + boxes: state.sheet.frames.map((entry, i) => { + const group = state.sheet.animations.findIndex((other) => other.frames.includes(i)) + return { ...entry.box, include: entry.include && state.sheet.animations[group]?.include === true, group: Math.max(0, group) } + }), frameGrids: animation.frames.map((entry) => { const dx = Math.max(0, Math.round(entry.box.x)) const dy = Math.max(0, Math.round(entry.box.y)) diff --git a/packages/tools/src/pixel/ui/viewer.ts b/packages/tools/src/pixel/ui/viewer.ts index c80f3e04..ff4477b7 100644 --- a/packages/tools/src/pixel/ui/viewer.ts +++ b/packages/tools/src/pixel/ui/viewer.ts @@ -52,6 +52,8 @@ export interface Box { export interface SceneBox extends Box { /** Left out of the animation: drawn dashed and dimmed. */ readonly include: boolean + /** Which animation the frame is in: neighbouring animations are drawn in different colors. */ + readonly group: number } export interface ViewerEvents { @@ -106,7 +108,8 @@ const MOVED_COLOR = "rgba(255, 200, 0, 0.95)" const HOVER_COLOR = "rgba(0, 220, 255, 1)" /** Pixels painted by hand: white, apart from the yellow of cleaned specks and moved lines. */ const PAINTED_COLOR = "rgba(255, 255, 255, 0.95)" -const BOX_COLOR = "rgba(0, 220, 255, 0.95)" +/** One color per animation, in turn. */ +const BOX_COLORS = ["rgba(0, 220, 255, 0.95)", "rgba(120, 255, 120, 0.95)", "rgba(255, 110, 200, 0.95)", "rgba(180, 150, 255, 0.95)"] const BOX_SELECTED = "rgba(255, 200, 0, 1)" const BOX_OUT = "rgba(200, 200, 210, 0.8)" @@ -326,14 +329,15 @@ export const makeViewer = (canvas: HTMLCanvasElement, events: ViewerEvents): Vie const box = shown[i]! const selected = scene.selectedBox === i const hovered = boxHover?.index === i - context.strokeStyle = !box.include ? BOX_OUT : selected ? BOX_SELECTED : hovered ? HOVER_COLOR : BOX_COLOR + const color = BOX_COLORS[box.group % BOX_COLORS.length]! + context.strokeStyle = !box.include ? BOX_OUT : selected ? BOX_SELECTED : hovered ? HOVER_COLOR : color context.lineWidth = selected ? 2.5 : 1.5 context.setLineDash(box.include ? [] : [6, 4]) context.strokeRect(Math.round(rect.left) + 0.5, Math.round(rect.top) + 0.5, Math.round(rect.width), Math.round(rect.height)) context.setLineDash([]) const label = String(i + 1) const tag = Math.max(20, context.measureText(label).width + 12) - context.fillStyle = !box.include ? BOX_OUT : selected ? BOX_SELECTED : BOX_COLOR + context.fillStyle = !box.include ? BOX_OUT : selected ? BOX_SELECTED : color context.beginPath() context.roundRect(Math.round(rect.left), Math.round(rect.top) - 20, tag, 18, 4) context.fill() diff --git a/packages/tools/test/agent.test.ts b/packages/tools/test/agent.test.ts index ca691270..38ca169a 100644 --- a/packages/tools/test/agent.test.ts +++ b/packages/tools/test/agent.test.ts @@ -16,7 +16,7 @@ import { decodeRecipe, defaultRecipe, encodeRecipe, presetPalette, type Recipe } import { checkImage, compareReports, isNative, reportOfArt } from "@typeonce/ritmo-tools/pixel/core/report.ts" import { artOfImage, formatSprite, parseJsonMap, parseSprite } from "@typeonce/ritmo-tools/pixel/core/sprite.ts" import { encodePng } from "@typeonce/ritmo-tools/pixel/cli/png.ts" -import { makeSprite, upscale } from "./synthetic.ts" +import { fakeSheet, makeSprite, place, upscale } from "./synthetic.ts" const sweetie = presetPalette("sweetie-16")! @@ -241,6 +241,40 @@ describe("the pixel CLI", () => { expect(given.json.storedRecipe).toBeUndefined() }) + it("fixes a sheet of two rows as two animations, and writes only the ones asked for", { timeout: 60_000 }, () => { + // Two rows of five frames, as two fake sheets one over the other: two animations. + const fire = [sweetie.colors[2]!, sweetie.colors[3]!, sweetie.colors[4]!, sweetie.colors[1]!] + const row = (seed: number) => fakeSheet(Array.from({ length: 5 }, (_, f) => place(makeSprite(10, 12, fire, seed, true), 12, 16, 1, 2 + (f % 2))), 6, seed).image + const top = row(3) + const bottom = row(4) + const data = new Uint8ClampedArray(top.data.length + bottom.data.length) + data.set(top.data) + data.set(bottom.data, top.data.length) + writeFileSync(join(dir, "two.png"), encodePng({ width: top.width, height: top.height + bottom.height, data })) + + const found = run("sheet", "two.png", "--colors", "6") + expect(found.json.animation.animations.map((animation: { name: string; frames: Array }) => [animation.name, animation.frames])).toEqual([ + ["animation-1", [1, 2, 3, 4, 5]], + ["animation-2", [6, 7, 8, 9, 10]] + ]) + expect(found.json.files.gifs).toHaveLength(2) + + const only = run("sheet", "two.png", "--colors", "6", "--animations", "walk=1-5;idle=6-10", "--only", "idle", "-o", "idle") + const json = JSON.parse(readFileSync(join(dir, "idle.json"), "utf8")) + expect(Object.keys(json.animations)).toEqual(["idle"]) + expect(only.json.files.gifs).toEqual(["idle.idle.gif"]) + // The animation left out stays in the recipe, to put back later. + const recipe = decodeRecipe(readFileSync(join(dir, "idle.recipe.json"), "utf8")) + expect(recipe.ok && recipe.value.sheet?.animations.map((animation) => [animation.name, animation.include])).toEqual([ + ["walk", false], + ["idle", true] + ]) + + const missing = run("sheet", "two.png", "--animations", "1-4;6-10") + expect(missing.code).toBe(2) + expect(missing.stderr + JSON.stringify(missing.json ?? {})).toContain("frames 5 are in no animation") + }) + it("reads the project's rules from pixel.config.json", { timeout: 60_000 }, () => { expect(run("init", "-p", "sweetie-16").code).toBe(0) const linted = run("lint", "coin.txt") diff --git a/packages/tools/test/animation.test.ts b/packages/tools/test/animation.test.ts index a44a7761..09f83d37 100644 --- a/packages/tools/test/animation.test.ts +++ b/packages/tools/test/animation.test.ts @@ -7,8 +7,26 @@ import { TRANSPARENT, type PixelArt } from "@typeonce/ritmo-tools/pixel/core/fix import { toGif } from "@typeonce/ritmo-tools/pixel/core/gif.ts" import { presets } from "@typeonce/ritmo-tools/pixel/core/palette.ts" import { decodeRecipe, defaultRecipe, encodeRecipe, type Recipe } from "@typeonce/ritmo-tools/pixel/core/recipe.ts" -import { clearSheet, findFrames, frameOf, layOut, looksAnimated, playOrder, removeGridLines, sheetBackground, type Sheet } from "@typeonce/ritmo-tools/pixel/core/sheet.ts" -import { sheetArt, spritesheetJson } from "@typeonce/ritmo-tools/pixel/core/spritesheet.ts" +import { + clearSheet, + defaultSheet, + findFrames, + frameOf, + groupFrames, + insertFrame, + joinAnimation, + layOut, + looksAnimated, + playOrder, + removeFrame, + removeGridLines, + renameAnimation, + sheetBackground, + splitAnimation, + withFrames, + type Sheet +} from "@typeonce/ritmo-tools/pixel/core/sheet.ts" +import { atlasOf, spritesheetJson } from "@typeonce/ritmo-tools/pixel/core/spritesheet.ts" import { crc32, toZip } from "@typeonce/ritmo-tools/pixel/core/zip.ts" import { fakeSheet, makeSprite, place } from "./synthetic.ts" @@ -87,6 +105,8 @@ describe("finding frames on a sheet", () => { expect(holding(210, 108, 3, 3)).toHaveLength(1) // The faint last frame does not make the sheet a picture. expect(looksAnimated({ width, height, data }, boxes)).toBe(true) + // Walking's two rows, throwing's row, and the explosion: four animations, the wisp in the explosion. + expect(groupFrames(boxes).map((animation) => animation.frames.length)).toEqual([4, 4, 4, 5]) }) it("paints out a grid whose lines are as dark as the art's outline, and keeps the outline", () => { @@ -124,22 +144,25 @@ describe("finding frames on a sheet", () => { }) it("reads every frame on one cell size, one palette, and one canvas, with the feet on one row", () => { - const sheet: Sheet = { frames: detectFrames(image, sheetRecipe), anchor: "feet", fps: 8, loop: "forward", padding: 1 } + const sheet: Sheet = { ...withFrames(defaultSheet, detectFrames(image, sheetRecipe)), anchor: "feet" } const outcome = runSheet(image, sheetRecipe, sheet) expect(outcome.cell.width).toBeGreaterThan(5.5) expect(outcome.cell.width).toBeLessThan(6.5) - expect(outcome.placed).toHaveLength(5) - const palette = outcome.placed[0]!.art.palette - for (const frame of outcome.placed) { + // One row of frames: one animation. + expect(outcome.clips).toHaveLength(1) + const clip = outcome.clips[0]! + expect(clip.placed).toHaveLength(5) + const palette = clip.placed[0]!.art.palette + for (const frame of clip.placed) { expect(frame.art.palette).toBe(palette) - expect(frame.art.width).toBe(outcome.width) - expect(frame.art.height).toBe(outcome.height) + expect(frame.art.width).toBe(clip.width) + expect(frame.art.height).toBe(clip.height) // Feet anchoring: the bob is gone, every frame's lowest pixel is on one row. - expect(Math.max(...opaqueRows(frame.art))).toBe(Math.max(...opaqueRows(outcome.placed[0]!.art))) + expect(Math.max(...opaqueRows(frame.art))).toBe(Math.max(...opaqueRows(clip.placed[0]!.art))) } // About the sprite's own size (12 by 14 with the bob, plus padding). - expect(outcome.width).toBeGreaterThanOrEqual(10) - expect(outcome.width).toBeLessThanOrEqual(15) + expect(clip.width).toBeGreaterThanOrEqual(10) + expect(clip.width).toBeLessThanOrEqual(15) }) it("keeps each frame's height as drawn on the ground anchor, so the bob stays", () => { @@ -147,21 +170,76 @@ describe("finding frames on a sheet", () => { // The cell size is given: on a sprite this small, finding it is off by a percent, and that alone moves rows. const { image: onGrid } = fakeSheet(frames, 6, 5, false) const recipe: Recipe = { ...sheetRecipe, grid: { kind: "cells", spec: { cellWidth: 6, cellHeight: 6, offsetX: 0, offsetY: 0 } } } - const sheet: Sheet = { frames: detectFrames(onGrid, recipe), anchor: "ground", fps: 8, loop: "forward", padding: 1 } - const outcome = runSheet(onGrid, recipe, sheet) - const lowest = outcome.placed.map((frame) => Math.max(...opaqueRows(frame.art))) + const sheet: Sheet = withFrames(defaultSheet, detectFrames(onGrid, recipe)) + const placed = runSheet(onGrid, recipe, sheet).clips[0]!.placed + const lowest = placed.map((frame) => Math.max(...opaqueRows(frame.art))) // The sprite is one pixel lower on odd frames. expect(lowest.map((row) => row - lowest[0]!)).toEqual([0, 1, 0, 1, 0]) // Sideways, the bodies still line up. - const left = outcome.placed.map((frame) => Math.min(...frame.art.indices.map((index, p) => (index === TRANSPARENT ? Infinity : p % frame.art.width)))) + const left = placed.map((frame) => Math.min(...frame.art.indices.map((index, p) => (index === TRANSPARENT ? Infinity : p % frame.art.width)))) expect(new Set(left).size).toBe(1) }) it("leaves out an excluded frame and keeps the rest", () => { const found = detectFrames(image, sheetRecipe) - const sheet: Sheet = { frames: found.map((frame, i) => (i === 2 ? { ...frame, include: false } : frame)), anchor: "feet", fps: 8, loop: "forward", padding: 1 } + const sheet: Sheet = withFrames(defaultSheet, found.map((frame, i) => (i === 2 ? { ...frame, include: false } : frame))) const outcome = runSheet(image, sheetRecipe, sheet) - expect(outcome.placed.map((frame) => frame.index)).toEqual([0, 1, 3, 4]) + expect(outcome.clips[0]!.placed.map((frame) => frame.index)).toEqual([0, 1, 3, 4]) + }) +}) + +describe("animations on a sheet", () => { + const box = (x: number, y: number, width: number, height: number) => ({ x, y, width, height }) + + it("makes one animation per row, splits a row across two blocks where they meet, and keeps a row of another count whole", () => { + const boxes = [ + // Across both blocks: 4 walking frames over the left block, 4 throws over the right one. + ...[0, 20, 40, 60].map((x) => box(x, 0, 20, 20)), + ...[100, 130, 160, 190].map((x) => box(x, 0, 30, 20)), + // The left block's next two rows, beside one taller row of the right block. + ...[0, 20, 40, 60].map((x) => box(x, 20, 20, 20)), + ...[100, 130, 160, 190].map((x) => box(x, 20, 30, 40)), + ...[0, 20, 40, 60].map((x) => box(x, 40, 20, 20)), + // An explosion under both, with a box edge right where the blocks meet: 6 frames, not 4 and 4. + ...[0, 30, 60, 90, 120, 150].map((x) => box(x, 60, 30, 20)) + ] + const animations = groupFrames(boxes) + expect(animations.map((animation) => animation.frames)).toEqual([ + [0, 1, 2, 3], + [4, 5, 6, 7], + [8, 9, 10, 11], + [12, 13, 14, 15], + [16, 17, 18, 19], + [20, 21, 22, 23, 24, 25] + ]) + expect(animations.map((animation) => animation.name)).toEqual(["animation-1", "animation-2", "animation-3", "animation-4", "animation-5", "animation-6"]) + }) + + it("keeps every frame in one animation as frames and animations change", () => { + let sheet = withFrames(defaultSheet, [0, 20, 40, 60].map((x) => frameOf(box(x, 0, 20, 20)))) + expect(sheet.animations.map((animation) => animation.frames)).toEqual([[0, 1, 2, 3]]) + sheet = splitAnimation(sheet, 0, 2) + expect(sheet.animations.map((animation) => [animation.name, animation.frames])).toEqual([ + ["animation-1", [0, 1]], + ["animation-1-2", [2, 3]] + ]) + // A frame added after frame 1 joins its animation, and the later frames move up one. + sheet = insertFrame(sheet, 2, frameOf(box(80, 0, 20, 20))) + expect(sheet.animations.map((animation) => animation.frames)).toEqual([ + [0, 1, 2], + [3, 4] + ]) + // Removing every frame of an animation removes the animation. + sheet = removeFrame(removeFrame(sheet, 3), 3) + expect(sheet.animations.map((animation) => animation.frames)).toEqual([[0, 1, 2]]) + sheet = splitAnimation(sheet, 0, 1) + sheet = renameAnimation(sheet, 1, "walk left") + expect(sheet.animations[1]!.name).toBe("walk-left") + // A taken name gets a number; one a game cannot look up is refused. + expect(renameAnimation(sheet, 0, "walk-left").animations[0]!.name).toBe("walk-left-2") + expect(renameAnimation(sheet, 0, "walk/left").animations[0]!.name).toBe("animation-1") + sheet = joinAnimation(sheet, 0) + expect(sheet.animations.map((animation) => [animation.name, animation.frames])).toEqual([["animation-1", [0, 1, 2]]]) }) }) @@ -184,20 +262,28 @@ describe("laying frames out", () => { expect(playOrder(4, "pingpong")).toEqual([0, 1, 2, 3, 2, 1]) }) - it("lays a sheet out in rows and says where each frame is", () => { - const frames = [0, 1, 2, 3, 4].map(() => dot(1, 1)) - const art = sheetArt(frames, { columns: 2, spacing: 1 }) - expect(art.width).toBe(9) - expect(art.height).toBe(14) - const json = JSON.parse( - spritesheetJson({ name: "fire", image: "fire.png", frameWidth: 4, frameHeight: 4, count: 5, layout: { columns: 2, spacing: 1 }, scale: 2, fps: 10, loop: "pingpong", anchor: { x: 1, y: 3 } }) - ) + it("lays each animation out from a new row and names its frames", () => { + const fire = { name: "fire", frames: [0, 1, 2, 3, 4].map(() => dot(1, 1)), anchor: { x: 1, y: 3 }, fps: 10, loop: "pingpong" as const } + // A second animation of another size: it starts its own row, under the first. + const big = (): PixelArt => ({ width: 6, height: 2, palette: fire.frames[0]!.palette, indices: new Int32Array(12).fill(0) }) + const smoke = { name: "smoke", frames: [big(), big()], anchor: { x: 3, y: 1 }, fps: 5, loop: "forward" as const } + const layout = { columns: 2, spacing: 1 } + const atlas = atlasOf([fire, smoke], layout) + // Fire: 3 rows of 4 high with 1 between (14), then 1, then smoke: 1 row of 2 (17). + expect(atlas.art.width).toBe(13) + expect(atlas.art.height).toBe(17) + const json = JSON.parse(spritesheetJson({ image: "fx.png", clips: [fire, smoke], atlas, scale: 2 })) expect(json.frames["fire 3"].frame).toEqual({ x: 10, y: 10, w: 8, h: 8 }) expect(json.frames["fire 0"].duration).toBe(100) expect(json.frames["fire 0"].anchor).toEqual({ x: 0.375, y: 1 }) - expect(json.animations.fire).toHaveLength(5) - expect(json.meta.size).toEqual({ w: 18, h: 28 }) - expect(json.meta.frameTags).toEqual([{ name: "fire", from: 0, to: 4, direction: "pingpong" }]) + expect(json.frames["smoke 1"].frame).toEqual({ x: 14, y: 30, w: 12, h: 4 }) + expect(json.frames["smoke 0"].duration).toBe(200) + expect(json.animations).toEqual({ fire: ["fire 0", "fire 1", "fire 2", "fire 3", "fire 4"], smoke: ["smoke 0", "smoke 1"] }) + expect(json.meta.size).toEqual({ w: 26, h: 34 }) + expect(json.meta.frameTags).toEqual([ + { name: "fire", from: 0, to: 4, direction: "pingpong" }, + { name: "smoke", from: 5, to: 6, direction: "forward" } + ]) }) }) @@ -304,15 +390,17 @@ describe("ZIP", () => { }) describe("an animation recipe", () => { - it("round-trips its frames, anchor, frame rate, and loop", () => { + it("round-trips its frames, animations, anchor, and margin", () => { const recipe: Recipe = { ...defaultRecipe, removeBackground: true, sheet: { - frames: [frameOf({ x: 1, y: 2, width: 30, height: 40 }), { box: { x: 40, y: 2, width: 30, height: 40 }, include: false, nudge: { x: 1, y: -2 } }], + frames: [frameOf({ x: 1, y: 2, width: 30, height: 40 }), { box: { x: 40, y: 2, width: 30, height: 40 }, include: false, nudge: { x: 1, y: -2 } }, frameOf({ x: 80, y: 2, width: 30, height: 40 })], + animations: [ + { name: "walk", frames: [1, 0], include: true, fps: 12, loop: "pingpong" }, + { name: "idle", frames: [2], include: false, fps: 4, loop: "forward" } + ], anchor: "center", - fps: 12, - loop: "pingpong", padding: 2 } } @@ -320,12 +408,24 @@ describe("an animation recipe", () => { expect(decodeRecipe(encodeRecipe(defaultRecipe))).toEqual({ ok: true, value: defaultRecipe }) }) - it("reports bad frames", () => { + it("reports bad frames, and animations that miss a frame or share one", () => { const decoded = decodeRecipe('{"sheet": {"frames": [{"box": {"x": 0, "y": 0, "width": 0, "height": 4}}], "fps": 0}}') expect(decoded.ok).toBe(false) if (!decoded.ok) { expect(decoded.error).toContain("sheet.frames[0]") expect(decoded.error).toContain("sheet.fps") } + const box = '{"box": {"x": 0, "y": 0, "width": 4, "height": 4}}' + const shared = decodeRecipe(`{"sheet": {"frames": [${box}, ${box}, ${box}], "animations": [{"name": "a", "frames": [0, 1]}, {"name": "b", "frames": [1]}]}}`) + expect(shared.ok === false && shared.error).toContain("frame 1 is in more than one animation") + expect(shared.ok === false && shared.error).toContain("every frame must be in an animation (1 are not)") + const named = decodeRecipe(`{"sheet": {"frames": [${box}], "animations": [{"name": "walk left", "frames": [0]}]}}`) + expect(named.ok === false && named.error).toContain("needs a name") + }) + + it("reads a recipe from before animations as one animation of every frame, at its speed", () => { + const box = '{"box": {"x": 0, "y": 0, "width": 4, "height": 4}}' + const decoded = decodeRecipe(`{"sheet": {"frames": [${box}, ${box}], "fps": 12, "loop": "pingpong"}}`) + expect(decoded.ok && decoded.value.sheet?.animations).toEqual([{ name: "animation-1", frames: [0, 1], include: true, fps: 12, loop: "pingpong" }]) }) }) From 393fd4abb315b45540bf350c1335536a064e9ad3 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 28 Oct 2026 21:04:35 +0900 Subject: [PATCH 2/3] Pixel fixer: split an animation from a line between its frames Co-Authored-By: Claude Opus 5.5 --- .changeset/pixel-sheet-animations.md | 2 +- packages/tools/README.md | 7 ++-- packages/tools/src/pixel/ui/kit.ts | 1 + packages/tools/src/pixel/ui/main.ts | 52 +++++++++++++++++---------- packages/tools/src/pixel/ui/style.css | 42 ++++++++++++++++++++++ 5 files changed, 81 insertions(+), 23 deletions(-) diff --git a/.changeset/pixel-sheet-animations.md b/.changeset/pixel-sheet-animations.md index f1463f97..be706575 100644 --- a/.changeset/pixel-sheet-animations.md +++ b/.changeset/pixel-sheet-animations.md @@ -3,7 +3,7 @@ "@typeonce/ritmo": patch --- -`ritmo-tools pixel`: several animations on one sheet. Each row of frames is one animation by default (`animation-1`, ... in reading order; where two blocks sit side by side, a row across both is split where they meet). The page's **Animations** panel chooses which are exported (**In**/**Out**), plays, renames, and sets each one's speed and loop, and **Split** / **Join with next** regroup them. Each animation gets its own canvas. The sheet PNG holds every exported animation, each from a new row; its JSON names each one under `animations` and `frameTags`, frames are named ` 0`, ..., and `meta.fps` is a map from animation to frames per second. `pixel sheet` writes a GIF per animation (`..gif`) and frames under `.frames//`, lists the animations in its report (`animation.animations`), and takes `--animations "walk=1-4;explode=5-14"` and `--only walk,explode`. +`ritmo-tools pixel`: several animations on one sheet. Each row of frames is one animation by default (`animation-1`, ... in reading order; where two blocks sit side by side, a row across both is split where they meet). The page's **Animations** panel chooses which are exported (**In**/**Out**), plays, renames, and sets each one's speed and loop, and **Split here** (between two frames of the chosen animation) and **Join with** regroup them. Each animation gets its own canvas. The sheet PNG holds every exported animation, each from a new row; its JSON names each one under `animations` and `frameTags`, frames are named ` 0`, ..., and `meta.fps` is a map from animation to frames per second. `pixel sheet` writes a GIF per animation (`..gif`) and frames under `.frames//`, lists the animations in its report (`animation.animations`), and takes `--animations "walk=1-4;explode=5-14"` and `--only walk,explode`. Breaking, for recipes and code reading the sheet: `sheet.fps` and `sheet.loop` moved onto each animation (`sheet.animations[i].fps`, `.loop`). A recipe without `animations` still reads, as one animation of every frame at its `fps` and `loop`. The report's `animation.fps` and `animation.loop` are now per animation in `animation.animations`, and `output` is the size of the sheet written. diff --git a/packages/tools/README.md b/packages/tools/README.md index 23579869..ec45939b 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -201,9 +201,10 @@ it is. chooses which are exported (the others keep their frames and settings); choose one to play it, rename it (the name a game plays it by), and set its speed and loop. Its frames are listed under it: **In**/**Out** leaves one - out without losing its box. **Split** cuts the animation in two before the - chosen frame, and **Join with next** puts it together with the next one, - for a sheet whose rows are not its animations. **Find again** starts over + out without losing its box. **Split here**, on the line between two of + its frames, cuts the animation in two there (the frames after it become a + new animation), and **Join with** puts it together with the next one, for + a sheet whose rows are not its animations. **Find again** starts over (boxes and animations changed by hand are dropped). - **Grid and palette.** Every frame is read on one cell size (found from all the frames together, or typed), each at its own offset, and fixed with one diff --git a/packages/tools/src/pixel/ui/kit.ts b/packages/tools/src/pixel/ui/kit.ts index 303007f1..d239c282 100644 --- a/packages/tools/src/pixel/ui/kit.ts +++ b/packages/tools/src/pixel/ui/kit.ts @@ -31,6 +31,7 @@ const paths = { lock: '', unlock: '', trash: '', + split: '', wand: '', image: '', close: '', diff --git a/packages/tools/src/pixel/ui/main.ts b/packages/tools/src/pixel/ui/main.ts index b5611442..818c370d 100644 --- a/packages/tools/src/pixel/ui/main.ts +++ b/packages/tools/src/pixel/ui/main.ts @@ -1945,15 +1945,13 @@ const framesPanel = (): HTMLElement | false => { const clip = selectedClip() const exportedCount = animations.filter((entry) => entry.include).length const selected = state.selectedFrame === undefined ? undefined : frames[state.selectedFrame] - // Where the chosen frame is in the chosen animation: a split goes before it. - const at = chosen === undefined || state.selectedFrame === undefined ? -1 : chosen.frames.indexOf(state.selectedFrame) return sectionOf( "frames", "Animations", { action: button("Find again", findFramesAgain, { variant: "ghost", small: true, icon: "wand", title: "Find the frames again, one animation per row (drops boxes and animations changed by hand)" }), help: - "Each box on the sheet is one frame, and each row of frames is one animation, named so a game can play it by name. In or Out chooses which animations are exported; the others keep their frames and settings. Choose one to play it, rename it, and set its speed. Split cuts the chosen animation in two before the chosen frame; Join puts it together with the next one. Choose Boxes in the toolbar over the sheet to move a box, drag its edges, or draw a new one. Frames are lined up by the anchor: Ground keeps each frame at the height it was drawn (hops stay), Feet pins the feet to one row. Nudge a frame by a pixel with the arrow keys when it looks off." + "Each box on the sheet is one frame, and each row of frames is one animation, named so a game can play it by name. In or Out chooses which animations are exported; the others keep their frames and settings. Choose one to play it, rename it, and set its speed. Its frames are listed under it: Split here, between two frames, cuts it in two there; Join puts it together with the next one. Choose Boxes in the toolbar over the sheet to move a box, drag its edges, or draw a new one. Frames are lined up by the anchor: Ground keeps each frame at the height it was drawn (hops stay), Feet pins the feet to one row. Nudge a frame by a pixel with the arrow keys when it looks off." }, callout( exportedCount > 0 ? "good" : "warn", @@ -2005,10 +2003,29 @@ const framesPanel = (): HTMLElement | false => { h( "div", { class: "frame-list" }, - chosen.frames.map((i) => { + chosen.frames.flatMap((i, position) => { const entry = frames[i]! const nudged = entry.nudge.x !== 0 || entry.nudge.y !== 0 - return listRow({ + // Between two frames: cuts the animation there, the frames below becoming a new one. + const seam = + position > 0 && + h( + "button", + { + class: "split-seam", + props: { type: "button" }, + attrs: { title: `Split ${chosen.name} here: frames ${chosen.frames.slice(position).map((frame) => frame + 1).join(", ")} become a new animation` }, + on: { + click: () => { + state.selectedFrame = undefined + setSheet((sheet) => splitAnimation(sheet, state.selectedAnimation, position)) + } + } + }, + icon("split"), + h("span", {}, "Split here") + ) + const row = listRow({ number: i + 1, text: `${entry.box.width} × ${entry.box.height} px${nudged ? ` · moved ${entry.nudge.x}, ${entry.nudge.y}` : ""}`, on: state.selectedFrame === i, @@ -2025,22 +2042,19 @@ const framesPanel = (): HTMLElement | false => { toggle: () => toggleFrame(i), toggleTitle: entry.include ? "In the animation: click to leave it out" : "Left out: click to put it back" }) + return seam === false ? [row] : [seam, row] }) ), - h( - "div", - { class: "inline-action" }, - button(at > 0 ? `Split before frame ${state.selectedFrame! + 1}` : "Split", () => { - state.selectedFrame = undefined - setSheet((sheet) => splitAnimation(sheet, state.selectedAnimation, at)) - }, { variant: "ghost", small: true, disabled: at < 1, title: "Choose a frame after the first to cut the animation in two before it" }), - button("Join with next", () => setSheet((sheet) => joinAnimation(sheet, state.selectedAnimation)), { - variant: "ghost", - small: true, - disabled: state.selectedAnimation >= animations.length - 1, - title: "Puts the next animation's frames at the end of this one" - }) - ) + state.selectedAnimation < animations.length - 1 && + h( + "div", + { class: "inline-action" }, + button(`Join with ${animations[state.selectedAnimation + 1]!.name}`, () => setSheet((sheet) => joinAnimation(sheet, state.selectedAnimation)), { + variant: "ghost", + small: true, + title: `Puts ${animations[state.selectedAnimation + 1]!.name}'s frames at the end of ${chosen.name}, as one animation` + }) + ) ), selected !== undefined && h( diff --git a/packages/tools/src/pixel/ui/style.css b/packages/tools/src/pixel/ui/style.css index 5023309e..3b99d63d 100644 --- a/packages/tools/src/pixel/ui/style.css +++ b/packages/tools/src/pixel/ui/style.css @@ -1684,3 +1684,45 @@ input + .affix { display: none; } } + +/* Between two frames of an animation: a faint line that cuts it in two there. */ +.split-seam { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + height: 14px; + margin: 0; + padding: 0 8px; + border: 0; + background: none; + color: var(--muted); + font: 500 11px/1 var(--font); + cursor: pointer; + opacity: 0.45; +} + +.split-seam::before, +.split-seam::after { + content: ""; + flex: 1; + border-top: 1px dashed currentColor; +} + +.split-seam:hover, +.split-seam:focus-visible { + color: var(--accent); + opacity: 1; +} + +.split-seam .icon { + order: 1; +} + +.split-seam span { + order: 2; +} + +.split-seam::after { + order: 3; +} From 30f66f075b9c971b0405d54bd2ac0c62b5fba923 Mon Sep 17 00:00:00 2001 From: SandroMaglione Date: Wed, 28 Oct 2026 21:08:42 +0900 Subject: [PATCH 3/3] Pixel fixer: keep the zoom chosen by hand when switching animations Co-Authored-By: Claude Opus 5.5 --- packages/tools/src/pixel/ui/player.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/tools/src/pixel/ui/player.ts b/packages/tools/src/pixel/ui/player.ts index 41399294..dbadda83 100644 --- a/packages/tools/src/pixel/ui/player.ts +++ b/packages/tools/src/pixel/ui/player.ts @@ -51,6 +51,10 @@ export const makePlayer = (canvas: HTMLCanvasElement, events: PlayerEvents): Pla let panY = 0 let fitted = false let fittedKey = "" + /** Zoomed or moved by hand since the last fit: a new picture keeps that zoom instead of being fitted. */ + let adjusted = false + /** The picture's size when last drawn, so a new one can take its place on screen. */ + let shown = { width: 0, height: 0 } let drag: { lastX: number; lastY: number; moved: number } | undefined const pointers = new Map() let pinch: { distance: number } | undefined @@ -81,6 +85,8 @@ export const makePlayer = (canvas: HTMLCanvasElement, events: PlayerEvents): Pla panX = Math.round((view.width - picture.width * zoom) / 2) panY = Math.round(top + (view.height - top - bottom - picture.height * zoom) / 2) fitted = true + adjusted = false + shown = picture draw() events.viewChanged() } @@ -165,9 +171,18 @@ export const makePlayer = (canvas: HTMLCanvasElement, events: PlayerEvents): Pla if (first === undefined) return const key = `${scene.view}:${scene.frames.length}:${first.width}x${first.height}` if (!fitted || key !== fittedKey) { + // Play and Frames show pictures of very different widths: a switch between them fits. + const sameView = fittedKey.startsWith(`${scene.view}:`) fittedKey = key - fit() - return + if (!fitted || !adjusted || !sameView) { + fit() + return + } + // Another picture (another animation, of another size) at the zoom chosen by hand, centered where the last one was. + const picture = extent() + panX = Math.round(panX + ((shown.width - picture.width) * zoom) / 2) + panY = Math.round(panY + ((shown.height - picture.height) * zoom) / 2) + shown = picture } context.imageSmoothingEnabled = false context.font = '600 11px "Inter", system-ui, sans-serif' @@ -234,6 +249,7 @@ export const makePlayer = (canvas: HTMLCanvasElement, events: PlayerEvents): Pla return } if (drag === undefined) return + if (at.x !== drag.lastX || at.y !== drag.lastY) adjusted = true panX += at.x - drag.lastX panY += at.y - drag.lastY drag.moved += Math.abs(at.x - drag.lastX) + Math.abs(at.y - drag.lastY) @@ -285,6 +301,7 @@ export const makePlayer = (canvas: HTMLCanvasElement, events: PlayerEvents): Pla panX = at.x - ((at.x - panX) * next) / zoom panY = at.y - ((at.y - panY) * next) / zoom zoom = next + adjusted = true draw() events.viewChanged() }