Let the agent edit animations, frames, tiles and tilemaps (#429)
feuerwerk / gate (push) Successful in 5m14s
feuerwerk / suite (push) Successful in 18m43s

After stage 5a the agent could build behaviors and scenes, but not
animate an object, cut a texture into frames or paint a tilemap.
Stage 5b adds three areas with 30 commands, 115 in all.

- animation (12): set the root, add, retarget and make rows relative,
  set, move, copy and delete keys, easing, insert or remove frames,
  and curve points.
- textures (8): add, change and delete frames, slice a grid, nine
  slice, sorting line and collision outline.
- tiles (10): the images of a tile, painting tilemaps, layers (add,
  copy, move, change) and the border of a placed tilemap.
- get_asset names the targets of an animation with their animatable
  properties. A tilemap up to 400 cells shows its cells, a bigger one
  only their count.
- After such a call the editor opens the tab of the animation, curve,
  texture, tile or tilemap. A frame edit selects the frame there.
- One paint step takes rectangles and single cells. Rectangles go
  first, so a cell can cut a hole into a rectangle. Cells outside the
  grid, ids that are no tile and more than 20 000 cells are refused.
  The grid is the tilemap's largest, a grown placement included.

Left out on purpose: Generate collision, because it reads the pixels
of the texture, and pasting keys, which goes through the clipboard.

Found on the way:
- Nine slice without fill now keeps the fill the image has. The
  model's default is repeat, not stretch as first described.
- Painting ran the other way round, rectangles overwrote cells.

Tests:
- agent-art (new, 11) and the other agent tests green, 64/64.
  5 bends of the code, all red.
- e2e 71 on atx: 23/23, the new check opens the agent's animation in
  its tab and reads its targets.
- Gate on atx: 321 files, 4969 tests.
- Live on a test project: a Spin animation of 360 degrees in 30
  frames matched 12 degrees per frame to 0.01 frames in the game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-18 16:24:27 +02:00
1 parent 0d8fd6ff52
commit d01e2b832e
10 files changed
+1110 -7

No files matched your search

+262
View File
@@ -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:<id of a Curve asset>".`,
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,
},
];
+11 -2
View File
@@ -33,9 +33,12 @@ export interface Frame {
export type Args = Record<string, unknown>;
/** 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<string, string>): StepTarget => ({
kind: 'asset',
asset: made.id!,
+210
View File
@@ -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<string, string> = {}): 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,
},
];
+271
View File
@@ -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)),
};
}
+18
View File
@@ -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 },
];
+4 -2
View File
@@ -105,11 +105,13 @@ function nextFrame(app: Application): Promise<void> {
});
}
/** 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;
+25 -2
View File
@@ -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;
}
+1 -1
View File
@@ -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." } },
+276
View File
@@ -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<string, unknown>;
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<string, string> }[];
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);
});
});
+32
View File
@@ -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' });