diff --git a/src/editor/agent/agentTools.ts b/src/editor/agent/agentTools.ts index d771de21..97953dab 100644 --- a/src/editor/agent/agentTools.ts +++ b/src/editor/agent/agentTools.ts @@ -7,6 +7,7 @@ import type { AgentView } from './agentView'; import { AgentEdit, deletedAssets, type EditStep } from './editPlan'; import type { StepTarget } from './commandCatalog'; import { cameraOfWorld } from '../../viewport/activeCamera'; +import { pixelCels } from '../../viewport/PixelCels'; import { type InputStep, inputProblem, @@ -72,6 +73,7 @@ export interface ToolHost { } const DEFAULT_SCREENSHOT_WIDTH = 1280; +const readCel = (file: string) => pixelCels.read(file); /** Longer than this, an answer is cut: an agent reads it whole, every time. */ const MAX_ANSWER_CHARS = 60_000; @@ -133,7 +135,7 @@ async function run(name: string, args: Record, host: ToolHost): assets: listAssets(host.project(), args.type as string, args.query as string), }); case 'get_asset': - return answer(getAsset(host.project(), args.asset as string) ?? missing(args.asset)); + return answer(getAsset(host.project(), args.asset as string, readCel) ?? missing(args.asset)); case 'get_graph': return answer(getGraph(host.project(), args.asset as string) ?? missing(args.asset, ' with a graph')); case 'lint_graph': { diff --git a/src/editor/agent/catalog/graphs.ts b/src/editor/agent/catalog/graphs.ts index 9ea13c76..6d408be4 100644 --- a/src/editor/agent/catalog/graphs.ts +++ b/src/editor/agent/catalog/graphs.ts @@ -46,7 +46,6 @@ import type { PropertyBag, PropertyValue } from '../../../core/model/Properties' import { findGraphAsset, type Project } from '../../../core/model/Project'; import { DECLARED_PORT_KINDS, type DeclaredPortKind } from '../../../core/properties/kinds'; import { coerceValue } from '../../../core/properties/PropertySchema'; -import { parseHex } from '../../../ui/color'; import { groupBoxAround, noteBoxAbove } from '../../graph/annotationDrag'; import { arrangeCommand, layoutOf } from '../../graph/graphLayout'; import { type PaletteEntry, paletteEntries } from '../../graph/paletteEntries'; @@ -57,6 +56,7 @@ import { type CatalogCommand, flag, GRAPH, + hexColor, id, ids, listOf, @@ -604,10 +604,7 @@ function requireAnnotationOf(project: Project, assetId: string, annotationId: st } function colorOf(value: string): number | null { - if (value === '') return null; - const color = parseHex(value); - if (color === null) throw new CommandError(`"${value}" is no color – write "#rrggbb"`); - return color; + return value === '' ? null : hexColor(value); } /** Where a group or comment on these nodes goes, by the nodes as they stand now. */ diff --git a/src/editor/agent/catalog/pixelart.ts b/src/editor/agent/catalog/pixelart.ts new file mode 100644 index 00000000..10784457 --- /dev/null +++ b/src/editor/agent/catalog/pixelart.ts @@ -0,0 +1,479 @@ +import { type Command, CommandError } from '../../../core/commands/Command'; +import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../../core/commands/pixelArtCommands'; +import { newId } from '../../../core/ids'; +import type { PaletteAsset, PixelArtAsset } from '../../../core/model/Asset'; +import { celOf, PALETTE_MAX, paletteColors } from '../../../core/model/pixelart'; +import { type CelPixels, emptyCel, PIXEL_CANVAS_MAX } from '../../../core/model/pixelCel'; +import { + BRUSH_MAX, + BRUSH_MIN, + cloneCel, + ellipsePoints, + floodFill, + linePoints, + type PixelPoint, + rectPoints, + type ResizeAnchor, + stampAll, +} from '../../../core/model/pixelDraw'; +import { + type PixelArtState, + withCels, + withFrameAdded, + withFrameMoved, + withFrameRemoved, + withLayerAdded, + withLayerChanged, + withLayerDuplicated, + withLayerMoved, + withLayerRemoved, +} from '../../../core/model/pixelEdit'; +import { findAssetOf, type Project } from '../../../core/model/Project'; +import { pixelCels } from '../../../viewport/PixelCels'; +import { + mergeLayerDownCommand, + removePaletteColorCommand, + resizeCanvasCommand, + STILL_LOADING, +} from '../../pixelart/pixelActions'; +import type { ArgSchema } from '../argCheck'; +import { + type Args, + type Built, + type CatalogCommand, + color, + flag, + hexColor, + id, + number, + numberOf, + object, + oneOf, + text, + theAsset, + theTab, + whole, +} from './shared'; + +const PIXEL_ART = id('Id of the pixel art asset.'); +const PALETTE = id('Id of the palette asset.'); +const LAYER = id('Id of the layer, as get_asset lists it under layers.'); +const FRAME = id('Id of the frame, as get_asset lists it under frames.'); +const INDEX = whole('Its new place, from 0 (the bottom layer, the first frame).', 0); +const AT = (description: string): ArgSchema => ({ + ...object({ x: whole('Column.', 0), y: whole('Row.', 0) }, ['x', 'y']), + description, +}); + +/** + * How the rows of a picture spell a palette index, one character a pixel: "." is 0 (transparent), + * "1"–"9" are 1–9, "a"–"z" 10–35 and "A"–"Z" 36–61. A space in rows to paint leaves the pixel alone. + */ +const ROW_ALPHABET = '.123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; +export const MAX_ROW_INDEX = ROW_ALPHABET.length - 1; + +/** The cel as rows of characters, or null when it uses an index past what one character spells. */ +export function celRows(cel: CelPixels): string[] | null { + const rows: string[] = []; + for (let y = 0; y < cel.height; y++) { + let row = ''; + for (let x = 0; x < cel.width; x++) { + const index = cel.data[y * cel.width + x]!; + if (index > MAX_ROW_INDEX) return null; + row += ROW_ALPHABET[index]; + } + rows.push(row); + } + return rows; +} + +const DRAWS = ['rows', 'pixels', 'line', 'rect', 'ellipse', 'fill', 'clear'] as const; +type Draw = (typeof DRAWS)[number]; + +interface DrawStep { + draw: Draw; + color?: number; + rows?: string[]; + at?: PixelPoint; + points?: PixelPoint[]; + from?: PixelPoint; + to?: PixelPoint; + filled?: boolean; + size?: number; + contiguous?: boolean; +} + +const DRAW_STEP = object( + { + draw: oneOf( + DRAWS, + 'rows: a picture from at (top-left), one string a row. pixels: the points. line, rect, ellipse: ' + + 'from one corner to the other, both included. fill: the area of one color around at. clear: the whole cel.', + ), + color: whole('Palette index to paint with; 0 erases. Not for rows and clear.', 0), + rows: { + type: 'array', + items: { type: 'string' }, + description: + 'For rows: one character a pixel. "." is 0, "1"-"9" are 1-9, "a"-"z" 10-35, "A"-"Z" 36-61; ' + + 'a space leaves the pixel as it is.', + }, + at: AT('For rows its top-left corner (0,0 when left out), for fill the pixel to start from.'), + points: { type: 'array', items: AT('A pixel.'), description: 'For pixels.' }, + from: AT('For line, rect and ellipse: one corner.'), + to: AT('The other corner.'), + filled: flag('For rect and ellipse: filled rather than an outline (default false).'), + size: whole( + `Brush size for line, rect and ellipse outlines, ${BRUSH_MIN} to ${BRUSH_MAX} (default 1).`, + 1, + ), + contiguous: flag('For fill: only the joined area (default true), or every pixel of that color.'), + }, + ['draw'], +); + +/** Pixel art: painting it, its layers, frames and canvas, and the colors of palettes. */ +export const PIXEL_ART_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'paint_pixels', + description: + 'Paints one layer on one frame of a pixel art asset, the draw steps in order. Colors are indices into ' + + 'its palette (get_asset lists them under colors, the first is index 1); 0 is transparent. 0,0 is the ' + + 'top-left pixel. Rows are the easiest way to set down a small picture.', + args: object( + { + asset: PIXEL_ART, + layer: { ...LAYER, description: 'Id of the layer; the first (bottom) one when left out.' }, + frame: { ...FRAME, description: 'Id of the frame; the first one when left out.' }, + draws: { type: 'array', minItems: 1, items: DRAW_STEP, description: 'What to paint, in order.' }, + }, + ['asset', 'draws'], + ), + build: (args, { project }) => paintCommand(project, args), + shows: theTab, + }, + { + name: 'add_pixel_layer', + description: 'Adds an empty layer right above another one, or on top.', + args: object({ asset: PIXEL_ART, above: { ...LAYER, description: 'The layer it goes above.' } }, [ + 'asset', + ]), + build: (args) => { + const layer = newId(); + const above = (args.above as string | undefined) ?? ''; + return made( + edit(args, 'Add layer', (s) => withLayerAdded(s, layer, above)), + layer, + ); + }, + shows: theTab, + }, + { + name: 'duplicate_pixel_layer', + description: 'Copies a layer with its pixels on every frame; the copy comes right above it.', + args: object({ asset: PIXEL_ART, layer: LAYER }, ['asset', 'layer']), + build: (args) => { + const layer = newId(); + return made( + edit(args, 'Duplicate layer', (s) => withLayerDuplicated(s, layer, text(args, 'layer'))), + layer, + ); + }, + shows: theTab, + }, + { + name: 'remove_pixel_layer', + description: 'Removes a layer with its pixels on every frame; the last layer stays.', + args: object({ asset: PIXEL_ART, layer: LAYER }, ['asset', 'layer']), + build: (args) => ({ + command: edit(args, 'Delete layer', (s) => withLayerRemoved(s, text(args, 'layer'))), + }), + shows: theTab, + }, + { + name: 'set_pixel_layer', + description: + 'Changes a layer: its name, whether it is hidden, its opacity (0 to 1). Give only what changes.', + args: object( + { + asset: PIXEL_ART, + layer: LAYER, + name: id('Its name.'), + hidden: flag('Hidden.'), + opacity: number('From 0 to 1.'), + }, + ['asset', 'layer'], + ), + build: (args) => { + const change = { + ...(typeof args.name === 'string' ? { name: args.name } : {}), + ...(typeof args.hidden === 'boolean' ? { hidden: args.hidden } : {}), + ...(typeof args.opacity === 'number' ? { opacity: args.opacity } : {}), + }; + if (Object.keys(change).length === 0) + throw new CommandError('Name what to change: name, hidden or opacity'); + return { command: edit(args, 'Change layer', (s) => withLayerChanged(s, text(args, 'layer'), change)) }; + }, + shows: theTab, + }, + { + name: 'move_pixel_layer', + description: 'Moves a layer to another place in the drawing order; the last one is drawn on top.', + args: object({ asset: PIXEL_ART, layer: LAYER, to: INDEX }, ['asset', 'layer', 'to']), + build: (args) => ({ + command: edit(args, 'Reorder layers', (s) => + withLayerMoved(s, text(args, 'layer'), numberOf(args, 'to')), + ), + }), + shows: theTab, + }, + { + name: 'merge_pixel_layer_down', + description: + 'Lays a layer onto the one below it on every frame and removes it. Its opacity is not kept: indexed colors ' + + 'have no in-between color.', + args: object({ asset: PIXEL_ART, layer: LAYER }, ['asset', 'layer']), + build: (args, { project }) => ({ + command: refusedIfText(mergeLayerDownCommand(requirePixelArt(project, args), text(args, 'layer'))), + }), + shows: theTab, + }, + { + name: 'add_pixel_frame', + description: + 'Adds a frame after another one, or at the end: empty, or with copy a copy of the frame it follows. All ' + + 'frames share the layers and the canvas size; a sprite shows one frame, an animation steps through them.', + args: object( + { + asset: PIXEL_ART, + after: { ...FRAME, description: 'The frame it follows.' }, + copy: flag('Copy the pixels of the frame it follows (needs after).'), + }, + ['asset'], + ), + build: (args) => { + const frame = newId(); + const after = (args.after as string | undefined) ?? ''; + if (args.copy === true && !after) throw new CommandError('Name the frame to copy in after'); + const label = args.copy === true ? 'Duplicate frame' : 'Add frame'; + return made( + edit(args, label, (s) => withFrameAdded(s, frame, after, args.copy === true ? after : '')), + frame, + ); + }, + shows: theTab, + }, + { + name: 'remove_pixel_frame', + description: 'Removes a frame; the last one stays.', + args: object({ asset: PIXEL_ART, frame: FRAME }, ['asset', 'frame']), + build: (args) => ({ + command: edit(args, 'Delete frame', (s) => withFrameRemoved(s, text(args, 'frame'))), + }), + shows: theTab, + }, + { + name: 'move_pixel_frame', + description: 'Moves a frame to another place in the sequence.', + args: object({ asset: PIXEL_ART, frame: FRAME, to: INDEX }, ['asset', 'frame', 'to']), + build: (args) => ({ + command: edit(args, 'Reorder frames', (s) => + withFrameMoved(s, text(args, 'frame'), numberOf(args, 'to')), + ), + }), + shows: theTab, + }, + { + name: 'resize_pixel_canvas', + description: + `Gives the canvas another size, 1 to ${PIXEL_CANVAS_MAX} pixels a side, on every layer and frame. The ` + + 'anchor says where the old picture stays; what sticks out is cut.', + args: object( + { + asset: PIXEL_ART, + width: whole('Width in pixels.', 1), + height: whole('Height in pixels.', 1), + anchorX: oneOf(['left', 'center', 'right'], 'Where the old picture stays across (default left).'), + anchorY: oneOf(['top', 'center', 'bottom'], 'And down (default top).'), + }, + ['asset', 'width', 'height'], + ), + build: (args, { project }) => { + const asset = requirePixelArt(project, args); + const width = numberOf(args, 'width'); + const height = numberOf(args, 'height'); + if (width > PIXEL_CANVAS_MAX || height > PIXEL_CANVAS_MAX) + throw new CommandError(`A side is 1 to ${PIXEL_CANVAS_MAX} pixels`); + const anchor: ResizeAnchor = { + x: ANCHORS[(args.anchorX as string | undefined) ?? 'left']!, + y: ANCHORS[(args.anchorY as string | undefined) ?? 'top']!, + }; + const command = resizeCanvasCommand(asset, width, height, anchor); + if (command === null) throw new CommandError(`${asset.name} is ${width}x${height} already`); + return { command: refusedIfText(command) }; + }, + shows: theTab, + }, + { + name: 'set_palette_colors', + description: + `Sets all colors of a palette at once, index 1 first; at most ${PALETTE_MAX}. Every pixel art painted ` + + 'with it changes color with it. To take a color out with its pixels, use remove_palette_color.', + args: object( + { + asset: PALETTE, + colors: { type: 'array', items: color('A color.'), description: 'The colors, index 1 first.' }, + }, + ['asset', 'colors'], + ), + build: (args, { project }) => { + const palette = requirePalette(project, args); + const colors = (args.colors as string[]).map(hexColor); + return { command: new SetPaletteColorsCommand(palette.id, 'Palette colors', colors) }; + }, + shows: theAsset, + }, + { + name: 'remove_palette_color', + description: + 'Takes one color out of a palette. Its pixels turn transparent in every pixel art painted with it, and ' + + 'the colors after it move down one index, so the other pixels keep their color.', + args: object({ asset: PALETTE, index: whole('Its index, from 1.', 1) }, ['asset', 'index']), + build: (args, { project }) => { + const palette = requirePalette(project, args); + const command = removePaletteColorCommand(project, palette, numberOf(args, 'index')); + if (command === null) + throw new CommandError( + `${palette.name} has ${palette.colors.length} colors, no index ${numberOf(args, 'index')}`, + ); + return { command: refusedIfText(command) }; + }, + shows: theAsset, + }, +]; + +const ANCHORS: Record = { left: 0, top: 0, center: 0.5, right: 1, bottom: 1 }; + +function requirePixelArt(project: Project, args: Args): PixelArtAsset { + const asset = findAssetOf(project, text(args, 'asset'), 'pixelart'); + if (!asset) throw new CommandError(`Pixel art ${text(args, 'asset')} not found`); + return asset; +} + +function requirePalette(project: Project, args: Args): PaletteAsset { + const asset = findAssetOf(project, text(args, 'asset'), 'palette'); + if (!asset) throw new CommandError(`Palette ${text(args, 'asset')} not found`); + return asset; +} + +function edit(args: Args, label: string, run: (state: PixelArtState) => PixelArtState): Command { + return new EditPixelArtCommand(text(args, 'asset'), label, run); +} + +function made(command: Command, id: string): Built { + return { command, made: { id } }; +} + +function refusedIfText(command: Command | string): Command { + if (typeof command === 'string') throw new CommandError(command); + return command; +} + +/** One cel painted as the steps say, written into the asset as one undo step. */ +function paintCommand(project: Project, args: Args): Built { + const asset = requirePixelArt(project, args); + const layerId = (args.layer as string | undefined) ?? asset.layers[0]!.id; + const frameId = (args.frame as string | undefined) ?? asset.frames[0]!.id; + if (!asset.layers.some((layer) => layer.id === layerId)) + throw new CommandError(`${asset.name} has no layer ${layerId}`); + const frame = asset.frames.find((f) => f.id === frameId); + if (!frame) throw new CommandError(`${asset.name} has no frame ${frameId}`); + const stored = pixelCels.read(celOf(frame, layerId)); + if (stored === undefined) throw new CommandError(STILL_LOADING); + const cel = stored ? cloneCel(stored) : emptyCel(asset.width, asset.height); + const colors = paletteColors(project, asset).length; + (args.draws as DrawStep[]).forEach((step, n) => { + try { + paintStep(cel, step, colors); + } catch (error) { + if (error instanceof CommandError) throw new CommandError(`draws[${n}]: ${error.message}`); + throw error; + } + }); + const file = pixelCels.put(cel); + return { + command: new EditPixelArtCommand(asset.id, 'Paint pixels', (s) => + withCels(s, [{ frameId, layerId, file }]), + ), + }; +} + +function paintStep(cel: CelPixels, step: DrawStep, colors: number): void { + const inside = (point: PixelPoint | undefined, what: string): PixelPoint => { + if (!point) throw new CommandError(`${step.draw} needs ${what}`); + if (point.x >= cel.width || point.y >= cel.height) + throw new CommandError(`${point.x},${point.y} lies outside the ${cel.width}x${cel.height} canvas`); + return point; + }; + const index = (): number => { + if (step.color === undefined) throw new CommandError(`${step.draw} needs a color`); + if (step.color > colors) + throw new CommandError(`The palette has ${colors} colors, so ${step.color} is no index (0 erases)`); + return step.color; + }; + const size = step.size ?? 1; + if (size > BRUSH_MAX) throw new CommandError(`A brush is ${BRUSH_MIN} to ${BRUSH_MAX} pixels`); + switch (step.draw) { + case 'rows': + return paintRows(cel, step, colors); + case 'pixels': { + if (!step.points?.length) throw new CommandError('pixels needs points'); + const color = index(); + stampAll( + cel, + step.points.map((point) => inside(point, 'points')), + 1, + color, + ); + return; + } + case 'line': + return stampAll(cel, linePoints(inside(step.from, 'from'), inside(step.to, 'to')), size, index()); + case 'rect': + case 'ellipse': { + const from = inside(step.from, 'from'); + const to = inside(step.to, 'to'); + const filled = step.filled === true; + const points = step.draw === 'rect' ? rectPoints(from, to, filled) : ellipsePoints(from, to, filled); + return stampAll(cel, points, filled ? 1 : size, index()); + } + case 'fill': + return floodFill(cel, inside(step.at, 'at'), index(), step.contiguous !== false); + case 'clear': + cel.data.fill(0); + return; + } +} + +function paintRows(cel: CelPixels, step: DrawStep, colors: number): void { + const rows = step.rows; + if (!rows?.length) throw new CommandError('rows needs rows'); + const x0 = step.at?.x ?? 0; + const y0 = step.at?.y ?? 0; + const widest = Math.max(...rows.map((row) => row.length)); + if (x0 + widest > cel.width || y0 + rows.length > cel.height) + throw new CommandError( + `${widest}x${rows.length} rows at ${x0},${y0} reach past the ${cel.width}x${cel.height} canvas`, + ); + rows.forEach((row, dy) => { + [...row].forEach((char, dx) => { + if (char === ' ') return; + const index = ROW_ALPHABET.indexOf(char); + if (index < 0) + throw new CommandError(`"${char}" in row ${dy} is no index – use . 1-9 a-z A-Z or a space`); + if (index > colors) + throw new CommandError(`"${char}" is index ${index}, the palette has ${colors} colors`); + cel.data[(y0 + dy) * cel.width + x0 + dx] = index; + }); + }); +} diff --git a/src/editor/agent/catalog/shared.ts b/src/editor/agent/catalog/shared.ts index 81a00fef..d3899f31 100644 --- a/src/editor/agent/catalog/shared.ts +++ b/src/editor/agent/catalog/shared.ts @@ -1,6 +1,7 @@ -import type { Command } from '../../../core/commands/Command'; +import { type Command, CommandError } from '../../../core/commands/Command'; import type { AnnotationKind } from '../../../core/graph'; import type { Project } from '../../../core/model/Project'; +import { parseHex } from '../../../ui/color'; import type { MeasureNode } from '../../graph/graphLayout'; import type { ArgSchema } from '../argCheck'; @@ -83,6 +84,15 @@ export const object = (properties: Record, required: string[] additionalProperties: false, }); +export const color = (description: string): ArgSchema => ({ + type: 'string', + description: `${description} As "#rrggbb".`, +}); +export const xy = (description: string): ArgSchema => ({ + ...object({ x: number('x'), y: number('y') }, ['x', 'y']), + description, +}); + export const ASSET = id('Id of the asset.'); export const HOLDER = id('Id of the scene or container the object is in.'); export const OBJECT = id('Id of the placed object.'); @@ -99,6 +109,13 @@ export const text = (args: Args, key: string): string => args[key] as string; export const numberOf = (args: Args, key: string): number => args[key] as number; export const listOf = (args: Args, key: string): string[] => args[key] as string[]; +/** A color the agent wrote as "#rrggbb", refused in words when it is none. */ +export function hexColor(value: string): number { + const parsed = parseHex(value); + if (parsed === null) throw new CommandError(`"${value}" is no color – write "#rrggbb"`); + return parsed; +} + export const theAsset = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset') }); export const theTab = (args: Args): StepTarget => ({ kind: 'asset', asset: text(args, 'asset'), open: true }); export const madeAsset = (_args: Args, made: Record): StepTarget => ({ diff --git a/src/editor/agent/catalog/vectorart.ts b/src/editor/agent/catalog/vectorart.ts new file mode 100644 index 00000000..3ed99ee1 --- /dev/null +++ b/src/editor/agent/catalog/vectorart.ts @@ -0,0 +1,593 @@ +import { type Command, CommandError } from '../../../core/commands/Command'; +import { CompoundCommand } from '../../../core/commands/CompoundCommand'; +import { requireVectorArt, requireVectorShape } from '../../../core/commands/require'; +import { + AddVectorLayerCommand, + AddVectorShapeCommand, + CombineVectorShapesCommand, + DuplicateVectorShapeCommand, + GroupVectorShapesCommand, + MoveVectorLayerCommand, + MoveVectorShapesCommand, + OrderVectorShapeCommand, + RemoveVectorLayerCommand, + RemoveVectorShapesCommand, + RenameVectorLayerCommand, + RenameVectorShapeCommand, + SetVectorLayerHiddenCommand, + SetVectorShapeRingsCommand, + SetVectorShapeStyleCommand, + type ShapeStyle, + transformShapesCommand, + UngroupVectorShapesCommand, +} from '../../../core/commands/vectorArtCommands'; +import type { + GradientStop, + VectorArtAsset, + VectorGradient, + VectorPaint, + VectorPoint, + VectorRing, + VectorShape, +} from '../../../core/model/Asset'; +import { IDENTITY, type Mat, multiply, translation } from '../../../core/model/matrix'; +import type { Project } from '../../../core/model/Project'; +import { SHAPE_FILL_DEFAULT } from '../../../core/model/vectorart'; +import type { ShapeBoolean } from '../../../core/model/vectorBoolean'; +import { textureFillRef } from '../../../core/model/vectorPaint'; +import { + findVectorItem, + isVectorGroup, + placeOf, + type ShapeOrderMove, + shapesIn, + type TreeDrop, +} from '../../../core/model/vectorTree'; +import { + extentCentre, + frameFlipped, + outlineExtent, + rotationAbout, + scaleAbout, +} from '../../../core/model/vectorTransform'; +import type { ArgSchema } from '../argCheck'; +import { + type Args, + type CatalogCommand, + color, + flag, + hexColor, + id, + ids, + listOf, + number, + numberOf, + object, + oneOf, + text, + theTab, + whole, + xy, +} from './shared'; + +const VECTOR_ART = id('Id of the vector art asset.'); +const SHAPE = id('Id of the shape (or group), as get_asset lists it in the layers.'); +const SHAPES = ids('Ids of the shapes or groups.'); +const LAYER = id('Id of the layer, as get_asset lists it under layers.'); + +const POINT: ArgSchema = object( + { + x: number('x in canvas pixels, from the left.'), + y: number('y in canvas pixels, from the top.'), + in: xy('Bezier handle towards the point before, as an offset from the point; leave out for a corner.'), + out: xy('Bezier handle towards the point after, as an offset.'), + mode: oneOf(['corner', 'smooth', 'symmetric'], 'How the handles move together when dragged.'), + }, + ['x', 'y'], +); +const RING: ArgSchema = object( + { + points: { type: 'array', minItems: 2, items: POINT, description: 'The points in order.' }, + closed: flag('A closed outline is filled; an open one is only stroked (default true).'), + }, + ['points'], +); +const BOX = (description: string): ArgSchema => ({ + ...object( + { + x: number('Left edge.'), + y: number('Top edge.'), + w: number('Width.'), + h: number('Height.'), + }, + ['x', 'y', 'w', 'h'], + ), + description, +}); +/** The outline of a shape, as rings or a figure; exactly one of them. */ +const OUTLINE: Record = { + rings: { + type: 'array', + minItems: 1, + items: RING, + description: 'Outlines; rings are filled even-odd, so a ring inside another cuts a hole.', + }, + rect: BOX('A rectangle instead of rings.'), + ellipse: BOX('The ellipse that fits this box instead of rings.'), +}; + +const STOP = object( + { + offset: number('0 at from, 1 at to.'), + color: color('Its color.'), + alpha: number('0 to 1 (default 1).'), + }, + ['offset', 'color'], +); +const PAINT: ArgSchema = object( + { + type: oneOf( + ['solid', 'linear', 'radial', 'texture'], + 'solid goes back to the plain fill color. linear runs from from to to; radial has its centre on from ' + + 'and reaches its last color at the distance of to; texture repeats a picture, one tile from from to to.', + ), + from: xy('In canvas pixels.'), + to: xy('In canvas pixels.'), + stops: { type: 'array', minItems: 2, items: STOP, description: 'For linear and radial: the colors.' }, + texture: id('For texture: id of a texture or one of its frames.'), + }, + ['type'], +); +const STROKE: ArgSchema = object( + { + color: color('Its color (default: the one it has, else black).'), + width: number('In canvas pixels; 0 takes the outline away.'), + paint: { + ...PAINT, + description: 'A linear or radial gradient along the outline; type solid takes it away.', + }, + }, + ['width'], +); +const STYLE: Record = { + fill: color('Fill color.'), + opacity: number('0 to 1.'), + paint: { ...PAINT, description: 'Fill with a gradient or a picture instead of the fill color.' }, + stroke: { ...STROKE, description: 'The outline drawn along the rings.' }, +}; + +/** Vector art: shapes, their outlines and paints, groups and layers. */ +export const VECTOR_ART_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'add_vector_shape', + description: + 'Adds a shape on top of a layer of a vector art asset, from rings or as a rect or ellipse. Coordinates ' + + 'are canvas pixels from the top-left corner; the canvas size is a property. The style can be given ' + + 'right away, as set_vector_shape_style takes it.', + args: object( + { + asset: VECTOR_ART, + layer: { ...LAYER, description: 'Id of the layer; the top one when left out.' }, + name: id('Its name (default "Shape n").'), + ...OUTLINE, + ...STYLE, + }, + ['asset'], + ), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const fill = typeof args.fill === 'string' ? hexColor(args.fill) : SHAPE_FILL_DEFAULT; + const add = new AddVectorShapeCommand( + asset.id, + outlineOf(args), + fill, + (args.layer as string | undefined) ?? '', + ); + const steps: Command[] = [add]; + if (typeof args.name === 'string') + steps.push(new RenameVectorShapeCommand(asset.id, add.shapeId, args.name)); + const style = styleOf(project, args, null); + if (style) steps.push(new SetVectorShapeStyleCommand(asset.id, add.shapeId, style)); + return { command: new CompoundCommand('Add shape', steps), made: { id: add.shapeId } }; + }, + shows: theTab, + }, + { + name: 'set_vector_shape_outline', + description: 'Writes the outline of a shape anew: rings, a rect or an ellipse. Its style stays.', + args: object({ asset: VECTOR_ART, shape: SHAPE, ...OUTLINE }, ['asset', 'shape']), + build: (args, { project }) => { + requireVectorShape(project, text(args, 'asset'), text(args, 'shape')); + return { + command: new SetVectorShapeRingsCommand(text(args, 'asset'), text(args, 'shape'), outlineOf(args)), + }; + }, + shows: theTab, + }, + { + name: 'set_vector_shape_style', + description: + 'Changes how a shape is painted: fill color, opacity, a gradient or picture fill, the outline. Give only ' + + 'what changes.', + args: object({ asset: VECTOR_ART, shape: SHAPE, ...STYLE }, ['asset', 'shape']), + build: (args, { project }) => { + const shape = requireVectorShape(project, text(args, 'asset'), text(args, 'shape')); + const style = styleOf(project, args, shape); + if (!style) throw new CommandError('Name what to change: fill, opacity, paint or stroke'); + return { command: new SetVectorShapeStyleCommand(text(args, 'asset'), shape.id, style) }; + }, + shows: theTab, + }, + { + name: 'transform_vector_shapes', + description: + 'Moves, scales, turns or mirrors shapes and groups with their paints, as one step. Scaling, turning and ' + + 'mirroring go round the middle of all of them unless about is given; the move comes last.', + args: object( + { + asset: VECTOR_ART, + shapes: SHAPES, + move: xy('How far, in canvas pixels.'), + scaleX: number('Factor across; negative mirrors.'), + scaleY: number('Factor down.'), + rotate: number('Degrees, clockwise.'), + flip: oneOf(['x', 'y'], 'x swaps left and right, y top and bottom.'), + about: xy('The point that stays where it is.'), + }, + ['asset', 'shapes'], + ), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const shapes = shapesOf(asset, listOf(args, 'shapes')); + const extent = outlineExtent(shapes); + const about = (args.about as { x: number; y: number } | undefined) ?? (extent && extentCentre(extent)); + let m: Mat = IDENTITY; + const then = (next: Mat) => (m = multiply(next, m)); + if (typeof args.flip === 'string') { + if (!extent) throw new CommandError('The shapes have no size to mirror'); + then(frameFlipped(extent, args.flip as 'x' | 'y')); + } + if (typeof args.scaleX === 'number' || typeof args.scaleY === 'number') { + if (!about) throw new CommandError('The shapes have no size to scale – give about'); + then( + scaleAbout( + about, + (args.scaleX as number | undefined) ?? 1, + (args.scaleY as number | undefined) ?? 1, + ), + ); + } + if (typeof args.rotate === 'number') { + if (!about) throw new CommandError('The shapes have no size to turn – give about'); + then(rotationAbout(about, args.rotate)); + } + const move = args.move as { x: number; y: number } | undefined; + if (move) then(translation(move.x, move.y)); + const command = transformShapesCommand( + asset, + shapes.map((shape) => shape.id), + m, + 'Transform shapes', + ); + if (!command) throw new CommandError('Nothing moves – give move, scaleX, scaleY, rotate or flip'); + return { command }; + }, + shows: theTab, + }, + { + name: 'duplicate_vector_shape', + description: 'Copies a shape or group right above it, on the same spot, with its style.', + args: object({ asset: VECTOR_ART, shape: SHAPE }, ['asset', 'shape']), + build: (args) => { + const command = new DuplicateVectorShapeCommand(text(args, 'asset'), text(args, 'shape')); + return { command, made: { id: command.shapeId } }; + }, + shows: theTab, + }, + { + name: 'remove_vector_shapes', + description: 'Removes shapes, and groups with all that is in them.', + args: object({ asset: VECTOR_ART, shapes: SHAPES }, ['asset', 'shapes']), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const wanted = listOf(args, 'shapes'); + requireItems(asset, wanted); + return { command: new RemoveVectorShapesCommand(asset.id, wanted) }; + }, + shows: theTab, + }, + { + name: 'combine_vector_shapes', + description: + 'Combines shapes into one: union, subtract (the ones above cut out of the lowest), intersect or exclude. ' + + 'The result keeps the place, name and style of the lowest; the others go.', + args: object( + { + asset: VECTOR_ART, + shapes: { ...SHAPES, minItems: 2 }, + op: oneOf(['union', 'subtract', 'intersect', 'exclude'], 'How they combine.'), + }, + ['asset', 'shapes', 'op'], + ), + build: (args) => { + const command = new CombineVectorShapesCommand( + text(args, 'asset'), + listOf(args, 'shapes'), + text(args, 'op') as ShapeBoolean, + ); + return { command, made: () => ({ id: command.shapeId }) }; + }, + shows: theTab, + }, + { + name: 'rename_vector_shape', + description: 'Renames a shape or group.', + args: object({ asset: VECTOR_ART, shape: SHAPE, name: id('The new name.') }, ['asset', 'shape', 'name']), + build: (args) => ({ + command: new RenameVectorShapeCommand(text(args, 'asset'), text(args, 'shape'), text(args, 'name')), + }), + shows: theTab, + }, + { + name: 'order_vector_shape', + description: + 'Moves a shape or group one step (forward, backward) or all the way (front, back) among the items of its ' + + 'own list; the last one is drawn on top.', + args: object( + { asset: VECTOR_ART, shape: SHAPE, move: oneOf(['forward', 'front', 'backward', 'back'], 'Where to.') }, + ['asset', 'shape', 'move'], + ), + build: (args) => ({ + command: new OrderVectorShapeCommand( + text(args, 'asset'), + text(args, 'shape'), + text(args, 'move') as ShapeOrderMove, + ), + }), + shows: theTab, + }, + { + name: 'move_vector_shapes', + description: + 'Moves shapes and groups to another place in the tree: onto another layer, into a group, or right over ' + + 'another item. They land on top of the layer or group unless over is given.', + args: object( + { + asset: VECTOR_ART, + shapes: SHAPES, + layer: { ...LAYER, description: 'The layer they go to; the one of into or over when left out.' }, + into: id('A group they go into.'), + over: id('An item they are drawn right over.'), + }, + ['asset', 'shapes'], + ), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const moved = listOf(args, 'shapes'); + requireItems(asset, moved); + const { layer, drop } = dropOf(asset, args, moved); + return { command: new MoveVectorShapesCommand(asset.id, moved, layer, drop) }; + }, + shows: theTab, + }, + { + name: 'group_vector_shapes', + description: 'Puts shapes and groups of one layer into a new group, where the topmost of them stood.', + args: object({ asset: VECTOR_ART, shapes: SHAPES }, ['asset', 'shapes']), + build: (args) => { + const command = new GroupVectorShapesCommand(text(args, 'asset'), listOf(args, 'shapes')); + return { command, made: { id: command.groupId } }; + }, + shows: theTab, + }, + { + name: 'ungroup_vector_shapes', + description: 'Takes groups away; what was in them stays where the group stood.', + args: object({ asset: VECTOR_ART, groups: ids('Ids of the groups.') }, ['asset', 'groups']), + build: (args) => ({ + command: new UngroupVectorShapesCommand(text(args, 'asset'), listOf(args, 'groups')), + }), + shows: theTab, + }, + { + name: 'add_vector_layer', + description: 'Adds an empty layer right over another one, or on top of all.', + args: object({ asset: VECTOR_ART, over: { ...LAYER, description: 'The layer it goes over.' } }, [ + 'asset', + ]), + build: (args) => { + const command = new AddVectorLayerCommand(text(args, 'asset'), (args.over as string | undefined) ?? ''); + return { command, made: { id: command.layerId } }; + }, + shows: theTab, + }, + { + name: 'remove_vector_layer', + description: 'Removes a layer with all that is drawn on it; the last layer stays.', + args: object({ asset: VECTOR_ART, layer: LAYER }, ['asset', 'layer']), + build: (args) => ({ command: new RemoveVectorLayerCommand(text(args, 'asset'), text(args, 'layer')) }), + shows: theTab, + }, + { + name: 'move_vector_layer', + description: 'Moves a layer to another place in the stack; the last one is drawn on top.', + args: object({ asset: VECTOR_ART, layer: LAYER, to: whole('Its new place, from 0 (the bottom).', 0) }, [ + 'asset', + 'layer', + 'to', + ]), + build: (args, { project }) => { + const asset = requireVectorArt(project, text(args, 'asset')); + const from = asset.layers.findIndex((layer) => layer.id === text(args, 'layer')); + if (from < 0) throw new CommandError(`${asset.name} has no layer ${text(args, 'layer')}`); + return { command: new MoveVectorLayerCommand(asset.id, from, numberOf(args, 'to')) }; + }, + shows: theTab, + }, + { + name: 'set_vector_layer', + description: 'Renames a layer or hides it. Give only what changes.', + args: object({ asset: VECTOR_ART, layer: LAYER, name: id('Its name.'), hidden: flag('Hidden.') }, [ + 'asset', + 'layer', + ]), + build: (args) => { + const asset = text(args, 'asset'); + const layer = text(args, 'layer'); + const steps: Command[] = []; + if (typeof args.name === 'string') steps.push(new RenameVectorLayerCommand(asset, layer, args.name)); + if (typeof args.hidden === 'boolean') + steps.push(new SetVectorLayerHiddenCommand(asset, layer, args.hidden)); + if (steps.length === 0) throw new CommandError('Name what to change: name or hidden'); + return { command: new CompoundCommand('Change layer', steps) }; + }, + shows: theTab, + }, +]; + +/** Bezier handles of a quarter ellipse: this share of the radius gives a circle within 0.03 %. */ +const KAPPA = 0.5522847498; + +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +/** The rings the arguments give: rings as written, or a rect or ellipse turned into one. */ +export function outlineOf(args: Args): VectorRing[] { + const given = (['rings', 'rect', 'ellipse'] as const).filter((key) => args[key] !== undefined); + if (given.length !== 1) throw new CommandError('Give the outline as exactly one of rings, rect or ellipse'); + if (args.rect) return [rectRing(args.rect as Box)]; + if (args.ellipse) return [ellipseRing(args.ellipse as Box)]; + return (args.rings as (Omit & { closed?: boolean })[]).map((ring) => ({ + points: ring.points.map((point) => ({ ...point })), + closed: ring.closed !== false, + })); +} + +function rectRing({ x, y, w, h }: Box): VectorRing { + return { + points: [ + { x, y }, + { x: x + w, y }, + { x: x + w, y: y + h }, + { x, y: y + h }, + ], + closed: true, + }; +} + +function ellipseRing({ x, y, w, h }: Box): VectorRing { + const rx = w / 2; + const ry = h / 2; + const cx = x + rx; + const cy = y + ry; + const kx = KAPPA * rx; + const ky = KAPPA * ry; + const point = (px: number, py: number, out: { x: number; y: number }): VectorPoint => ({ + x: px, + y: py, + in: { x: -out.x, y: -out.y }, + out, + mode: 'symmetric', + }); + // Clockwise on screen from the top: right, bottom, left. + return { + points: [ + point(cx, y, { x: kx, y: 0 }), + point(x + w, cy, { x: 0, y: ky }), + point(cx, y + h, { x: -kx, y: 0 }), + point(x, cy, { x: 0, y: -ky }), + ], + closed: true, + }; +} + +interface PaintArg { + type: 'solid' | 'linear' | 'radial' | 'texture'; + from?: { x: number; y: number }; + to?: { x: number; y: number }; + stops?: { offset: number; color: string; alpha?: number }[]; + texture?: string; +} + +/** The style the arguments give, null when they name none; `shape` is the one it changes, if any. */ +function styleOf(project: Project, args: Args, shape: VectorShape | null): ShapeStyle | null { + const style: ShapeStyle = {}; + if (typeof args.fill === 'string') style.fill = hexColor(args.fill); + if (typeof args.opacity === 'number') style.opacity = args.opacity; + if (args.paint) style.paint = paintOf(project, args.paint as PaintArg, 'paint'); + const stroke = args.stroke as { color?: string; width: number; paint?: PaintArg } | undefined; + if (stroke) { + if (stroke.width <= 0) style.stroke = null; + else { + const paint = stroke.paint ? paintOf(project, stroke.paint, 'stroke.paint') : shape?.stroke?.paint; + if (paint?.type === 'texture') throw new CommandError('An outline takes a gradient, not a picture'); + style.stroke = { + color: stroke.color ? hexColor(stroke.color) : (shape?.stroke?.color ?? 0x000000), + width: stroke.width, + ...(paint ? { paint } : {}), + }; + } + } + return Object.keys(style).length > 0 ? style : null; +} + +function paintOf(project: Project, paint: PaintArg, at: string): VectorPaint | null { + if (paint.type === 'solid') return null; + if (!paint.from || !paint.to) throw new CommandError(`${at} of type ${paint.type} needs from and to`); + if (paint.type === 'texture') { + if (!paint.texture || !textureFillRef(project, paint.texture)) + throw new CommandError(`${at}.texture has to name a texture or one of its frames`); + return { type: 'texture', from: { ...paint.from }, to: { ...paint.to }, texture: paint.texture }; + } + if (!paint.stops) throw new CommandError(`${at} of type ${paint.type} needs stops`); + const stops: GradientStop[] = paint.stops.map((stop) => ({ + offset: Math.max(0, Math.min(1, stop.offset)), + color: hexColor(stop.color), + alpha: Math.max(0, Math.min(1, stop.alpha ?? 1)), + })); + const gradient: VectorGradient = { type: paint.type, from: { ...paint.from }, to: { ...paint.to }, stops }; + return gradient; +} + +/** Every shape the ids name, the shapes inside a named group too; refused when one names nothing. */ +function shapesOf(asset: VectorArtAsset, itemIds: readonly string[]): VectorShape[] { + requireItems(asset, itemIds); + const shapes = new Map(); + for (const itemId of itemIds) + for (const shape of shapesIn([findVectorItem(asset, itemId)!])) shapes.set(shape.id, shape); + return [...shapes.values()]; +} + +function requireItems(asset: VectorArtAsset, itemIds: readonly string[]): void { + const missing = itemIds.find((itemId) => !findVectorItem(asset, itemId)); + if (missing) throw new CommandError(`${asset.name} has no shape or group ${missing}`); +} + +/** Where moved items land: the layer, and the place in it – on top of a group, or right over an item. */ +function dropOf( + asset: VectorArtAsset, + args: Args, + moved: readonly string[], +): { layer: string; drop: TreeDrop | null } { + const into = args.into as string | undefined; + const over = args.over as string | undefined; + if (into && over) throw new CommandError('Give into or over, not both'); + const anchor = into ?? over; + const place = anchor ? placeOf(asset.layers, anchor) : null; + if (anchor && !place) throw new CommandError(`${asset.name} has no shape or group ${anchor}`); + const layer = (args.layer as string | undefined) ?? place?.layer.id; + if (!layer) throw new CommandError('Name the layer, or a group (into) or item (over) to move to'); + if (place && place.layer.id !== layer) + throw new CommandError(`${anchor} lies on another layer than ${layer}`); + if (into) { + if (!isVectorGroup(place!.item)) throw new CommandError(`${into} is a shape, not a group`); + const staying = place!.item.items.filter((item) => !moved.includes(item.id)); + return { layer, drop: { parentId: into, belowId: staying[staying.length - 1]?.id ?? null } }; + } + if (over) return { layer, drop: { parentId: place!.parent?.id ?? null, belowId: over } }; + return { layer, drop: null }; +} diff --git a/src/editor/agent/commandCatalog.ts b/src/editor/agent/commandCatalog.ts index 08f9eb39..b97e7874 100644 --- a/src/editor/agent/commandCatalog.ts +++ b/src/editor/agent/commandCatalog.ts @@ -2,11 +2,13 @@ import { ANIMATION_COMMANDS } from './catalog/animation'; import { ASSET_COMMANDS } from './catalog/assets'; import { GRAPH_COMMANDS } from './catalog/graphs'; import { OBJECT_COMMANDS } from './catalog/objects'; +import { PIXEL_ART_COMMANDS } from './catalog/pixelart'; import { PROJECT_COMMANDS } from './catalog/project'; import type { CatalogCommand } from './catalog/shared'; import { TEXTURE_COMMANDS } from './catalog/textures'; import { TILE_COMMANDS } from './catalog/tiles'; import { VARIABLE_COMMANDS } from './catalog/variables'; +import { VECTOR_ART_COMMANDS } from './catalog/vectorart'; export type { BuildContext, Built, CatalogCommand, Frame, StepTarget } from './catalog/shared'; @@ -51,6 +53,16 @@ export const AREAS: readonly CommandArea[] = [ about: 'Pictures of tiles; painting tilemaps, their layers, and the edges of a placed one.', commands: TILE_COMMANDS, }, + { + area: 'pixelart', + about: 'Painting Pixel art assets, their layers, frames and canvas size, and the colors of palettes.', + commands: PIXEL_ART_COMMANDS, + }, + { + area: 'vectorart', + about: 'Shapes of Vector art assets: their outlines, fills, gradients and strokes, groups and layers.', + commands: VECTOR_ART_COMMANDS, + }, { area: 'project', about: 'Settings of the project as a whole.', commands: PROJECT_COMMANDS }, ]; diff --git a/src/editor/agent/readTools.ts b/src/editor/agent/readTools.ts index 5824d0b9..7687c24e 100644 --- a/src/editor/agent/readTools.ts +++ b/src/editor/agent/readTools.ts @@ -1,16 +1,19 @@ import { type GraphAnnotation, type GraphNode, resolveDef } from '../../core/graph'; import type { PortDef } from '../../core/graph/NodeRegistry'; import { animationTargets, targetDefs } from '../../core/model/animation'; -import type { AnimationAsset, Asset } from '../../core/model/Asset'; +import type { AnimationAsset, Asset, PixelArtAsset } from '../../core/model/Asset'; import { ASSET_TYPE_LABELS, ASSET_TYPES, type AssetType, isPlaceableType } from '../../core/model/AssetTypes'; import { graphLookup } from '../../core/model/graphLookup'; import { isTemplateContent, resolveObjects } from '../../core/model/instance'; import { findAsset, findGraphAsset, type Project } from '../../core/model/Project'; +import { celOf, paletteColors } from '../../core/model/pixelart'; +import type { CelPixels } from '../../core/model/pixelCel'; import { objectName } from '../../core/model/SceneObject'; import { VARIABLE_KINDS } from '../../core/properties/kinds'; import { type PropertyDef, schemaFor, schemaForAsset } from '../../core/properties/PropertySchema'; import { type PaletteEntry, paletteEntries, rankedEntries } from '../graph/paletteEntries'; import { toHex } from '../../ui/color'; +import { celRows } from './catalog/pixelart'; import { AREAS, type CatalogCommand } from './commandCatalog'; /** @@ -51,8 +54,14 @@ export function listAssets(project: Project, type?: string, query?: string): obj })); } -/** An asset as stored, with the names of what a scene or container holds and the rows of its properties. */ -export function getAsset(project: Project, assetId: string): object | null { +/** Reads the pixels of a cel: null for an empty one, undefined while it loads. */ +export type CelLookup = (file: string) => CelPixels | null | undefined; + +/** + * An asset as stored, with the names of what a scene or container holds and the rows of its + * properties. Colors read as "#rrggbb", and `readCel` lets the pixels of small pixel art be read too. + */ +export function getAsset(project: Project, assetId: string, readCel?: CelLookup): object | null { const asset = findAsset(project, assetId); if (!asset) return null; const plain = JSON.parse(JSON.stringify(asset, trimmed)) as Record; @@ -66,6 +75,12 @@ export function getAsset(project: Project, assetId: string): object | null { })); } if (asset.type === 'animation') plain.targets = animationTargetRows(project, asset); + if (asset.type === 'palette') plain.colors = asset.colors.map(toHex); + if (asset.type === 'vectorart') plain.layers = hexColors(plain.layers); + if (asset.type === 'pixelart') { + plain.colors = paletteColors(project, asset).map(toHex); + plain.frames = pixelFrames(asset, readCel); + } plain.properties = schemaForAsset(project, asset).map(propertyRow); return plain; } @@ -88,6 +103,43 @@ function animationTargetRows(project: Project, animation: AnimationAsset): objec } const MAX_CELLS_IN_FULL = 400; +/** The most pixels one answer spells out, over all cels: four of 64x64. */ +export const MAX_PIXELS_IN_FULL = 16384; + +/** + * The frames of pixel art with what each layer holds there: its rows (see `celRows`) while the whole + * asset is small enough, else how many pixels are painted; "empty", or "loading" until it is read. + */ +function pixelFrames(asset: PixelArtAsset, readCel?: CelLookup): object[] { + const cels = asset.frames.length * asset.layers.length; + const spelled = readCel !== undefined && cels * asset.width * asset.height <= MAX_PIXELS_IN_FULL; + return asset.frames.map((frame) => ({ + id: frame.id, + layers: Object.fromEntries( + asset.layers.map((layer): [string, unknown] => { + const file = celOf(frame, layer.id); + if (!file) return [layer.id, 'empty']; + const cel = readCel?.(file); + if (cel === undefined) return [layer.id, readCel ? 'loading' : 'painted']; + if (cel === null) return [layer.id, 'empty']; + const painted = cel.data.reduce((n, index) => n + (index === 0 ? 0 : 1), 0); + return [layer.id, (spelled && celRows(cel)) || { painted }]; + }), + ), + })); +} + +/** The JSON of vector layers with every fill and color written as "#rrggbb". */ +function hexColors(value: unknown): unknown { + if (Array.isArray(value)) return value.map(hexColors); + if (value === null || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value).map(([key, inner]) => [ + key, + (key === 'fill' || key === 'color') && typeof inner === 'number' ? toHex(inner) : hexColors(inner), + ]), + ); +} /** Leaves out what is too big to read and has a tool of its own, or tells only how much there is. */ function trimmed(key: string, value: unknown): unknown { diff --git a/src/editor/agent/tools.json b/src/editor/agent/tools.json index aeaa6ee8..644228b3 100644 --- a/src/editor/agent/tools.json +++ b/src/editor/agent/tools.json @@ -44,7 +44,7 @@ { "name": "get_asset", "title": "Read an asset", - "description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap. A graph is only counted here; read it with get_graph.", + "description": "One asset as stored: its property values, the rows of its properties (key, kind, default), the objects of a scene or container with their names and positions (the content of a placed container too, marked fromContainer), what the rows of an animation may aim at (targets), the cells of a small tilemap, the pixels of small pixel art as rows of palette indices (colors as \"#rrggbb\"). A graph is only counted here; read it with get_graph.", "inputSchema": { "type": "object", "properties": { "asset": { "type": "string", "description": "Id of the asset." } }, diff --git a/src/editor/pixelart/pixelActions.ts b/src/editor/pixelart/pixelActions.ts index dd933909..c2d2ad9d 100644 --- a/src/editor/pixelart/pixelActions.ts +++ b/src/editor/pixelart/pixelActions.ts @@ -3,10 +3,12 @@ * pure state edits; resizing and merging also need the pixels, which only this browser holds, so * their new cels are worked out here and handed to the edit by name. */ +import type { Command } from '../../core/commands/Command'; import { oneStep } from '../../core/commands/CompoundCommand'; import { EditPixelArtCommand, SetPaletteColorsCommand } from '../../core/commands/pixelArtCommands'; import { newId } from '../../core/ids'; import type { PaletteAsset, PixelArtAsset } from '../../core/model/Asset'; +import type { Project } from '../../core/model/Project'; import { celFilesOf, celOf, isPixelArt, PALETTE_MAX, paletteAssetOf } from '../../core/model/pixelart'; import { type CelPixels, emptyCel } from '../../core/model/pixelCel'; import { mergeCels, type ResizeAnchor, resizeCel, withoutColorIndex } from '../../core/model/pixelDraw'; @@ -47,7 +49,7 @@ function loadedCels(asset: PixelArtAsset): Map | null { return cels; } -const STILL_LOADING = 'The pixels are still loading – try again in a moment.'; +export const STILL_LOADING = 'The pixels are still loading – try again in a moment.'; /** The new layer's id, so the tab can go on painting on it. */ export function addLayer(state: EditorState, asset: PixelArtAsset, aboveId: string): string { @@ -101,17 +103,16 @@ export function moveLayer(state: EditorState, asset: PixelArtAsset, layerId: str /** The layer laid onto the one below it on every frame; its opacity is not kept (see `mergeCels`). */ export function mergeLayerDown(state: EditorState, asset: PixelArtAsset, layerId: string): boolean { + return run(state, mergeLayerDownCommand(asset, layerId)); +} + +/** The merge as one command, or why it cannot be done now. */ +export function mergeLayerDownCommand(asset: PixelArtAsset, layerId: string): Command | string { const index = asset.layers.findIndex((layer) => layer.id === layerId); const lower = asset.layers[index - 1]; - if (!lower) { - state.setNotice('There is no layer below to merge into.'); - return false; - } + if (!lower) return 'There is no layer below to merge into.'; const cels = loadedCels(asset); - if (!cels) { - state.setNotice(STILL_LOADING); - return false; - } + if (!cels) return STILL_LOADING; const empty = emptyCel(asset.width, asset.height); const merged: Record = {}; for (const frame of asset.frames) { @@ -119,7 +120,9 @@ export function mergeLayerDown(state: EditorState, asset: PixelArtAsset, layerId const above = cels.get(celOf(frame, layerId)) ?? empty; merged[frame.id] = pixelCels.put(mergeCels(below, above)); } - return edit(state, asset, 'Merge layer down', (s) => withLayerMergedDown(s, layerId, merged)); + return new EditPixelArtCommand(asset.id, 'Merge layer down', (s) => + withLayerMergedDown(s, layerId, merged), + ); } /** A new frame after `afterId`: empty, or a copy of it. Returns its id. */ @@ -147,15 +150,23 @@ export function resizeCanvas( height: number, anchor: ResizeAnchor, ): boolean { - if (width === asset.width && height === asset.height) return false; + const command = resizeCanvasCommand(asset, width, height, anchor); + return command !== null && run(state, command); +} + +/** The resize as one command, why it cannot be done now, or null when the size is the same. */ +export function resizeCanvasCommand( + asset: PixelArtAsset, + width: number, + height: number, + anchor: ResizeAnchor, +): Command | string | null { + if (width === asset.width && height === asset.height) return null; const cels = loadedCels(asset); - if (!cels) { - state.setNotice(STILL_LOADING); - return false; - } + if (!cels) return STILL_LOADING; const resized: Record = {}; for (const [file, cel] of cels) resized[file] = pixelCels.put(resizeCel(cel, width, height, anchor)); - return edit(state, asset, 'Resize canvas', (s) => withCanvasSize(s, width, height, resized)); + return new EditPixelArtCommand(asset.id, 'Resize canvas', (s) => withCanvasSize(s, width, height, resized)); } export function setPaletteColor( @@ -186,17 +197,24 @@ export function addPaletteColor(state: EditorState, palette: PaletteAsset, color * after it move down one. Palette and pictures change in one undo step. */ export function removePaletteColor(state: EditorState, palette: PaletteAsset, index: number): boolean { - if (index < 1 || index > palette.colors.length) return false; - const users = state.project.assets.filter( - (asset): asset is PixelArtAsset => isPixelArt(asset) && paletteAssetOf(state.project, asset) === palette, + const command = removePaletteColorCommand(state.project, palette, index); + return command !== null && run(state, command); +} + +/** The removal as one command, why it cannot be done now, or null for an index the palette lacks. */ +export function removePaletteColorCommand( + project: Project, + palette: PaletteAsset, + index: number, +): Command | string | null { + if (index < 1 || index > palette.colors.length) return null; + const users = project.assets.filter( + (asset): asset is PixelArtAsset => isPixelArt(asset) && paletteAssetOf(project, asset) === palette, ); const rewrites: EditPixelArtCommand[] = []; for (const asset of users) { const cels = loadedCels(asset); - if (!cels) { - state.setNotice(STILL_LOADING); - return false; - } + if (!cels) return STILL_LOADING; const replaced: Record = {}; for (const [file, cel] of cels) { const next = withoutColorIndex(cel, index); @@ -207,5 +225,14 @@ export function removePaletteColor(state: EditorState, palette: PaletteAsset, in } const colors = palette.colors.filter((_, i) => i !== index - 1); const recolor = new SetPaletteColorsCommand(palette.id, 'Remove color', colors); - return state.execute(oneStep('Remove color', [recolor, ...rewrites])); + return oneStep('Remove color', [recolor, ...rewrites]); +} + +/** Runs a command, or shows why there is none. */ +function run(state: EditorState, command: Command | string): boolean { + if (typeof command === 'string') { + state.setNotice(command); + return false; + } + return state.execute(command); } diff --git a/tests/editor/agent-drawing.test.ts b/tests/editor/agent-drawing.test.ts new file mode 100644 index 00000000..311c7293 --- /dev/null +++ b/tests/editor/agent-drawing.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, it } from 'vitest'; +import type { PaletteAsset, PixelArtAsset, VectorArtAsset, VectorShape } from '../../src/core/model/Asset'; +import { celOf } from '../../src/core/model/pixelart'; +import { flattenRing } from '../../src/core/model/vectorBoolean'; +import { outlineExtent } from '../../src/core/model/vectorTransform'; +import { allShapes } from '../../src/core/model/vectorTree'; +import { runTool } from '../../src/editor/agent/agentTools'; +import { pixelCels } from '../../src/viewport/PixelCels'; +import { makeSpriteScene, type SceneFixture } from '../helpers'; +import { hostFor, text } from './agentTestHost'; + +/** The commands of #429 stage 5c: pixel art, palettes and vector art. */ + +const run = async (fixture: SceneFixture, steps: object[], follows = false) => { + const { host, shown } = hostFor(fixture, null, follows); + const result = await runTool('run_commands', { steps }, host); + return { result, shown, text: text(result) }; +}; + +const read = async (fixture: SceneFixture, asset: string) => + (await runTool('get_asset', { asset }, hostFor(fixture).host)).structuredContent as Record; + +const ofType = (fixture: SceneFixture, type: string) => + fixture.project.assets.find((asset) => asset.type === type) as T; + +/** The pixels of a cel as rows of indices, for asserting without the alphabet of get_asset. */ +function pixels(asset: PixelArtAsset, frame = 0, layer = 0): number[][] { + const cel = pixelCels.read(celOf(asset.frames[frame]!, asset.layers[layer]!.id)); + if (!cel) return []; + return Array.from({ length: cel.height }, (_, y) => [ + ...cel.data.subarray(y * cel.width, (y + 1) * cel.width), + ]); +} + +async function smallPixelArt(fixture: SceneFixture, side = 6): Promise { + const made = await run(fixture, [ + { command: 'create_asset', args: { type: 'pixelart', name: 'Hero' }, as: 'art' }, + { command: 'resize_pixel_canvas', args: { asset: '$art', width: side, height: side } }, + ]); + expect(made.result.isError).toBeUndefined(); + return ofType(fixture, 'pixelart'); +} + +describe('pixel art', () => { + it('paints rows, a rect and a fill in order, reads them back as rows and shows the tab', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture); + const { result, shown } = await run( + fixture, + [ + { + command: 'paint_pixels', + args: { + asset: art.id, + draws: [ + { draw: 'rect', color: 2, from: { x: 0, y: 0 }, to: { x: 5, y: 5 } }, + { draw: 'fill', color: 8, at: { x: 2, y: 2 } }, + { draw: 'rows', at: { x: 1, y: 1 }, rows: ['a b', ' . '] }, + ], + }, + }, + ], + true, + ); + expect(result.isError).toBeUndefined(); + expect(shown).toEqual([{ kind: 'asset', asset: art.id, open: true }]); + const frames = (await read(fixture, art.id)).frames as { id: string; layers: Record }[]; + expect(frames).toHaveLength(1); + expect(frames[0]!.layers[art.layers[0]!.id]).toEqual([ + '222222', + '2a8b82', + '28.882', + '288882', + '288882', + '222222', + ]); + }); + + it('reads the palette as colors and refuses an index the palette lacks, a pixel outside, an unknown character', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 4); + const colors = (await read(fixture, art.id)).colors as string[]; + expect(colors).toHaveLength(16); + expect(colors[0]).toBe('#000000'); + const paint = (draw: object) => + run(fixture, [{ command: 'paint_pixels', args: { asset: art.id, draws: [draw] } }]); + expect((await paint({ draw: 'pixels', color: 17, points: [{ x: 0, y: 0 }] })).text).toMatch( + /draws\[0\]: The palette has 16 colors, so 17 is no index/, + ); + expect((await paint({ draw: 'pixels', color: 1, points: [{ x: 4, y: 0 }] })).text).toMatch( + /4,0 lies outside the 4x4 canvas/, + ); + expect((await paint({ draw: 'rows', rows: ['..#.'] })).text).toMatch(/"#" in row 0 is no index/); + expect((await paint({ draw: 'rows', rows: ['.....'] })).text).toMatch(/reach past the 4x4 canvas/); + expect(pixels(art)).toEqual([]); + }); + + it('paints a copied frame and another layer, merges the layer down and resizes round the middle', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 4); + const first = art.frames[0]!.id; + const { result } = await run(fixture, [ + { command: 'paint_pixels', args: { asset: art.id, draws: [{ draw: 'rows', rows: ['1111'] }] } }, + { command: 'add_pixel_frame', args: { asset: art.id, after: first, copy: true }, as: 'second' }, + { command: 'add_pixel_layer', args: { asset: art.id }, as: 'top' }, + { + command: 'paint_pixels', + args: { asset: art.id, layer: '$top', frame: '$second', draws: [{ draw: 'rows', rows: ['.3'] }] }, + }, + { command: 'merge_pixel_layer_down', args: { asset: art.id, layer: '$top' } }, + { command: 'resize_pixel_canvas', args: { asset: art.id, width: 6, height: 4, anchorX: 'center' } }, + ]); + expect(result.isError).toBeUndefined(); + expect(art.layers).toHaveLength(1); + expect(art.width).toBe(6); + expect(pixels(art, 0)[0]).toEqual([0, 1, 1, 1, 1, 0]); + expect(pixels(art, 1)[0]).toEqual([0, 1, 3, 1, 1, 0]); + }); + + it('takes a palette color out with its pixels, the later ones moving down an index', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 3); + const { result } = await run(fixture, [ + { command: 'create_asset', args: { type: 'palette', name: 'Mine' }, as: 'pal' }, + { command: 'set_palette_colors', args: { asset: '$pal', colors: ['#ff0000', '#00ff00', '#0000ff'] } }, + { command: 'set_asset_property', args: { asset: art.id, key: 'palette', value: '$pal' } }, + { command: 'paint_pixels', args: { asset: art.id, draws: [{ draw: 'rows', rows: ['123'] }] } }, + { command: 'remove_palette_color', args: { asset: '$pal', index: 2 } }, + ]); + expect(result.isError).toBeUndefined(); + expect(ofType(fixture, 'palette').colors).toEqual([0xff0000, 0x0000ff]); + expect(pixels(art)[0]).toEqual([1, 0, 2]); + expect((await read(fixture, art.id)).colors).toEqual(['#ff0000', '#0000ff']); + }); + + it('a big canvas tells only how many pixels are painted', async () => { + const fixture = makeSpriteScene(); + const art = await smallPixelArt(fixture, 200); + await run(fixture, [ + { + command: 'paint_pixels', + args: { + asset: art.id, + draws: [{ draw: 'rect', color: 1, from: { x: 0, y: 0 }, to: { x: 9, y: 9 }, filled: true }], + }, + }, + ]); + const frames = (await read(fixture, art.id)).frames as { layers: Record }[]; + expect(frames[0]!.layers[art.layers[0]!.id]).toEqual({ painted: 100 }); + }); +}); + +describe('vector art', () => { + async function drawing(fixture: SceneFixture, steps: object[]) { + const made = await run(fixture, [ + { command: 'create_asset', args: { type: 'vectorart', name: 'Logo' }, as: 'art' }, + ...steps, + ]); + return { ...made, art: ofType(fixture, 'vectorart') }; + } + + it('adds an ellipse with fill and outline, which reaches exactly its box and reads in "#rrggbb"', async () => { + const fixture = makeSpriteScene(); + const { result, art } = await drawing(fixture, [ + { + command: 'add_vector_shape', + args: { + asset: '$art', + name: 'Sun', + ellipse: { x: 10, y: 20, w: 100, h: 60 }, + fill: '#ffcc00', + stroke: { color: '#aa5500', width: 3 }, + }, + }, + ]); + expect(result.isError).toBeUndefined(); + const [shape] = allShapes(art); + expect(shape).toMatchObject({ name: 'Sun', fill: 0xffcc00, stroke: { color: 0xaa5500, width: 3 } }); + const extent = outlineExtent([shape!])!; + for (const [got, want] of [ + [extent.x0, 10], + [extent.y0, 20], + [extent.x1, 110], + [extent.y1, 80], + ] as const) + expect(got).toBeCloseTo(want, 6); + // Every sample of the drawn curve lies on the ellipse, not only the four points it is made of. + const samples = flattenRing(shape!.rings[0]!); + expect(samples.length).toBeGreaterThan(8); + for (const { x, y } of samples) expect(Math.hypot((x - 60) / 50, (y - 50) / 30)).toBeCloseTo(1, 3); + const layers = (await read(fixture, art.id)).layers as { + items: { fill: string; stroke: { color: string } }[]; + }[]; + expect(layers[0]!.items[0]).toMatchObject({ fill: '#ffcc00', stroke: { color: '#aa5500' } }); + }); + + it('moves and turns a rect round its middle, and refuses two outlines at once', async () => { + const fixture = makeSpriteScene(); + const { art } = await drawing(fixture, [ + { command: 'add_vector_shape', args: { asset: '$art', rect: { x: 0, y: 0, w: 40, h: 20 } }, as: 'box' }, + { + command: 'transform_vector_shapes', + args: { asset: '$art', shapes: ['$box'], rotate: 90, move: { x: 5, y: 0 } }, + }, + ]); + const extent = outlineExtent(allShapes(art))!; + // 40x20 round (20,10) turned a quarter is 20x40 round the same middle, then 5 to the right. + expect([extent.x0, extent.y0, extent.x1, extent.y1].map((v) => Math.round(v * 1e6) / 1e6)).toEqual([ + 15, -10, 35, 30, + ]); + const twice = await run(fixture, [ + { + command: 'add_vector_shape', + args: { asset: art.id, rect: { x: 0, y: 0, w: 1, h: 1 }, ellipse: { x: 0, y: 0, w: 1, h: 1 } }, + }, + ]); + expect(twice.text).toMatch(/exactly one of rings, rect or ellipse/); + }); + + it('a gradient fill goes back to solid, and an outline of width 0 goes away', async () => { + const fixture = makeSpriteScene(); + const { art } = await drawing(fixture, [ + { + command: 'add_vector_shape', + args: { asset: '$art', rect: { x: 0, y: 0, w: 10, h: 10 }, stroke: { width: 2 } }, + as: 'box', + }, + { + command: 'set_vector_shape_style', + args: { + asset: '$art', + shape: '$box', + paint: { + type: 'linear', + from: { x: 0, y: 0 }, + to: { x: 10, y: 0 }, + stops: [ + { offset: 0, color: '#ff0000' }, + { offset: 1, color: '#0000ff', alpha: 0.5 }, + ], + }, + }, + }, + ]); + const shape = (): VectorShape => allShapes(art)[0]!; + expect(shape().paint).toMatchObject({ + type: 'linear', + stops: [{ color: 0xff0000, alpha: 1 }, { alpha: 0.5 }], + }); + expect(shape().stroke).toEqual({ color: 0x000000, width: 2 }); + await run(fixture, [ + { + command: 'set_vector_shape_style', + args: { asset: art.id, shape: shape().id, paint: { type: 'solid' }, stroke: { width: 0 } }, + }, + ]); + expect(shape().paint).toBeUndefined(); + expect(shape().stroke).toBeUndefined(); + }); + + it('moves a shape on top of a group, and refuses to remove a shape that is not there', async () => { + const fixture = makeSpriteScene(); + const box = (name: string) => ({ + command: 'add_vector_shape', + args: { asset: '$art', name, rect: { x: 0, y: 0, w: 5, h: 5 } }, + as: name, + }); + const { result, art } = await drawing(fixture, [ + box('a'), + box('b'), + box('c'), + { command: 'group_vector_shapes', args: { asset: '$art', shapes: ['$a', '$b'] }, as: 'g' }, + { command: 'move_vector_shapes', args: { asset: '$art', shapes: ['$c'], into: '$g' } }, + ]); + expect(result.isError).toBeUndefined(); + const group = art.layers[0]!.items[0] as { items: { name: string }[] }; + expect(art.layers[0]!.items).toHaveLength(1); + expect(group.items.map((item) => item.name)).toEqual(['a', 'b', 'c']); + const missing = await run(fixture, [ + { command: 'remove_vector_shapes', args: { asset: art.id, shapes: [allShapes(art)[0]!.id, 'nope'] } }, + ]); + expect(missing.text).toMatch(/Logo has no shape or group nope/); + expect(allShapes(art)).toHaveLength(3); + }); +}); diff --git a/tools/e2e/71-mcp.mjs b/tools/e2e/71-mcp.mjs index 75050173..30fae924 100644 --- a/tools/e2e/71-mcp.mjs +++ b/tools/e2e/71-mcp.mjs @@ -32,7 +32,7 @@ const TOOLS = [ */ export async function mcp(t) { const { check, json, sleep, waitUntil, settle, press, locate, click, cdp, baseUrl, projectId } = t; - const { clickText, menuTicked, exceptions, navigate, shot, holdKey, entity } = t; + const { clickText, menuTicked, exceptions, navigate, shot, holdKey, entity, evaluate } = t; const site = process.env.FEUERWERK_MCP_BASE_URL ?? baseUrl; const endpoint = `${site}mcp`; @@ -417,6 +417,52 @@ export async function mcp(t) { }), ); + // 71g7. Drawing (#429 stage 5c): pixel art the agent paints comes up in its tab, reads back as rows, + // and its cel reaches the server when the project is saved. + const painted = await callTool('run_commands', { + label: 'paint', + steps: [ + { command: 'create_asset', args: { type: 'pixelart', name: `${TOKEN_NAME} pixels` }, as: 'art' }, + { command: 'resize_pixel_canvas', args: { asset: '$art', width: 4, height: 3 } }, + { + command: 'paint_pixels', + args: { asset: '$art', draws: [{ draw: 'rows', rows: ['8..8', '.88.', '8..8'] }] }, + }, + ], + }); + await settle(); + const artId = painted?.structuredContent?.made?.find((entry) => entry.step === 1)?.id; + const artView = await viewNow(); + const artRead = artId ? (await callTool('get_asset', { asset: artId }))?.structuredContent : null; + const artRows = artRead ? Object.values(artRead.frames?.[0]?.layers ?? {})[0] : null; + await waitUntil('__gm.state.saveStatus === "saved"', { timeout: 10000, label: 'saved' }).catch(() => {}); + const celFiles = artId + ? await json( + `Object.values(__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(artId)}).frames[0].cels)`, + ) + : []; + const celStatus = celFiles[0] + ? Number(await evaluate(`fetch('/feuerwerk/data/pixels/${celFiles[0]}').then((r) => String(r.status))`)) + : null; + await callTool('undo'); + await callTool('open', { asset: walkId }); + await settle(); + check( + 'pixel art the agent paints comes up in its tab, reads back as rows, and its cel is saved to the server', + painted?.isError === false && + artView?.document?.asset?.id === artId && + JSON.stringify(artRows) === JSON.stringify(['8..8', '.88.', '8..8']) && + celFiles.length === 1 && + celStatus === 200, + JSON.stringify({ + text: painted?.content?.[0]?.text?.slice(0, 300), + document: artView?.document, + artRows, + celFiles, + celStatus, + }), + ); + // 71h. Played by the agent, the object walks left while the user holds the arrow. const started = await callTool('play', { action: 'play' }); await waitUntil('__gm.state.mode === "play"', { timeout: 10000, label: 'the game runs' });