/** * The pet: a single canvas that follows the pointer as a subtle background * decoration. * * Placement: the plugin registers into the shell's `shell.overlay` slot, the * only frame-wide surface a plugin may use. The canvas is rendered there but * styled as a background element rather than a foreground control: it is * `position:fixed` (out of layout, no reflow), fully `pointer-events:none` * (never blocks a click, never captures focus), given a low z-index so it sits * below the other overlay entries and can never cover a dialog/menu, and given * < 1 opacity so the page content behind it shows through. It therefore never * alters or obscures the real page. * * Animation: a requestAnimationFrame loop with a damped spring toward the * pointer (small overshoot, then settle), plus breathe, blink, squash/stretch, * and a pupil that tracks the pointer. The canvas is DPR-scaled so it is crisp * on HiDPI displays. */ import { useEffect, useRef } from 'react' import type { ReactElement } from 'react' import { BODY_BOTTOM, BODY_TOP, CHEEK, MOUTH, PUPIL, SIZE, OPACITY, DAMPING, FPS, STIFFNESS, VECTOR, FRAME_COLS, FRAME_H, FRAME_ROWS, FRAME_W, SPRITE, } from './sprite.ts' const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v > hi ? hi : v) /** Draw the cream face blob (cheeks/eyes/mouth) with the pupil toward (px,py). */ function drawFace(ctx: CanvasRenderingContext2D, r: number, blink: number, pupX: number, pupY: number): void { const eyeY = -r * 0.12 const eyeDX = r * 0.34 const eyeR = r * 0.21 // Cheeks ctx.fillStyle = CHEEK for (const side of [-1, 1]) { ctx.beginPath() ctx.ellipse(side * r * 0.58, r * 0.14, r * 0.15, r * 0.10, 0, 0, Math.PI * 2) ctx.fill() } // Eyes for (const side of [-1, 1]) { const ex = side * eyeDX ctx.fillStyle = '#ffffff' ctx.beginPath() ctx.ellipse(ex, eyeY, eyeR, eyeR * 1.15, 0, 0, Math.PI * 2) ctx.fill() const lid = blink if (lid < 0.92) { const pr = eyeR * 0.48 const px = ex + pupX * eyeR * 0.42 const py = eyeY + pupY * eyeR * 0.42 + lid * 2 ctx.fillStyle = PUPIL ctx.beginPath() ctx.arc(px, py, pr, 0, Math.PI * 2) ctx.fill() ctx.fillStyle = 'rgba(255,255,255,0.92)' ctx.beginPath() ctx.arc(px - pr * 0.34, py - pr * 0.34, pr * 0.30, 0, Math.PI * 2) ctx.fill() } // Eyelid closes the eye over the blink arc if (lid > 0) { ctx.fillStyle = BODY_TOP const h = eyeR * 2.3 * (lid > 1 ? 1 : lid) ctx.fillRect(ex - eyeR * 1.3, eyeY - eyeR * 1.15, eyeR * 2.6, h + 0.5) } } // Mouth: a small smile; a softly opening "o" as it settles ctx.strokeStyle = MOUTH ctx.lineWidth = r * 0.10 ctx.lineCap = 'round' ctx.beginPath() ctx.arc(0, r * 0.02, r * 0.20, 0.15 * Math.PI, 0.85 * Math.PI) ctx.stroke() } /** Draw the full pet at the current state. */ function drawPet( ctx: CanvasRenderingContext2D, size: number, x: number, y: number, ang: number, amount: number, breathe: number, blink: number, pupX: number, pupY: number, ): void { const r = size * 0.30 // Positional translate only; squash/stretch is applied beneath it so the // face stays stable (and unrotated) while the body deforms. ctx.save() ctx.translate(x, y) // Grounding shadow (unrotated, so it stays under the body). ctx.save() ctx.scale(1, 0.32) ctx.fillStyle = 'rgba(0,0,0,0.10)' ctx.beginPath() ctx.arc(0, r * 1.25, r * 0.85, 0, Math.PI * 2) ctx.fill() ctx.restore() // Body: squash/stretch along the velocity axis, then breathe. ctx.save() ctx.rotate(ang) ctx.scale(1 + amount, 1 - amount) ctx.rotate(-ang) ctx.scale(1 - breathe * 0.02, 1 + breathe * 0.02) const g = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.15, 0, 0, r * 1.15) g.addColorStop(0, BODY_TOP) g.addColorStop(1, BODY_BOTTOM) ctx.fillStyle = g ctx.beginPath() ctx.ellipse(0, 0, r, r * 1.06, 0, 0, Math.PI * 2) ctx.fill() ctx.fillStyle = 'rgba(255,255,255,0.55)' ctx.beginPath() ctx.ellipse(-r * 0.32, -r * 0.42, r * 0.20, r * 0.12, -0.4, 0, Math.PI * 2) ctx.fill() ctx.restore() drawFace(ctx, r, blink, pupX, pupY) ctx.restore() } export function Pet(_props: Record): ReactElement { const canvasRef = useRef(null) useEffect(() => { const canvas = canvasRef.current if (canvas === null) return const ctx = canvas.getContext('2d') if (ctx === null) return const dpr = Math.max(1, window.devicePixelRatio || 1) canvas.width = SIZE * dpr canvas.height = SIZE * dpr ctx.scale(dpr, dpr) let tx = window.innerWidth / 2 let ty = window.innerHeight / 2 let x = tx let y = ty let vx = 0 let vy = 0 const onMove = (event: PointerEvent): void => { tx = event.clientX - SIZE / 2 ty = event.clientY - SIZE / 2 } window.addEventListener('pointermove', onMove) let raf = 0 let last = performance.now() const start = last let frameCounter = 0 let sheet: HTMLImageElement | undefined let spriteReady = false if (!VECTOR && SPRITE.length > 0) { const img = new Image() img.src = SPRITE img.onload = () => { spriteReady = true } sheet = img } const draw = (now: number): void => { if (now - last >= 1000 / FPS) { frameCounter = (frameCounter + 1) % (FRAME_COLS * FRAME_ROWS) last = now } const t = (now - start) / 1000 // Damped spring toward the pointer: slight overshoot, then settle. vx = (vx + (tx - x) * STIFFNESS) * DAMPING vy = (vy + (ty - y) * STIFFNESS) * DAMPING x += vx y += vy const speed = Math.hypot(vx, vy) const amount = Math.min(speed / 900, 0.16) const ang = Math.atan2(vy, vx) const breathe = Math.sin(t * 2.4) const blinkT = (t % 3.3) const blink = blinkT < 0.24 ? Math.sin((blinkT / 0.24) * Math.PI) : 0 const dx = tx - x const dy = ty - y const len = Math.hypot(dx, dy) || 1 const pupX = clamp(dx / len, -1, 1) const pupY = clamp(dy / len, -1, 1) ctx.clearRect(0, 0, SIZE, SIZE) if (!VECTOR && spriteReady && sheet !== undefined) { const row = Math.floor(frameCounter / FRAME_COLS) const col = frameCounter % FRAME_COLS ctx.drawImage(sheet, col * FRAME_W, row * FRAME_H, FRAME_W, FRAME_H, 0, 0, SIZE, SIZE) } else { drawPet(ctx, SIZE, x, y, ang, amount, breathe, blink, pupX, pupY) } canvas.style.transform = `translate(${x}px, ${y}px)` raf = requestAnimationFrame(draw) } raf = requestAnimationFrame(draw) return () => { window.removeEventListener('pointermove', onMove) cancelAnimationFrame(raf) } }, []) return ( ) }