diff --git a/README.md b/README.md index 0949f41a..b411e61b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/time.ts b/src/core/time.ts new file mode 100644 index 00000000..a2df6dc8 --- /dev/null +++ b/src/core/time.ts @@ -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(); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts index 3b3003cc..22b6889d 100644 --- a/src/editor/Editor.ts +++ b/src/editor/Editor.ts @@ -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(); } diff --git a/src/editor/EditorRun.ts b/src/editor/EditorRun.ts index a38bbf0d..affb8ab4 100644 --- a/src/editor/EditorRun.ts +++ b/src/editor/EditorRun.ts @@ -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()); diff --git a/src/editor/PlaySession.ts b/src/editor/PlaySession.ts index 575e1ad5..3bd77eee 100644 --- a/src/editor/PlaySession.ts +++ b/src/editor/PlaySession.ts @@ -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(); diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index a20ae23f..5ed94818 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -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. */ diff --git a/src/editor/particles/ParticlePreview.ts b/src/editor/particles/ParticlePreview.ts index 59719f5f..6a464cbe 100644 --- a/src/editor/particles/ParticlePreview.ts +++ b/src/editor/particles/ParticlePreview.ts @@ -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 { diff --git a/src/viewport/ParticleLibrary.ts b/src/viewport/ParticleLibrary.ts index 6bf14031..8cd76ebf 100644 --- a/src/viewport/ParticleLibrary.ts +++ b/src/viewport/ParticleLibrary.ts @@ -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(); /** 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 { diff --git a/src/viewport/ShaderLibrary.ts b/src/viewport/ShaderLibrary.ts index 0e0ffdf2..0f478a0e 100644 --- a/src/viewport/ShaderLibrary.ts +++ b/src/viewport/ShaderLibrary.ts @@ -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(); /** 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 { diff --git a/src/viewport/particles.ts b/src/viewport/particles.ts index 45777708..ec528b55 100644 --- a/src/viewport/particles.ts +++ b/src/viewport/particles.ts @@ -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 }; diff --git a/tests/core/time.test.ts b/tests/core/time.test.ts new file mode 100644 index 00000000..3c56cdaa --- /dev/null +++ b/tests/core/time.test.ts @@ -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); + }); +}); diff --git a/tools/e2e/18-pause.mjs b/tools/e2e/18-pause.mjs index 5c4f6a37..3eec3307 100644 --- a/tools/e2e/18-pause.mjs +++ b/tools/e2e/18-pause.mjs @@ -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', diff --git a/tools/e2e/25-animation.mjs b/tools/e2e/25-animation.mjs index a221ab44..d9d80856 100644 --- a/tools/e2e/25-animation.mjs +++ b/tools/e2e/25-animation.mjs @@ -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); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index e6ccd0d3..68c67446 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -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()'); diff --git a/tools/e2e/31-particles.mjs b/tools/e2e/31-particles.mjs index 4a272921..6e73b1ac 100644 --- a/tools/e2e/31-particles.mjs +++ b/tools/e2e/31-particles.mjs @@ -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)})`);