helva-robot/face/var/www/html/app.js

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