355 lines
10 KiB
JavaScript
355 lines
10 KiB
JavaScript
// /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();
|
|
})();
|
|
|