fix(desktop): sleep idle pixel egg animation
This commit is contained in:
committed by
Teknium
parent
eb63c254bc
commit
36d5dd3aee
@@ -0,0 +1,160 @@
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { PixelEggSprite } from './pixel-egg-sprite'
|
||||
|
||||
let root: Root | null = null
|
||||
let container: HTMLDivElement | null = null
|
||||
|
||||
function render(mode: 'bounce' | 'hatch', onDone?: () => void) {
|
||||
container = document.createElement('div')
|
||||
document.body.append(container)
|
||||
root = createRoot(container)
|
||||
|
||||
act(() => {
|
||||
root!.render(<PixelEggSprite mode={mode} onDone={onDone} size={32} />)
|
||||
})
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root!.unmount()
|
||||
})
|
||||
}
|
||||
|
||||
container?.remove()
|
||||
root = null
|
||||
container = null
|
||||
}
|
||||
|
||||
function installRaf(startId = 1) {
|
||||
let nextId = startId
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
|
||||
const request = vi.fn((callback: FrameRequestCallback) => {
|
||||
const id = nextId++
|
||||
frames.set(id, callback)
|
||||
|
||||
return id
|
||||
})
|
||||
|
||||
const cancel = vi.fn((id: number) => {
|
||||
frames.delete(id)
|
||||
})
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: request })
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', { configurable: true, value: cancel })
|
||||
|
||||
return {
|
||||
pending: () => frames.size,
|
||||
request,
|
||||
runNext: (now: number) => {
|
||||
const next = frames.entries().next().value
|
||||
|
||||
if (!next) {
|
||||
throw new Error('No pending RAF')
|
||||
}
|
||||
|
||||
const [id, callback] = next
|
||||
frames.delete(id)
|
||||
callback(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('PixelEggSprite scheduling', () => {
|
||||
beforeEach(() => {
|
||||
;(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
Object.defineProperty(window, 'devicePixelRatio', { configurable: true, value: 1 })
|
||||
vi.stubGlobal(
|
||||
'Image',
|
||||
class {
|
||||
complete = true
|
||||
onerror: (() => undefined) | null = null
|
||||
onload: (() => undefined) | null = null
|
||||
|
||||
set src(_value: string) {
|
||||
queueMicrotask(() => this.onload?.())
|
||||
}
|
||||
} as unknown as typeof Image
|
||||
)
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({ data: new Uint8ClampedArray(32 * 32 * 4) })),
|
||||
imageSmoothingEnabled: false,
|
||||
putImageData: vi.fn()
|
||||
} as unknown as CanvasRenderingContext2D)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('sleeps during the long bounce rest instead of chaining RAFs', async () => {
|
||||
const raf = installRaf()
|
||||
|
||||
render('bounce')
|
||||
await act(async () => undefined)
|
||||
|
||||
act(() => {
|
||||
raf.runNext(0)
|
||||
})
|
||||
|
||||
expect(raf.pending()).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(650)
|
||||
})
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
|
||||
cleanup()
|
||||
expect(raf.pending()).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stops scheduling frames after the final hatch frame', async () => {
|
||||
const raf = installRaf()
|
||||
const onDone = vi.fn()
|
||||
|
||||
render('hatch', onDone)
|
||||
await act(async () => undefined)
|
||||
|
||||
act(() => {
|
||||
raf.runNext(0)
|
||||
})
|
||||
|
||||
for (const now of [190, 380, 570, 760, 950]) {
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(162)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
raf.runNext(now)
|
||||
})
|
||||
}
|
||||
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
|
||||
it('cancels a pending animation frame even when the browser assigns ID zero', async () => {
|
||||
const raf = installRaf(0)
|
||||
|
||||
render('bounce')
|
||||
await act(async () => undefined)
|
||||
|
||||
expect(raf.pending()).toBe(1)
|
||||
cleanup()
|
||||
expect(raf.pending()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -128,9 +128,6 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
|
||||
const offCtx = off.getContext('2d', { willReadFrequently: true })
|
||||
|
||||
let sheet: HTMLImageElement | null = null
|
||||
void loadSheet().then(img => {
|
||||
sheet = img
|
||||
})
|
||||
|
||||
const render = (frame: number) => {
|
||||
if (!sheet || !offCtx) {
|
||||
@@ -161,7 +158,9 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
|
||||
ctx.drawImage(off, 0, 0, FRAME, FRAME, 0, 0, dim, dim)
|
||||
}
|
||||
|
||||
let raf = 0
|
||||
let raf: number | null = null
|
||||
let wakeTimer: number | null = null
|
||||
let stopped = false
|
||||
let step = 0
|
||||
let finished = false
|
||||
// bounce: `nextAt` is when the next thing happens — the next bounce frame, or
|
||||
@@ -169,40 +168,64 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
|
||||
let resting = mode === 'bounce'
|
||||
let nextAt = 0
|
||||
let lastHatch = 0
|
||||
let hatchStarted = false
|
||||
|
||||
const cancelWakeTimer = () => {
|
||||
if (wakeTimer !== null) {
|
||||
window.clearTimeout(wakeTimer)
|
||||
wakeTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const cancelRaf = () => {
|
||||
if (raf !== null) {
|
||||
window.cancelAnimationFrame(raf)
|
||||
raf = null
|
||||
}
|
||||
}
|
||||
|
||||
let scheduleFrame: (delayMs?: number) => void
|
||||
|
||||
const tick = (now: number) => {
|
||||
raf = requestAnimationFrame(tick)
|
||||
raf = null
|
||||
|
||||
if (!sheet) {
|
||||
if (stopped || !sheet) {
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'hatch') {
|
||||
if (!lastHatch) {
|
||||
if (!hatchStarted) {
|
||||
hatchStarted = true
|
||||
lastHatch = now
|
||||
render(HATCH_START)
|
||||
step = HATCH_START
|
||||
render(step)
|
||||
scheduleFrame(frameMs)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (now - lastHatch < frameMs) {
|
||||
const remaining = frameMs - (now - lastHatch)
|
||||
|
||||
if (remaining > 0) {
|
||||
scheduleFrame(remaining)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lastHatch = now
|
||||
const frame = Math.min(HATCH_START + step, lastFrame)
|
||||
render(frame)
|
||||
step = Math.min(step + 1, lastFrame)
|
||||
render(step)
|
||||
|
||||
if (frame >= lastFrame) {
|
||||
if (step >= lastFrame) {
|
||||
if (!finished) {
|
||||
finished = true
|
||||
onDoneRef.current?.()
|
||||
}
|
||||
|
||||
return // hold the cracked-open last frame
|
||||
return // hold the cracked-open last frame without further work
|
||||
}
|
||||
|
||||
step += 1
|
||||
scheduleFrame(frameMs)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -211,11 +234,14 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
|
||||
if (!nextAt) {
|
||||
render(0)
|
||||
nextAt = now + firstDelay // staggered first bounce, per slot
|
||||
scheduleFrame(firstDelay)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (now < nextAt) {
|
||||
scheduleFrame(nextAt - now)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -224,6 +250,7 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
|
||||
step = 0
|
||||
render(0)
|
||||
nextAt = now + frameMs
|
||||
scheduleFrame(frameMs)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -233,19 +260,44 @@ export function PixelEggSprite({ mode, size, index = 0, className, style, onDone
|
||||
if (step >= BOUNCE_FRAMES) {
|
||||
resting = true
|
||||
render(0)
|
||||
nextAt = now + restMs()
|
||||
const delay = restMs()
|
||||
nextAt = now + delay
|
||||
scheduleFrame(delay)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
render(step)
|
||||
nextAt = now + frameMs
|
||||
scheduleFrame(frameMs)
|
||||
}
|
||||
|
||||
raf = requestAnimationFrame(tick)
|
||||
scheduleFrame = (delayMs = 0) => {
|
||||
if (stopped || raf !== null || wakeTimer !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
if (delayMs > 16) {
|
||||
wakeTimer = window.setTimeout(() => {
|
||||
wakeTimer = null
|
||||
scheduleFrame()
|
||||
}, delayMs)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
void loadSheet().then(img => {
|
||||
sheet = img
|
||||
scheduleFrame()
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf)
|
||||
stopped = true
|
||||
cancelWakeTimer()
|
||||
cancelRaf()
|
||||
}
|
||||
}, [mode, size, index])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user