// /var/www/html/face.js // Minimal: black background, filled cyan sticker-style face elements only. // Touch-only overlay (links) is handled in HTML/CSS. // Updates via SSE /events (nginx -> 127.0.0.1:8001/events) // Payload example: // {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0},"speaking":true,"eyesMoving":true} (() => { const OVERLAY_TIMEOUT_MS = 2500; const clamp = (n, a, b) => Math.max(a, Math.min(b, n)); const clamp01 = (v) => { const n = Number(v); if (Number.isNaN(n)) return 0; return clamp(n, 0, 1); }; const EMOTIONS = new Set(["neutral", "happy", "sad", "angry", "sleepy", "surprised", "excited"]); const state = { emotion: "neutral", intensity: 0.85, look: { x: 0, y: 0 }, lookLock: false, speaking: false, eyesMoving: true, }; // DOM const stage = document.getElementById("stage"); const overlay = document.getElementById("overlay"); const eyeL = document.getElementById("eyeL"); const eyeR = document.getElementById("eyeR"); const eyesGroup = document.getElementById("eyesGroup"); const mouth = document.getElementById("mouth"); // Touch overlay show let overlayTimer = null; function showOverlay() { if (!overlay) return; overlay.classList.add("show"); clearTimeout(overlayTimer); overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS); } if (stage) stage.addEventListener("pointerdown", () => showOverlay(), { passive: true }); // Tuned to match your reference image more closely const BASE = { L: { x: 385, y: 265 }, R: { x: 615, y: 265 }, lookMaxPx: 18, mouthCx: 500, mouthY: 388, }; // Sticker colors: filled cyan with light edge function setStickerStyle(el, edge = 5) { const a = 0.86 + 0.14 * state.intensity; el.setAttribute("fill", `rgba(120,235,255,${a})`); el.setAttribute("stroke", `rgba(210,255,255,${0.55 + 0.35 * state.intensity})`); el.setAttribute("stroke-width", String(edge)); el.setAttribute("stroke-linejoin", "round"); el.setAttribute("stroke-linecap", "round"); } // ----- EYES (filled) ----- function eyeOval(cx, cy) { const rx = 40, ry = 74; return `M ${cx},${cy - ry} C ${cx + rx},${cy - ry} ${cx + rx},${cy + ry} ${cx},${cy + ry} C ${cx - rx},${cy + ry} ${cx - rx},${cy - ry} ${cx},${cy - ry} Z`; } function eyeSurprised(cx, cy) { const rx = 44, ry = 82; return `M ${cx},${cy - ry} C ${cx + rx},${cy - ry} ${cx + rx},${cy + ry} ${cx},${cy + ry} C ${cx - rx},${cy + ry} ${cx - rx},${cy - ry} ${cx},${cy - ry} Z`; } function eyeHappy(cx, cy) { // thick cap-like filled arc const w = 100, h = 74; const x1 = cx - w / 2; const x2 = cx + w / 2; const yTop = cy - 40; const yBot = yTop + 38; return `M ${x1},${yBot} Q ${cx},${yTop + h} ${x2},${yBot} Q ${cx},${yTop + h * 0.62} ${x1},${yBot} Z`; } function eyeSleep(cx, cy) { // longer thin pill const w = 128, h = 20; const x1 = cx - w / 2; const x2 = cx + w / 2; const y1 = cy - h / 2; const y2 = cy + h / 2; return `M ${x1},${y1} Q ${cx},${y1 - 6} ${x2},${y1} L ${x2},${y2} Q ${cx},${y2 + 6} ${x1},${y2} Z`; } function eyeAngryL(cx, cy) { // wedge, slanting down toward center return `M ${cx - 92},${cy - 58} L ${cx + 20},${cy - 18} L ${cx + 64},${cy - 84} L ${cx - 60},${cy - 96} Z`; } function eyeAngryR(cx, cy) { return `M ${cx + 92},${cy - 58} L ${cx - 20},${cy - 18} L ${cx - 64},${cy - 84} L ${cx + 60},${cy - 96} Z`; } // ----- MOUTHS (filled) ----- function mouthDot(cx, y) { const w = 28, h = 12; return `M ${cx - w / 2},${y - h / 2} L ${cx + w / 2},${y - h / 2} L ${cx + w / 2},${y + h / 2} L ${cx - w / 2},${y + h / 2} Z`; } function mouthGrin(cx, y) { // wide grin like sticker const w = 400, h = 190; const x1 = cx - w / 2; const x2 = cx + w / 2; const yTop = y - 78; return `M ${x1},${yTop} Q ${cx},${yTop + h} ${x2},${yTop} Q ${cx},${yTop + h * 0.58} ${x1},${yTop} Z`; } function mouthSmile(cx, y) { // narrower fallback const w = 340, h = 170; const x1 = cx - w / 2; const x2 = cx + w / 2; const yTop = y - 70; return `M ${x1},${yTop} Q ${cx},${yTop + h} ${x2},${yTop} Q ${cx},${yTop + h * 0.58} ${x1},${yTop} Z`; } function mouthFrown(cx, y) { const w = 360, h = 170; const x1 = cx - w / 2; const x2 = cx + w / 2; const yBot = y + 78; return `M ${x1},${yBot} Q ${cx},${yBot - h} ${x2},${yBot} Q ${cx},${yBot - h * 0.58} ${x1},${yBot} Z`; } function mouthShout(cx, y) { // trapezoid open mouth (less tall) const wTop = 160, wBot = 320, h = 120; const x1 = cx - wTop / 2; const x2 = cx + wTop / 2; const x3 = cx + wBot / 2; const x4 = cx - wBot / 2; const y1 = y - 70; const y2 = y1 + h; return `M ${x1},${y1} L ${x2},${y1} L ${x3},${y2} L ${x4},${y2} Z`; } // Speaking animation: scale mouth vertically (works for all mouth shapes) let speakTimer = null; function startSpeaking() { stopSpeaking(); let open = false; const base = state.emotion === "excited" ? 85 : state.emotion === "sleepy" ? 170 : 115; speakTimer = setInterval(() => { if (!state.speaking) return; open = !open; const amp = 0.10 + 0.18 * state.intensity; const sy = 1 + amp * (open ? 1 : -0.35); const ty = open ? 5 : 0; mouth.setAttribute("transform", `translate(0,${ty}) scale(1,${sy})`); }, base); } function stopSpeaking() { if (speakTimer) clearInterval(speakTimer); speakTimer = null; mouth.setAttribute("transform", ""); } // Eye drift when no locked look and eyesMoving enabled let driftTimer = null; function scheduleDrift() { clearTimeout(driftTimer); if (state.lookLock || !state.eyesMoving) return; // do not drift for icon-locked emotions const iconLocked = state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"; if (iconLocked) return; state.look = { x: (Math.random() * 2 - 1) * 0.5, y: (Math.random() * 2 - 1) * 0.35, }; render(); driftTimer = setTimeout(scheduleDrift, 900 + Math.random() * 900); } function render() { if (!eyeL || !eyeR || !mouth || !eyesGroup) return; // subtle look shift (eyesGroup translate) const lookX = clamp(state.look.x, -1, 1); const lookY = clamp(state.look.y, -1, 1); const iconLocked = state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"; const scale = iconLocked ? 0.18 : 1.0; eyesGroup.setAttribute( "transform", `translate(${lookX * BASE.lookMaxPx * scale}, ${lookY * BASE.lookMaxPx * scale})` ); // style setStickerStyle(eyeL, 5); setStickerStyle(eyeR, 5); setStickerStyle(mouth, 5); // mapping switch (state.emotion) { case "angry": eyeL.setAttribute("d", eyeAngryL(BASE.L.x, BASE.L.y)); eyeR.setAttribute("d", eyeAngryR(BASE.R.x, BASE.R.y)); mouth.setAttribute("d", mouthShout(BASE.mouthCx, BASE.mouthY + 50)); break; case "happy": eyeL.setAttribute("d", eyeHappy(BASE.L.x, BASE.L.y)); eyeR.setAttribute("d", eyeHappy(BASE.R.x, BASE.R.y)); mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY + 14)); break; case "excited": eyeL.setAttribute("d", eyeSurprised(BASE.L.x, BASE.L.y + 2)); eyeR.setAttribute("d", eyeSurprised(BASE.R.x, BASE.R.y + 2)); mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY + 4)); break; case "surprised": eyeL.setAttribute("d", eyeSurprised(BASE.L.x, BASE.L.y + 2)); eyeR.setAttribute("d", eyeSurprised(BASE.R.x, BASE.R.y + 2)); mouth.setAttribute("d", mouthShout(BASE.mouthCx, BASE.mouthY + 34)); break; case "sleepy": eyeL.setAttribute("d", eyeSleep(BASE.L.x, BASE.L.y + 28)); eyeR.setAttribute("d", eyeSleep(BASE.R.x, BASE.R.y + 28)); mouth.setAttribute("d", mouthFrown(BASE.mouthCx, BASE.mouthY + 18)); break; case "sad": eyeL.setAttribute("d", eyeSleep(BASE.L.x, BASE.L.y + 32)); eyeR.setAttribute("d", eyeSleep(BASE.R.x, BASE.R.y + 32)); mouth.setAttribute("d", mouthFrown(BASE.mouthCx, BASE.mouthY + 24)); break; default: // neutral eyeL.setAttribute("d", eyeOval(BASE.L.x, BASE.L.y)); eyeR.setAttribute("d", eyeOval(BASE.R.x, BASE.R.y)); mouth.setAttribute("d", mouthDot(BASE.mouthCx, BASE.mouthY + 42)); break; } } // Public API for incoming state window.applyFaceState = (payload) => { if (!payload || typeof payload !== "object") return; if (payload.intensity !== undefined) state.intensity = clamp01(payload.intensity); if (typeof payload.emotion === "string") { state.emotion = EMOTIONS.has(payload.emotion) ? payload.emotion : "neutral"; } if (payload.look && typeof payload.look === "object") { state.look = { x: clamp(Number(payload.look.x ?? 0), -1, 1), y: clamp(Number(payload.look.y ?? 0), -1, 1), }; state.lookLock = true; } else { state.lookLock = false; } if (typeof payload.speaking === "boolean") state.speaking = payload.speaking; if (typeof payload.eyesMoving === "boolean") state.eyesMoving = payload.eyesMoving; render(); if (state.speaking) startSpeaking(); else stopSpeaking(); if (!state.lookLock) scheduleDrift(); }; // SSE /events (supports event: state and plain message) (function connectSSE() { let es; function open() { es = new EventSource("/events"); const handle = (ev) => { try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} }; es.addEventListener("state", handle); es.onmessage = handle; es.onerror = () => { try { es.close(); } catch (_) {} setTimeout(open, 1200); }; } open(); })(); // Init render(); scheduleDrift(); })();