Merge branch 'fix/issue-19' into main
This commit is contained in:
commit
6f01c8de89
|
|
@ -181,6 +181,9 @@ export interface ActSceneState {
|
|||
facing: number
|
||||
tick: number
|
||||
tickRate: number
|
||||
simTps: number
|
||||
renderFps: number
|
||||
fps: number
|
||||
/**
|
||||
* GPU draw calls the last frame actually issued.
|
||||
*
|
||||
|
|
@ -220,7 +223,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: [], animatedTiles: 0,
|
||||
|
|
@ -2054,6 +2057,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
|
||||
|
|
@ -2066,11 +2072,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.variant} · ${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)}` : '')
|
||||
}
|
||||
},
|
||||
|
|
|
|||
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
|
||||
* 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<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.
|
||||
|
|
@ -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<typeof setTimeout>)
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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