diff --git a/README.md b/README.md index 0095f930..b1ebaaa2 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 928 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 943 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index e516a6cf..30bbe6f1 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -1,42 +1,267 @@ +import { colourDistance } from '../lib/harness.mjs'; import { openTab } from './lib/scaffold.mjs'; +const RED = '#ff0000'; +const SIZE = 40; + /** - * Animations (#97): the timeline replaced the picture strip. Until the timeline editor lands this - * step only holds the tab to the new model – an animation over a sprite opens, and the readout - * counts its rows and frames. It removes everything it made. + * Animations (#97, #98, #99, #110): a row is added through the dialog, keys are set, moved, banded + * and deleted on the timeline with real gestures, 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, mutate, shot } = 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(` - __gm.state.project.assets.push( - { id: 'an-sprite', type: 'sprite', name: 'Dancer', props: { width: 40, height: 40 } }, - { - id: 'an-walk', type: 'animation', name: 'Walk', props: { fps: 10, playback: 'loop' }, root: 'an-sprite', - tracks: [{ id: 'an-row', target: '', property: 'x', keys: [{ frame: 0, value: 0 }, { frame: 9, value: 90 }] }], - }, + 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' }); - __gm.state.preview(); `); await openTab(t, 'animation'); - await shot('01-tab'); + 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 armProperties = (await json('__gm.animation.rowDialog()')).properties.length; + 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( - 'its readout counts one row over ten frames, one second at 10 fps', - info === '1 row · 10 frames · 10 fps · 1.0 s', + 'the readout counts to the last key: 11 frames, 1.1 s', + info === '1 row · 11 frames · 10 fps · 1.1 s', info, ); + // 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 centre = await json('__gm.animation.preview.centreAt()'); + await click(centre.x, centre.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. 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 mutate(`__gm.state.project.assets.find((a) => a.id === 'an-walk').props.playback = 'loop'`); + 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 }), + ); + await mutate(` const p = __gm.state.project; - p.assets = p.assets.filter((a) => a.id !== 'an-walk' && a.id !== 'an-sprite'); + p.assets = p.assets.filter((a) => !['an-walk', 'an-box', 'an-sprite'].includes(a.id)); __gm.state.select(null); - __gm.state.preview(); `); - const left = await json( - "__gm.state.project.assets.filter((a) => a.id === 'an-walk' || a.id === 'an-sprite').length", + 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', left === 0, String(left)); + 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); + } }