195 lines
5.0 KiB
JavaScript
Executable File
195 lines
5.0 KiB
JavaScript
Executable File
const label = document.getElementById("label");
|
|
const eyes = Array.from(document.querySelectorAll(".eye"));
|
|
const mouthShape = document.querySelector(".mouth-shape");
|
|
|
|
let current = "neutral";
|
|
let wanderEnabled = true;
|
|
let wanderTimer = null;
|
|
|
|
let talkEnabled = false;
|
|
let talkCfg = { rate_hz: 3.2, amount: 0.9, jitter: 0.25 };
|
|
let talkTimer = null;
|
|
|
|
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
|
|
|
|
function setEmotion(name) {
|
|
const safe = String(name || "neutral")
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9_-]/g, "");
|
|
|
|
current = safe;
|
|
document.body.className = `emotion-${safe}`;
|
|
label.textContent = safe;
|
|
|
|
// surprised -> O-mouth
|
|
if (safe === "surprised") document.body.classList.add("has-omouth");
|
|
else document.body.classList.remove("has-omouth");
|
|
|
|
// frown for sad/angry
|
|
if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown");
|
|
else mouthShape.classList.remove("frown");
|
|
}
|
|
|
|
function blinkOnce() {
|
|
eyes.forEach(e => e.classList.add("blink"));
|
|
setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120);
|
|
}
|
|
|
|
function setLook(x, y) {
|
|
// x/y in -1..1 -> px offsets
|
|
const pxX = Math.round(clamp(x, -1, 1) * 12);
|
|
const pxY = Math.round(clamp(y, -1, 1) * 10);
|
|
document.documentElement.style.setProperty("--pupil-x", `${pxX}px`);
|
|
document.documentElement.style.setProperty("--pupil-y", `${pxY}px`);
|
|
}
|
|
|
|
function setIntensity(v) {
|
|
const val = clamp(Number(v ?? 0.7), 0, 1);
|
|
document.documentElement.style.setProperty("--intensity", String(val));
|
|
}
|
|
|
|
function setMouthOpen(v) {
|
|
const val = clamp(Number(v ?? 0), 0, 1);
|
|
document.documentElement.style.setProperty("--mouth-open", String(val));
|
|
}
|
|
|
|
function speak(amount = 0.8, durationMs = 600) {
|
|
if (talkEnabled) return; // Talk-Mode steuert Mund
|
|
setMouthOpen(amount);
|
|
setTimeout(() => setMouthOpen(0), Math.max(0, durationMs));
|
|
}
|
|
|
|
function stopTalk() {
|
|
talkEnabled = false;
|
|
if (talkTimer) {
|
|
clearTimeout(talkTimer);
|
|
talkTimer = null;
|
|
}
|
|
setMouthOpen(0);
|
|
}
|
|
|
|
function startTalk(cfg) {
|
|
talkEnabled = true;
|
|
talkCfg = {
|
|
rate_hz: clamp(Number(cfg?.rate_hz ?? 3.2), 0.5, 10),
|
|
amount: clamp(Number(cfg?.amount ?? 0.9), 0, 1),
|
|
jitter: clamp(Number(cfg?.jitter ?? 0.25), 0, 1),
|
|
};
|
|
|
|
if (talkTimer) clearTimeout(talkTimer);
|
|
|
|
// “sprech”-Animation: Mund öffnet/schließt schnell, mit bisschen Zufall
|
|
const tick = () => {
|
|
if (!talkEnabled) return;
|
|
|
|
const base = talkCfg.amount;
|
|
const j = talkCfg.jitter;
|
|
|
|
// random-ish open amount between ~0.2..1.0, scaled
|
|
const r = (0.35 + Math.random() * 0.65);
|
|
const open = clamp(base * r * (1 - j + Math.random() * j), 0, 1);
|
|
setMouthOpen(open);
|
|
|
|
// timing from rate_hz (Hz -> ms)
|
|
const interval = Math.max(60, Math.round(1000 / talkCfg.rate_hz));
|
|
// add a bit of jitter to cadence
|
|
const next = interval + Math.round((Math.random() * 2 - 1) * interval * 0.25);
|
|
|
|
talkTimer = setTimeout(tick, next);
|
|
};
|
|
|
|
tick();
|
|
}
|
|
|
|
|
|
/* Pupillen-Wandern nur wenn kein look gesetzt ist */
|
|
function startWander() {
|
|
if (wanderTimer) clearTimeout(wanderTimer);
|
|
|
|
const tick = () => {
|
|
if (!wanderEnabled) return;
|
|
|
|
const x = (Math.random() * 2 - 1) * 0.7;
|
|
const y = (Math.random() * 2 - 1) * 0.6;
|
|
setLook(x, y);
|
|
|
|
const next = 600 + Math.random() * 900;
|
|
wanderTimer = setTimeout(tick, next);
|
|
};
|
|
tick();
|
|
}
|
|
|
|
function applyState(s) {
|
|
if (!s || typeof s !== "object") return;
|
|
|
|
if (s.emotion) setEmotion(s.emotion);
|
|
if (s.intensity !== undefined) setIntensity(s.intensity);
|
|
|
|
// one-shot blink
|
|
if (s.blink) blinkOnce();
|
|
|
|
// look: if null -> enable wander. if object -> fixed look
|
|
if ("look" in s) {
|
|
if (s.look === null) {
|
|
wanderEnabled = true;
|
|
startWander();
|
|
} else if (typeof s.look === "object") {
|
|
wanderEnabled = false;
|
|
setLook(s.look.x ?? 0, s.look.y ?? 0);
|
|
}
|
|
}
|
|
|
|
// mouth command
|
|
if (s.mouth && typeof s.mouth === "object") {
|
|
const open = !!s.mouth.open;
|
|
const amount = clamp(Number(s.mouth.amount ?? 0.8), 0, 1);
|
|
const dur = Number(s.mouth.duration_ms ?? 600);
|
|
|
|
if (open) speak(amount, dur);
|
|
else setMouthOpen(0);
|
|
}
|
|
|
|
// talk mode
|
|
if (s.talk && typeof s.talk === "object") {
|
|
const enabled = !!s.talk.enabled;
|
|
if (enabled) startTalk(s.talk);
|
|
else stopTalk();
|
|
}
|
|
}
|
|
|
|
function connect() {
|
|
const es = new EventSource("/events");
|
|
es.addEventListener("state", (e) => {
|
|
try { applyState(JSON.parse(e.data)); } catch {}
|
|
});
|
|
es.onmessage = (e) => {
|
|
// fallback: treat as state json
|
|
try { applyState(JSON.parse(e.data)); } catch {}
|
|
};
|
|
es.onerror = () => {
|
|
es.close();
|
|
setTimeout(connect, 1000);
|
|
};
|
|
}
|
|
|
|
connect();
|
|
startWander();
|
|
|
|
/* zufälliges Blinzeln unabhängig vom Push */
|
|
(function startBlinkLoop(){
|
|
const loop = () => {
|
|
let base = 3500;
|
|
if (current === "sleepy") base = 2200;
|
|
if (current === "surprised") base = 4200;
|
|
|
|
const next = base + Math.random() * 2200;
|
|
setTimeout(() => {
|
|
blinkOnce();
|
|
if (Math.random() < 0.12) setTimeout(blinkOnce, 220);
|
|
loop();
|
|
}, next);
|
|
};
|
|
loop();
|
|
})();
|
|
|