import { colourDistance } from '../lib/harness.mjs'; import { edge, node, propertiesOf, variable, written } from '../lib/builders.mjs'; import { MAKE_OBJECT, openTab } from './lib/scaffold.mjs'; /** * Animations: made from a texture, edited in their own tab, worn by a sprite through `texture`, * run in play only, and heard by `On animation finished`. The step pastes a strip of four colour * cells, so which frame is on screen is a `pixelAt` question. It removes everything it made. */ const CELLS = ['#e63c3c', '#3cc85a', '#466ef0', '#f5f5f5']; const NAMES = ['red', 'green', 'blue', 'white']; const PASTE_STRIP = `(async () => { const canvas = document.createElement('canvas'); canvas.width = 64; canvas.height = 16; const g = canvas.getContext('2d'); ${JSON.stringify(CELLS)}.forEach((colour, i) => { g.fillStyle = colour; g.fillRect(i * 16, 0, 16, 16); }); const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); const transfer = new DataTransfer(); transfer.items.add(new File([blob], 'strip.png', { type: 'image/png' })); window.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, bubbles: true })); })()`; const FRAME_IDS = ['an-f0', 'an-f1', 'an-f2', 'an-f3']; function nearestCell(sample) { let best = -1; let bestDistance = Infinity; CELLS.forEach((hex, i) => { const distance = colourDistance(sample, hex); if (distance < bestDistance) { bestDistance = distance; best = i; } }); return bestDistance < 60 ? NAMES[best] : `other ${sample}`; } /** `On animation finished (walk) → done += 1, and the owner wears Run`. */ const logicGraph = (walkId) => ({ nodes: [ node('an-done', 'OnAnimationFinished', 0, 0, { animation: walkId }), node('an-count', 'Variables', 300, 0, {}, [written('done', 'number')]), node('an-add', 'Add', 300, 200, { b: 1 }), node('an-owner', 'Owner', 300, 360), node('an-wear', 'Properties', 600, 0, { ...propertiesOf('sprite'), texture: 'an-run' }, [ written('texture', 'texture'), ]), ], edges: [ edge('an-e1', 'an-done', 'exec', 'an-count', 'exec'), edge('an-e2', 'an-count', 'done', 'an-add', 'a'), edge('an-e3', 'an-add', 'result', 'an-count', 'done'), edge('an-e4', 'an-count', 'exec', 'an-wear', 'exec'), edge('an-e5', 'an-owner', 'object', 'an-wear', 'object'), ], }); export async function animation(t) { const { check, sleep, json, evaluate, shot, mutate, press, click, clickText, drag, undo, redo, showTab } = t; const { mouse, typeText } = t; const { entity, stop } = t; const info = () => json('__gm.animation.current()'); const frames = async (id) => await json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)})?.frames ?? null`); const previewColour = async () => { const rect = (await info()).preview; return rect ? nearestCell( await json(`__gm.pixelAt(${Math.round(rect.x + rect.w / 2)}, ${Math.round(rect.y + rect.h / 2)})`), ) : 'none'; }; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await evaluate('__gm.state.select(null)'); // 25a. A strip of four cells, cut into four frames and moved to the head of the asset list. await evaluate(PASTE_STRIP); await sleep(2500); const strip = await json( "(__gm.state.project.assets.find((a) => a.type === 'texture' && a.name === 'strip') ?? {}).id ?? ''", ); check('the strip is imported as a texture', strip !== '', strip); await mutate(` const p = __gm.state.project; const index = p.assets.findIndex((a) => a.id === ${JSON.stringify(strip)}); const texture = p.assets.splice(index, 1)[0]; texture.props.filter = 'nearest'; texture.frames = ${JSON.stringify(FRAME_IDS)}.map((id, i) => ({ id, name: 'Cell ' + (i + 1), x: i * 16, y: 0, w: 16, h: 16 })); p.assets.unshift(texture); `); // 25b. The texture's context menu makes an animation of its frames, as one undo step. const row = await json(`__gm.assetRow(${JSON.stringify(strip)})`); await click(row.x, row.y, 'right'); await clickText('Create animation from texture'); const made = await json( "__gm.state.project.assets.filter((a) => a.type === 'animation').map((a) => ({ id: a.id, name: a.name, frames: a.frames, props: a.props }))", ); const walk = made[0] ?? {}; check( 'Create animation from texture plays its frames in order', made.length === 1 && JSON.stringify(walk.frames) === JSON.stringify(FRAME_IDS), JSON.stringify(made), ); check( 'and is selected, named after the texture, at 10 fps looping', (await json('__gm.state.selection?.assetId')) === walk.id && walk.name === 'strip 2' && walk.props?.fps === 10 && walk.props?.playback === 'loop', JSON.stringify({ selection: await json('__gm.state.selection'), walk }), ); await undo(); const undone = await json("__gm.state.project.assets.filter((a) => a.type === 'animation').length"); await redo(); check( 'undo takes asset and frames back in one step, redo brings both', undone === 0 && JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS), JSON.stringify({ undone, after: await frames(walk.id) }), ); // 25c. The Animation tab shows it and plays it; the inspector keeps its two properties. await evaluate(`__gm.state.select({ kind: 'asset', assetId: ${JSON.stringify(walk.id)} })`); await openTab(t, 'animation'); check( 'the inspector edits frames per second and playback', (await t.has('Frames per second')) && (await t.has('Playback')) && (await t.has('Loop')), ); const opened = await info(); check( 'the tab button has room for its bold label', (await json("__gm.shownLabel('Animation')")) === 'Animation', String(await json("__gm.shownLabel('Animation')")), ); check( 'the Animation tab shows the selected animation with its four frames, playing', (await json('__gm.tab()')) === 'animation' && opened.assetId === walk.id && opened.frames === 4 && opened.playing, JSON.stringify(opened), ); const shown = new Set(); for (let i = 0; i < 8; i++) { shown.add((await info()).shown); await sleep(70); } check('the preview runs through the frames', shown.size >= 3, JSON.stringify([...shown])); await shot('01-playing'); // 25c-ii. (#90) The preview runs on the global clock like everything else that moves by itself, // so a scale of 0 holds the frame on screen while the preview is still *playing*. await evaluate('__gm.setTimeScale(0)'); await sleep(300); const atZero = await info(); await sleep(500); const stillZero = await info(); await evaluate('__gm.setTimeScale(1)'); const moved = new Set(); for (let i = 0; i < 8; i++) { moved.add((await info()).shown); await sleep(70); } check( 'a global time scale of 0 holds the frame without stopping the play, and 1 runs it on', atZero.playing === true && stillZero.playing === true && stillZero.shown === atZero.shown && moved.size >= 3, JSON.stringify({ atZero: atZero.shown, stillZero: stillZero.shown, afterwards: [...moved] }), ); // 25d. Pausing holds the frame on screen, and the preview shows that frame's picture. await click(...Object.values(await json('__gm.animation.playButton()'))); const paused = await info(); await sleep(350); check( 'the play button pauses, and the frame on screen stays', !paused.playing && (await json('__gm.animation.playLabel()')) === '▶' && (await info()).shown === paused.shown, JSON.stringify({ paused, later: await info() }), ); check( 'the preview draws the frame it says it shows', (await previewColour()) === NAMES[paused.shown], JSON.stringify({ shown: paused.shown, colour: await previewColour() }), ); // 25e. A click on a tile selects it and shows it. const tile = async (i) => json(`__gm.animation.tileCentre(${i})`); const third = await tile(2); await click(third.x, third.y); const picked = await info(); check( 'clicking a tile selects it and the preview shows it', picked.selected === 2 && picked.shown === 2 && (await previewColour()) === 'blue', JSON.stringify({ picked, colour: await previewColour() }), ); await shot('02-tile-picked'); // 25e'. The wheel zooms the preview about the pointer, the middle button pans, Fit undoes both. const fitted = picked.preview; const pointer = { x: Math.round(fitted.x + fitted.w * 0.25), y: Math.round(fitted.y + fitted.h * 0.5) }; const wheel = async (x, y, deltaY) => { await mouse('mouseMoved', x, y); await mouse('mouseWheel', x, y, 'none', { deltaX: 0, deltaY }); await sleep(250); }; await wheel(pointer.x, pointer.y, -240); const zoomed = (await info()).preview; const share = (rect) => ((pointer.x - rect.x) / rect.w).toFixed(3); check( 'the wheel zooms the preview in about the pointer', zoomed.w > fitted.w * 1.3 && share(zoomed) === share(fitted), JSON.stringify({ fitted, zoomed }), ); check( 'the pixel under the pointer is still the frame shown', nearestCell(await json(`__gm.pixelAt(${pointer.x}, ${pointer.y})`)) === 'blue', ); await drag(pointer.x, pointer.y, pointer.x + 40, pointer.y - 30, { button: 'middle' }); const panned = (await info()).preview; check( 'the middle button pans it', Math.round(panned.x - zoomed.x) === 40 && Math.round(panned.y - zoomed.y) === -30 && panned.w === zoomed.w, JSON.stringify({ zoomed, panned }), ); await wheel(third.x, third.y, -240); check( 'the wheel over the strip leaves the zoom alone', JSON.stringify((await info()).preview) === JSON.stringify(panned), JSON.stringify((await info()).preview), ); await shot('02b-zoomed'); await clickText('Fit'); check( 'Fit puts the preview back', JSON.stringify((await info()).preview) === JSON.stringify(fitted), JSON.stringify((await info()).preview), ); // 25f. Dragging a tile into another gap reorders the frames, as one undo step. const first = await tile(0); await drag(first.x, first.y, third.x + 39, third.y, 10); const reordered = await frames(walk.id); check( 'dragging the first tile behind the third moves that frame there', JSON.stringify(reordered) === JSON.stringify(['an-f1', 'an-f2', 'an-f0', 'an-f3']) && (await info()).selected === 2, JSON.stringify({ reordered, info: await info() }), ); await undo(); check('undo puts it back', JSON.stringify(await frames(walk.id)) === JSON.stringify(FRAME_IDS)); // 25g. Delete removes the selected tile. const second = await tile(1); await click(second.x, second.y); await press('Delete'); const deleted = await frames(walk.id); check( 'Delete removes the selected frame and nothing else', JSON.stringify(deleted) === JSON.stringify(['an-f0', 'an-f2', 'an-f3']) && (await json(`__gm.state.project.assets.some((a) => a.id === ${JSON.stringify(walk.id)})`)), JSON.stringify(deleted), ); await undo(); // 25h. + Frames offers a sheet's frames at once and inserts them after the selection. await clickText('+ Frames'); // Search first: in the full suite a 17-frame sheet sorts ahead of this one and pushes the later // cells out of the list's view, and a row outside its list is not on the screen to be read. // The filter also sharpens the check, because the animation is called "strip 2" and would match. await typeText('strip'); const offered = await json('__gm.overlayTexts()'); check( '+ Frames offers the whole strip and each cell, but no animation', offered.includes('strip – all 4 frames') && offered.includes('strip / Cell 3') && offered.includes('strip / Cell 4') && !offered.some((s) => s.startsWith('strip 2')), JSON.stringify(offered), ); await clickText('strip – all 4 frames'); const grown = await frames(walk.id); check( 'all four go in right after the selected frame', JSON.stringify(grown) === JSON.stringify(['an-f0', 'an-f1', ...FRAME_IDS, 'an-f2', 'an-f3']), JSON.stringify(grown), ); await undo(); // 25i. A sprite wears the animation through its Texture picker. await mutate(` const p = __gm.state.project; const sprite = structuredClone(p.assets.find((a) => a.type === 'sprite')); Object.assign(sprite, { id: 'an-sprite', name: 'Animated' }); sprite.props = { ...sprite.props, width: 80, height: 80, color: 0xffffff, texture: '' }; p.assets.unshift(sprite); const scene = __gm.state.activeScene; ${MAKE_OBJECT} const box = make('an-box', 'an-sprite', '', 120, 'Animated'); box.position.z = 950; scene.objects.push(box); __gm.state.select({ kind: 'asset', assetId: 'an-sprite' }); `); await sleep(500); const picker = await json("__gm.inspectorField('Texture', true)"); await click(picker.x, picker.y); const choices = await json('__gm.overlayTexts()'); check( 'the Texture picker offers the animation first, marked as one', choices.indexOf('strip 2 (animation)') === choices.indexOf('● None') + 1, JSON.stringify(choices), ); await clickText('strip 2 (animation)'); check( 'picking it makes the sprite wear the animation', (await json("__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture")) === walk.id, ); // 25j. The scene shows the first frame and does not play. await showTab('scene'); await sleep(700); const box = await json("__gm.objectRect('an-box')"); const sceneColour = async () => nearestCell(await json(`__gm.pixelAt(${Math.round(box.x)}, ${Math.round(box.y)})`)); const before = await sceneColour(); await sleep(450); check( 'the Scene tab shows the first frame and stays on it', before === 'red' && (await sceneColour()) === 'red', JSON.stringify({ box, before, after: await sceneColour() }), ); await shot('03-scene-first-frame'); // 25j'. A frame is a texture of its own. Drawn from the sheet, an edge that landed inside a screen // pixel showed the neighbouring cell: the partly covered pixel is evaluated at its centre, // past the edge. Measured in play, where the background is flat. await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = 'an-f1'`); const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); const fraction = (v) => v - Math.floor(v); const edgesOf = async () => { const r = await json("__gm.gameRect('an-box')"); return { y: Math.round(r.y), left: r.x - r.w / 2, right: r.x + r.w / 2, scale: r.w / 80 }; }; // Moves the box so the edge's fraction lands where the pixel is partly covered but its centre // lies outside; `outward` says which way the neighbouring cell would be. const edgePixels = async (side, target, outward) => { await t.play(); const first = await edgesOf(); await stop(); const shift = (target - fraction(first[side]) + 1) % 1; await mutate( `__gm.state.activeScene.objects.find((o) => o.id === 'an-box').position.x += ${shift / first.scale}`, ); await t.play(); const e = await edgesOf(); const x = Math.floor(e[side]); const edge = rgb(await json(`__gm.pixelAt(${x}, ${e.y})`)); const outside = rgb(await json(`__gm.pixelAt(${x + outward * 2}, ${e.y})`)); await stop(); return { fraction: fraction(e[side]).toFixed(2), edge, outside }; }; // The edge pixel is the cell's green over the background and nothing else: red and blue rise // above the background only in the cell's own proportion to the rise in green (plus rounding), // and green rises enough to prove it is the partly covered pixel, not plain background. const cell = rgb(CELLS[1]); const onlyOwnColour = ({ edge, outside }) => { const green = edge[1] - outside[1]; return ( green > 10 && [0, 2].every( (c) => edge[c] - outside[c] <= (green * (cell[c] - outside[c])) / (cell[1] - outside[1]) + 3, ) ); }; const leftEdge = await edgePixels('left', 0.75, -1); check( 'the left edge of a frame shows nothing of the cell to its left', onlyOwnColour(leftEdge), JSON.stringify(leftEdge), ); const rightEdge = await edgePixels('right', 0.25, 1); check( 'nor the right edge anything of the cell to its right', onlyOwnColour(rightEdge), JSON.stringify(rightEdge), ); await mutate( `__gm.state.project.assets.find((a) => a.id === 'an-sprite').props.texture = ${JSON.stringify(walk.id)}`, ); // 25k. Play runs it: the pixels the game draws change colour over time. await t.play(); const colours = new Set(); for (let i = 0; i < 10; i++) { const at = await json("__gm.gameScreen('an-box')"); if (at) colours.add(nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`))); await sleep(60); } const clock = (await entity('an-box'))?.animation; await shot('04-playing'); await stop(); check( 'in play the sprite runs through the frames', [...colours].filter((c) => NAMES.includes(c)).length >= 3 && clock?.id === walk.id && clock.time > 0, JSON.stringify({ colours: [...colours], clock }), ); // 25l. Once: the end is an event, and a Properties node puts another animation on. await mutate(` const p = __gm.state.project; p.assets.find((a) => a.id === ${JSON.stringify(walk.id)}).props.playback = 'once'; p.assets.unshift({ id: 'an-run', type: 'animation', name: 'Run', props: { fps: 10, playback: 'loop' }, frames: ['an-f3'] }); const logic = structuredClone(p.assets.find((a) => a.id === 'mover')); Object.assign(logic, { id: 'an-logic', name: 'Animation logic', props: { enabled: true, runInEditor: false, done: 0 }, variables: ${JSON.stringify([variable('done', 'number', 0)])}, graph: ${JSON.stringify(logicGraph(walk.id))}, }); p.assets.unshift(logic); const scene = __gm.state.activeScene; ${MAKE_OBJECT} scene.objects.push(make('an-brain', 'an-logic', 'an-box', 0, 'Animation logic')); __gm.state.select(null); `); await t.play(); await sleep(900); const brain = (await entity('an-brain'))?.props; const worn = await entity('an-box'); const at = await json("__gm.gameScreen('an-box')"); const after = at ? nearestCell(await json(`__gm.pixelAt(${Math.round(at.x)}, ${Math.round(at.y)})`)) : 'none'; await stop(); check( 'On animation finished fires once when a once animation ends', brain?.done === 1, JSON.stringify({ brain, log: await json('__gm.state.notice') }), ); check( 'and the Properties node it runs puts the other animation on', worn?.props.texture === 'an-run' && worn?.animation?.id === 'an-run' && after === 'white', JSON.stringify({ texture: worn?.props.texture, clock: worn?.animation, after }), ); // 25l'. A picture is a kind of its own: the Texture node hands one into the graph, and a // variable may be one. Both are picked, never typed. const pickPicture = async (label, name) => { const field = await json(`__gm.inspectorField(${JSON.stringify(label)}, true)`); await click(field.x, field.y); await typeText(name); await press('Enter'); }; await mutate(` const logic = __gm.state.project.assets.find((a) => a.id === 'an-logic'); logic.graph.nodes.push(${JSON.stringify(node('an-pic', 'Texture', 600, 300))}); logic.variables.push(${JSON.stringify(variable('pic', 'texture', ''))}); logic.props.pic = ''; __gm.state.select({ kind: 'node', assetId: 'an-logic', nodeId: 'an-pic', nodeIds: ['an-pic'] }); `); await pickPicture('Value', walk.name); const picNode = (await t.asset('an-logic')).graph.nodes.find((n) => n.id === 'an-pic'); const picRows = (await json("__gm.graph.nodeSettings('an-pic')")) ?? []; check( 'the Texture node picks its picture and shows it by name', picNode?.params.value === walk.id && picRows.some((r) => r.includes(walk.name)), JSON.stringify({ params: picNode?.params, rows: picRows }), ); await evaluate("__gm.state.select({ kind: 'asset', assetId: 'an-logic' })"); await pickPicture('pic', walk.name); check( 'a texture variable is picked with the same picker', (await t.asset('an-logic')).props.pic === walk.id, JSON.stringify((await t.asset('an-logic')).props), ); await shot('04b-texture-kind'); // 25m. Deleting the texture counts the animations that play its frames. const again = await json(`__gm.assetRow(${JSON.stringify(strip)})`); await click(again.x, again.y, 'right'); await clickText('Delete…'); const dialog = await json('__gm.overlayTexts()'); await shot('05-delete-dialog'); check( 'deleting the texture names the animations that use it', dialog.some((s) => s.includes('2 animation(s)')), JSON.stringify(dialog), ); check('and its bold Delete button is not cut short', dialog.includes('Delete'), JSON.stringify(dialog)); const cancel = await t.locate('Cancel', true); await click(cancel.x, cancel.y); await mutate(` const p = __gm.state.project; const mine = new Set(['an-sprite', 'an-run', 'an-logic', ${JSON.stringify(walk.id)}, ${JSON.stringify(strip)}]); p.assets = p.assets.filter((a) => !mine.has(a.id)); const scene = __gm.state.activeScene; scene.objects = scene.objects.filter((o) => o.id !== 'an-box' && o.id !== 'an-brain'); __gm.state.select(null); __gm.state.preview(); `); const left = await json( "__gm.state.project.assets.filter((a) => a.type === 'animation' || a.id === 'an-sprite' || a.id === 'an-logic').length", ); check('the step leaves the fixture as it found it', left === 0, String(left)); }