Let an animation drive a sprite's video picture by picture (#404)
game-maker / gate (push) Successful in 4m16s
game-maker / suite (push) Failing after 16m12s

A video used to run on its own clock as soon as a sprite wore it. An
animation could switch the sprite on and off, but never say which
picture shows at which frame. Cutting a video in an animation needs
the opposite: the animation sets the time and the video follows.

- Sprites wearing a video or a clip get two rows, Video clock and
  Video time. They show only while the texture holds a video.
- With Video clock on Driven the video does not play by itself. It
  shows the picture at Video time, counted from the clip's start.
  Past the end it goes round with Loop and holds the last picture
  without.
- Video time is a number like any other, so keys, step, easing and
  curves give stills, slow motion and ramps.
- A driven sprite gets a player of its own, keyed by the wearer's
  name. The same clip can run twice at different times.
- Following has two ways (`videoFollow.ts`). Running forward at 0.25x
  to 4x the video plays at that rate and is nudged. A still, a scrub
  or a step backwards seeks, and only the newest seek counts.
- Sound is heard only near 1x. The animation tab now plays it too, so
  a cut can follow the sound. Scrubbing is silent.
- Scene, game view, the inspector's PREVIEW and the shader preview
  show the driven picture. In a running game Play animation sets it.

Found on the way:

- After a scrub the video in the animation tab kept running. The
  preview redrew only while the playhead moved, so nobody asked for
  the video once it stood. `AnimationEditor.tick` now redraws it every
  frame while it shows a video.
- Every sprite has two more defaults (`videoClock: 'free'`,
  `videoTime: 0`). Eight tests in five files pinned the old list and
  are updated.

Tests:
- Unit: video-clock and video-follow new, 4 files with 42 tests green.
- e2e 66 (melektaus-atx): ALL PASSED, 22 checks, 3 new. 66i: two
  sprites on one clip stand at 0.2 s red and 0.7 s green. 66j: a
  scrub to frames 2, 7, 12 and 3 shows red, green, blue, red, and
  each colour still stands 0.7 s later. 66k: playing runs 10-30 ms
  behind at rate 1.00-1.03 and is not muted.
- Counterchecks: a seek that keeps the rate turns the seek test red.
  Not seeing Driven turns 66i, 66j and 66k red.
- Gate (melektaus-atx): exit 0, 297 files, 4546 tests.
- Not tested in the browser: Play animation in a running game.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-11 14:23:07 +02:00
1 parent a62d1d0faf
commit c9e8a3f6ac
24 files changed
+659 -41

No files matched your search

+93 -2
View File
@@ -16,6 +16,10 @@ import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs';
*/
const SPRITE = 'vd66-sprite';
const OBJECT = 'vd66-object';
const DRIVEN = 'vd66-driven';
const EARLY = 'vd66-early';
const LATE = 'vd66-late';
const CUT = 'vd66-cut';
const LANE = LANES.video;
const COLORS = { red: '#fe0000', green: '#00ff01', blue: '#0000ff', white: '#ffffff' };
@@ -265,10 +269,97 @@ export async function video(t) {
JSON.stringify({ thrown, inspected, kept, inPreview }),
);
// 66i. Driven by its Video time (#404): two sprites wearing the same video stand still in the
// scene, each at its own time (0.2 s red, 0.7 s green), each on a player of its own.
await mutate(`
const p = __gm.state.project;
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => o.id !== '${OBJECT}');
p.assets = p.assets.filter((a) => a.id !== '${SPRITE}' && a.id !== ${JSON.stringify(assetId)});
const scene = __gm.state.activeScene;
${MAKE_OBJECT}
p.assets.unshift({ id: '${DRIVEN}', type: 'sprite', name: '${DRIVEN}', props: { width: 320, height: 180, color: 0xffffff, texture: ${JSON.stringify(assetId)}, shader: '', videoClock: 'driven', videoTime: 0 } });
const early = make('${EARLY}', '${DRIVEN}', '', ${LANE}, 'at 0.2');
const late = make('${LATE}', '${DRIVEN}', '', ${LANE}, 'at 0.7');
early.overrides = { videoTime: 0.2 };
late.position.x = 400;
late.overrides = { videoTime: 0.7 };
scene.objects.push(early, late);
__gm.state.select(null);
__gm.lookAt(200, ${LANE});`);
const colorAt = (r) => r && json(`__gm.pixelAt(${Math.round(r.x)}, ${Math.round(r.y)})`);
const pair = async () => [
await colorAt(await json(`__gm.objectRect('${EARLY}')`)),
await colorAt(await json(`__gm.objectRect('${LATE}')`)),
];
const stills = await waitFor(pair, ([a, b]) => a === COLORS.red && b === COLORS.green);
await sleep(700);
const later = await pair();
const drivenPlayers = (await json('__gm.videos()')).filter((v) => v.key.includes('@'));
check(
'driven sprites stand still at their Video time, two of the same video on players of their own',
stills[0] === COLORS.red &&
stills[1] === COLORS.green &&
later[0] === COLORS.red &&
later[1] === COLORS.green &&
drivenPlayers.length === 2 &&
drivenPlayers.every((v) => v.paused),
JSON.stringify({ stills, later, drivenPlayers }),
);
await shot('66-video-driven');
// 66j. Keyed in an animation, the preview shows the picture of the time at the playhead, back and
// forth: frame 2 = 0.2 s red, 7 = green, 12 = blue, and back to 3 = red.
await mutate(`
__gm.state.project.assets.push({ id: '${CUT}', type: 'animation', name: '${CUT}', props: { fps: 10, playback: 'loop' },
root: '${DRIVEN}',
tracks: [{ id: 'vd66-row', target: '', property: 'videoTime', keys: [{ frame: 0, value: 0 }, { frame: 20, value: 2 }] }] });`);
await t.evaluate(`__gm.openDocument('asset:${CUT}')`);
await sleep(300);
const rootId = await json("__gm.animation.preview.objectId('')");
// Each picture must also stay: a video playing by itself shows every colour within 2 s.
const previewColor = async () =>
colorAt(await json(`__gm.animation.preview.rect(${JSON.stringify(rootId)})`));
const scrubbed = [];
const wanted = [COLORS.red, COLORS.green, COLORS.blue, COLORS.red];
for (const [i, frame] of [2, 7, 12, 3].entries()) {
await t.evaluate(`__gm.animation.scrub(${frame})`);
const first = await waitFor(previewColor, (color) => color === wanted[i], 10);
await sleep(700);
scrubbed.push([first, await previewColor()]);
}
check(
'scrubbing the animation shows the picture of the keyed Video time and holds it, back and forth',
scrubbed.every(([first, held], i) => first === wanted[i] && held === wanted[i]),
JSON.stringify(scrubbed),
);
await shot('66-video-scrubbed');
// 66k. Played in the tab, the video plays along at 1x, heard, at the time the playhead asks for
// (the clip and the animation both loop at 2 s, so the gap is measured round the loop).
const playCut = await json('__gm.animation.playAt()');
await click(playCut.x, playCut.y);
await sleep(600);
const along = [];
for (let i = 0; i < 5; i++) {
const asked = (await json(`__gm.animation.preview.item(${JSON.stringify(rootId)})`))?.props.videoTime;
const player = (await json('__gm.videos()')).find(
(v) => v.key.includes(`@`) && !v.key.includes(EARLY) && !v.key.includes(LATE),
);
along.push({ asked, ...player });
await sleep(200);
}
await click(playCut.x, playCut.y);
const gap = (a, b) => Math.min(Math.abs(a - b), 2 - Math.abs(a - b));
check(
'playing the animation plays the video along at 1x and lets it be heard',
along.every((p) => !p.paused && !p.muted && Math.abs(p.rate - 1) <= 0.1 && gap(p.time, p.asked) < 0.15),
JSON.stringify(along),
);
await showTab('scene');
await mutate(`
const p = __gm.state.project;
const gone = ['${OBJECT}', '${EARLY}', '${LATE}'];
for (const holder of p.assets) if (holder.objects) holder.objects = holder.objects.filter((o) => !gone.includes(o.id));
p.assets = p.assets.filter((a) => ![ '${SPRITE}', '${DRIVEN}', '${CUT}', ${JSON.stringify(assetId)} ].includes(a.id));
__gm.state.select(null);
__gm.lookAt(0, 0);`);
}