One clock under everything that moves by itself (#90)
game-maker / suite (push) Successful in 10m4s
game-maker / suite (push) Successful in 10m4s
Particle age and shader time each read performance.now() off their own library, and the world got the frame's delta straight from the ticker. Three clocks, so a global time scale would have been three multiplications and a pause three separate freezes. core/time.ts holds one clock instead: it accumulates dt x scale per frame rather than reading the wall clock, and Editor.tick hands the very step it took to the play session, the editor run and the animation preview. Delta time, particle age, shader time and that preview are the same quantity now, so the scale is a single multiplication and a pause is that scale at 0 — nothing underneath needs rebasing, because the clock itself stands still. The Math.min(dt, 0.1) caps in PlaySession and EditorRun are gone: the cap (MAX_FRAME) now sits on the raw frame before scaling, and capping twice would make the world run slower than the particles at scale 2. A pause gates the scale rather than overwriting it — effectiveScale() answers 0 while paused, and for anything that is not a positive finite number, while gameClock.scale keeps its value. A slider later survives a pause that way; the issue asks for none yet. The clock goes back to zero at both ends of a run, so a run is reproducible from second 0 and the scene view looks freshly loaded after Stop. Resetting only on Stop would not do it: the editor clock runs on afterwards, and the next Play would start at "seconds since the last Stop". The trap in that: ParticleField keeps its origin as an absolute second of this clock, so after a reset it lies in the future and every particle would age backwards. One line in sync() starts such a field over, which covers the scene, the game and the tab preview at once — and fixes an older bug of the same shape, where releasing a particleTime() freeze left the preview empty for good. Found while measuring that: ParticlePreview stood at second 0.5 by computing the clock reading that would mean 0.5 since the field started. Over 200000 random origins the round trip misses the last bit in 18.8% of them and shows one particle too few in 9.4%, because floor(0.5 * 20) comes out 9. The check had been flaky since it was written. The panel hands the field the age itself now (ParticleField.hold), and the faked clock is gone. 833 checks (824 + 9), 931 unit tests (921 + 10). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
78baf0c719
commit
0e0a21dee1
15 files changed
+332
-26
No files matched your search
+70
-1
@@ -244,7 +244,76 @@ export async function pause(t) {
|
||||
JSON.stringify((await asset('pauser')).graph.nodes.map((n) => n.id)),
|
||||
);
|
||||
|
||||
// 18g. Play discarded its copy, so the box is back at the position the model holds.
|
||||
// 18g. (#90) One clock under everything that moves by itself – the world's delta, particle age and
|
||||
// shader time. It starts over with a run and ends with it, and a pause is a scale of 0.
|
||||
const clock = () => json('__gm.globalTime()');
|
||||
// The bounds are a multiple apart rather than an epsilon: reading the clock costs a round trip,
|
||||
// and without the reset the run would start at everything the editor counted, not a little of it.
|
||||
await sleep(2500);
|
||||
const beforePlay = await clock();
|
||||
check(
|
||||
'the clock runs in edit mode too, so the previews move without a game',
|
||||
beforePlay.now > 2 && beforePlay.effective === 1,
|
||||
JSON.stringify(beforePlay),
|
||||
);
|
||||
await t.play();
|
||||
const atStart = await clock();
|
||||
check(
|
||||
'a run starts the clock over, however long the editor has been counting',
|
||||
atStart.now < 1 && atStart.now < beforePlay.now / 2,
|
||||
`editor at ${beforePlay.now.toFixed(2)} s → run at ${atStart.now.toFixed(2)} s`,
|
||||
);
|
||||
|
||||
await sleep(400);
|
||||
const runningClock = await clock();
|
||||
await clickText('❚❚ Pause');
|
||||
await sleep(150);
|
||||
const heldClock = await clock();
|
||||
await sleep(800);
|
||||
const stillHeldClock = await clock();
|
||||
check(
|
||||
'a pause is an effective scale of 0, and it gates the scale instead of overwriting it',
|
||||
runningClock.effective === 1 &&
|
||||
heldClock.effective === 0 &&
|
||||
heldClock.scale === 1 &&
|
||||
Math.abs(stillHeldClock.now - heldClock.now) < 0.02,
|
||||
`effective ${runningClock.effective} → ${heldClock.effective} (scale ${heldClock.scale}), clock ${heldClock.now.toFixed(2)} → ${stillHeldClock.now.toFixed(2)} over 0.8 s`,
|
||||
);
|
||||
|
||||
// Twice the scale has to move the clock and the world by the same factor: the delta a behavior
|
||||
// reads is the step the clock took, not a second measurement of the wall clock.
|
||||
await press('Enter', { ctrl: true });
|
||||
await sleep(150);
|
||||
const slowFrom = { t: await clock(), x: await boxX() };
|
||||
await sleep(600);
|
||||
const slowTo = { t: await clock(), x: await boxX() };
|
||||
await evaluate('__gm.setTimeScale(2)');
|
||||
await sleep(150);
|
||||
const fastFrom = { t: await clock(), x: await boxX() };
|
||||
await sleep(600);
|
||||
const fastTo = { t: await clock(), x: await boxX() };
|
||||
await evaluate('__gm.setTimeScale(1)');
|
||||
const slow = { time: slowTo.t.now - slowFrom.t.now, drift: slowTo.x - slowFrom.x };
|
||||
const fast = { time: fastTo.t.now - fastFrom.t.now, drift: fastTo.x - fastFrom.x };
|
||||
const twice = (a, b) => b > a * 1.7 && b < a * 2.3;
|
||||
check(
|
||||
'at scale 2 the clock and the drifting box both move twice as far in the same wall second',
|
||||
slow.drift > 20 && twice(slow.time, fast.time) && twice(slow.drift, fast.drift),
|
||||
JSON.stringify({ slow, fast }),
|
||||
);
|
||||
|
||||
await stop();
|
||||
const afterStopClock = await clock();
|
||||
check(
|
||||
'stopping puts the clock back to zero, so the editor shows a freshly started scene again',
|
||||
afterStopClock.now < 1 &&
|
||||
afterStopClock.now < fastTo.t.now / 2 &&
|
||||
afterStopClock.scale === 1 &&
|
||||
afterStopClock.effective === 1,
|
||||
`run ended at ${fastTo.t.now.toFixed(2)} s → editor at ${afterStopClock.now.toFixed(2)} s`,
|
||||
);
|
||||
|
||||
// 18h. Play discarded its copy, so the box is back at the position the model holds.
|
||||
const left = await rig.remove();
|
||||
check(
|
||||
'the step leaves nothing of its own behind',
|
||||
|
||||
@@ -160,6 +160,28 @@ export async function animation(t) {
|
||||
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()')));
|
||||
await sleep(200);
|
||||
|
||||
@@ -693,6 +693,21 @@ export async function shader(t) {
|
||||
animates.scene === true && animates.game === false && first !== second,
|
||||
{ animates, first, second },
|
||||
);
|
||||
// (#90) The shader clock is the global one: a scale of 0 holds the colour where it is without
|
||||
// freezing anything shader-specific, and 1 lets it move on.
|
||||
await evaluate('__gm.setTimeScale(0)');
|
||||
await sleep(400);
|
||||
const frozenEarly = await pixel(-0.3, -0.3);
|
||||
await sleep(400);
|
||||
const frozenLate = await pixel(-0.3, -0.3);
|
||||
await evaluate('__gm.setTimeScale(1)');
|
||||
await sleep(400);
|
||||
const thawed = await pixel(-0.3, -0.3);
|
||||
check(
|
||||
'a global time scale of 0 stops the shader clock, and 1 starts it again',
|
||||
frozenEarly === frozenLate && thawed !== frozenLate,
|
||||
{ frozenEarly, frozenLate, thawed },
|
||||
);
|
||||
await play();
|
||||
await sleep(500);
|
||||
const playing = await json('__gm.animates()');
|
||||
|
||||
@@ -287,6 +287,25 @@ export async function particles(t) {
|
||||
await evaluate('__gm.particleTime(null)');
|
||||
await sleep(150);
|
||||
|
||||
// 31d-vii. (#90) A particle's age is measured on the global clock, so a scale of 0 stops the panel
|
||||
// where it stands – without any hold of its own – and 1 lets it run on.
|
||||
await evaluate('__gm.setTimeScale(0)');
|
||||
await sleep(300);
|
||||
const scaleZero = await json('{ p: __gm.particles.preview(), g: __gm.globalTime() }');
|
||||
await sleep(500);
|
||||
const stillZero = await json('__gm.particles.preview()');
|
||||
await evaluate('__gm.setTimeScale(1)');
|
||||
await sleep(400);
|
||||
const scaleOne = await json('__gm.particles.preview()');
|
||||
check(
|
||||
'a global time scale of 0 holds the panel still without holding it, and 1 lets it run on',
|
||||
scaleZero.g.effective === 0 &&
|
||||
scaleZero.p.holding === false &&
|
||||
stillZero.time === scaleZero.p.time &&
|
||||
scaleOne.time > stillZero.time + 0.2,
|
||||
JSON.stringify({ scaleZero, stillZero, scaleOne }),
|
||||
);
|
||||
|
||||
// 31e. Placed in the scene, the same asset is a source with its own rows.
|
||||
await mutate(`
|
||||
__gm.state.activeScene.objects.push({ id: ${JSON.stringify(SOURCE)}, assetId: ${JSON.stringify(assetId)},
|
||||
@@ -518,6 +537,27 @@ export async function particles(t) {
|
||||
);
|
||||
await shot('31-particles-scene');
|
||||
|
||||
// 31l-ii. (#90) A field keeps its origin as an absolute second of the global clock, so anything
|
||||
// that moves that clock backwards leaves that origin in the future: releasing the freeze
|
||||
// above, and the reset at either end of a run. Ageing backwards from there draws nothing
|
||||
// at all — for as long as the editor has been up — so the field has to start over instead.
|
||||
await evaluate('__gm.particleTime(null)');
|
||||
await sleep(400);
|
||||
const thawed = await drawn(1);
|
||||
await t.play();
|
||||
await sleep(300);
|
||||
await t.stop();
|
||||
await sleep(400);
|
||||
const afterRun = await drawn(1);
|
||||
check(
|
||||
'the source draws on after the clock is pulled out from under it: a freeze released, a run ended',
|
||||
(thawed?.count ?? 0) > 0 && (afterRun?.count ?? 0) > 0,
|
||||
JSON.stringify({ thawed: thawed?.count ?? null, afterRun: afterRun?.count ?? null }),
|
||||
);
|
||||
// Back to the frozen second the icon checks below were written against.
|
||||
await rewind();
|
||||
await at(0.1);
|
||||
|
||||
// 31m. (#89) A source draws only its particles, so an empty one would be invisible: it wears an
|
||||
// icon of its own, in the overlay above the objects, with its name beside it.
|
||||
const icon = await json(`__gm.sceneIcon(${JSON.stringify(SOURCE)})`);
|
||||
|
||||
Reference in new issue
Block a user