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

+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);
});
});