diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 3e71dac..74e4729 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -171,6 +171,9 @@ export interface ActSceneState { facing: number tick: number tickRate: number + simTps: number + renderFps: number + fps: number /** * GPU draw calls the last frame actually issued. * @@ -208,7 +211,7 @@ const state: ActSceneState = { pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [], character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1, skippedDraws: 0, objects: 0, loadMs: 0, - missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, + missing: 0, walkable: 0, x: 0, y: 0, facing: 0, tick: 0, tickRate: 0, simTps: 0, renderFps: 0, fps: 0, drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0, zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null, dialog: [], npcs: 0, npcsNear: [], @@ -1961,6 +1964,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: state.quadsDrawn = renderer.quadsSubmitted state.culledDraws = culledDraws state.tickRate = loop.tickRate + state.simTps = loop.simTps + state.renderFps = loop.fps + state.fps = loop.fps state.zoom = Number(camera.zoom.toFixed(2)) state.canvasWidth = canvas.width state.canvasHeight = canvas.height @@ -1973,11 +1979,14 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started: // reader and stops the overlay from competing with the frame it describes. if (renderStarted >= hudNextMs) { hudNextMs = renderStarted + HUD_INTERVAL_MS + const simTpsText = loop.simTps.toFixed(1) + const renderFpsText = loop.fps.toFixed(0) + const renderMsText = (loop.frameMs > 0 ? loop.frameMs : state.renderMs).toFixed(1) hud.textContent = `${runtime.level} · ${runtime.quadrant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}` + ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙` + ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}` - + ` · 对象 ${String(runtime.objects)} · ${loop.tickRate.toFixed(1)} tps · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` - + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})· ${state.renderMs.toFixed(1)} ms` + + ` · 对象 ${String(runtime.objects)} · sim: ${simTpsText} tps | render: ${renderFpsText} fps (${renderMsText}ms) · pos ${engine.world.player.x.toFixed(0)},${engine.world.player.y.toFixed(0)}` + + ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})` + (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '') } }, diff --git a/src/sim/loop.ts b/src/sim/loop.ts index 8e5eebd..eb684d0 100644 --- a/src/sim/loop.ts +++ b/src/sim/loop.ts @@ -4,10 +4,15 @@ * Diablo's simulation runs at 25 ticks per second, and that rate is not * cosmetic: animation frames, movement, and monster AI all advance per tick, * so anything derived from wall-clock time would diverge from the original - * behaviour. The loop therefore ticks at a fixed rate and lets rendering happen - * whenever the browser paints, interpolating only the camera-facing positions - * the caller asks to smooth. + * behaviour. + * + * To avoid tying simulation progress to rendering performance (where frame drops + * or heavy scenes would artificially drag down simulation rate or catastrophically + * drop backlog time), simulation ticks are driven by a dedicated timer mechanism + * at the deterministic rate (1000 / tickRate). Meanwhile, requestAnimationFrame + * handles rendering and frame metrics with proper interpolation alpha. */ + /** Loop wiring. */ export interface LoopOptions { /** Ticks per second (Diablo's simulation rate: 25). */ @@ -22,25 +27,39 @@ export interface LoopOptions { * @param frameMs - wall-clock duration of the previous frame. */ readonly onRender: (alpha: number, frameMs: number) => void - /** Most ticks to run in one frame before discarding the backlog. */ + /** Most ticks to run in one timer/frame step before clamping the backlog. */ readonly maxCatchUp?: number + /** Optional custom clock source for testing. Defaults to performance.now. */ + readonly clock?: () => number } /** - * A fixed-rate loop driven by `requestAnimationFrame`. + * A fixed-rate simulation loop decoupled from rendering. */ export class GameLoop { private readonly options: LoopOptions - private readonly tickMs: number - private readonly maxCatchUp: number - private accumulator = 0 - private lastTime = 0 - private handle = 0 - private running = false + readonly tickMs: number + readonly maxCatchUp: number + + private rawAccumulator = 0 private consumed = 0 - private rateWindowStart = 0 - private rateWindowTicks = 0 - private measuredRate = 0 + private running = false + + // Simulation timer & metrics + private simTimerHandle: ReturnType | null = null + private lastSimTime = 0 + private simWindowStart = 0 + private simTicksInWindow = 0 + private simVirtualElapsed = 0 + private measuredSimRate = 0 + + // Render loop & metrics + private renderHandle = 0 + private lastRenderTime = 0 + private lastFrameMs = 0 + private renderWindowStart = 0 + private renderFramesInWindow = 0 + private measuredFps = 0 /** * @param options - loop wiring. @@ -48,7 +67,12 @@ export class GameLoop { constructor(options: LoopOptions) { this.options = options this.tickMs = 1000 / options.tickRate - this.maxCatchUp = options.maxCatchUp ?? 5 + this.maxCatchUp = options.maxCatchUp ?? 10 + } + + /** Current time in ms from options.clock or performance.now. */ + private now(): number { + return this.options.clock ? this.options.clock() : performance.now() } /** Ticks simulated since the loop started. */ @@ -56,49 +80,239 @@ export class GameLoop { return this.consumed } - /** Measured ticks per second over the last window (0 until one has elapsed). */ + /** Measured simulation ticks per second over the last window (or target rate when active). */ get tickRate(): number { - return this.measuredRate + return this.simTps } - /** Begin ticking. Idempotent. */ + /** Simulation ticks per second. */ + get simTps(): number { + if (this.measuredSimRate > 0) return this.measuredSimRate + const elapsed = this.now() - this.simWindowStart + if (elapsed >= 200 && this.simTicksInWindow > 0) { + return (this.simTicksInWindow * 1000) / elapsed + } + if (this.simVirtualElapsed >= 200 && this.simTicksInWindow > 0) { + return (this.simTicksInWindow * 1000) / this.simVirtualElapsed + } + return this.running ? this.options.tickRate : 0 + } + + /** Measured render frames per second. */ + get fps(): number { + return this.renderFps + } + + /** Render frames per second. */ + get renderFps(): number { + if (this.measuredFps > 0) return this.measuredFps + const elapsed = this.now() - this.renderWindowStart + if (elapsed >= 200 && this.renderFramesInWindow > 0) { + return (this.renderFramesInWindow * 1000) / elapsed + } + return this.lastFrameMs > 0 ? Math.min(120, 1000 / this.lastFrameMs) : 0 + } + + /** Duration of the last render frame in milliseconds. */ + get frameMs(): number { + return this.lastFrameMs + } + + /** Whether the loop is currently running. */ + get isRunning(): boolean { + return this.running + } + + /** Current simulation accumulator in milliseconds. */ + get accumulator(): number { + const elapsedSinceLastSim = this.running ? Math.max(0, this.now() - this.lastSimTime) : 0 + return this.rawAccumulator + elapsedSinceLastSim + } + + /** Interpolation factor in 0..1 between the current tick and the next tick. */ + get alpha(): number { + return Math.min(1, Math.max(0, this.accumulator / this.tickMs)) + } + + /** + * Manually advance the simulation by deltaMs milliseconds. + * Useful for headless simulation, test clocks, and deterministic testing. + * + * @param deltaMs - Milliseconds to advance. + * @param maxCatchUp - Optional override for max ticks to simulate in this step (0 = unlimited). + */ + step(deltaMs: number, maxCatchUp?: number): void { + if (deltaMs <= 0) return + this.rawAccumulator += deltaMs + this.lastSimTime = this.now() + + const maxSteps = maxCatchUp !== undefined ? maxCatchUp : (this.options.maxCatchUp !== undefined ? this.maxCatchUp : 0) + let steps = 0 + while (this.rawAccumulator >= this.tickMs && (maxSteps <= 0 || steps < maxSteps)) { + this.rawAccumulator -= this.tickMs + this.consumed += 1 + this.simTicksInWindow += 1 + this.options.onTick(this.consumed) + steps += 1 + } + + if (maxSteps > 0) { + const maxBacklog = this.tickMs * maxSteps + if (this.rawAccumulator > maxBacklog) { + this.rawAccumulator = maxBacklog + } + } + + this.simVirtualElapsed += deltaMs + if (this.simVirtualElapsed >= 1000) { + this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed + this.simVirtualElapsed = 0 + this.simTicksInWindow = 0 + } else if (this.simVirtualElapsed >= 200) { + this.measuredSimRate = (this.simTicksInWindow * 1000) / this.simVirtualElapsed + } + } + + /** + * Manually trigger one render call. + */ + render(frameMs?: number): void { + const elapsed = frameMs ?? (this.lastFrameMs > 0 ? this.lastFrameMs : 16.6) + this.lastFrameMs = elapsed + this.options.onRender(this.alpha, elapsed) + } + + /** Advance simulation according to elapsed wall-clock time. */ + private advanceSimulation(now: number): void { + const elapsed = now - this.lastSimTime + if (elapsed <= 0) return + this.lastSimTime = now + this.rawAccumulator += elapsed + + let steps = 0 + while (this.rawAccumulator >= this.tickMs && (this.maxCatchUp <= 0 || steps < this.maxCatchUp)) { + this.rawAccumulator -= this.tickMs + this.consumed += 1 + this.simTicksInWindow += 1 + this.options.onTick(this.consumed) + steps += 1 + } + + // Prevent catastrophic discard: clamp to max backlog instead of wiping to 0 + const maxBacklog = this.tickMs * this.maxCatchUp + if (this.rawAccumulator > maxBacklog) { + this.rawAccumulator = maxBacklog + } + + const windowElapsed = now - this.simWindowStart + if (windowElapsed >= 1000) { + this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed + this.simWindowStart = now + this.simTicksInWindow = 0 + } else if (this.measuredSimRate === 0 && windowElapsed >= 200) { + this.measuredSimRate = (this.simTicksInWindow * 1000) / windowElapsed + } + } + + /** Begin ticking and rendering. Idempotent. */ start(): void { if (this.running) return this.running = true - this.lastTime = performance.now() - this.rateWindowStart = this.lastTime - this.rateWindowTicks = 0 - const step = (now: number): void => { - if (!this.running) return - const elapsed = now - this.lastTime - this.lastTime = now - this.accumulator += elapsed - let steps = 0 - while (this.accumulator >= this.tickMs && steps < this.maxCatchUp) { - this.accumulator -= this.tickMs - this.consumed += 1 - this.rateWindowTicks += 1 - this.options.onTick(this.consumed) - steps += 1 - } - // A long stall (a backgrounded tab, a slow load) must not replay minutes - // of simulation on the next frame: drop the backlog instead. - if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0 - if (now - this.rateWindowStart >= 1000) { - this.measuredRate = (this.rateWindowTicks * 1000) / (now - this.rateWindowStart) - this.rateWindowStart = now - this.rateWindowTicks = 0 - } - this.options.onRender(this.accumulator / this.tickMs, elapsed) - this.handle = requestAnimationFrame(step) - } - this.handle = requestAnimationFrame(step) + + const now = this.now() + this.lastSimTime = now + this.lastRenderTime = now + this.simWindowStart = now + this.renderWindowStart = now + this.simTicksInWindow = 0 + this.renderFramesInWindow = 0 + + this.startSimTimer() + this.startRenderLoop() } - /** Stop ticking. Idempotent. */ + /** Start the self-correcting timer loop for simulation ticks. */ + private startSimTimer(): void { + if (this.simTimerHandle !== null) return + let nextTick = this.now() + this.tickMs + + const simStep = (): void => { + if (!this.running) return + const now = this.now() + this.advanceSimulation(now) + + // Self-correct next tick timestamp to eliminate drift + nextTick += this.tickMs + if (nextTick < now - this.tickMs * this.maxCatchUp) { + nextTick = now + this.tickMs + } else { + while (nextTick <= now) { + nextTick += this.tickMs + } + } + const delay = Math.max(0, nextTick - this.now()) + this.simTimerHandle = setTimeout(simStep, delay) + } + + this.simTimerHandle = setTimeout(simStep, this.tickMs) + } + + /** Start requestAnimationFrame render loop. */ + private startRenderLoop(): void { + if (this.renderHandle !== 0) return + + const renderStep = (): void => { + if (!this.running) return + const now = this.now() + const frameMs = this.lastRenderTime > 0 ? now - this.lastRenderTime : 16.6 + this.lastRenderTime = now + this.lastFrameMs = frameMs + this.renderFramesInWindow += 1 + + const renderWindowElapsed = now - this.renderWindowStart + if (renderWindowElapsed >= 1000) { + this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed + this.renderWindowStart = now + this.renderFramesInWindow = 0 + } else if (this.measuredFps === 0 && renderWindowElapsed >= 200) { + this.measuredFps = (this.renderFramesInWindow * 1000) / renderWindowElapsed + } + + this.options.onRender(this.alpha, frameMs) + + if (this.running) { + this.renderHandle = this.requestFrame(renderStep) + } + } + + this.renderHandle = this.requestFrame(renderStep) + } + + private requestFrame(cb: (time: number) => void): number { + if (typeof requestAnimationFrame === 'function') { + return requestAnimationFrame(cb) + } + return setTimeout(() => cb(this.now()), 16) as unknown as number + } + + private cancelFrame(handle: number): void { + if (typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(handle) + return + } + clearTimeout(handle as unknown as ReturnType) + } + + /** Stop ticking and rendering. Idempotent. */ stop(): void { this.running = false - if (this.handle !== 0) cancelAnimationFrame(this.handle) - this.handle = 0 + if (this.simTimerHandle !== null) { + clearTimeout(this.simTimerHandle) + this.simTimerHandle = null + } + if (this.renderHandle !== 0) { + this.cancelFrame(this.renderHandle) + this.renderHandle = 0 + } } } diff --git a/tests/loop.test.ts b/tests/loop.test.ts new file mode 100644 index 0000000..4ad9394 --- /dev/null +++ b/tests/loop.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi } from 'vitest' +import { GameLoop } from '../src/sim/loop.ts' + +describe('GameLoop (Issue #19: Decouple simulation from rAF)', () => { + it('1. Deterministic ticking at 25 tps in headless/step mode', () => { + let tickCount = 0 + const ticks: number[] = [] + + const loop = new GameLoop({ + tickRate: 25, // 40ms per tick + onTick: (tick) => { + tickCount++ + ticks.push(tick) + }, + onRender: () => {} + }) + + expect(loop.tickMs).toBe(40) + expect(loop.tick).toBe(0) + + // Advancing by less than tickMs does not produce a tick + loop.step(20) + expect(tickCount).toBe(0) + expect(loop.accumulator).toBe(20) + expect(loop.alpha).toBeCloseTo(0.5, 2) + + // Advancing another 20ms completes 40ms -> exactly 1 tick + loop.step(20) + expect(tickCount).toBe(1) + expect(loop.tick).toBe(1) + expect(loop.accumulator).toBe(0) + expect(loop.alpha).toBe(0) + + // Advancing by 80ms produces exactly 2 ticks + loop.step(80) + expect(tickCount).toBe(3) + expect(loop.tick).toBe(3) + expect(loop.accumulator).toBe(0) + + // Advancing by 1000ms produces exactly 25 ticks + loop.step(1000) + expect(tickCount).toBe(28) // 3 + 25 + expect(loop.tick).toBe(28) + expect(loop.tickRate).toBe(25.0) + expect(loop.simTps).toBe(25.0) + }) + + it('1b. Deterministic ticking at 25 tps with timers', async () => { + let tickCount = 0 + + const loop = new GameLoop({ + tickRate: 25, // 40ms per tick + onTick: () => { + tickCount++ + }, + onRender: () => {} + }) + + loop.start() + expect(loop.isRunning).toBe(true) + + // Wait ~260ms -> should produce at least 5 ticks (40ms * 5 = 200ms) + await new Promise(resolve => setTimeout(resolve, 260)) + loop.stop() + expect(loop.isRunning).toBe(false) + + expect(tickCount).toBeGreaterThanOrEqual(5) + expect(tickCount).toBeLessThanOrEqual(8) + }) + + it('2. Artificial render delay test: when onRender has a 50ms stall, simulation ticks do NOT drop and world.tick advances smoothly', async () => { + const world = { tick: 0 } + let renderCalls = 0 + + const loop = new GameLoop({ + tickRate: 25, // 40ms per tick + onTick: (tick) => { + world.tick = tick + }, + onRender: () => { + renderCalls++ + // Simulate heavy render frame stall of 50ms + const stallStart = performance.now() + while (performance.now() - stallStart < 50) { + // busy wait + } + } + }) + + loop.start() + + // Run for ~220ms + // With 50ms render stall per frame, at most 4 render calls can happen (~20 FPS) + // But simulation runs at 25 TPS (40ms per tick) -> should produce ~5 ticks + await new Promise(resolve => setTimeout(resolve, 220)) + loop.stop() + + expect(renderCalls).toBeLessThanOrEqual(4) + expect(world.tick).toBeGreaterThanOrEqual(5) + // Simulation TPS remains high despite slowed rendering + expect(loop.simTps).toBeGreaterThanOrEqual(20) + }) + + it('3. Accumulator recovery and no catastrophic drop on transient lag', () => { + let tickCount = 0 + + const loop = new GameLoop({ + tickRate: 25, // 40ms per tick + maxCatchUp: 5, // max 5 ticks (200ms) per step + onTick: () => { + tickCount++ + }, + onRender: () => {} + }) + + // Transient lag of 150ms (3.75 ticks) + loop.step(150) + // Consumed 3 ticks (120ms), 30ms remaining + expect(tickCount).toBe(3) + expect(loop.accumulator).toBe(30) + expect(loop.alpha).toBeCloseTo(30 / 40, 2) + + // Next step of 20ms: 30ms + 20ms = 50ms >= 40ms -> consumes 1 tick, 10ms left + loop.step(20) + expect(tickCount).toBe(4) + expect(loop.accumulator).toBe(10) + expect(loop.alpha).toBeCloseTo(10 / 40, 2) + + // Huge lag of 500ms (exceeding maxCatchUp * tickMs = 200ms) + // Old implementation had `if (this.accumulator > this.tickMs * this.maxCatchUp) this.accumulator = 0` + // which wiped out all remaining simulation time! + // New implementation clamps the backlog to maxBacklog (200ms) instead of wiping to 0. + loop.step(500) + // 5 ticks executed in this step (tickCount 4 + 5 = 9) + expect(tickCount).toBe(9) + // Accumulator is clamped to maxBacklog (200ms), NOT catastrophically dropped to 0! + expect(loop.accumulator).toBe(200) + + // Subsequent steps recover and consume the preserved backlog + loop.step(40) + // Consumes 5 ticks from the 200ms backlog + 40ms new time + expect(tickCount).toBe(14) + expect(loop.accumulator).toBe(40) + + loop.step(40) + expect(tickCount).toBe(16) + expect(loop.accumulator).toBe(0) + }) + + it('4. Clean start / stop lifecycle and timer cleanup', async () => { + let tickCount = 0 + let renderCount = 0 + + const loop = new GameLoop({ + tickRate: 25, + onTick: () => { + tickCount++ + }, + onRender: () => { + renderCount++ + } + }) + + expect(loop.isRunning).toBe(false) + + // Start + loop.start() + expect(loop.isRunning).toBe(true) + + // Calling start() again is idempotent + loop.start() + expect(loop.isRunning).toBe(true) + + await new Promise(resolve => setTimeout(resolve, 60)) + expect(tickCount).toBeGreaterThanOrEqual(1) + expect(renderCount).toBeGreaterThanOrEqual(1) + + // Stop + loop.stop() + expect(loop.isRunning).toBe(false) + const stoppedTicks = tickCount + const stoppedRenders = renderCount + + // Calling stop() again is idempotent + loop.stop() + expect(loop.isRunning).toBe(false) + + // Wait and verify timers are cleaned up and no more callbacks fire + await new Promise(resolve => setTimeout(resolve, 100)) + expect(tickCount).toBe(stoppedTicks) + expect(renderCount).toBe(stoppedRenders) + + // Restart cleanly + loop.start() + expect(loop.isRunning).toBe(true) + await new Promise(resolve => setTimeout(resolve, 60)) + expect(tickCount).toBeGreaterThan(stoppedTicks) + expect(renderCount).toBeGreaterThan(stoppedRenders) + loop.stop() + }) + + it('5. Interpolation alpha calculation and manual render stepping', () => { + let lastAlpha = -1 + let lastFrameMs = -1 + + const loop = new GameLoop({ + tickRate: 25, // 40ms + onTick: () => {}, + onRender: (alpha, frameMs) => { + lastAlpha = alpha + lastFrameMs = frameMs + } + }) + + loop.render(16.6) + expect(lastAlpha).toBe(0) + expect(lastFrameMs).toBeCloseTo(16.6, 1) + + loop.step(10) // accumulator = 10ms (10/40 = 0.25) + loop.render(16.6) + expect(lastAlpha).toBeCloseTo(0.25, 2) + + loop.step(20) // accumulator = 30ms (30/40 = 0.75) + loop.render(16.6) + expect(lastAlpha).toBeCloseTo(0.75, 2) + + loop.step(10) // accumulator = 40ms -> ticks, accumulator = 0ms + loop.render(16.6) + expect(lastAlpha).toBe(0) + }) +})