Let the agent edit animations, frames, tiles and tilemaps (#429)
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:
1 parent
0d8fd6ff52
commit
d01e2b832e
10 files changed
+1110
-7
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user