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

342 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
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,
};
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 });
// STYLE: closer to sticker art (thin edge, almost no glow)
function applyStyle(el) {
const a = 0.90 + 0.10 * state.intensity;
el.setAttribute("fill", `rgba(120,235,255,${a})`);
el.setAttribute("stroke", `rgba(210,255,255,${0.45 + 0.35 * state.intensity})`);
el.setAttribute("stroke-width", "4.5");
el.setAttribute("stroke-linejoin", "round");
el.setAttribute("stroke-linecap", "round");
}
// Helpers: Ellipse path (absolute, clean)
function ellipsePath(cx, cy, rx, ry) {
return `M ${cx-rx},${cy}
C ${cx-rx},${cy-ry} ${cx},${cy-ry} ${cx},${cy-ry}
C ${cx+rx},${cy-ry} ${cx+rx},${cy} ${cx+rx},${cy}
C ${cx+rx},${cy+ry} ${cx},${cy+ry} ${cx},${cy+ry}
C ${cx-rx},${cy+ry} ${cx-rx},${cy} ${cx-rx},${cy} Z`;
}
// ICON SHAPES (hand-tuned proportions similar to your reference)
// Coordinate system: viewBox 0..1000 x 0..600
const ICONS = {
neutral: {
// friendly neutral eyes: a bit wider + slightly shorter (feels warm, not surprised)
eyeL: ellipsePath(395, 270, 50, 90),
eyeR: ellipsePath(605, 270, 50, 90),
// friendly micro-smile: more curve + a bit wider
mouth: `M 400,442
Q 500,505 600,442
Q 560,478 500,478
Q 440,478 400,442 Z`,
// speaking visemes (keep "smile family" so it stays friendly while talking)
visemes: [
`M 400,442
Q 500,505 600,442
Q 560,478 500,478
Q 440,478 400,442 Z`,
`M 390,432
Q 500,520 610,432
Q 565,492 500,492
Q 435,492 390,432 Z`,
`M 375,418
Q 500,540 625,418
Q 570,510 500,510
Q 430,510 375,418 Z`,
],
allowLook: true,
},
happy: {
// filled caps (thick arcs) like sticker
eyeL: `M 338,248
Q 395,318 452,248
Q 430,340 395,340
Q 360,340 338,248 Z`,
eyeR: `M 548,248
Q 605,318 662,248
Q 640,340 605,340
Q 570,340 548,248 Z`,
mouth: `M 330,360
Q 500,540 670,360
Q 610,520 500,520
Q 390,520 330,360 Z`,
visemes: [
`M 330,360 Q 500,540 670,360 Q 610,520 500,520 Q 390,520 330,360 Z`,
`M 320,350 Q 500,560 680,350 Q 615,535 500,535 Q 385,535 320,350 Z`,
`M 305,338 Q 500,585 695,338 Q 620,555 500,555 Q 380,555 305,338 Z`,
],
allowLook: false,
},
sad: {
// thin sleepy-ish eyes like reference bottom-right
eyeL: `M 330,270
Q 395,250 460,270
Q 460,294 395,294
Q 330,294 330,270 Z`,
eyeR: `M 540,270
Q 605,250 670,270
Q 670,294 605,294
Q 540,294 540,270 Z`,
mouth: `M 330,490
Q 500,350 670,490
Q 610,420 500,420
Q 390,420 330,490 Z`,
visemes: [
`M 330,490 Q 500,350 670,490 Q 610,420 500,420 Q 390,420 330,490 Z`,
`M 350,500 Q 500,360 650,500 Q 600,440 500,440 Q 400,440 350,500 Z`,
`M 365,510 Q 500,380 635,510 Q 590,460 500,460 Q 410,460 365,510 Z`,
],
allowLook: false,
},
sleepy: {
// even flatter than sad
eyeL: `M 320,270
Q 395,258 470,270
Q 470,292 395,304
Q 320,292 320,270 Z`,
eyeR: `M 530,270
Q 605,258 680,270
Q 680,292 605,304
Q 530,292 530,270 Z`,
mouth: `M 335,495
Q 500,360 665,495
Q 610,435 500,435
Q 390,435 335,495 Z`,
visemes: [
`M 335,495 Q 500,360 665,495 Q 610,435 500,435 Q 390,435 335,495 Z`,
`M 355,505 Q 500,380 645,505 Q 595,455 500,455 Q 405,455 355,505 Z`,
`M 370,515 Q 500,400 630,515 Q 585,470 500,470 Q 415,470 370,515 Z`,
],
allowLook: false,
},
angry: {
// wedges like top-left reference
eyeL: `M 300,245
L 430,285
L 495,230
L 360,195 Z`,
eyeR: `M 700,245
L 570,285
L 505,230
 L 640,195 Z`,
mouth: `M 380,360
L 620,360
L 720,520
L 280,520 Z`,
visemes: [
`M 380,360 L 620,360 L 720,520 L 280,520 Z`,
`M 395,350 L 605,350 L 705,530 L 295,530 Z`,
`M 410,340 L 590,340 L 690,540 L 310,540 Z`,
],
allowLook: false,
},
surprised: {
eyeL: ellipsePath(395, 270, 50, 100),
eyeR: ellipsePath(605, 270, 50, 100),
mouth: `M 450,382
Q 500,340 550,382
Q 585,450 550,518
Q 500,560 450,518
Q 415,450 450,382 Z`,
visemes: [
`M 450,382 Q 500,340 550,382 Q 585,450 550,518 Q 500,560 450,518 Q 415,450 450,382 Z`,
`M 440,370 Q 500,320 560,370 Q 600,450 560,530 Q 500,580 440,530 Q 400,450 440,370 Z`,
`M 430,360 Q 500,300 570,360 Q 615,450 570,540 Q 500,600 430,540 Q 385,450 430,360 Z`,
],
allowLook: true,
},
excited: {
// excited: big surprised eyes, big grin
eyeL: ellipsePath(395, 270, 54, 108),
eyeR: ellipsePath(605, 270, 54, 108),
mouth: `M 315,350
Q 500,560 685,350
Q 620,545 500,545
Q 380,545 315,350 Z`,
visemes: [
`M 315,350 Q 500,560 685,350 Q 620,545 500,545 Q 380,545 315,350 Z`,
`M 305,340 Q 500,580 695,340 Q 625,560 500,560 Q 375,560 305,340 Z`,
`M 290,330 Q 500,600 710,330 Q 630,575 500,575 Q 370,575 290,330 Z`,
],
allowLook: true,
},
};
// Look movement only for allowedLook emotions (otherwise it destroys icon-eyes)
function applyLook() {
const cfg = ICONS[state.emotion] ?? ICONS.neutral;
if (!cfg.allowLook) {
eyesGroup.setAttribute("transform", "");
return;
}
const dx = clamp(state.look.x, -1, 1) * 16;
const dy = clamp(state.look.y, -1, 1) * 12;
eyesGroup.setAttribute("transform", `translate(${dx},${dy})`);
}
// Speaking: use viseme cycling instead of scaling (keeps shapes “sticker clean”)
let speakTimer = null;
let visemeIndex = 0;
function startSpeaking() {
stopSpeaking();
visemeIndex = 0;
const base = state.emotion === "excited" ? 90 : (state.emotion === "sleepy" ? 180 : 120);
speakTimer = setInterval(() => {
if (!state.speaking) return;
visemeIndex = (visemeIndex + 1) % 3;
render(); // will pick viseme
}, base);
}
function stopSpeaking() {
if (speakTimer) clearInterval(speakTimer);
speakTimer = null;
visemeIndex = 0;
mouth.setAttribute("transform", "");
}
// Optional drift (only if allowLook and not locked)
let driftTimer = null;
function scheduleDrift() {
clearTimeout(driftTimer);
const cfg = ICONS[state.emotion] ?? ICONS.neutral;
if (state.lookLock || !state.eyesMoving || !cfg.allowLook) 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() {
const cfg = ICONS[state.emotion] ?? ICONS.neutral;
applyStyle(eyeL);
applyStyle(eyeR);
applyStyle(mouth);
eyeL.setAttribute("d", cfg.eyeL);
eyeR.setAttribute("d", cfg.eyeR);
const mouthPath = (state.speaking && cfg.visemes) ? cfg.visemes[visemeIndex] : cfg.mouth;
mouth.setAttribute("d", mouthPath);
applyLook();
}
// 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");
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();
})();