// Ambient components — magic particles, potion sparks, eyes, lore runes, glitch const { useState, useEffect, useRef, useMemo } = React; // ===== Magic Particles drifting up ===== function ParticleField({ count = 40 }) { const particles = useMemo(() => { const arr = []; for (let i = 0; i < count; i++) { const isViolet = Math.random() > 0.5; const sizeRoll = Math.random(); let sizeClass = ""; if (sizeRoll < 0.35) sizeClass = "tiny";else if (sizeRoll > 0.85) sizeClass = "large"; arr.push({ id: i, color: isViolet ? "violet" : "green", size: sizeClass, left: Math.random() * 100, duration: 8 + Math.random() * 14, delay: -Math.random() * 20 }); } return arr; }, [count]); return (
{particles.map((p) =>
)}
); } // ===== Bubbling potion sparks (concentrated pockets) ===== function SparkBurst({ style, color = "green", count = 6 }) { const sparks = useMemo(() => { return Array.from({ length: count }, (_, i) => ({ id: i, left: Math.random() * 60 - 30, delay: Math.random() * 3, duration: 2 + Math.random() * 2 })); }, [count]); return (
{sparks.map((s) => )}
); } // ===== Single Eye — plays ALL of its animations in a random order, on its own clock. // `closedFrame` marks the most-shut frame of the blink clip (only eye1 truly closes; // eyes 2 & 3 "blink" via the iris, so their closedFrame is the last frame). // `minW`/`maxW` give each eye TYPE a size range — instances pick a random size in it. const EYE_REGISTRY = { 1: { minW: 96, maxW: 138, idle: { dir: "assets/eyes/eye1/idle", count: 6, fps: 8 }, blink: { dir: "assets/eyes/eye1/blink", count: 16, fps: 18, closedFrame: 7 }, look: { dir: "assets/eyes/eye1/look", count: 5, fps: 8 }, }, 2: { minW: 72, maxW: 110, idle: { dir: "assets/eyes/eye2/idle", count: 6, fps: 8 }, blink: { dir: "assets/eyes/eye2/blink", count: 4, fps: 12, closedFrame: 3 }, look: { dir: "assets/eyes/eye2/look", count: 7, fps: 10 }, }, 3: { minW: 80, maxW: 126, idle: { dir: "assets/eyes/eye3/idle", count: 4, fps: 6, }, blink: { dir: "assets/eyes/eye3/blink", count: 4, fps: 12, closedFrame: 3 }, look: { dir: "assets/eyes/eye3/look", count: 4, fps: 8 }, }, }; const padFrame = (i) => String(i + 1).padStart(4, "0"); function Eye({ eye = 1, src, // legacy escape hatch top, left, right, bottom, width, // optional explicit override; otherwise random within the type's min–max rotate = 0, blinkDelay = 0, fadeDelay = 0, blinkPeriod = 5.5, fadePeriod = 14, }) { const reg = EYE_REGISTRY[eye]; // ----- per-instance jitter (rotation + mirror + size), stable across re-renders ----- const jitter = useMemo(() => { const min = (reg && reg.minW) || 70; const max = (reg && reg.maxW) || 100; return { rotJitter: (Math.random() - 0.5) * 8, // ±4° mirror: Math.random() < 0.5, size: width != null ? width : Math.round(min + Math.random() * (max - min)), }; }, [eye, width]); const position = {}; if (top !== undefined) position.top = top; if (left !== undefined) position.left = left; if (right !== undefined) position.right = right; if (bottom !== undefined) position.bottom = bottom; const finalRotate = rotate + jitter.rotJitter; const mirrorScale = jitter.mirror ? -1 : 1; const transform = `rotate(${finalRotate}deg) scaleX(${mirrorScale})`; const finalWidth = jitter.size; const hasFrames = reg && reg.idle && reg.blink; // Static fallback (only if explicit src override) if (!hasFrames || src) { const finalSrc = src || (reg && reg.staticSrc) || "assets/eye-3.png"; return (
); } // ----- sprite scheduler ----- const spriteRef = useRef(null); const refs = useRef({ idle: [], blink: [], look: [] }); useEffect(() => { return runEyeSequence({ reg, spriteRef, refs, blinkPeriod, blinkDelay, fadePeriod, fadeDelay, }); }, [eye, blinkPeriod, blinkDelay, fadePeriod, fadeDelay]); return (
{["idle", "blink", "look"].flatMap((state) => reg[state] ? Array.from({ length: reg[state].count }, (_, i) => (refs.current[state][i] = el)} src={`${reg[state].dir}/${padFrame(i)}.png`} alt="" draggable={false} /> ) : [] )}
); } // ===== Eye sequencer ========================================================== // Each cycle: blink-IN → a shuffled playlist of EVERY animation the eye owns // (idle / look / blink, with little idle "breaths" between) → blink-OUT → hidden. // • Fade IN is bound to a blink opening: opacity 0→1 as the lid lifts off the // closed frame (eye1) or across the iris blink (eyes 2 & 3). // • Fade OUT is bound to a blink closing: opacity 1→0 reaching 0 exactly on the // closed frame, so the eye vanishes the instant it shuts. // onPhaseChange(phase) is optional (used by the Eyes.html workshop panel). // Returns a cleanup function that cancels the raf. function runEyeSequence({ reg, spriteRef, refs, blinkPeriod, blinkDelay, fadePeriod, fadeDelay, onPhaseChange = null, }) { const sprite = spriteRef.current; if (!sprite) return () => {}; const show = (state, idx) => { const allStates = ["idle", "blink", "look"]; for (const s of allStates) { const arr = refs.current[s]; for (let i = 0; i < arr.length; i++) { if (!arr[i]) continue; const want = s === state && i === idx; if (want !== arr[i].classList.contains("on")) { arr[i].classList.toggle("on", want); } } } }; const setOpacity = (o) => { sprite.style.opacity = o; }; const smooth = (t) => { t = Math.max(0, Math.min(1, t)); return t * t * (3 - 2 * t); }; const rand = (a, b) => a + Math.random() * (b - a); const shuffle = (arr) => { for (let i = arr.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [arr[i], arr[j]] = [arr[j], arr[i]]; } return arr; }; const idleMs = 1000 / reg.idle.fps; const blinkMs = 1000 / reg.blink.fps; const lookMs = reg.look ? 1000 / reg.look.fps : idleMs; const closedF = reg.blink.closedFrame != null ? reg.blink.closedFrame : reg.blink.count - 1; const lastF = reg.blink.count - 1; const hasClose = closedF < lastF; // true blink that shuts mid-clip (eye1) // Hidden window scales with fadePeriod (the desync knob from each call site). const hiddenDur = () => Math.max(1600, fadePeriod * 1000 * (0.3 + Math.random() * 0.3)); // Build one cycle: blink-in, every animation once (shuffled, idle breaths // between), blink-out, hidden. function buildCycle() { const beats = [{ type: "blinkIn" }]; const clips = ["idle", "look", "blink"].filter((c) => reg[c]); shuffle(clips); clips.forEach((c) => { if (c === "idle") beats.push({ type: "idle", loops: 1 + Math.floor(Math.random() * 2) }); else beats.push({ type: c }); if (reg.idle && Math.random() < 0.55) beats.push({ type: "idle", loops: 1 }); }); beats.push({ type: "blinkOut" }); beats.push({ type: "hidden", ms: hiddenDur() }); return beats; } const PHASE = { blinkIn: "appearing", idle: "idle", look: "look", blink: "blink", blinkOut: "blinkOut", hidden: "hidden", }; // Start hidden, staggered, then the first cycle. const initialStagger = Math.max(250, fadeDelay * 600 + blinkDelay * 250 + Math.random() * 1500); let beats = [{ type: "hidden", ms: initialStagger }].concat(buildCycle()); let bi = 0; let beat = null; let acc = 0, phaseElapsed = 0, frame = 0, loopsDone = 0, idleFrame = 0; let last = performance.now(); let raf; function startBeat() { beat = beats[bi]; acc = 0; phaseElapsed = 0; loopsDone = 0; if (onPhaseChange) onPhaseChange(PHASE[beat.type]); if (beat.type === "blinkIn") { frame = hasClose ? closedF : 0; // open from the closed pose where possible setOpacity(0); show("blink", frame); } else if (beat.type === "blinkOut" || beat.type === "blink") { frame = 0; setOpacity(1); show("blink", 0); } else if (beat.type === "look") { frame = 0; beat.sub = "fwd"; setOpacity(1); show("look", 0); } else if (beat.type === "idle") { setOpacity(1); show("idle", idleFrame); } else if (beat.type === "hidden") { setOpacity(0); show("idle", -1); } } function nextBeat() { bi++; if (bi >= beats.length) { beats = buildCycle(); bi = 0; } startBeat(); } startBeat(); const tick = (now) => { const dt = now - last; last = now; phaseElapsed += dt; const t = beat.type; if (t === "hidden") { if (phaseElapsed >= beat.ms) { nextBeat(); } } else if (t === "blinkIn") { const startF = hasClose ? closedF : 0; const totalMs = (lastF - startF + 1) * blinkMs; setOpacity(smooth(Math.min(1, phaseElapsed / totalMs))); acc += dt; while (acc >= blinkMs) { acc -= blinkMs; frame++; if (frame > lastF) { setOpacity(1); idleFrame = 0; nextBeat(); return; } show("blink", frame); } } else if (t === "blink") { // a normal, in-place blink (stays visible) acc += dt; while (acc >= blinkMs) { acc -= blinkMs; frame++; if (frame > lastF) { nextBeat(); return; } show("blink", frame); } } else if (t === "blinkOut") { const endF = hasClose ? closedF : lastF; // Fade is bound to the lid SHUTTING with a gentle ease-in: opacity leaves // 1 the instant the close begins (no hold/hitch), stays high while the lid // is visibly closing, and reaches exactly 0 as the closed frame lands. const closeMs = Math.max(1, endF) * blinkMs; const ct = Math.min(1, phaseElapsed / closeMs); setOpacity(1 - ct * ct); acc += dt; while (acc >= blinkMs) { acc -= blinkMs; frame++; if (frame >= endF) { show("blink", endF); setOpacity(0); nextBeat(); return; } show("blink", frame); } } else if (t === "idle") { acc += dt; while (acc >= idleMs) { acc -= idleMs; idleFrame = (idleFrame + 1) % reg.idle.count; show("idle", idleFrame); if (idleFrame === 0) { loopsDone++; if (loopsDone >= beat.loops) { nextBeat(); return; } } } } else if (t === "look") { if (beat.sub === "fwd") { acc += dt; while (acc >= lookMs) { acc -= lookMs; frame++; if (frame >= reg.look.count - 1) { frame = reg.look.count - 1; show("look", frame); beat.sub = "hold"; beat.holdMs = rand(170, 360); beat.holdEl = 0; break; } show("look", frame); } } else if (beat.sub === "hold") { beat.holdEl += dt; // step back the instant the dwell ends (acc primed) so the eye never // sits an extra frame on the open-most pose if (beat.holdEl >= beat.holdMs) { beat.sub = "rev"; acc = lookMs; } } else { // rev acc += dt; while (acc >= lookMs) { acc -= lookMs; frame--; if (frame <= 0) { show("look", 0); nextBeat(); return; } show("look", frame); } } } }; const loop = (now) => { tick(now); raf = requestAnimationFrame(loop); }; raf = requestAnimationFrame((now) => { last = now; loop(now); }); return () => cancelAnimationFrame(raf); } // ===== Lore Rune (clickable easter egg) ===== const RUNE_SHAPES = [ , , , , ]; function LoreRune({ shape = 0, text, style, color = "violet" }) { const [hover, setHover] = useState(false); const [pos, setPos] = useState({ x: 0, y: 0 }); const ref = useRef(null); const handleMove = () => { if (!ref.current) return; const r = ref.current.getBoundingClientRect(); setPos({ x: r.left + 32, y: r.top - 20 }); }; return ( <> {setHover(true);handleMove();}} onMouseLeave={() => setHover(false)}> {RUNE_SHAPES[shape % RUNE_SHAPES.length]} {hover &&
{text}
} ); } // ===== Glitchable text ===== function Glitch({ children, className = "", trigger = "hover" }) { const [active, setActive] = useState(false); useEffect(() => { if (trigger !== "auto") return; const t = setInterval(() => { setActive(true); setTimeout(() => setActive(false), 300); }, 5000 + Math.random() * 4000); return () => clearInterval(t); }, [trigger]); const events = trigger === "hover" ? { onMouseEnter: () => setActive(true), onMouseLeave: () => setActive(false) } : {}; return ( {children} {children} {children} ); } Object.assign(window, { ParticleField, SparkBurst, Eye, LoreRune, Glitch });