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