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
@@ -37,7 +37,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, 824 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 833 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
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* The one clock everything time-based reads: the world's delta, particle age and shader time. It
|
||||
* accumulates scaled frames instead of reading the wall clock, which is what makes a global time
|
||||
* scale a single multiplication – a paused game is scale 0, and nothing underneath has to be
|
||||
* rebased because the clock itself stands still.
|
||||
*/
|
||||
|
||||
/** A frame longer than this (a breakpoint, a hidden tab) is treated as this long. */
|
||||
export const MAX_FRAME = 0.1;
|
||||
|
||||
/**
|
||||
* The scale that actually applies: a pause is a scale of 0, and so is anything that is not a
|
||||
* positive number – time never runs backwards.
|
||||
*/
|
||||
export function effectiveScale(scale: number, paused: boolean): number {
|
||||
if (paused || !Number.isFinite(scale) || scale <= 0) return 0;
|
||||
return scale;
|
||||
}
|
||||
|
||||
/** How far the clock moves in one frame. */
|
||||
export function timeStep(dt: number, scale: number, paused: boolean): number {
|
||||
return Math.min(Math.max(dt, 0), MAX_FRAME) * effectiveScale(scale, paused);
|
||||
}
|
||||
|
||||
export class GameClock {
|
||||
private seconds = 0;
|
||||
/** The global time scale: 1 is real time, 2 twice as fast, 0 frozen. */
|
||||
scale = 1;
|
||||
|
||||
/** Seconds the clock has run, in its own scaled time. */
|
||||
get now(): number {
|
||||
return this.seconds;
|
||||
}
|
||||
|
||||
/** Moves the clock on and returns the step, which is the delta every ticked world gets. */
|
||||
advance(dt: number, paused = false): number {
|
||||
const step = timeStep(dt, this.scale, paused);
|
||||
this.seconds += step;
|
||||
return step;
|
||||
}
|
||||
|
||||
/** Back to second zero. Anything holding an absolute second of this clock has to start over. */
|
||||
reset(): void {
|
||||
this.seconds = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const gameClock = new GameClock();
|
||||
@@ -4,6 +4,7 @@ import { findAsset, findObjectHolder } from '../core/model/Project';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import type { FontStore } from '../core/store/FontStore';
|
||||
import type { TextureStore } from '../core/store/TextureStore';
|
||||
import { gameClock } from '../core/time';
|
||||
import { clamp, HStack, Panel, Splitter, Theme, ui, VStack, type Widget } from '../ui';
|
||||
import { TabStrip, type TabPlace } from './TabStrip';
|
||||
import { ConsolePanel } from './console/ConsolePanel';
|
||||
@@ -317,12 +318,15 @@ export class Editor implements PlayHost {
|
||||
|
||||
private tick(dt: number): void {
|
||||
this.measureFps(dt);
|
||||
this.play.tick(dt);
|
||||
this.editorRun.tick(dt);
|
||||
// One step for everything below: a world gets exactly the seconds the particle, shader and
|
||||
// animation clocks moved, so the global time scale cannot pull them apart.
|
||||
const step = gameClock.advance(dt, this.state.paused);
|
||||
this.play.tick(step);
|
||||
this.editorRun.tick(step);
|
||||
this.viewport.animate();
|
||||
this.gameView.animate();
|
||||
this.console.refresh();
|
||||
if (this.tab === 'animation') this.animationEditor.tick(dt);
|
||||
if (this.tab === 'animation') this.animationEditor.tick(step);
|
||||
if (this.tab === 'particles') this.particlesEditor.tick();
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ export class EditorRun {
|
||||
if (!this.writing) this.stale = true;
|
||||
}
|
||||
|
||||
tick(dt: number): void {
|
||||
/** `step` is what the global clock moved this frame – already scaled and capped. */
|
||||
tick(step: number): void {
|
||||
if (this.state.mode !== 'edit') {
|
||||
// Playing runs every behavior anyway – and on the runtime copy, which this world is not on.
|
||||
this.stop();
|
||||
@@ -56,7 +57,7 @@ export class EditorRun {
|
||||
this.hadFocus = focus;
|
||||
this.input.releaseAll();
|
||||
}
|
||||
world.tick(Math.min(dt, 0.1));
|
||||
world.tick(step);
|
||||
this.input.endFrame();
|
||||
if (this.control(world)) return;
|
||||
this.commit(world.syncToModel());
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type Trace,
|
||||
} from '../runtime/systems/BehaviorSystem';
|
||||
import type { AnimationClock } from '../runtime/Entity';
|
||||
import { gameClock } from '../core/time';
|
||||
import { Input } from '../runtime/systems/Input';
|
||||
import { World } from '../runtime/World';
|
||||
import { ui } from '../ui';
|
||||
@@ -85,6 +86,8 @@ export class PlaySession {
|
||||
const scene = this.state.activeScene;
|
||||
if (!scene) return;
|
||||
ui().focus.blur();
|
||||
// A run starts at second zero, whatever the editor's previews have been counting up to.
|
||||
gameClock.reset();
|
||||
const runtime = this.state.enterPlay();
|
||||
const world = World.instantiate(runtime, scene.id);
|
||||
this.world = world;
|
||||
@@ -104,6 +107,8 @@ export class PlaySession {
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// The run's seconds end with it: the editor goes back to the picture of a freshly loaded scene.
|
||||
gameClock.reset();
|
||||
const world = this.world;
|
||||
this.world = null;
|
||||
this.behaviors = null;
|
||||
@@ -117,7 +122,8 @@ export class PlaySession {
|
||||
this.state.setNotice(lastError ?? '', 'error');
|
||||
}
|
||||
|
||||
tick(dt: number): void {
|
||||
/** `step` is what the global clock moved this frame – already scaled and capped. */
|
||||
tick(step: number): void {
|
||||
const world = this.world;
|
||||
if (!world) return;
|
||||
if (this.state.paused) {
|
||||
@@ -127,7 +133,7 @@ export class PlaySession {
|
||||
world.dropPointerEdges();
|
||||
return;
|
||||
}
|
||||
world.tick(Math.min(dt, 0.1));
|
||||
world.tick(step);
|
||||
this.input.endFrame();
|
||||
if (world.stopRequested) {
|
||||
this.stop();
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type Application, Color, type ColorSource, type Container, Mesh, Rectan
|
||||
import { resolveObjects } from '../core/model/instance';
|
||||
import { findObjectHolder } from '../core/model/Project';
|
||||
import type { FrameRect } from '../core/model/texture';
|
||||
import { effectiveScale, gameClock } from '../core/time';
|
||||
import { activeDrag, Button, ui, Widget } from '../ui';
|
||||
import { particleLibrary } from '../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../viewport/ShaderLibrary';
|
||||
@@ -455,6 +456,16 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
walk(app.stage);
|
||||
return n;
|
||||
},
|
||||
/** The global clock: the second it stands at, the scale set, and the one a pause makes of it. */
|
||||
globalTime: () => ({
|
||||
now: gameClock.now,
|
||||
scale: gameClock.scale,
|
||||
effective: effectiveScale(gameClock.scale, state.paused),
|
||||
}),
|
||||
/** Sets the global time scale – there is no slider for it in the GUI yet. */
|
||||
setTimeScale: (scale: number) => {
|
||||
gameClock.scale = scale;
|
||||
},
|
||||
/** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */
|
||||
shaderTime: (at: number | null) => shaderLibrary.freezeTime(at),
|
||||
/** The same for particle time, kept apart so one can be held without the other. */
|
||||
|
||||
@@ -91,14 +91,8 @@ export class ParticlePreview extends Widget {
|
||||
const texture = ref ? textureLibrary.get(ref, SHEET_EDGE) : null;
|
||||
// The source sits in the middle of the panel; a fountain then has room above and below it.
|
||||
const m = translation(this.w / 2, this.h / 2);
|
||||
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, this.clock());
|
||||
}
|
||||
|
||||
/** Holding at second t means handing the field a clock that reads exactly t since it started. */
|
||||
private clock(): number {
|
||||
const now = particleLibrary.now();
|
||||
if (this.held === null) return now;
|
||||
return now - this.field.elapsed(now) + this.held;
|
||||
this.field.hold(this.held);
|
||||
this.field.sync(build.compiled, props, params, m, texture ?? Texture.WHITE, particleLibrary.now());
|
||||
}
|
||||
|
||||
override destroy(): void {
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import type { ParticlesAsset } from '../core/model/Asset';
|
||||
import { compileParticles, type ParticlesBuild, particlesFingerprint } from '../core/particles/compile';
|
||||
import { gameClock } from '../core/time';
|
||||
|
||||
/**
|
||||
* Compiled particle graphs, one per asset, recompiled when the graph's content changes – the same
|
||||
* contract the `ShaderLibrary` keeps, with a clock of its own so a test can hold particle time
|
||||
* contract the `ShaderLibrary` keeps, including its own freeze so a test can hold particle time
|
||||
* still without freezing the shaders too.
|
||||
*/
|
||||
export class ParticleLibrary {
|
||||
private readonly builds = new Map<string, { fingerprint: string; build: ParticlesBuild }>();
|
||||
/** How often a graph went through the compiler – what the checks read to prove a move did not. */
|
||||
compiles = 0;
|
||||
private readonly started = performance.now();
|
||||
private frozenAt: number | null = null;
|
||||
|
||||
/** Seconds since the library came up – the clock every source's age is measured on. */
|
||||
/** The clock every source's age is measured on: the global one, so a pause reaches the fields. */
|
||||
now(): number {
|
||||
return this.frozenAt ?? (performance.now() - this.started) / 1000;
|
||||
return this.frozenAt ?? gameClock.now;
|
||||
}
|
||||
|
||||
freezeTime(at: number | null): void {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ShaderAsset } from '../core/model/Asset';
|
||||
import { findAssetOf, type Project } from '../core/model/Project';
|
||||
import { compileShader, shaderFingerprint, type ShaderBuild } from '../core/shader/compile';
|
||||
import { gameClock } from '../core/time';
|
||||
import type { ShaderSource } from './shaders';
|
||||
|
||||
/**
|
||||
@@ -11,13 +12,12 @@ export class ShaderLibrary {
|
||||
private readonly builds = new Map<string, { fingerprint: string; build: ShaderBuild }>();
|
||||
/** How often a graph went through the compiler – what the checks read to prove a move did not. */
|
||||
compiles = 0;
|
||||
private readonly started = performance.now();
|
||||
/** Set from the test hook: the clock stands at this second until released. */
|
||||
private frozenAt: number | null = null;
|
||||
|
||||
/** Seconds since the library came up – what a Time node reads. */
|
||||
/** What a Time node reads: the global clock, so a pause or a time scale reaches the shaders. */
|
||||
now(): number {
|
||||
return this.frozenAt ?? (performance.now() - this.started) / 1000;
|
||||
return this.frozenAt ?? gameClock.now;
|
||||
}
|
||||
|
||||
freezeTime(at: number | null): void {
|
||||
|
||||
@@ -58,6 +58,8 @@ export class ParticleField {
|
||||
private highest = -1;
|
||||
/** The Emit count last seen; a change is a request to start over. */
|
||||
private emits = -1;
|
||||
/** Set while a panel holds the field at one age, instead of reading it off the clock. */
|
||||
private heldAge: number | null = null;
|
||||
|
||||
constructor() {
|
||||
this.container.eventMode = 'none';
|
||||
@@ -68,6 +70,15 @@ export class ParticleField {
|
||||
return this.startedAt === null ? -1 : now - this.startedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands the field at exactly this age; null lets it read the clock again. Handing it a clock that
|
||||
* *would* read that age instead costs the last bits of the second – 0.5 comes out as
|
||||
* 0.49999999999999994 about one time in ten, which is one particle short of the field due there.
|
||||
*/
|
||||
hold(age: number | null): void {
|
||||
this.heldAge = age;
|
||||
}
|
||||
|
||||
/** Starts the clock over: a burst goes out again, a continuous stream begins at particle 0. */
|
||||
restart(now: number): void {
|
||||
this.startedAt = now;
|
||||
@@ -110,9 +121,15 @@ export class ParticleField {
|
||||
if (this.emits >= 0) this.restart(now);
|
||||
this.emits = emits;
|
||||
}
|
||||
if (this.startedAt === null) this.startedAt = now;
|
||||
// An origin in the future means the global clock went back to zero under the field (a play run
|
||||
// began or ended); ageing every particle backwards from there is no state worth keeping.
|
||||
let startedAt = this.startedAt;
|
||||
if (startedAt === null || now < startedAt) {
|
||||
this.restart(now);
|
||||
startedAt = now;
|
||||
}
|
||||
const settings = emitterSettings(props);
|
||||
const time = fieldTime(settings, now - this.startedAt);
|
||||
const time = fieldTime(settings, this.heldAge ?? now - startedAt);
|
||||
if (emitting) this.emittedUntil = time;
|
||||
|
||||
const range = compiled ? liveRange(settings, time, this.emittedUntil) : { from: 0, to: -1 };
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/** The global clock: one scaled step a frame, which is what the world, the particles and the shaders share. */
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { effectiveScale, GameClock, MAX_FRAME, timeStep } from '../../src/core/time';
|
||||
|
||||
describe('effectiveScale', () => {
|
||||
it('is the scale itself while running', () => {
|
||||
expect(effectiveScale(1, false)).toBe(1);
|
||||
expect(effectiveScale(2.5, false)).toBe(2.5);
|
||||
});
|
||||
|
||||
it('is zero while paused, whatever the scale says', () => {
|
||||
expect(effectiveScale(1, true)).toBe(0);
|
||||
expect(effectiveScale(4, true)).toBe(0);
|
||||
});
|
||||
|
||||
it('is zero for anything that is not a positive number', () => {
|
||||
expect(effectiveScale(0, false)).toBe(0);
|
||||
expect(effectiveScale(-1, false)).toBe(0);
|
||||
expect(effectiveScale(Number.NaN, false)).toBe(0);
|
||||
expect(effectiveScale(Number.POSITIVE_INFINITY, false)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeStep', () => {
|
||||
it('is the frame itself at scale 1', () => {
|
||||
expect(timeStep(0.016, 1, false)).toBeCloseTo(0.016, 9);
|
||||
});
|
||||
|
||||
it('caps the frame before scaling it', () => {
|
||||
// A stalled frame must not jump the game, and at scale 2 the cap is still on the frame, so
|
||||
// twice speed really is twice the step.
|
||||
expect(timeStep(5, 1, false)).toBe(MAX_FRAME);
|
||||
expect(timeStep(5, 2, false)).toBe(MAX_FRAME * 2);
|
||||
});
|
||||
|
||||
it('never runs backwards', () => {
|
||||
expect(timeStep(-0.5, 1, false)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameClock', () => {
|
||||
let clock: GameClock;
|
||||
|
||||
beforeEach(() => {
|
||||
clock = new GameClock();
|
||||
});
|
||||
|
||||
it('starts at zero and runs in real time', () => {
|
||||
expect(clock.now).toBe(0);
|
||||
expect(clock.scale).toBe(1);
|
||||
expect(clock.advance(0.02)).toBeCloseTo(0.02, 9);
|
||||
clock.advance(0.02);
|
||||
expect(clock.now).toBeCloseTo(0.04, 9);
|
||||
});
|
||||
|
||||
it('stands still while paused and picks up where it stopped', () => {
|
||||
clock.advance(0.05);
|
||||
expect(clock.advance(0.05, true)).toBe(0);
|
||||
expect(clock.advance(0.05, true)).toBe(0);
|
||||
expect(clock.now).toBeCloseTo(0.05, 9);
|
||||
clock.advance(0.05);
|
||||
expect(clock.now).toBeCloseTo(0.1, 9);
|
||||
});
|
||||
|
||||
it('runs at the scale it is set to', () => {
|
||||
clock.scale = 2;
|
||||
clock.advance(0.03);
|
||||
expect(clock.now).toBeCloseTo(0.06, 9);
|
||||
clock.scale = 0;
|
||||
clock.advance(0.03);
|
||||
expect(clock.now).toBeCloseTo(0.06, 9);
|
||||
});
|
||||
|
||||
it('goes back to zero on reset', () => {
|
||||
clock.advance(0.08);
|
||||
clock.reset();
|
||||
expect(clock.now).toBe(0);
|
||||
});
|
||||
});
|
||||
+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