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