// Pixel engine — crisp nearest-neighbor canvas rendering. Ported from prototype/js/pixel.jsx. import { useEffect, useRef, useState } from 'preact/hooks' import type { JSX } from 'preact' import { type Pal, drawMap } from './draw' export type ScenePainter = ( ctx: CanvasRenderingContext2D, w: number, h: number, t: number, ) => void interface PixelCanvasProps { frames?: string[][] map?: string[] pal?: Pal px?: number fps?: number className?: string style?: JSX.CSSProperties playing?: boolean onClick?: (e: MouseEvent) => void } /** A sprite, optionally animated across frames. */ export function PixelCanvas({ frames, map, pal, px = 4, fps = 4, className, style, playing = true, onClick, }: PixelCanvasProps) { const ref = useRef(null) const allFrames = frames || [map as string[]] const cols = allFrames[0][0].length const rows = allFrames[0].length const [f, setF] = useState(0) useEffect(() => { if (!playing || allFrames.length < 2) return const id = setInterval(() => setF((v) => (v + 1) % allFrames.length), 1000 / fps) return () => clearInterval(id) }, [playing, allFrames.length, fps]) useEffect(() => { const cv = ref.current if (!cv) return const ctx = cv.getContext('2d')! ctx.imageSmoothingEnabled = false ctx.clearRect(0, 0, cv.width, cv.height) drawMap(ctx, allFrames[f], pal, px) }, [f, px, pal, allFrames]) return ( ) } interface SceneCanvasProps { paint: ScenePainter w?: number h?: number className?: string style?: JSX.CSSProperties deps?: unknown[] anim?: boolean full?: boolean rain?: boolean } /** Procedural painter at low internal res. Static scenes paint once to an offscreen * buffer; anim scenes blit the buffer + a cheap rain overlay so we never re-dither * the whole canvas every frame. `full` forces a true per-frame repaint. */ export function SceneCanvas({ paint, w = 240, h = 135, className, style, deps = [], anim = false, full = false, rain = true, }: SceneCanvasProps) { const ref = useRef(null) const bufRef = useRef(null) const tRef = useRef(0) useEffect(() => { const cv = ref.current if (!cv) return const ctx = cv.getContext('2d')! ctx.imageSmoothingEnabled = false const buf = document.createElement('canvas') buf.width = w buf.height = h const bctx = buf.getContext('2d')! bctx.imageSmoothingEnabled = false paint(bctx, w, h, 0) bufRef.current = buf let raf = 0 // always paint a first frame synchronously (rAF is paused in background iframes) ctx.clearRect(0, 0, w, h) ctx.drawImage(buf, 0, 0) if (full) { let last = 0 const loop = (ts: number) => { if (ts - last > 110) { last = ts tRef.current += 1 ctx.clearRect(0, 0, w, h) paint(ctx, w, h, tRef.current) } raf = requestAnimationFrame(loop) } raf = requestAnimationFrame(loop) } else if (anim && rain) { let last = 0 const loop = (ts: number) => { if (ts - last > 70) { last = ts tRef.current += 1 ctx.clearRect(0, 0, w, h) ctx.drawImage(buf, 0, 0) ctx.fillStyle = 'rgba(176,196,206,0.26)' const t = tRef.current for (let i = 0; i < 36; i++) { const x = (i * 41 + t * 5) % w const y = (i * 57 + t * 9) % h ctx.fillRect(Math.floor(x), Math.floor(y), 1, 3) } } raf = requestAnimationFrame(loop) } raf = requestAnimationFrame(loop) } else { ctx.clearRect(0, 0, w, h) ctx.drawImage(buf, 0, 0) } return () => cancelAnimationFrame(raf) // eslint-disable-next-line react-hooks/exhaustive-deps }, deps) return ( ) } /** Full-screen pixel rain on a canvas. */ export function RainFX({ density = 90 }: { density?: number }) { const ref = useRef(null) useEffect(() => { const cv = ref.current if (!cv) return const ctx = cv.getContext('2d')! let W = 0 let H = 0 let drops: { x: number; y: number; v: number; len: number }[] = [] let raf = 0 const resize = () => { W = cv.width = Math.ceil(window.innerWidth / 3) H = cv.height = Math.ceil(window.innerHeight / 3) cv.style.width = window.innerWidth + 'px' cv.style.height = window.innerHeight + 'px' drops = Array.from({ length: density }, () => ({ x: Math.random() * W, y: Math.random() * H, v: 2 + Math.random() * 3, len: 3 + Math.random() * 5, })) } resize() window.addEventListener('resize', resize) let last = 0 const loop = (ts: number) => { if (ts - last > 33) { last = ts ctx.clearRect(0, 0, W, H) ctx.fillStyle = 'rgba(180,200,210,0.30)' for (const d of drops) { ctx.fillRect(Math.floor(d.x), Math.floor(d.y), 1, Math.floor(d.len)) d.y += d.v d.x += 0.4 if (d.y > H) { d.y = -d.len d.x = Math.random() * W } } } raf = requestAnimationFrame(loop) } raf = requestAnimationFrame(loop) return () => { cancelAnimationFrame(raf) window.removeEventListener('resize', resize) } }, [density]) return } /** True if the user asked the OS to reduce motion. */ export function prefersReducedMotion(): boolean { return typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches } /** Typewriter hook: returns [visibleText, done]. Honors prefers-reduced-motion (instant). */ export function useTypewriter(text: string, speed = 28, on = true): [string, boolean] { if (prefersReducedMotion()) on = false const [out, setOut] = useState(on ? '' : text) const [done, setDone] = useState(!on) useEffect(() => { if (!on) { setOut(text) setDone(true) return } setOut('') setDone(false) if (!text) { setDone(true) return } let i = 0 const id = setInterval(() => { i++ setOut(text.slice(0, i)) if (i >= text.length) { clearInterval(id) setDone(true) } }, speed) return () => clearInterval(id) }, [text, speed, on]) return [out, done] }