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

313 lines
11 KiB
JavaScript

// Helva Display Face (ONLY visor/display) - state driven
// Supports payload like:
// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0}}
(() => {
const OVERLAY_TIMEOUT_MS = 3500;
const state = {
emotion: "neutral",
intensity: 0.7, // 0..1
look: { x: 0, y: 0 }, // -1..1
lookLock: false, // if true: no drift (stare)
speaking: false,
eyesMoving: true,
connected: true,
};
// DOM
const stage = document.getElementById("stage");
const overlay = document.getElementById("overlay");
const dotConn = document.getElementById("dotConn");
const pillText = document.getElementById("pillText");
const emotionLabel = document.getElementById("emotionLabel");
const emotionControlLink = document.getElementById("emotionControlLink");
const btnSpeak = document.getElementById("btnSpeak");
const btnEyes = document.getElementById("btnEyes");
const speakState = document.getElementById("speakState");
const eyesState = document.getElementById("eyesState");
const mouthShape = document.getElementById("mouthShape");
const mouthInner = document.getElementById("mouthInner");
const statusLed = document.getElementById("statusLed");
const cheekL = document.getElementById("cheekL");
const cheekR = document.getElementById("cheekR");
const eyeLIris = document.querySelector("#eyeL .iris");
const eyeRIris = document.querySelector("#eyeR .iris");
const pupilL = document.querySelector("#eyeL .pupil");
const pupilR = document.querySelector("#eyeR .pupil");
const lidL = document.getElementById("lidL");
const lidR = document.getElementById("lidR");
const mouthGroup = document.getElementById("mouth");
// Overlay on touch
let overlayTimer = null;
function showOverlay() {
overlay.classList.add("show");
clearTimeout(overlayTimer);
overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS);
}
stage.addEventListener("pointerdown", () => showOverlay(), { passive: true });
btnSpeak.addEventListener("click", (e) => { e.stopPropagation(); window.setSpeaking(!state.speaking); showOverlay(); });
btnEyes.addEventListener("click", (e) => { e.stopPropagation(); window.setEyesMoving(!state.eyesMoving); showOverlay(); });
// Helpers
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
function clamp01(v) {
const n = Number(v);
if (Number.isNaN(n)) return 0;
return clamp(n, 0, 1);
}
// Emotions
const EMOTIONS = new Set(["neutral","happy","sad","angry","sleepy","surprised","excited"]);
function updateControlLink() {
emotionLabel.textContent = state.emotion;
emotionControlLink.href = `/control?current=${encodeURIComponent(state.emotion)}`;
}
function setEyeOffset(dx, dy) {
const max = 10;
const x = clamp(dx, -max, max);
const y = clamp(dy, -max, max);
// base positions: L(200,170), R(320,170)
eyeLIris.setAttribute("cx", 200 + x);
eyeLIris.setAttribute("cy", 170 + y);
eyeRIris.setAttribute("cx", 320 + x);
eyeRIris.setAttribute("cy", 170 + y);
pupilL.setAttribute("cx", 200 + x);
pupilL.setAttribute("cy", 170 + y);
pupilR.setAttribute("cx", 320 + x);
pupilR.setAttribute("cy", 170 + y);
}
function applyLook(look, lock = true) {
if (!look) return;
const x = clamp(Number(look.x ?? 0), -1, 1);
const y = clamp(Number(look.y ?? 0), -1, 1);
state.look = { x, y };
state.lookLock = !!lock;
setEyeOffset(x * 10, y * 10);
}
function applyIntensity(intensity) {
state.intensity = clamp01(intensity);
// LED brightness
statusLed.setAttribute("opacity", String(0.35 + state.intensity * 0.65));
// cheeks baseline
cheekL.style.opacity = String(0.12 + 0.55 * state.intensity);
cheekR.style.opacity = String(0.12 + 0.55 * state.intensity);
}
function setEmotionUI(em) {
const emotion = EMOTIONS.has(em) ? em : "neutral";
state.emotion = emotion;
updateControlLink();
const mouths = {
neutral: {
outer: "M225,250 C245,262 275,262 295,250 C290,275 230,275 225,250Z",
inner: "M238,258 C250,267 270,267 282,258 C275,272 245,272 238,258Z"
},
happy: {
outer: "M215,242 C240,292 280,292 305,242 C295,310 225,310 215,242Z",
inner: "M232,265 C250,288 270,288 288,265 C280,298 240,298 232,265Z"
},
sad: {
outer: "M225,282 C245,260 275,260 295,282 C285,245 235,245 225,282Z",
inner: "M240,276 C250,265 270,265 280,276 C272,258 248,258 240,276Z"
},
angry: {
outer: "M220,270 C255,255 265,255 300,270 C292,292 228,292 220,270Z",
inner: "M240,272 C255,264 265,264 280,272 C274,283 246,283 240,272Z"
},
sleepy: {
outer: "M230,280 C250,286 270,286 290,280 C283,292 237,292 230,280Z",
inner: "M244,282 C252,286 268,286 276,282 C270,290 250,290 244,282Z"
},
surprised: {
outer: "M240,238 C260,228 260,228 280,238 C305,265 305,298 260,305 C215,298 215,265 240,238Z",
inner: "M248,254 C260,246 260,246 272,254 C288,270 288,286 260,291 C232,286 232,270 248,254Z"
},
excited: {
outer: "M210,242 C240,322 280,322 310,242 C300,336 220,336 210,242Z",
inner: "M228,274 C250,308 270,308 292,274 C284,322 236,322 228,274Z"
}
};
const m = mouths[emotion] ?? mouths.neutral;
mouthShape.setAttribute("d", m.outer);
mouthInner.setAttribute("d", m.inner);
// LED color hint per emotion
switch (emotion) {
case "sad":
case "sleepy":
statusLed.setAttribute("fill", "rgba(124,183,255,.90)");
break;
case "angry":
statusLed.setAttribute("fill", "rgba(255,107,107,.92)");
break;
default:
statusLed.setAttribute("fill", "rgba(124,255,201,.92)");
}
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
}
function setConnectedUI(on) {
state.connected = !!on;
dotConn.classList.toggle("on", state.connected);
dotConn.classList.toggle("off", !state.connected);
pillText.textContent = state.connected ? (state.speaking ? (state.emotion + " • speaking") : state.emotion) : "offline";
}
function setSpeakingUI(on) {
state.speaking = !!on;
speakState.textContent = state.speaking ? "an" : "aus";
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
}
function setEyesMovingUI(on) {
state.eyesMoving = !!on;
eyesState.textContent = state.eyesMoving ? "an" : "aus";
}
// Animations: blink + drift + speaking flap
let blinkTimer = null;
let driftTimer = null;
let speakTimer = null;
function doBlink() {
const down = 70, up = 90;
lidL.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
lidR.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
setTimeout(() => {
lidL.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
lidR.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
}, down + 25);
}
function scheduleBlink() {
clearTimeout(blinkTimer);
const base = state.emotion === "sleepy" ? 4500 : 2600;
blinkTimer = setTimeout(() => { doBlink(); scheduleBlink(); }, base + Math.random() * 2200);
}
function scheduleEyeDrift() {
clearTimeout(driftTimer);
if (state.lookLock) {
setEyeOffset(state.look.x * 10, state.look.y * 10);
return;
}
if (!state.eyesMoving) {
setEyeOffset(0,0);
return;
}
const dx = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
const dy = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
const startX = (parseFloat(eyeLIris.getAttribute("cx")) - 200) || 0;
const startY = (parseFloat(eyeLIris.getAttribute("cy")) - 170) || 0;
const steps = 18;
let i = 0;
const tick = () => {
i++;
const t = i / steps;
const ease = t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2,2)/2;
setEyeOffset(startX + (dx - startX)*ease, startY + (dy - startY)*ease);
if (i < steps) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
driftTimer = setTimeout(scheduleEyeDrift, 700 + Math.random() * 700);
}
function startSpeakingFlap() {
stopSpeakingFlap();
let open = false;
const interval = (state.emotion === "excited") ? 90 : (state.emotion === "sleepy" ? 170 : 120);
speakTimer = setInterval(() => {
if (!state.speaking) return;
open = !open;
const s = open ? 1.12 : 0.96;
mouthGroup.setAttribute("transform", `translate(0, ${open ? -2 : 0}) scale(1, ${s})`);
}, interval);
}
function stopSpeakingFlap() {
if (speakTimer) clearInterval(speakTimer);
speakTimer = null;
mouthGroup.setAttribute("transform", "translate(0,0)");
}
// Public API
window.setEmotion = (emotion) => { setEmotionUI(emotion); scheduleBlink(); scheduleEyeDrift(); };
window.setSpeaking = (on) => { setSpeakingUI(on); if (state.speaking) startSpeakingFlap(); else stopSpeakingFlap(); };
window.setEyesMoving = (on) => { setEyesMovingUI(on); scheduleEyeDrift(); };
window.setConnected = (on) => { setConnectedUI(on); };
window.applyFaceState = (payload) => {
if (!payload || typeof payload !== "object") return;
if (payload.intensity !== undefined) applyIntensity(payload.intensity);
if (typeof payload.emotion === "string") setEmotionUI(payload.emotion);
if (payload.look && typeof payload.look === "object") {
applyLook(payload.look, true); // lock stare
} else {
state.lookLock = false;
scheduleEyeDrift();
}
if (typeof payload.speaking === "boolean") window.setSpeaking(payload.speaking);
if (typeof payload.eyesMoving === "boolean") window.setEyesMoving(payload.eyesMoving);
if (typeof payload.connected === "boolean") window.setConnected(payload.connected);
scheduleBlink();
if (state.speaking) startSpeakingFlap();
};
// Optional WebSocket to /ws (if present)
(function connectWS(){
const proto = (location.protocol === "https:") ? "wss" : "ws";
const wsUrl = `${proto}://${location.host}/ws`;
let ws;
function open() {
ws = new WebSocket(wsUrl);
ws.onopen = () => window.setConnected(true);
ws.onmessage = (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
};
ws.onclose = () => { window.setConnected(false); setTimeout(open, 800); };
ws.onerror = () => { try { ws.close(); } catch (_) {} };
}
open();
})();
// Init
updateControlLink();
applyIntensity(state.intensity);
setConnectedUI(true);
setEmotionUI("neutral");
setSpeakingUI(false);
setEyesMovingUI(true);
scheduleBlink();
scheduleEyeDrift();
})();