fix(desktop): sleep idle pixel egg animation

This commit is contained in:
Jakub Wolniewicz
2026-08-17 14:30:38 +02:00
committed by Teknium
parent eb63c254bc
commit 36d5dd3aee
2 changed files with 229 additions and 17 deletions
@@ -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])