Merge branch 'fix/issue-19' into main
This commit is contained in:
commit
6f01c8de89
|
|
@ -181,6 +181,9 @@ export interface ActSceneState {
|
||||||
facing: number
|
facing: number
|
||||||
tick: number
|
tick: number
|
||||||
tickRate: number
|
tickRate: number
|
||||||
|
simTps: number
|
||||||
|
renderFps: number
|
||||||
|
fps: number
|
||||||
/**
|
/**
|
||||||
* GPU draw calls the last frame actually issued.
|
* GPU draw calls the last frame actually issued.
|
||||||
*
|
*
|
||||||
|
|
@ -220,7 +223,7 @@ const state: ActSceneState = {
|
||||||
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
pagesLoaded: 0, pagesTotal: 0, pagesAtFirstFrame: -1, firstFrameMs: 0, priorityPages: [],
|
||||||
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
character: false, characterMembers: 0, characterFrames: 0, characterGroup: -1,
|
||||||
skippedDraws: 0, objects: 0, loadMs: 0,
|
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,
|
drawCalls: 0, quadsDrawn: 0, culledDraws: 0, renderMs: 0,
|
||||||
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
zoom: 0, canvasWidth: 0, canvasHeight: 0, error: null,
|
||||||
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
|
dialog: [], npcs: 0, npcsNear: [], animatedTiles: 0,
|
||||||
|
|
@ -2054,6 +2057,9 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
state.quadsDrawn = renderer.quadsSubmitted
|
state.quadsDrawn = renderer.quadsSubmitted
|
||||||
state.culledDraws = culledDraws
|
state.culledDraws = culledDraws
|
||||||
state.tickRate = loop.tickRate
|
state.tickRate = loop.tickRate
|
||||||
|
state.simTps = loop.simTps
|
||||||
|
state.renderFps = loop.fps
|
||||||
|
state.fps = loop.fps
|
||||||
state.zoom = Number(camera.zoom.toFixed(2))
|
state.zoom = Number(camera.zoom.toFixed(2))
|
||||||
state.canvasWidth = canvas.width
|
state.canvasWidth = canvas.width
|
||||||
state.canvasHeight = canvas.height
|
state.canvasHeight = canvas.height
|
||||||
|
|
@ -2066,11 +2072,14 @@ function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, started:
|
||||||
// reader and stops the overlay from competing with the frame it describes.
|
// reader and stops the overlay from competing with the frame it describes.
|
||||||
if (renderStarted >= hudNextMs) {
|
if (renderStarted >= hudNextMs) {
|
||||||
hudNextMs = renderStarted + HUD_INTERVAL_MS
|
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.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
hud.textContent = `${runtime.level} · ${runtime.variant} · ${runtime.source === 'pack' ? '资源包' : '读归档'}`
|
||||||
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
|
+ ` · ${String(runtime.cellsX)}×${String(runtime.cellsY)} 格 · ${String(runtime.floors.length)} 地面 / ${String(runtime.walls.length)} 墙`
|
||||||
+ ` · 页 ${String(state.pagesLoaded)}/${String(state.pagesTotal)}`
|
+ ` · 页 ${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(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)})· ${state.renderMs.toFixed(1)} ms`
|
+ ` · ${String(state.quadsDrawn)} 图元 / ${String(state.drawCalls)} 批次(剔除 ${String(culledDraws)})`
|
||||||
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
|
+ (skippedDraws > 0 ? ` · 待页 ${String(skippedDraws)}` : '')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
312
src/sim/loop.ts
312
src/sim/loop.ts
|
|
@ -4,10 +4,15 @@
|
||||||
* Diablo's simulation runs at 25 ticks per second, and that rate is not
|
* 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,
|
* cosmetic: animation frames, movement, and monster AI all advance per tick,
|
||||||
* so anything derived from wall-clock time would diverge from the original
|
* 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
|
* behaviour.
|
||||||
* whenever the browser paints, interpolating only the camera-facing positions
|
*
|
||||||
* the caller asks to smooth.
|
* 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. */
|
/** Loop wiring. */
|
||||||
export interface LoopOptions {
|
export interface LoopOptions {
|
||||||
/** Ticks per second (Diablo's simulation rate: 25). */
|
/** Ticks per second (Diablo's simulation rate: 25). */
|
||||||
|
|
@ -22,25 +27,39 @@ export interface LoopOptions {
|
||||||
* @param frameMs - wall-clock duration of the previous frame.
|
* @param frameMs - wall-clock duration of the previous frame.
|
||||||
*/
|
*/
|
||||||
readonly onRender: (alpha: number, frameMs: number) => void
|
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
|
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 {
|
export class GameLoop {
|
||||||
private readonly options: LoopOptions
|
private readonly options: LoopOptions
|
||||||
private readonly tickMs: number
|
readonly tickMs: number
|
||||||
private readonly maxCatchUp: number
|
readonly maxCatchUp: number
|
||||||
private accumulator = 0
|
|
||||||
private lastTime = 0
|
private rawAccumulator = 0
|
||||||
private handle = 0
|
|
||||||
private running = false
|
|
||||||
private consumed = 0
|
private consumed = 0
|
||||||
private rateWindowStart = 0
|
private running = false
|
||||||
private rateWindowTicks = 0
|
|
||||||
private measuredRate = 0
|
// Simulation timer & metrics
|
||||||
|
private simTimerHandle: ReturnType<typeof setTimeout> | 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.
|
* @param options - loop wiring.
|
||||||
|
|
@ -48,7 +67,12 @@ export class GameLoop {
|
||||||
constructor(options: LoopOptions) {
|
constructor(options: LoopOptions) {
|
||||||
this.options = options
|
this.options = options
|
||||||
this.tickMs = 1000 / options.tickRate
|
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. */
|
/** Ticks simulated since the loop started. */
|
||||||
|
|
@ -56,49 +80,239 @@ export class GameLoop {
|
||||||
return this.consumed
|
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 {
|
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 {
|
start(): void {
|
||||||
if (this.running) return
|
if (this.running) return
|
||||||
this.running = true
|
this.running = true
|
||||||
this.lastTime = performance.now()
|
|
||||||
this.rateWindowStart = this.lastTime
|
const now = this.now()
|
||||||
this.rateWindowTicks = 0
|
this.lastSimTime = now
|
||||||
const step = (now: number): void => {
|
this.lastRenderTime = now
|
||||||
if (!this.running) return
|
this.simWindowStart = now
|
||||||
const elapsed = now - this.lastTime
|
this.renderWindowStart = now
|
||||||
this.lastTime = now
|
this.simTicksInWindow = 0
|
||||||
this.accumulator += elapsed
|
this.renderFramesInWindow = 0
|
||||||
let steps = 0
|
|
||||||
while (this.accumulator >= this.tickMs && steps < this.maxCatchUp) {
|
this.startSimTimer()
|
||||||
this.accumulator -= this.tickMs
|
this.startRenderLoop()
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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<typeof setTimeout>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop ticking and rendering. Idempotent. */
|
||||||
stop(): void {
|
stop(): void {
|
||||||
this.running = false
|
this.running = false
|
||||||
if (this.handle !== 0) cancelAnimationFrame(this.handle)
|
if (this.simTimerHandle !== null) {
|
||||||
this.handle = 0
|
clearTimeout(this.simTimerHandle)
|
||||||
|
this.simTimerHandle = null
|
||||||
|
}
|
||||||
|
if (this.renderHandle !== 0) {
|
||||||
|
this.cancelFrame(this.renderHandle)
|
||||||
|
this.renderHandle = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue