One clock under everything that moves by itself (#90)
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:
rootandClaude Opus 5 committed 2026-09-14 09:31:53 +02:00
1 parent 78baf0c719
commit 0e0a21dee1
15 files changed
+332 -26

No files matched your search

+70 -1
View File
@@ -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',
+22
View File
@@ -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);
+15
View File
@@ -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()');
+40
View File
@@ -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)})`);