import { colourDistance } from '../lib/harness.mjs'; import { openTab } from './lib/scaffold.mjs'; const RED = '#ff0000'; const SIZE = 40; /** * Animations (#97, #98, #99, #110, #102): a row is added through the dialog, keys are set, moved, * banded, deleted, copied, pasted and duplicated on the timeline with real gestures, frames are * inserted, and the preview above it is read at the playhead – as a model value, as a screen rectangle and as pixels. The subject is a container whose child * is animated, since a row aiming inside the root is the case a single sprite cannot show. It * removes everything it made. */ export async function animation(t) { const { check, json, evaluate, mutate, shot, click, doubleClick, drag, press, typeText } = t; const { undo, settle, sleep, waitUntil, find } = t; await press('Escape'); await evaluate('document.activeElement instanceof HTMLElement && document.activeElement.blur()'); await mutate(` const p = __gm.state.project; const scene = __gm.state.activeScene; const sprite = structuredClone(p.assets.find((a) => a.id === 'crate')); Object.assign(sprite, { id: 'an-sprite', name: 'Arm sprite' }); sprite.props = { ...sprite.props, width: ${SIZE}, height: ${SIZE}, color: 0xff0000, texture: '', shader: '' }; const arm = structuredClone(scene.objects[0]); Object.assign(arm, { id: 'an-arm', assetId: 'an-sprite', name: 'Arm', parentId: '', position: { x: 0, y: 0, z: 0 }, overrides: {}, instanceEdits: {}, }); p.assets.push( sprite, { id: 'an-box', type: 'container', name: 'Dancer', props: { enabled: true }, objects: [arm] }, { id: 'an-walk', type: 'animation', name: 'Walk', props: { fps: 10, playback: 'loop' }, root: 'an-box', tracks: [] }, ); __gm.state.select({ kind: 'asset', assetId: 'an-walk' }); `); await openTab(t, 'animation'); await sleep(300); check('the animation tab shows the animation', (await json('__gm.animation.current()')) === 'an-walk'); const walk = () => json("__gm.state.project.assets.find((a) => a.id === 'an-walk')"); const template = () => json("JSON.stringify(__gm.state.project.assets.find((a) => a.id === 'an-box').objects)"); const keysOf = async () => (await walk()).tracks[0]?.keys ?? []; const templateBefore = await template(); // 25a. The dialog offers the root and what is placed inside it; a row aims at the child. const add = await json('__gm.animation.addRowAt()'); await click(add.x, add.y); const offered = await json('__gm.animation.rowDialog()'); check( 'the row dialog offers the root itself and the object placed inside it', JSON.stringify(offered?.targets) === JSON.stringify(['Dancer (itself)', 'Arm']), JSON.stringify(offered), ); await evaluate("__gm.animation.rowDialogPick('an-arm', 'x')"); await settle(); const armOffered = (await json('__gm.animation.rowDialog()')).properties; const armProperties = armOffered.length; check( 'the position rows come first, Z (the draw order) after X and Y', JSON.stringify(armOffered.slice(0, 3)) === JSON.stringify(['X', 'Y', 'Z']), JSON.stringify(armOffered), ); const addButton = await find('Add row', true); await click(addButton.x, addButton.y); const row = (await walk()).tracks; check( 'confirming adds one row aimed at the child, with no keys yet', row.length === 1 && row[0].target === 'an-arm' && row[0].property === 'x' && row[0].keys.length === 0, JSON.stringify(row), ); const trackId = row[0]?.id; await click(add.x, add.y); await evaluate("__gm.animation.rowDialogPick('an-arm', 'x')"); await settle(); const again = await json('__gm.animation.rowDialog()'); check( 'the same object and property is not offered a second time and cannot be confirmed', again.properties.length === armProperties - 1 && again.ok === false, JSON.stringify({ armProperties, again }), ); const cancel = await find('Cancel', true); await click(cancel.x, cancel.y); check('cancelling adds nothing', (await walk()).tracks.length === 1); // 25b. A double click sets a key with the value the row has there; the inspector edits the key. const cell = (frame) => json(`__gm.animation.cellAt(${JSON.stringify(trackId)}, ${frame})`); const c0 = await cell(0); await doubleClick(c0.x, c0.y); const c10 = await cell(10); await doubleClick(c10.x, c10.y); const doubled = await keysOf(); check( 'a double click sets a key holding the value in force there – the child stands at 0', JSON.stringify(doubled) === JSON.stringify([ { frame: 0, value: 0 }, { frame: 10, value: 0 }, ]), JSON.stringify(doubled), ); const selection = (await t.state()).selection; const valueField = await json("__gm.inspectorField('Value')"); check( 'the key just set is picked and the inspector shows its value', selection?.kind === 'track' && selection.keys?.[0]?.frame === 10 && Boolean(valueField), JSON.stringify({ selection, valueField }), ); await click(valueField.x, valueField.y); await press('a', { ctrl: true }); await typeText('100'); await press('Enter'); await settle(); const edited = await keysOf(); check( 'typing a value writes the key, and the container template stays as it was', edited.find((k) => k.frame === 10)?.value === 100 && (await template()) === templateBefore, JSON.stringify(edited), ); const info = await json('__gm.animation.info()'); check( 'the readout counts to the last key: 11 frames, 1.1 s', info === '1 row · 11 frames · 10 fps · 1.1 s', info, ); // 25b-ii. Easing is two fields: the curve, and for a curve its direction. Each pick is a step, // all three are undone here so 25h counts only its own. const easingOf = async () => (await keysOf()).find((k) => k.frame === 10)?.interpolation ?? 'linear'; const pick = async (field, label) => { const row = await json(`__gm.inspectorField(${JSON.stringify(field)})`); await click(row.x, row.y); await sleep(100); const rows = await json('__gm.menuRows()'); const entry = await t.locate(label, true); await click(entry.x, entry.y); await settle(); return rows; }; const shapes = await pick('Easing', 'Cubic'); const cubicDirection = await json("__gm.inspectorField('Direction')"); check( 'the Easing field lists every curve, and a curve brings the Direction field, eased both ways', JSON.stringify(shapes) === JSON.stringify([ 'Linear', 'Step', 'Sine', 'Quad', 'Cubic', 'Quart', 'Quint', 'Expo', 'Circ', 'Back', 'Elastic', 'Bounce', 'Curve', ]) && (await easingOf()) === 'cubic-in-out' && Boolean(cubicDirection), JSON.stringify({ shapes, easing: await easingOf(), cubicDirection }), ); const directions = await pick('Direction', 'Out'); await shot('25-animation-easing'); const outward = await easingOf(); await pick('Easing', 'Back'); const back = await easingOf(); check( 'Direction offers In, Out and In-out, and a new curve keeps the direction picked', JSON.stringify(directions) === JSON.stringify(['In', 'Out', 'In-out']) && outward === 'cubic-out' && back === 'back-out', JSON.stringify({ directions, outward, back }), ); await pick('Easing', 'Linear'); const linearDirection = await json("__gm.inspectorField('Direction')"); const linear = await easingOf(); await focusTimeline(); await undo(); const backAgain = await easingOf(); for (let i = 0; i < 3; i++) await undo(); const easingUndone = await easingOf(); const keptValue = (await keysOf()).find((k) => k.frame === 10)?.value; check( 'linear drops the Direction field and the stored easing, and undo takes the four picks back one by one', !linearDirection && linear === 'linear' && backAgain === 'back-out' && easingUndone === 'linear' && keptValue === 100, JSON.stringify({ linearDirection, linear, backAgain, easingUndone, keptValue }), ); // 25c. The ruler scrubs, and the preview draws the child at the playhead. Framed at the far end, // so both ends and the middle are on screen together. const scrubTo = async (frame) => { const p = await json(`__gm.animation.rulerAt(${frame})`); await click(p.x, p.y); await sleep(100); }; const armId = await json("__gm.animation.preview.objectId('an-arm')"); const rectAt = async (frame) => { await scrubTo(frame); return json(`__gm.animation.preview.rect(${JSON.stringify(armId)})`); }; await scrubTo(10); const fit = await json('__gm.animation.preview.fitAt()'); await click(fit.x, fit.y); await sleep(200); const start = await rectAt(0); const end = await rectAt(10); const middle = await rectAt(5); const playhead = (await json('__gm.animation.view()')).playhead; const drawn = await json(`__gm.animation.preview.item(${JSON.stringify(armId)})`); const expectedX = start && end ? (start.x + end.x) / 2 : NaN; check( 'clicking the ruler at 5 puts the playhead there, and the child is drawn half way at x 50', playhead === 5 && drawn?.x === 50 && Math.abs(middle?.x - expectedX) <= 1 && end?.x - start?.x > SIZE, JSON.stringify({ playhead, drawn: drawn?.x, start, middle, end }), ); await shot('25-animation-preview'); if (!start || !middle || !end) return; // Pixels: red all over the child where it is drawn, and nothing red where it stood at frame 0. const grid = []; for (const fx of [-0.3, 0, 0.3]) for (const fy of [-0.3, 0, 0.3]) grid.push([fx, fy]); const samples = await json( `[${grid.map(([fx, fy]) => `__gm.pixelAt(${Math.round(middle.x + fx * middle.w)}, ${Math.round(middle.y + fy * middle.h)})`).join(',')}]`, ); const left = await json(`__gm.pixelAt(${Math.round(start.x)}, ${Math.round(start.y)})`); check( 'the preview paints the child red at frame 5, and the spot it held at frame 0 is empty', samples.every((c) => colourDistance(c, RED) < 30) && colourDistance(left, RED) > 150, JSON.stringify({ samples, left }), ); // 25d. Drag moves a key, landing on another replaces it; undo brings the replaced one back. const c5 = await cell(5); await doubleClick(c5.x, c5.y); const withMiddle = await keysOf(); const from = await cell(5); const to = await cell(10); await drag(from.x, from.y, to.x, to.y); const landed = await keysOf(); check( 'a key dragged onto another replaces it and keeps its own value', withMiddle.find((k) => k.frame === 5)?.value === 50 && JSON.stringify(landed) === JSON.stringify([ { frame: 0, value: 0 }, { frame: 10, value: 50 }, ]), JSON.stringify({ withMiddle, landed }), ); await undo(); check( 'undo takes the drag back, the replaced key included', JSON.stringify(await keysOf()) === JSON.stringify(withMiddle), JSON.stringify(await keysOf()), ); // 25e. A band from the empty space below the row over all three keys, then Delete. const bandFrom = { x: (await cell(14)).x, y: c0.y + 40 }; const bandTo = { x: c0.x - 12, y: c0.y - 11 }; await drag(bandFrom.x, bandFrom.y, bandTo.x, bandTo.y); const banded = (await json('__gm.animation.keys()')).filter((k) => k.selected).map((k) => k.frame); await press('Delete'); const deleted = await keysOf(); check( 'the band picks every key it spans, and Delete removes them all but keeps the row', banded.join() === '0,5,10' && deleted.length === 0 && (await walk()).tracks.length === 1, JSON.stringify({ banded, deleted }), ); await undo(); check( 'one undo brings all three keys back', JSON.stringify(await keysOf()) === JSON.stringify(withMiddle), JSON.stringify(await keysOf()), ); // 25f. Once (hold) keeps the last value; once (reset) puts the starting value back at the end. const playThrough = async (playback) => { await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = '${playback}'`); await scrubTo(0); const play = await json('__gm.animation.playAt()'); await click(play.x, play.y); await waitUntil('!__gm.animation.view().playing', { timeout: 5000, label: `the ${playback} pass ended` }); await sleep(100); return { playhead: (await json('__gm.animation.view()')).playhead, x: (await json(`__gm.animation.preview.item(${JSON.stringify(armId)})`))?.x, button: (await json('__gm.animation.playAt()')).text, }; }; const held = await playThrough('once'); const reset = await playThrough('once-reset'); check( 'a once pass ends holding the last key, a once-reset pass ends showing the starting value', held.x === 100 && reset.x === 0 && reset.playhead >= 10 && reset.button === 'Play', JSON.stringify({ held, reset }), ); // 25g. Copy and paste (#102): two keys banded, Ctrl+C, the playhead to 12, Ctrl+V – they land // relative to the playhead, keeping their distance, and come back picked. Each step undone. await mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = 'loop'`); const three = JSON.stringify(withMiddle); const pickedFrames = async () => (await json('__gm.animation.keys()')).filter((k) => k.selected).map((k) => k.frame); const c3 = await cell(3); await drag((await cell(14)).x, c0.y + 40, c3.x, c0.y - 11); const copied = await pickedFrames(); await press('c', { ctrl: true }); const clip = await json('__gm.animation.clipboard()'); await scrubTo(12); await press('v', { ctrl: true }); const pasted = await keysOf(); const pastedPick = await pickedFrames(); check( 'Ctrl+C and Ctrl+V paste the banded keys relative to the playhead and pick them', copied.join() === '5,10' && JSON.stringify(clip?.rows?.[0]?.keys?.map((k) => k.offset)) === '[0,5]' && JSON.stringify(pasted) === JSON.stringify([...withMiddle, { frame: 12, value: 50 }, { frame: 17, value: 100 }]) && pastedPick.join() === '12,17', JSON.stringify({ copied, clip, pasted, pastedPick }), ); await undo(); check( 'one undo takes the paste back', JSON.stringify(await keysOf()) === three, JSON.stringify(await keysOf()), ); // Ctrl+drag duplicates: the key at 5 stays, a copy lands on 7. const k5 = await cell(5); const k7 = await cell(7); await click(k5.x, k5.y); await drag(k5.x, k5.y, k7.x, k7.y, { modifiers: { ctrl: true } }); const duplicated = await keysOf(); check( 'Ctrl+drag leaves the key where it was and drops a copy where the pointer lets go', JSON.stringify(duplicated) === JSON.stringify([withMiddle[0], withMiddle[1], { frame: 7, value: 50 }, withMiddle[2]]), JSON.stringify(duplicated), ); await undo(); // Right-click → Insert frames… at the playhead: every key from there on moves by the count. await scrubTo(5); const inCells = await cell(2); await click(inCells.x, inCells.y + 40, 'right'); const insertItem = await find('Insert frames at 5…', true); await click(insertItem.x, insertItem.y); await evaluate('__gm.animation.framesDialogCount(3)'); await settle(); const dialog = await json('__gm.animation.framesDialog()'); const confirm = await json('__gm.animation.framesDialogConfirm()'); await click(confirm.x, confirm.y); const inserted = await keysOf(); check( 'the menu inserts 3 frames at the playhead: the keys on 5 and 10 move to 8 and 13, the one on 0 stays', dialog?.summary === 'Moves 2 keys 3 frames later.' && JSON.stringify(inserted.map((k) => k.frame)) === '[0,8,13]' && (await json('__gm.animation.framesDialog()')) === null, JSON.stringify({ dialog, inserted }), ); await undo(); check( 'undo takes the inserted frames back', JSON.stringify(await keysOf()) === three, JSON.stringify(await keysOf()), ); // Right-click on the row's header → Delete row: the row goes, the animation stays picked; undo. const rowHeader = await json(`__gm.animation.headerAt(${JSON.stringify(trackId)})`); await click(rowHeader.x, rowHeader.y, 'right'); const deleteItem = await find('Delete row', true); // A destructive entry is red text on the plain menu, not a filled red row. const menuRows = await json('__gm.menuRows()'); const looks = await json('__gm.menuLooks()'); const danger = await json(`__gm.themeColor('danger')`); const plain = await json(`__gm.themeColor('text')`); const deleteLook = looks[menuRows.indexOf('Delete row')]; const insertLook = looks[menuRows.findIndex((row) => row?.startsWith('Insert frames'))]; check( 'Delete row is drawn in red text without a red fill, a plain entry in the normal text colour', deleteLook?.text === danger && deleteLook.fill !== danger && insertLook?.text === plain, JSON.stringify({ deleteLook, insertLook, danger, plain }), ); await click(deleteItem.x, deleteItem.y); const rowsGone = (await walk()).tracks.length; const afterDelete = await json('__gm.state.selection'); await undo(); check( 'the menu on a row header deletes that row, and undo brings it back with its keys', rowsGone === 0 && afterDelete?.kind === 'asset' && (await walk()).tracks.length === 1 && JSON.stringify(await keysOf()) === three, JSON.stringify({ rowsGone, afterDelete, keys: await keysOf() }), ); // 25h. Undo all the way: the key at 5, the value edit, the keys at 10 and 0, then the row. Exactly // that many – one more would take back a command of an earlier step, so the count is checked // on the way: after four the row is still there, empty. await focusTimeline(); for (let i = 0; i < 4; i++) await undo(); const emptied = await walk(); await undo(); const undone = await walk(); check( 'undo takes back the keys and the value edit one by one, and the row last', emptied.tracks.length === 1 && emptied.tracks[0].keys.length === 0 && undone.tracks.length === 0 && (await template()) === templateBefore, JSON.stringify({ emptied: emptied.tracks, undone: undone.tracks }), ); // 25i. The header (#408): rows grouped under their object in the order of the hierarchy, each // object's rows in the order of its properties, the value at the playhead in the row, and // a value typed there keys it at the playhead. await mutate(` __gm.state.project.assets.find((a) => a.id === 'an-walk').tracks = [ { id: 'an-r1', target: 'an-arm', property: 'color', keys: [] }, { id: 'an-r2', target: '', property: 'enabled', keys: [] }, { id: 'an-r3', target: 'an-arm', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 10, value: 100 }] }, ]; `); await evaluate('__gm.animation.scrub(4)'); await sleep(150); const lines = await json('__gm.animation.lines()'); const shape = lines.map((l) => (l.kind === 'group' ? l.text : ` ${l.text}=${JSON.stringify(l.value)}`)); check( 'the header groups the rows under Dancer and Arm, X before Color, each with its value at frame 4', JSON.stringify(shape) === JSON.stringify(['Dancer', ' Enabled=true', 'Arm', ' X=40', ` Color=${0xff0000}`]) && lines[1].indent > lines[0].indent, JSON.stringify(lines), ); await shot('20-grouped-header'); const xField = await json("__gm.animation.valueAt('an-r3')"); await click(xField.x, xField.y); await typeText('55'); await press('Enter'); await sleep(150); const typed = (await walk()).tracks.find((r) => r.id === 'an-r3').keys; const typedPick = await json('__gm.state.selectedKeys'); check( 'a value typed into the row keys it at the playhead and picks that key', JSON.stringify(typed) === JSON.stringify([ { frame: 0, value: 0 }, { frame: 4, value: 55 }, { frame: 10, value: 100 }, ]) && JSON.stringify(typedPick) === JSON.stringify([{ trackId: 'an-r3', frame: 4 }]), JSON.stringify({ typed, typedPick }), ); // Pressing the ruler while playing pauses – and the button says Play again. const playButton = await json('__gm.animation.playAt()'); await click(playButton.x, playButton.y); const whilePlaying = (await json('__gm.animation.playAt()')).text; const ruler = await json('__gm.animation.rulerAt(2)'); await click(ruler.x, ruler.y); const scrubbed = { text: (await json('__gm.animation.playAt()')).text, ...(await json('__gm.animation.view()')), }; check( 'scrubbing on the ruler while playing pauses, and the button turns back to Play', whilePlaying === 'Pause' && scrubbed.text === 'Play' && scrubbed.playing === false, JSON.stringify({ whilePlaying, scrubbed }), ); // 25i-ii. A double click into a row without keys keys what its header showed (#412): the Arm // stands at x 40, not at the property's default 0. await mutate(` const p = __gm.state.project; p.assets.find((a) => a.id === 'an-box').objects[0].position.x = 40; p.assets.find((a) => a.id === 'an-walk').tracks = [{ id: 'an-r4', target: 'an-arm', property: 'x', keys: [] }]; `); await sleep(150); const shownX = (await json('__gm.animation.lines()')).find((l) => l.kind !== 'group')?.value; const emptyCell = await json("__gm.animation.cellAt('an-r4', 3)"); await doubleClick(emptyCell.x, emptyCell.y); const keyedX = (await walk()).tracks[0].keys; check( 'a double click into an empty row keys the value its header showed – 40, not the default 0', shownX === 40 && JSON.stringify(keyedX) === JSON.stringify([{ frame: 3, value: 40 }]), JSON.stringify({ shownX, keyedX }), ); await mutate(` const p = __gm.state.project; p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id)); __gm.state.select(null); `); const leftOver = await json( "__gm.state.project.assets.filter((a) => ['an-walk', 'an-box', 'an-sprite'].includes(a.id)).length", ); check('the step leaves the fixture as it found it', leftOver === 0, String(leftOver)); /** Puts the keyboard on the timeline, so Ctrl+Z is not taken by a text field. */ async function focusTimeline() { const header = await json(`__gm.animation.headerAt(${JSON.stringify(trackId)})`); await click(header.x, header.y); } }