313 lines
11 KiB
JavaScript
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();
|
|
})();
|
|
|