case0 / web /src /engine /pixel.tsx
HusseinEid's picture
Case Zero - initial public release (fully local: Qwen2.5-1.5B via llama.cpp + Supertonic, custom pixel-noir SPA via gradio.Server)
414dc55
raw
history blame
6.92 kB
// 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<HTMLCanvasElement>(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 (
<canvas
ref={ref}
width={cols * px}
height={rows * px}
onClick={onClick}
class={className}
style={{ imageRendering: 'pixelated', display: 'block', ...style }}
/>
)
}
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<HTMLCanvasElement>(null)
const bufRef = useRef<HTMLCanvasElement | null>(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 (
<canvas
ref={ref}
width={w}
height={h}
class={className}
style={{ imageRendering: 'pixelated', display: 'block', width: '100%', height: '100%', ...style }}
/>
)
}
/** Full-screen pixel rain on a canvas. */
export function RainFX({ density = 90 }: { density?: number }) {
const ref = useRef<HTMLCanvasElement>(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 <canvas ref={ref} class="fx-rain" style={{ position: 'fixed', inset: 0, imageRendering: 'pixelated' }} />
}
/** 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]
}