diff --git a/src/editor/agent/catalog/animation.ts b/src/editor/agent/catalog/animation.ts new file mode 100644 index 00000000..c9f73f4c --- /dev/null +++ b/src/editor/agent/catalog/animation.ts @@ -0,0 +1,262 @@ +import { + AddAnimationTrackCommand, + KeyStraightRunCommand, + MoveAnimationKeysCommand, + RemoveAnimationKeysCommand, + RemoveAnimationTracksCommand, + RetargetAnimationTrackCommand, + SetAnimationKeyCommand, + SetAnimationRootCommand, + SetKeyInterpolationCommand, + SetTrackRelativeCommand, + ShiftAnimationFramesCommand, +} from '../../../core/commands/animationCommands'; +import { SetCurvePointsCommand } from '../../../core/commands/curveCommands'; +import { + ANIMATION_ROOT_TYPES, + EASING_DIRECTIONS, + EASING_SHAPES, + easingValue, + type KeyRef, +} from '../../../core/model/animation'; +import type { AnimationKey, KeyInterpolation, VectorPoint } from '../../../core/model/Asset'; +import type { PropertyValue } from '../../../core/model/Properties'; +import type { ArgSchema } from '../argCheck'; +import { + anyValue, + ASSET, + type Args, + type CatalogCommand, + flag, + id, + ids, + listOf, + number, + numberOf, + object, + oneOf, + text, + theTab, + whole, +} from './shared'; + +const TRACK = id('Id of the row, as get_asset lists it under tracks.'); +const TARGET: ArgSchema = { + type: 'string', + description: 'What the row animates: "" for the root itself, or an id get_asset lists under targets.', +}; +const PROPERTY = id('Key of the property, as get_asset lists it for the target.'); +const FRAME = whole('A frame, from 0.', 0); + +const KEYS: ArgSchema = { + type: 'array', + minItems: 1, + items: object({ track: TRACK, frame: FRAME }, ['track', 'frame']), + description: 'Keys by their row and frame.', +}; + +const keyRefs = (args: Args): KeyRef[] => + (args.keys as { track: string; frame: number }[]).map((key) => ({ trackId: key.track, frame: key.frame })); + +const KEY: ArgSchema = object({ frame: FRAME, value: anyValue }, ['frame', 'value']); +const keyOf = (args: Args, name: string): AnimationKey => { + const key = args[name] as { frame: number; value: PropertyValue }; + return { frame: key.frame, value: key.value }; +}; + +/** Every easing a key can name, but the curve ones: those take the id of a Curve asset. */ +const EASINGS = EASING_SHAPES.flatMap(({ value }) => + value === 'linear' || value === 'step' + ? [value] + : value === 'curve' + ? [] + : EASING_DIRECTIONS.map((direction) => easingValue(value, direction.value)), +); + +const HANDLE = object({ x: number('x'), y: number('y') }, ['x', 'y']); +const POINT: ArgSchema = object( + { + x: number('Share of the way, 0 to 1.'), + y: number('Value; 0 to 1 is start to end, outside overshoots.'), + in: { + ...HANDLE, + description: 'Bezier handle towards the point before, as an offset; leave out for a corner.', + }, + out: { ...HANDLE, description: 'Bezier handle towards the point after, as an offset.' }, + mode: oneOf(['corner', 'smooth', 'symmetric'], 'How the handles move together when dragged.'), + }, + ['x', 'y'], +); + +/** Timelines of Animation assets and the points of Curve assets. */ +export const ANIMATION_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'set_animation_root', + description: + `Sets what an animation animates: a ${ANIMATION_ROOT_TYPES.join(', ')} asset, or "" for nothing. ` + + 'Its rows reach the root and the objects inside it; get_asset lists them under targets.', + args: object({ asset: ASSET, root: { type: 'string', description: 'Id of the asset, or "".' } }, [ + 'asset', + 'root', + ]), + build: (args) => ({ command: new SetAnimationRootCommand(text(args, 'asset'), text(args, 'root')) }), + shows: theTab, + }, + { + name: 'add_animation_track', + description: 'Adds a row to the timeline for one property of the root or of an object inside it.', + args: object({ asset: ASSET, target: TARGET, property: PROPERTY }, ['asset', 'target', 'property']), + build: (args) => { + const command = new AddAnimationTrackCommand( + text(args, 'asset'), + text(args, 'target'), + text(args, 'property'), + ); + return { command, made: { id: command.trackId } }; + }, + shows: theTab, + }, + { + name: 'remove_animation_tracks', + description: 'Removes rows with all their keys.', + args: object({ asset: ASSET, tracks: ids('Ids of the rows.') }, ['asset', 'tracks']), + build: (args) => ({ + command: new RemoveAnimationTracksCommand(text(args, 'asset'), listOf(args, 'tracks')), + }), + shows: theTab, + }, + { + name: 'retarget_animation_track', + description: 'Aims a row at another property, of the same kind, and keeps its keys.', + args: object({ asset: ASSET, track: TRACK, target: TARGET, property: PROPERTY }, [ + 'asset', + 'track', + 'target', + 'property', + ]), + build: (args) => ({ + command: new RetargetAnimationTrackCommand( + text(args, 'asset'), + text(args, 'track'), + text(args, 'target'), + text(args, 'property'), + ), + }), + shows: theTab, + }, + { + name: 'set_track_relative', + description: + 'Makes a number row relative (its keys add to the value the object had when the animation started) or absolute.', + args: object({ asset: ASSET, track: TRACK, relative: flag('Relative.') }, ['asset', 'track', 'relative']), + build: (args) => ({ + command: new SetTrackRelativeCommand( + text(args, 'asset'), + text(args, 'track'), + args.relative as boolean, + ), + }), + shows: theTab, + }, + { + name: 'set_animation_key', + description: + 'Sets the key of a row on a frame, replacing one there. Without a value it takes the value the row has on ' + + 'that frame. The last key of all rows decides how long the animation runs; fps and playback are properties.', + args: object({ asset: ASSET, track: TRACK, frame: FRAME, value: anyValue }, ['asset', 'track', 'frame']), + build: (args) => ({ + command: new SetAnimationKeyCommand( + text(args, 'asset'), + text(args, 'track'), + numberOf(args, 'frame'), + args.value as PropertyValue | undefined, + ), + }), + shows: theTab, + }, + { + name: 'set_key_easing', + description: + 'How keys blend into the next one (number and color rows only): ' + + `${EASINGS.join(', ')}, or "curve:".`, + args: object({ asset: ASSET, keys: KEYS, easing: id('The easing.') }, ['asset', 'keys', 'easing']), + build: (args) => ({ + command: new SetKeyInterpolationCommand( + text(args, 'asset'), + keyRefs(args), + text(args, 'easing') as KeyInterpolation, + ), + }), + shows: theTab, + }, + { + name: 'remove_animation_keys', + description: 'Removes keys.', + args: object({ asset: ASSET, keys: KEYS }, ['asset', 'keys']), + build: (args) => ({ command: new RemoveAnimationKeysCommand(text(args, 'asset'), keyRefs(args)) }), + shows: theTab, + }, + { + name: 'move_animation_keys', + description: + 'Moves keys by whole frames together; one landing on a key that stays replaces it. With copy the keys stay ' + + 'and copies move.', + args: object( + { asset: ASSET, keys: KEYS, by: whole('Frames, negative is earlier.'), copy: flag('Copy.') }, + ['asset', 'keys', 'by'], + ), + build: (args) => ({ + command: new MoveAnimationKeysCommand( + text(args, 'asset'), + keyRefs(args), + numberOf(args, 'by'), + args.copy === true, + ), + }), + shows: theTab, + }, + { + name: 'shift_animation_frames', + description: + 'Inserts frames at a frame (count above 0), moving every later key, or removes them (below 0) with the keys on them.', + args: object({ asset: ASSET, at: FRAME, count: whole('How many frames.') }, ['asset', 'at', 'count']), + build: (args) => ({ + command: new ShiftAnimationFramesCommand( + text(args, 'asset'), + numberOf(args, 'at'), + numberOf(args, 'count'), + ), + }), + shows: theTab, + }, + { + name: 'key_straight_run', + description: + 'Keys a number row in a straight line from one key to another: both set, the first linear, the keys between ' + + 'them removed – a driven video at 1x.', + args: object({ asset: ASSET, track: TRACK, from: KEY, to: KEY }, ['asset', 'track', 'from', 'to']), + build: (args) => ({ + command: new KeyStraightRunCommand( + text(args, 'asset'), + text(args, 'track'), + keyOf(args, 'from'), + keyOf(args, 'to'), + ), + }), + shows: theTab, + }, + { + name: 'set_curve_points', + description: + 'Sets all points of a Curve asset, which an easing or a Lerp node follows from (0, 0) to (1, 1). The first ' + + 'point is moved to x 0, the last to x 1.', + args: object( + { asset: ASSET, points: { type: 'array', minItems: 2, items: POINT, description: 'Left to right.' } }, + ['asset', 'points'], + ), + build: (args) => ({ + command: new SetCurvePointsCommand(text(args, 'asset'), args.points as VectorPoint[]), + }), + shows: theTab, + }, +]; diff --git a/src/editor/agent/catalog/shared.ts b/src/editor/agent/catalog/shared.ts index 6d4a7dde..81a00fef 100644 --- a/src/editor/agent/catalog/shared.ts +++ b/src/editor/agent/catalog/shared.ts @@ -33,9 +33,12 @@ export interface Frame { export type Args = Record; -/** What a step worked on, for the editor to show the user: an asset, objects of a holder, nodes of a graph. */ +/** + * What a step worked on, for the editor to show the user: an asset (in the inspector, or with `open` + * in its tab, there with one of its texture frames picked), objects of a holder, nodes of a graph. + */ export type StepTarget = - | { kind: 'asset'; asset: string } + | { kind: 'asset'; asset: string; open?: true; frame?: string } | { kind: 'objects'; in: string; ids: string[] } | { kind: 'nodes'; asset: string; ids: string[] }; @@ -53,6 +56,11 @@ export interface CatalogCommand { export const id = (description: string): ArgSchema => ({ type: 'string', minLength: 1, description }); export const number = (description: string): ArgSchema => ({ type: 'number', description }); +export const whole = (description: string, minimum?: number): ArgSchema => ({ + type: 'integer', + description, + ...(minimum === undefined ? {} : { minimum }), +}); export const flag = (description: string): ArgSchema => ({ type: 'boolean', description }); export const ids = (description: string): ArgSchema => ({ type: 'array', @@ -92,6 +100,7 @@ export const numberOf = (args: Args, key: string): number => args[key] as number export const listOf = (args: Args, key: string): string[] => args[key] as string[]; 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 => ({ kind: 'asset', asset: made.id!, diff --git a/src/editor/agent/catalog/textures.ts b/src/editor/agent/catalog/textures.ts new file mode 100644 index 00000000..0cafb2af --- /dev/null +++ b/src/editor/agent/catalog/textures.ts @@ -0,0 +1,210 @@ +import { + AddFrameCommand, + RemoveFrameCommand, + RenameFrameCommand, + SetFrameCollisionCommand, + SetFrameRectCommand, + SetSliceCommand, + SetSortLineCommand, + SliceFramesCommand, +} from '../../../core/commands/frameCommands'; +import type { FrameFill } from '../../../core/model/Asset'; +import { type CutTarget, findCut } from '../../../core/model/cutTarget'; +import type { Vec2 } from '../../../core/model/Properties'; +import { cutFill, DEFAULT_FILL, type FrameRect } from '../../../core/model/texture'; +import type { ArgSchema } from '../argCheck'; +import { + ASSET, + type Args, + type CatalogCommand, + flag, + id, + number, + numberOf, + object, + oneOf, + type StepTarget, + text, + theTab, + whole, +} from './shared'; + +const TEXTURE = id('Id of the texture.'); +const FRAME = id('Id of the frame, as get_asset lists it under frames.'); +const CUT_FRAME: ArgSchema = { + type: 'string', + minLength: 1, + description: 'Id of a frame of the texture; leave out for a pixel art or vector art as a whole.', +}; +const RECT = { + x: number('Left edge, image pixels.'), + y: number('Top edge, image pixels.'), + w: number('Width.'), + h: number('Height.'), +}; + +const rectOf = (args: Args): FrameRect => ({ + x: numberOf(args, 'x'), + y: numberOf(args, 'y'), + w: numberOf(args, 'w'), + h: numberOf(args, 'h'), +}); + +const cutOf = (args: Args): CutTarget => ({ + assetId: text(args, 'asset'), + ...(typeof args.frame === 'string' ? { frameId: args.frame } : {}), +}); + +const theFrame = (args: Args, made: Record = {}): StepTarget => { + const frame = made.id ?? (args.frame as string | undefined); + return { kind: 'asset', asset: text(args, 'asset'), open: true, ...(frame ? { frame } : {}) }; +}; + +/** The pictures cut from textures – what sprites and tiles wear – and how a picture is drawn and sorted. */ +export const TEXTURE_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'add_frame', + description: + 'Cuts a frame out of a texture: a named rectangle a sprite or tile can wear. It is kept inside the image.', + args: object({ asset: TEXTURE, ...RECT, name: id('Its name; "Frame" with a number when left out.') }, [ + 'asset', + 'x', + 'y', + 'w', + 'h', + ]), + build: (args) => { + const command = new AddFrameCommand(text(args, 'asset'), rectOf(args), args.name as string | undefined); + return { command, made: { id: command.frameId } }; + }, + shows: theFrame, + }, + { + name: 'set_frame_rect', + description: 'Moves or resizes a frame of a texture.', + args: object({ asset: TEXTURE, frame: FRAME, ...RECT }, ['asset', 'frame', 'x', 'y', 'w', 'h']), + build: (args) => ({ + command: new SetFrameRectCommand(text(args, 'asset'), text(args, 'frame'), rectOf(args)), + }), + shows: theFrame, + }, + { + name: 'rename_frame', + description: 'Renames a frame; a name taken in the texture gets a number.', + args: object({ asset: TEXTURE, frame: FRAME, name: id('The name.') }, ['asset', 'frame', 'name']), + build: (args) => ({ + command: new RenameFrameCommand(text(args, 'asset'), text(args, 'frame'), text(args, 'name')), + }), + shows: theFrame, + }, + { + name: 'remove_frame', + description: 'Removes a frame. Sprites and tiles wearing it show no picture until it comes back.', + args: object({ asset: TEXTURE, frame: FRAME }, ['asset', 'frame']), + build: (args) => ({ command: new RemoveFrameCommand(text(args, 'asset'), text(args, 'frame')) }), + shows: theTab, + }, + { + name: 'slice_frames', + description: + 'Cuts a texture into a grid of equal frames, left to right and top to bottom, named "Frame" with numbers. ' + + 'With replace the frames already there go first.', + args: object( + { + asset: TEXTURE, + columns: whole('Columns.', 1), + rows: whole('Rows.', 1), + offsetX: number('Pixels skipped at the left edge.'), + offsetY: number('Pixels skipped at the top edge.'), + spacingX: number('Pixels between two columns.'), + spacingY: number('Pixels between two rows.'), + replace: flag('Remove the frames there first.'), + }, + ['asset', 'columns', 'rows'], + ), + build: (args) => { + const extra = (key: string) => (typeof args[key] === 'number' ? args[key] : 0); + const options = { + columns: numberOf(args, 'columns'), + rows: numberOf(args, 'rows'), + offsetX: extra('offsetX'), + offsetY: extra('offsetY'), + spacingX: extra('spacingX'), + spacingY: extra('spacingY'), + }; + return { command: new SliceFramesCommand(text(args, 'asset'), options, args.replace === true) }; + }, + shows: theTab, + }, + { + name: 'set_nine_slice', + description: + 'Nine-slices a picture: the borders, in image pixels from its edges, keep their size when a sprite wearing ' + + 'it is resized, and the middle stretches or repeats. All borders 0 takes the slice off.', + args: object( + { + asset: ASSET, + frame: CUT_FRAME, + left: number('Left border.'), + top: number('Top border.'), + right: number('Right border.'), + bottom: number('Bottom border.'), + fill: oneOf(['stretch', 'repeat'], 'How the middle and the edges fill; left out keeps what it has.'), + }, + ['asset', 'left', 'top', 'right', 'bottom'], + ), + build: (args, { project }) => { + const target = cutOf(args); + const found = findCut(project, target); + return { + command: new SetSliceCommand(target, { + border: { + left: numberOf(args, 'left'), + top: numberOf(args, 'top'), + right: numberOf(args, 'right'), + bottom: numberOf(args, 'bottom'), + }, + fill: (args.fill as FrameFill | undefined) ?? (found ? cutFill(found.cut) : DEFAULT_FILL), + }), + }; + }, + shows: theFrame, + }, + { + name: 'set_sort_line', + description: + 'Where a sprite wearing a picture stands when its layer sorts by Y: whole pixels up from the bottom edge.', + args: object({ asset: ASSET, frame: CUT_FRAME, line: number('Pixels up from the bottom.') }, [ + 'asset', + 'line', + ]), + build: (args) => ({ command: new SetSortLineCommand(cutOf(args), numberOf(args, 'line')) }), + shows: theFrame, + }, + { + name: 'set_frame_collision', + description: + 'Sets the outlines a tile made of this frame blocks with, in pixels from its top-left corner. An outline ' + + 'needs three corners or more; an empty list takes them all off.', + args: object( + { + asset: TEXTURE, + frame: FRAME, + outlines: { + type: 'array', + items: { type: 'array', items: object({ x: number('x'), y: number('y') }, ['x', 'y']) }, + description: 'Each a list of corners.', + }, + }, + ['asset', 'frame', 'outlines'], + ), + build: (args) => ({ + command: new SetFrameCollisionCommand( + text(args, 'asset'), + text(args, 'frame'), + args.outlines as Vec2[][], + ), + }), + shows: theFrame, + }, +]; diff --git a/src/editor/agent/catalog/tiles.ts b/src/editor/agent/catalog/tiles.ts new file mode 100644 index 00000000..7274e83d --- /dev/null +++ b/src/editor/agent/catalog/tiles.ts @@ -0,0 +1,271 @@ +import { type Command, CommandError } from '../../../core/commands/Command'; +import { CompoundCommand } from '../../../core/commands/CompoundCommand'; +import { requireTilemap, requireTilemapLayer } from '../../../core/commands/require'; +import { + AddTilePictureCommand, + MoveTilePictureCommand, + RemoveTilePictureCommand, +} from '../../../core/commands/tileCommands'; +import { + AddTilemapLayerCommand, + type CellEdit, + DuplicateTilemapLayerCommand, + MoveTilemapLayerCommand, + PaintTilesCommand, + RemoveTilemapLayerCommand, + RenameTilemapLayerCommand, + ResizeTilemapEdgeCommand, + SetTilemapLayerHiddenCommand, + SetTilemapLayerSortByYCommand, + SetTilemapLayerZCommand, +} from '../../../core/commands/tilemapCommands'; +import type { TilemapAsset } from '../../../core/model/Asset'; +import { findAsset, objectHolders, type Project } from '../../../core/model/Project'; +import { TILEMAP_EDGES, type TilemapEdge, tilemapColumns, tilemapRows } from '../../../core/model/tilemap'; +import type { ArgSchema } from '../argCheck'; +import { + type Args, + type CatalogCommand, + flag, + HOLDER, + id, + number, + numberOf, + object, + oneOf, + text, + theObject, + theTab, + whole, +} from './shared'; + +const TILE = id('Id of the tile asset.'); +const TILEMAP = id('Id of the tilemap asset.'); +const LAYER = id('Id of the layer, as get_asset lists it under layers.'); +const INDEX = whole('Place in the list, from 0.', 0); +const PAINT_TILE: ArgSchema = { type: 'string', description: 'Id of a tile asset, or "" to erase.' }; + +/** The most cells one step paints, so a slip in a rectangle cannot freeze the editor. */ +export const MAX_PAINTED_CELLS = 20000; + +/** Tile pictures, painting tilemaps and their layers. */ +export const TILE_COMMANDS: readonly CatalogCommand[] = [ + { + name: 'add_tile_picture', + description: + 'Adds a picture (a texture or one of its frames) to a tile, at the end or before the one at index. Several ' + + 'pictures are the animation of the tile; its kind (an autotile, for one), rate and playback are properties.', + args: object({ asset: TILE, picture: id('Id of the texture or frame.'), at: INDEX }, [ + 'asset', + 'picture', + ]), + build: (args) => ({ + command: new AddTilePictureCommand( + text(args, 'asset'), + text(args, 'picture'), + args.at as number | undefined, + ), + }), + shows: theTab, + }, + { + name: 'remove_tile_picture', + description: 'Removes the picture at an index from a tile.', + args: object({ asset: TILE, index: INDEX }, ['asset', 'index']), + build: (args) => ({ + command: new RemoveTilePictureCommand(text(args, 'asset'), numberOf(args, 'index')), + }), + shows: theTab, + }, + { + name: 'move_tile_picture', + description: 'Moves a picture of a tile to another place in its list.', + args: object({ asset: TILE, from: INDEX, to: INDEX }, ['asset', 'from', 'to']), + build: (args) => ({ + command: new MoveTilePictureCommand(text(args, 'asset'), numberOf(args, 'from'), numberOf(args, 'to')), + }), + shows: theTab, + }, + { + name: 'paint_tiles', + description: + 'Paints cells of a tilemap layer with tiles, or erases them. Cell 0,0 is the top-left corner; columns and ' + + 'rows are properties. Rectangles are painted first, then single cells, so a cell can cut into one. An autotile picks its ' + + `picture from its neighbours by itself. At most ${MAX_PAINTED_CELLS} cells a step.`, + args: object( + { + asset: TILEMAP, + layer: { ...LAYER, description: 'Id of the layer; the first one when left out.' }, + cells: { + type: 'array', + items: object({ x: whole('Column.', 0), y: whole('Row.', 0), tile: PAINT_TILE }, [ + 'x', + 'y', + 'tile', + ]), + description: 'Single cells.', + }, + rects: { + type: 'array', + items: object( + { + x: whole('Left column.', 0), + y: whole('Top row.', 0), + w: whole('Columns.', 1), + h: whole('Rows.', 1), + tile: PAINT_TILE, + }, + ['x', 'y', 'w', 'h', 'tile'], + ), + description: 'Rectangles of cells, filled.', + }, + }, + ['asset'], + ), + build: (args, { project }) => { + const map = requireTilemap(project, text(args, 'asset')); + const layer = (args.layer as string | undefined) ?? map.layers[0]?.id ?? ''; + requireTilemapLayer(project, map.id, layer); + return { command: new PaintTilesCommand(map.id, layer, paintedCells(project, map, args)) }; + }, + shows: theTab, + }, + { + name: 'add_tilemap_layer', + description: 'Adds an empty layer on top of a tilemap.', + args: object({ asset: TILEMAP }, ['asset']), + build: (args) => { + const command = new AddTilemapLayerCommand(text(args, 'asset')); + return { command, made: { id: command.layerId } }; + }, + shows: theTab, + }, + { + name: 'duplicate_tilemap_layer', + description: 'Copies a layer with what is painted on it; the copy comes right above it.', + args: object({ asset: TILEMAP, layer: LAYER }, ['asset', 'layer']), + build: (args) => { + const command = new DuplicateTilemapLayerCommand(text(args, 'asset'), text(args, 'layer')); + return { command, made: { id: command.layerId } }; + }, + shows: theTab, + }, + { + name: 'remove_tilemap_layer', + description: 'Removes a layer with what is painted on it; the last layer stays.', + args: object({ asset: TILEMAP, layer: LAYER }, ['asset', 'layer']), + build: (args) => ({ command: new RemoveTilemapLayerCommand(text(args, 'asset'), text(args, 'layer')) }), + shows: theTab, + }, + { + name: 'move_tilemap_layer', + description: 'Moves a layer to another place in the drawing order; the last one is drawn on top.', + args: object({ asset: TILEMAP, layer: LAYER, to: INDEX }, ['asset', 'layer', 'to']), + build: (args, { project }) => { + const map = requireTilemap(project, text(args, 'asset')); + const from = map.layers.findIndex((layer) => layer.id === text(args, 'layer')); + if (from < 0) throw new CommandError(`${map.name} has no layer ${text(args, 'layer')}`); + return { command: new MoveTilemapLayerCommand(map.id, from, numberOf(args, 'to')) }; + }, + shows: theTab, + }, + { + name: 'set_tilemap_layer', + description: + 'Changes a layer: its name, its z (added to the z of the placed tilemap), whether it sorts by Y with the ' + + 'objects on its z, whether it is hidden. Give only what changes.', + args: object( + { + asset: TILEMAP, + layer: LAYER, + name: id('Its name.'), + z: number('Its z.'), + sortByY: flag('Sort by Y.'), + 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 RenameTilemapLayerCommand(asset, layer, args.name)); + if (typeof args.z === 'number') steps.push(new SetTilemapLayerZCommand(asset, layer, args.z)); + if (typeof args.sortByY === 'boolean') + steps.push(new SetTilemapLayerSortByYCommand(asset, layer, args.sortByY)); + if (typeof args.hidden === 'boolean') + steps.push(new SetTilemapLayerHiddenCommand(asset, layer, args.hidden)); + if (steps.length === 0) throw new CommandError('Name what to change: name, z, sortByY or hidden'); + return { command: new CompoundCommand('Change layer', steps) }; + }, + shows: theTab, + }, + { + name: 'resize_placed_tilemap', + description: + 'Adds (count above 0) or removes columns or rows at one edge of a placed tilemap, keeping the painted cells ' + + 'where they are in the scene. What falls off comes back when the edge grows again.', + args: object( + { + in: HOLDER, + object: id('Id of the placed tilemap.'), + edge: oneOf(TILEMAP_EDGES, 'The edge.'), + count: whole('How many.'), + }, + ['in', 'object', 'edge', 'count'], + ), + build: (args) => ({ + command: new ResizeTilemapEdgeCommand( + text(args, 'in'), + text(args, 'object'), + text(args, 'edge') as TilemapEdge, + numberOf(args, 'count'), + ), + }), + shows: theObject, + }, +]; + +/** + * The cells a paint step writes, refused when one names no tile or lies outside the grid – the + * largest one the tilemap has, in the asset or in any of its placements. + */ +export function paintedCells(project: Project, map: TilemapAsset, args: Args): CellEdit[] { + const edits: CellEdit[] = []; + const add = (x: number, y: number, tileId: string) => { + if (edits.length >= MAX_PAINTED_CELLS) + throw new CommandError(`More than ${MAX_PAINTED_CELLS} cells in one step – split it`); + edits.push({ x, y, tileId }); + }; + for (const rect of (args.rects ?? []) as { x: number; y: number; w: number; h: number; tile: string }[]) { + for (let y = rect.y; y < rect.y + rect.h; y++) + for (let x = rect.x; x < rect.x + rect.w; x++) add(x, y, rect.tile); + } + for (const cell of (args.cells ?? []) as { x: number; y: number; tile: string }[]) + add(cell.x, cell.y, cell.tile); + if (edits.length === 0) throw new CommandError('Give cells or rects to paint'); + const { columns, rows } = gridOf(project, map); + const outside = edits.find((edit) => edit.x >= columns || edit.y >= rows); + if (outside) + throw new CommandError( + `Cell ${outside.x},${outside.y} lies outside ${map.name}, which has ${columns}x${rows}`, + ); + for (const tileId of new Set(edits.map((edit) => edit.tileId))) { + if (tileId !== '' && findAsset(project, tileId)?.type !== 'tile') + throw new CommandError(`${tileId} is not a tile asset`); + } + return edits; +} + +function gridOf(project: Project, map: TilemapAsset): { columns: number; rows: number } { + const bags = [ + map.props, + ...objectHolders(project).flatMap((holder) => + holder.objects.filter((o) => o.assetId === map.id).map((o) => ({ ...map.props, ...o.overrides })), + ), + ]; + return { + columns: Math.max(...bags.map(tilemapColumns)), + rows: Math.max(...bags.map(tilemapRows)), + }; +} diff --git a/src/editor/agent/commandCatalog.ts b/src/editor/agent/commandCatalog.ts index e5fb4eb0..08f9eb39 100644 --- a/src/editor/agent/commandCatalog.ts +++ b/src/editor/agent/commandCatalog.ts @@ -1,8 +1,11 @@ +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 { 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'; export type { BuildContext, Built, CatalogCommand, Frame, StepTarget } from './catalog/shared'; @@ -33,6 +36,21 @@ export const AREAS: readonly CommandArea[] = [ 'Nodes, edges, groups, comments and ports of behaviors, state machines, shaders and the other graphs.', commands: GRAPH_COMMANDS, }, + { + area: 'animation', + about: 'Rows, keys and easing of Animation assets, and the points of Curve assets.', + commands: ANIMATION_COMMANDS, + }, + { + area: 'textures', + about: 'Frames cut from textures, nine-slices, sort lines and collision outlines of pictures.', + commands: TEXTURE_COMMANDS, + }, + { + area: 'tiles', + about: 'Pictures of tiles; painting tilemaps, their layers, and the edges of a placed one.', + commands: TILE_COMMANDS, + }, { area: 'project', about: 'Settings of the project as a whole.', commands: PROJECT_COMMANDS }, ]; diff --git a/src/editor/agent/editorToolHost.ts b/src/editor/agent/editorToolHost.ts index 8043640b..bd713434 100644 --- a/src/editor/agent/editorToolHost.ts +++ b/src/editor/agent/editorToolHost.ts @@ -105,11 +105,13 @@ function nextFrame(app: Application): Promise { }); } -/** Brings up what an edit worked on: a graph's tab, the scene or container, the asset in the inspector. */ +/** Brings up what an edit worked on: a graph's tab, the scene or container, the asset in its tab or the inspector. */ function show(parts: EditorParts, focus: StepTarget): void { const { state } = parts; if (focus.kind === 'asset') { - state.select({ kind: 'asset', assetId: focus.asset }); + if (focus.frame) state.select({ kind: 'frame', assetId: focus.asset, frameId: focus.frame }); + else if (focus.open) state.openAsset(focus.asset); + else state.select({ kind: 'asset', assetId: focus.asset }); return; } const holder = focus.kind === 'objects' ? focus.in : focus.asset; diff --git a/src/editor/agent/readTools.ts b/src/editor/agent/readTools.ts index e602d194..5824d0b9 100644 --- a/src/editor/agent/readTools.ts +++ b/src/editor/agent/readTools.ts @@ -1,6 +1,7 @@ import { type GraphAnnotation, type GraphNode, resolveDef } from '../../core/graph'; import type { PortDef } from '../../core/graph/NodeRegistry'; -import type { Asset } from '../../core/model/Asset'; +import { animationTargets, targetDefs } from '../../core/model/animation'; +import type { AnimationAsset, Asset } 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'; @@ -64,10 +65,30 @@ export function getAsset(project: Project, assetId: string): object | null { ...(isTemplateContent(asset, object.id) ? { fromContainer: true } : {}), })); } + if (asset.type === 'animation') plain.targets = animationTargetRows(project, asset); plain.properties = schemaForAsset(project, asset).map(propertyRow); return plain; } +/** What the rows of an animation may aim at – the root as "", the objects inside it – and what each offers. */ +function animationTargetRows(project: Project, animation: AnimationAsset): object[] { + const root = findAsset(project, animation.root); + if (!root) return []; + const row = (id: string, name: string) => ({ + id, + name, + properties: (targetDefs(project, animation, id) ?? []).map(({ key, kind }) => ({ key, kind })), + }); + return [ + row('', root.name), + ...animationTargets(project, root).map(({ id, object }) => + row(id, objectName(object, findAsset(project, object.assetId))), + ), + ]; +} + +const MAX_CELLS_IN_FULL = 400; + /** 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 { if (key === 'graph' && value !== null && typeof value === 'object' && 'nodes' in value) { @@ -75,7 +96,9 @@ function trimmed(key: string, value: unknown): unknown { return { nodes: graph.nodes.length, edges: graph.edges.length, read: 'get_graph' }; } if ((key === 'cels' || key === 'cells') && value !== null && typeof value === 'object') { - return { count: Object.keys(value).length }; + const count = Object.keys(value).length; + // The cells of a small map are worth reading: which tile lies where, by "x,y". + return key === 'cells' && count <= MAX_CELLS_IN_FULL ? value : { count }; } return value; } diff --git a/src/editor/agent/tools.json b/src/editor/agent/tools.json index 8229d0b0..aeaa6ee8 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). 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. 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/tests/editor/agent-art.test.ts b/tests/editor/agent-art.test.ts new file mode 100644 index 00000000..8589b7d3 --- /dev/null +++ b/tests/editor/agent-art.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'vitest'; +import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; +import type { AnimationAsset, CurveAsset, TextureAsset, TilemapAsset } from '../../src/core/model/Asset'; +import { findAsset } from '../../src/core/model/Project'; +import { runTool } from '../../src/editor/agent/agentTools'; +import { MAX_PAINTED_CELLS } from '../../src/editor/agent/catalog/tiles'; +import { makeSpriteScene, place, type SceneFixture } from '../helpers'; +import { hostFor, text } from './agentTestHost'; + +/** The commands of #429 stage 5b: animations, curves, texture frames, tiles and tilemaps. */ + +const run = (fixture: SceneFixture, steps: object[], follows = false) => { + const { host, shown } = hostFor(fixture, null, follows); + return runTool('run_commands', { steps }, host).then((result) => ({ result, shown })); +}; + +const read = async (fixture: SceneFixture, asset: string) => + (await runTool('get_asset', { asset }, hostFor(fixture).host)).structuredContent as Record; + +function makeTexture(fixture: SceneFixture, width = 64, height = 64): string { + const command = new CreateAssetCommand('texture', 'Sheet'); + fixture.stack.execute(command); + const sheet = findAsset(fixture.project, command.asset.id) as TextureAsset; + sheet.file = `${'a'.repeat(64)}.png`; + sheet.width = width; + sheet.height = height; + return sheet.id; +} + +describe('animations', () => { + it('keys a row of the root with easing, and shows the animation in its tab', async () => { + const fixture = makeSpriteScene(); + const { result, shown } = await run( + fixture, + [ + { command: 'create_asset', args: { type: 'animation', name: 'Slide' }, as: 'anim' }, + { command: 'set_animation_root', args: { asset: '$anim', root: fixture.spriteId } }, + { command: 'add_animation_track', args: { asset: '$anim', target: '', property: 'x' }, as: 'row' }, + { command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 0, value: 0 } }, + { command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 30, value: 100 } }, + { + command: 'set_key_easing', + args: { asset: '$anim', keys: [{ track: '$row', frame: 0 }], easing: 'sine-out' }, + }, + ], + true, + ); + expect(result.isError).toBeUndefined(); + const anim = fixture.project.assets.find((asset) => asset.type === 'animation') as AnimationAsset; + expect(anim.tracks.map(({ target, property, keys }) => ({ target, property, keys }))).toEqual([ + { + target: '', + property: 'x', + keys: [ + { frame: 0, value: 0, interpolation: 'sine-out' }, + { frame: 30, value: 100 }, + ], + }, + ]); + expect(shown).toEqual([{ kind: 'asset', asset: anim.id, open: true }]); + }); + + it('get_asset lists what the rows may aim at, the objects inside a scene too', async () => { + const fixture = makeSpriteScene(); + const box = place(fixture, fixture.spriteId, { x: 0, y: 0 }); + await run(fixture, [ + { command: 'create_asset', args: { type: 'animation', name: 'Level intro' }, as: 'anim' }, + { command: 'set_animation_root', args: { asset: '$anim', root: fixture.sceneId } }, + ]); + const anim = fixture.project.assets.find((asset) => asset.type === 'animation')!; + const targets = (await read(fixture, anim.id)).targets as { + id: string; + name: string; + properties: { key: string; kind: string }[]; + }[]; + expect(targets.map(({ id, name }) => ({ id, name }))).toEqual([ + { id: '', name: 'Level' }, + { id: box, name: 'Box' }, + ]); + expect(targets[1]!.properties).toContainEqual({ key: 'x', kind: 'number' }); + }); + + it('refuses an easing there is none of, naming the step', async () => { + const fixture = makeSpriteScene(); + const { result } = await run(fixture, [ + { command: 'create_asset', args: { type: 'animation', name: 'Slide' }, as: 'anim' }, + { command: 'set_animation_root', args: { asset: '$anim', root: fixture.spriteId } }, + { command: 'add_animation_track', args: { asset: '$anim', target: '', property: 'x' }, as: 'row' }, + { command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 0, value: 0 } }, + { + command: 'set_key_easing', + args: { asset: '$anim', keys: [{ track: '$row', frame: 0 }], easing: 'wobble' }, + }, + ]); + expect(text(result)).toContain('Step 5 (set_key_easing): No such easing'); + expect(fixture.project.assets.some((asset) => asset.type === 'animation')).toBe(false); + }); + + it('moves and copies keys, and inserts frames before them', async () => { + const fixture = makeSpriteScene(); + await run(fixture, [ + { command: 'create_asset', args: { type: 'animation', name: 'Slide' }, as: 'anim' }, + { command: 'set_animation_root', args: { asset: '$anim', root: fixture.spriteId } }, + { command: 'add_animation_track', args: { asset: '$anim', target: '', property: 'x' }, as: 'row' }, + { command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 10, value: 5 } }, + { + command: 'move_animation_keys', + args: { asset: '$anim', keys: [{ track: '$row', frame: 10 }], by: 5, copy: true }, + }, + { command: 'shift_animation_frames', args: { asset: '$anim', at: 0, count: 2 } }, + ]); + const anim = fixture.project.assets.find((asset) => asset.type === 'animation') as AnimationAsset; + expect(anim.tracks[0]!.keys.map((key) => key.frame)).toEqual([12, 17]); + }); +}); + +describe('curves', () => { + it('set_curve_points holds the curve from x 0 to x 1', async () => { + const fixture = makeSpriteScene(); + const { result } = await run(fixture, [ + { command: 'create_asset', args: { type: 'curve', name: 'Pop' }, as: 'curve' }, + { + command: 'set_curve_points', + args: { + asset: '$curve', + points: [ + { x: 0.2, y: 0 }, + { x: 0.5, y: 1.2, in: { x: -0.1, y: 0 }, out: { x: 0.1, y: 0 }, mode: 'smooth' }, + { x: 0.9, y: 1 }, + ], + }, + }, + ]); + expect(result.isError).toBeUndefined(); + const curve = fixture.project.assets.find((asset) => asset.type === 'curve') as CurveAsset; + expect(curve.points.map((point) => [point.x, point.y])).toEqual([ + [0, 0], + [0.5, 1.2], + [1, 1], + ]); + expect(curve.points[1]!.mode).toBe('smooth'); + }); +}); + +describe('texture frames', () => { + it('a frame added is handed on, nine-sliced, and picked in the texture tab', async () => { + const fixture = makeSpriteScene(); + const sheet = makeTexture(fixture); + const { result, shown } = await run( + fixture, + [ + { command: 'add_frame', args: { asset: sheet, x: 0, y: 0, w: 32, h: 32, name: 'Panel' }, as: 'f' }, + { + command: 'set_nine_slice', + args: { asset: sheet, frame: '$f', left: 4, top: 4, right: 4, bottom: 4, fill: 'stretch' }, + }, + ], + true, + ); + expect(result.isError).toBeUndefined(); + const frame = (findAsset(fixture.project, sheet) as TextureAsset).frames[0]!; + expect(frame).toMatchObject({ + name: 'Panel', + w: 32, + border: { left: 4, top: 4, right: 4, bottom: 4 }, + fill: 'stretch', + }); + expect(shown).toEqual([{ kind: 'asset', asset: sheet, open: true, frame: frame.id }]); + + await run(fixture, [ + { + command: 'set_nine_slice', + args: { asset: sheet, frame: frame.id, left: 2, top: 2, right: 2, bottom: 2 }, + }, + ]); + expect((findAsset(fixture.project, sheet) as TextureAsset).frames[0]).toMatchObject({ + border: { left: 2 }, + fill: 'stretch', + }); + }); + + it('slices a sheet into a grid and replaces the frames there', async () => { + const fixture = makeSpriteScene(); + const sheet = makeTexture(fixture, 64, 32); + await run(fixture, [{ command: 'add_frame', args: { asset: sheet, x: 0, y: 0, w: 8, h: 8 } }]); + const { result } = await run(fixture, [ + { command: 'slice_frames', args: { asset: sheet, columns: 4, rows: 2, replace: true } }, + ]); + expect(result.isError).toBeUndefined(); + const frames = (findAsset(fixture.project, sheet) as TextureAsset).frames; + expect(frames).toHaveLength(8); + expect(frames.map(({ x, y, w, h }) => ({ x, y, w, h }))[5]).toEqual({ x: 16, y: 16, w: 16, h: 16 }); + }); +}); + +describe('tiles and tilemaps', () => { + async function tiled(fixture: SceneFixture) { + const sheet = makeTexture(fixture); + await run(fixture, [ + { command: 'add_frame', args: { asset: sheet, x: 0, y: 0, w: 16, h: 16 }, as: 'f' }, + { command: 'create_asset', args: { type: 'tile', name: 'Grass' }, as: 'tile' }, + { command: 'add_tile_picture', args: { asset: '$tile', picture: '$f' } }, + { command: 'create_asset', args: { type: 'tilemap', name: 'Ground' }, as: 'map' }, + ]); + const tile = fixture.project.assets.find((asset) => asset.type === 'tile')!; + const map = fixture.project.assets.find((asset) => asset.type === 'tilemap') as TilemapAsset; + return { tile: tile.id, map: map.id, sheet }; + } + + it('paints rectangles, then cells cutting into them, and get_asset reads them back', async () => { + const fixture = makeSpriteScene(); + const { tile, map } = await tiled(fixture); + expect((findAsset(fixture.project, tile) as { pictures: string[] }).pictures).toHaveLength(1); + const { result } = await run(fixture, [ + { + command: 'paint_tiles', + args: { asset: map, rects: [{ x: 0, y: 0, w: 3, h: 2, tile }], cells: [{ x: 1, y: 1, tile: '' }] }, + }, + ]); + expect(result.isError).toBeUndefined(); + const layers = (await read(fixture, map)).layers as { cells: Record }[]; + expect(Object.keys(layers[0]!.cells).sort()).toEqual(['0,0', '0,1', '1,0', '2,0', '2,1']); + }); + + it('refuses a cell outside the grid, a tile that is none, and too many cells', async () => { + const fixture = makeSpriteScene(); + const { tile, map, sheet } = await tiled(fixture); + const paint = (args: object) => run(fixture, [{ command: 'paint_tiles', args: { asset: map, ...args } }]); + expect(text((await paint({ cells: [{ x: 20, y: 0, tile }] })).result)).toContain( + 'Cell 20,0 lies outside Ground, which has 20x15', + ); + expect(text((await paint({ cells: [{ x: 0, y: 0, tile: sheet }] })).result)).toContain( + `${sheet} is not a tile asset`, + ); + expect(text((await paint({ rects: [{ x: 0, y: 0, w: 1000, h: 1000, tile }] })).result)).toContain( + `More than ${MAX_PAINTED_CELLS} cells`, + ); + expect(Object.keys((findAsset(fixture.project, map) as TilemapAsset).layers[0]!.cells)).toHaveLength(0); + }); + + it('a placement grown at its edge lets the cells beyond the asset be painted', async () => { + const fixture = makeSpriteScene(); + const { tile, map } = await tiled(fixture); + const placed = place(fixture, map, { x: 0, y: 0 }); + const { result } = await run(fixture, [ + { + command: 'resize_placed_tilemap', + args: { in: fixture.sceneId, object: placed, edge: 'right', count: 2 }, + }, + { command: 'paint_tiles', args: { asset: map, cells: [{ x: 21, y: 0, tile }] } }, + ]); + expect(result.isError).toBeUndefined(); + expect(fixture.object(placed).overrides.columns).toBe(22); + }); + + it('adds a layer, changes it in one step and moves it to the bottom', async () => { + const fixture = makeSpriteScene(); + const { map } = await tiled(fixture); + const { result } = await run(fixture, [ + { command: 'add_tilemap_layer', args: { asset: map }, as: 'top' }, + { + command: 'set_tilemap_layer', + args: { asset: map, layer: '$top', name: 'Roofs', z: 2, hidden: true }, + }, + { command: 'move_tilemap_layer', args: { asset: map, layer: '$top', to: 0 } }, + ]); + expect(result.isError).toBeUndefined(); + const layers = (findAsset(fixture.project, map) as TilemapAsset).layers; + expect(layers.map(({ name, z, hidden }) => ({ name, z, hidden }))).toEqual([ + { name: 'Roofs', z: 2, hidden: true }, + { name: 'Layer 1', z: undefined, hidden: undefined }, + ]); + await runTool('undo', {}, hostFor(fixture).host); + expect((findAsset(fixture.project, map) as TilemapAsset).layers).toHaveLength(1); + }); +}); diff --git a/tools/e2e/71-mcp.mjs b/tools/e2e/71-mcp.mjs index 7c82e159..75050173 100644 --- a/tools/e2e/71-mcp.mjs +++ b/tools/e2e/71-mcp.mjs @@ -385,6 +385,38 @@ export async function mcp(t) { JSON.stringify({ text: gathered?.content?.[0]?.text?.slice(0, 300), framing, readVars, gatheredLint }), ); + // 71g6. Past graphs (#429 stage 5b): an animation keyed by the agent comes up in its own tab. + const heroId = made.find((entry) => entry.step === 1)?.id; + const animated = await callTool('run_commands', { + label: 'slide', + steps: [ + { command: 'create_asset', args: { type: 'animation', name: `${TOKEN_NAME} slide` }, as: 'anim' }, + { command: 'set_animation_root', args: { asset: '$anim', root: heroId } }, + { command: 'add_animation_track', args: { asset: '$anim', target: '', property: 'x' }, as: 'row' }, + { command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 0, value: 0 } }, + { command: 'set_animation_key', args: { asset: '$anim', track: '$row', frame: 30, value: 120 } }, + ], + }); + await settle(); + const animId = animated?.structuredContent?.made?.find((entry) => entry.step === 1)?.id; + const animView = await viewNow(); + const animRead = animId ? (await callTool('get_asset', { asset: animId }))?.structuredContent : null; + await callTool('undo'); + await callTool('open', { asset: walkId }); + await settle(); + check( + 'an animation the agent keys comes up in its tab, and get_asset names the root among its targets', + animated?.isError === false && + animView?.document?.asset?.id === animId && + animRead?.tracks?.[0]?.keys?.length === 2 && + animRead?.targets?.[0]?.properties?.some((property) => property.key === 'x'), + JSON.stringify({ + text: animated?.content?.[0]?.text?.slice(0, 300), + document: animView?.document, + targets: animRead?.targets?.length, + }), + ); + // 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' });