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
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user