additional new face stuff

This commit is contained in:
max 2026-02-08 22:21:48 +01:00
parent e3b553696c
commit afca03954c
1 changed files with 199 additions and 115 deletions

View File

@ -1,5 +1,7 @@
// Minimal: black background, cyan face elements only. // /var/www/html/face.js
// Input via SSE /events (nginx -> :8001/events) // 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: // Payload example:
// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0},"speaking":true,"eyesMoving":true} // {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0},"speaking":true,"eyesMoving":true}
@ -36,103 +38,157 @@
// Touch overlay show // Touch overlay show
let overlayTimer = null; let overlayTimer = null;
function showOverlay() { function showOverlay() {
if (!overlay) return;
overlay.classList.add("show"); overlay.classList.add("show");
clearTimeout(overlayTimer); clearTimeout(overlayTimer);
overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS); overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS);
} }
stage.addEventListener("pointerdown", () => showOverlay(), { passive:true }); if (stage) stage.addEventListener("pointerdown", () => showOverlay(), { passive: true });
// Face layout (centered) // Tuned to match your reference image more closely
const BASE = { const BASE = {
L: { x: 380, y: 270 }, L: { x: 385, y: 265 },
R: { x: 620, y: 270 }, R: { x: 615, y: 265 },
lookMaxPx: 22, lookMaxPx: 18,
mouthCx: 500, mouthCx: 500,
mouthY: 400, mouthY: 388,
}; };
function setStickerStyle(el, edge = 6) { // Sticker colors: filled cyan with light edge
const a = 0.80 + 0.20 * state.intensity; function setStickerStyle(el, edge = 5) {
el.setAttribute("fill", `rgba(120,220,255,${a})`); const a = 0.86 + 0.14 * state.intensity;
el.setAttribute("stroke", `rgba(200,255,255,${0.55 + 0.35 * 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-width", String(edge));
el.setAttribute("stroke-linejoin", "round"); el.setAttribute("stroke-linejoin", "round");
el.setAttribute("stroke-linecap", "round"); el.setAttribute("stroke-linecap", "round");
} }
// --- Shapes (filled cyan like your reference) --- // ----- EYES (filled) -----
function eyeOval(cx, cy) { function eyeOval(cx, cy) {
const rx=34, ry=58; const rx = 40,
return `M ${cx},${cy-ry} ry = 74;
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} 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}
C ${cx - rx},${cy + ry} ${cx - rx},${cy - ry} ${cx},${cy - ry} Z`; 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) { function mouthDot(cx, y) {
const w=36, h=14; const w = 28,
return `M ${cx-w/2},${y-h/2} L ${cx+w/2},${y-h/2} h = 12;
L ${cx+w/2},${y+h/2} L ${cx-w/2},${y+h/2} Z`; 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) { function mouthGrin(cx, y) {
const w=320, h=150; // wide grin like sticker
const x1=cx-w/2, x2=cx+w/2, y1=y-55; const w = 400,
return `M ${x1},${y1} h = 190;
Q ${cx},${y1+h} ${x2},${y1} const x1 = cx - w / 2;
Q ${cx},${y1+h*0.55} ${x1},${y1} Z`; 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) { function mouthSmile(cx, y) {
const w=260, h=140; // narrower fallback
const x1=cx-w/2, x2=cx+w/2, y1=y-40; const w = 340,
return `M ${x1},${y1} h = 170;
Q ${cx},${y1+h} ${x2},${y1} const x1 = cx - w / 2;
Q ${cx},${y1+h*0.55} ${x1},${y1} Z`; 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) { function mouthFrown(cx, y) {
const w=260, h=130; const w = 360,
const x1=cx-w/2, x2=cx+w/2, y1=y+45; h = 170;
return `M ${x1},${y1} const x1 = cx - w / 2;
Q ${cx},${y1-130} ${x2},${y1} const x2 = cx + w / 2;
Q ${cx},${y1-72} ${x1},${y1} Z`; 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) { function mouthShout(cx, y) {
const wTop=140, wBot=260, h=120; // trapezoid open mouth (less tall)
const x1=cx-wTop/2, x2=cx+wTop/2, x3=cx+wBot/2, x4=cx-wBot/2; const wTop = 160,
const y1=y-35, y2=y+h-35; 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`; return `M ${x1},${y1} L ${x2},${y1} L ${x3},${y2} L ${x4},${y2} Z`;
} }
// Speaking animation: scale mouth vertically (works for all) // Speaking animation: scale mouth vertically (works for all mouth shapes)
let speakTimer = null; let speakTimer = null;
function startSpeaking() { function startSpeaking() {
stopSpeaking(); stopSpeaking();
let open = false; let open = false;
const base = state.emotion === "excited" ? 85 : (state.emotion === "sleepy" ? 170 : 115); const base = state.emotion === "excited" ? 85 : state.emotion === "sleepy" ? 170 : 115;
speakTimer = setInterval(() => { speakTimer = setInterval(() => {
if (!state.speaking) return; if (!state.speaking) return;
open = !open; open = !open;
@ -142,34 +198,40 @@
mouth.setAttribute("transform", `translate(0,${ty}) scale(1,${sy})`); mouth.setAttribute("transform", `translate(0,${ty}) scale(1,${sy})`);
}, base); }, base);
} }
function stopSpeaking() { function stopSpeaking() {
if (speakTimer) clearInterval(speakTimer); if (speakTimer) clearInterval(speakTimer);
speakTimer = null; speakTimer = null;
mouth.setAttribute("transform", ""); mouth.setAttribute("transform", "");
} }
// Optional eye drift when no look is locked and eyesMoving true // Eye drift when no locked look and eyesMoving enabled
let driftTimer = null; let driftTimer = null;
function scheduleDrift() { function scheduleDrift() {
clearTimeout(driftTimer); clearTimeout(driftTimer);
if (state.lookLock || !state.eyesMoving) return; if (state.lookLock || !state.eyesMoving) return;
const iconLocked = (state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"); // do not drift for icon-locked emotions
const iconLocked = state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy";
if (iconLocked) return; if (iconLocked) return;
state.look = { state.look = {
x: (Math.random() * 2 - 1) * 0.5, x: (Math.random() * 2 - 1) * 0.5,
y: (Math.random()*2-1) * 0.35 y: (Math.random() * 2 - 1) * 0.35,
}; };
render(); render();
driftTimer = setTimeout(scheduleDrift, 900 + Math.random() * 900); driftTimer = setTimeout(scheduleDrift, 900 + Math.random() * 900);
} }
function render() { function render() {
// subtle look shift of eye group if (!eyeL || !eyeR || !mouth || !eyesGroup) return;
// subtle look shift (eyesGroup translate)
const lookX = clamp(state.look.x, -1, 1); const lookX = clamp(state.look.x, -1, 1);
const lookY = clamp(state.look.y, -1, 1); const lookY = clamp(state.look.y, -1, 1);
const iconLocked = (state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy");
const iconLocked = state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy";
const scale = iconLocked ? 0.18 : 1.0; const scale = iconLocked ? 0.18 : 1.0;
eyesGroup.setAttribute( eyesGroup.setAttribute(
@ -177,54 +239,66 @@
`translate(${lookX * BASE.lookMaxPx * scale}, ${lookY * BASE.lookMaxPx * scale})` `translate(${lookX * BASE.lookMaxPx * scale}, ${lookY * BASE.lookMaxPx * scale})`
); );
setStickerStyle(eyeL, 6); // style
setStickerStyle(eyeR, 6); setStickerStyle(eyeL, 5);
setStickerStyle(mouth, 6); setStickerStyle(eyeR, 5);
setStickerStyle(mouth, 5);
// mapping
switch (state.emotion) { switch (state.emotion) {
case "angry": case "angry":
eyeL.setAttribute("d", eyeAngryL(BASE.L.x, BASE.L.y)); eyeL.setAttribute("d", eyeAngryL(BASE.L.x, BASE.L.y));
eyeR.setAttribute("d", eyeAngryR(BASE.R.x, BASE.R.y)); eyeR.setAttribute("d", eyeAngryR(BASE.R.x, BASE.R.y));
mouth.setAttribute("d", mouthShout(BASE.mouthCx, BASE.mouthY + 40)); mouth.setAttribute("d", mouthShout(BASE.mouthCx, BASE.mouthY + 50));
break; break;
case "happy": case "happy":
eyeL.setAttribute("d", eyeHappy(BASE.L.x, BASE.L.y)); eyeL.setAttribute("d", eyeHappy(BASE.L.x, BASE.L.y));
eyeR.setAttribute("d", eyeHappy(BASE.R.x, BASE.R.y)); eyeR.setAttribute("d", eyeHappy(BASE.R.x, BASE.R.y));
mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY + 10)); mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY + 14));
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; break;
case "excited": case "excited":
eyeL.setAttribute("d", eyeSurprised(BASE.L.x, BASE.L.y)); eyeL.setAttribute("d", eyeSurprised(BASE.L.x, BASE.L.y + 2));
eyeR.setAttribute("d", eyeSurprised(BASE.R.x, BASE.R.y)); eyeR.setAttribute("d", eyeSurprised(BASE.R.x, BASE.R.y + 2));
mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY)); mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY + 4));
break; 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 default: // neutral
eyeL.setAttribute("d", eyeOval(BASE.L.x, BASE.L.y)); eyeL.setAttribute("d", eyeOval(BASE.L.x, BASE.L.y));
eyeR.setAttribute("d", eyeOval(BASE.R.x, BASE.R.y)); eyeR.setAttribute("d", eyeOval(BASE.R.x, BASE.R.y));
mouth.setAttribute("d", mouthDot(BASE.mouthCx, BASE.mouthY + 35)); mouth.setAttribute("d", mouthDot(BASE.mouthCx, BASE.mouthY + 42));
break;
} }
} }
// Public API // Public API for incoming state
window.applyFaceState = (payload) => { window.applyFaceState = (payload) => {
if (!payload || typeof payload !== "object") return; if (!payload || typeof payload !== "object") return;
if (payload.intensity !== undefined) state.intensity = clamp01(payload.intensity); if (payload.intensity !== undefined) state.intensity = clamp01(payload.intensity);
if (typeof payload.emotion === "string") state.emotion = EMOTIONS.has(payload.emotion) ? payload.emotion : "neutral";
if (typeof payload.emotion === "string") {
state.emotion = EMOTIONS.has(payload.emotion) ? payload.emotion : "neutral";
}
if (payload.look && typeof payload.look === "object") { if (payload.look && typeof payload.look === "object") {
state.look = { state.look = {
@ -240,26 +314,36 @@
if (typeof payload.eyesMoving === "boolean") state.eyesMoving = payload.eyesMoving; if (typeof payload.eyesMoving === "boolean") state.eyesMoving = payload.eyesMoving;
render(); render();
if (state.speaking) startSpeaking(); else stopSpeaking(); if (state.speaking) startSpeaking();
else stopSpeaking();
if (!state.lookLock) scheduleDrift(); if (!state.lookLock) scheduleDrift();
}; };
// SSE /events // SSE /events (supports event: state and plain message)
(function connectSSE() { (function connectSSE() {
let es; let es;
function open() { function open() {
es = new EventSource("/events"); es = new EventSource("/events");
es.addEventListener("state", (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} const handle = (ev) => {
}); try {
es.onmessage = (ev) => { window.applyFaceState(JSON.parse(ev.data));
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} } catch (_) {}
}; };
es.addEventListener("state", handle);
es.onmessage = handle;
es.onerror = () => { es.onerror = () => {
try { es.close(); } catch (_) {} try {
es.close();
} catch (_) {}
setTimeout(open, 1200); setTimeout(open, 1200);
}; };
} }
open(); open();
})(); })();