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

271 lines
8.8 KiB
JavaScript

// Minimal: black background, cyan face elements only.
// Input via SSE /events (nginx -> :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(){
overlay.classList.add("show");
clearTimeout(overlayTimer);
overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS);
}
stage.addEventListener("pointerdown", () => showOverlay(), { passive:true });
// Face layout (centered)
const BASE = {
L: { x: 380, y: 270 },
R: { x: 620, y: 270 },
lookMaxPx: 22,
mouthCx: 500,
mouthY: 400,
};
function setStickerStyle(el, edge = 6) {
const a = 0.80 + 0.20 * state.intensity;
el.setAttribute("fill", `rgba(120,220,255,${a})`);
el.setAttribute("stroke", `rgba(200,255,255,${0.55 + 0.35 * state.intensity})`);
el.setAttribute("stroke-width", String(edge));
el.setAttribute("stroke-linejoin", "round");
el.setAttribute("stroke-linecap", "round");
}
// --- Shapes (filled cyan like your reference) ---
function eyeOval(cx, cy) {
const rx=34, ry=58;
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) {
const w=70, h=52;
const x1=cx-w/2, x2=cx+w/2, y=cy-18;
return `M ${x1},${y}
Q ${cx},${y+h} ${x2},${y}
Q ${cx},${y+h*0.55} ${x1},${y} Z`;
}
function eyeSleep(cx, cy) {
const w=90, h=16;
return `M ${cx-w/2},${cy-h/2} L ${cx+w/2},${cy-h/2}
L ${cx+w/2},${cy+h/2} L ${cx-w/2},${cy+h/2} Z`;
}
function eyeAngryL(cx, cy) {
return `M ${cx-75},${cy-35} L ${cx+15},${cy-5}
L ${cx+55},${cy-60} L ${cx-40},${cy-70} Z`;
}
function eyeAngryR(cx, cy) {
return `M ${cx+75},${cy-35} L ${cx-15},${cy-5}
L ${cx-55},${cy-60} L ${cx+40},${cy-70} Z`;
}
function eyeSurprised(cx, cy) {
const rx=38, ry=64;
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 mouthDot(cx, y) {
const w=36, h=14;
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) {
const w=320, h=150;
const x1=cx-w/2, x2=cx+w/2, y1=y-55;
return `M ${x1},${y1}
Q ${cx},${y1+h} ${x2},${y1}
Q ${cx},${y1+h*0.55} ${x1},${y1} Z`;
}
function mouthSmile(cx, y) {
const w=260, h=140;
const x1=cx-w/2, x2=cx+w/2, y1=y-40;
return `M ${x1},${y1}
Q ${cx},${y1+h} ${x2},${y1}
Q ${cx},${y1+h*0.55} ${x1},${y1} Z`;
}
function mouthFrown(cx, y) {
const w=260, h=130;
const x1=cx-w/2, x2=cx+w/2, y1=y+45;
return `M ${x1},${y1}
Q ${cx},${y1-130} ${x2},${y1}
Q ${cx},${y1-72} ${x1},${y1} Z`;
}
function mouthShout(cx, y) {
const wTop=140, wBot=260, h=120;
const x1=cx-wTop/2, x2=cx+wTop/2, x3=cx+wBot/2, x4=cx-wBot/2;
const y1=y-35, y2=y+h-35;
return `M ${x1},${y1} L ${x2},${y1} L ${x3},${y2} L ${x4},${y2} Z`;
}
// Speaking animation: scale mouth vertically (works for all)
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", "");
}
// Optional eye drift when no look is locked and eyesMoving true
let driftTimer = null;
function scheduleDrift(){
clearTimeout(driftTimer);
if (state.lookLock || !state.eyesMoving) return;
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(){
// subtle look shift of eye group
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})`
);
setStickerStyle(eyeL, 6);
setStickerStyle(eyeR, 6);
setStickerStyle(mouth, 6);
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 + 40));
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 + 10));
break;
case "sleepy":
eyeL.setAttribute("d", eyeSleep(BASE.L.x, BASE.L.y + 14));
eyeR.setAttribute("d", eyeSleep(BASE.R.x, BASE.R.y + 14));
mouth.setAttribute("d", mouthFrown(BASE.mouthCx, BASE.mouthY + 25));
break;
case "sad":
eyeL.setAttribute("d", eyeSleep(BASE.L.x, BASE.L.y + 18));
eyeR.setAttribute("d", eyeSleep(BASE.R.x, BASE.R.y + 18));
mouth.setAttribute("d", mouthFrown(BASE.mouthCx, BASE.mouthY + 25));
break;
case "surprised":
eyeL.setAttribute("d", eyeSurprised(BASE.L.x, BASE.L.y));
eyeR.setAttribute("d", eyeSurprised(BASE.R.x, BASE.R.y));
mouth.setAttribute("d", mouthShout(BASE.mouthCx, BASE.mouthY + 25));
break;
case "excited":
eyeL.setAttribute("d", eyeSurprised(BASE.L.x, BASE.L.y));
eyeR.setAttribute("d", eyeSurprised(BASE.R.x, BASE.R.y));
mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY));
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 + 35));
}
}
// Public API
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
(function connectSSE(){
let es;
function open(){
es = new EventSource("/events");
es.addEventListener("state", (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
});
es.onmessage = (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
};
es.onerror = () => {
try { es.close(); } catch (_) {}
setTimeout(open, 1200);
};
}
open();
})();
// Init
render();
scheduleDrift();
})();