// 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 (
);
}
// ===== 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 });