e2e module 25 drives the animation timeline and its preview with real gestures (#110)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
89c1d6130c
commit
353b348c3c
2 files changed
+245
-20
No files matched your search
+244
-19
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user