444 lines
12 KiB
JavaScript
444 lines
12 KiB
JavaScript
(() => {
|
|
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");
|
|
const mouthHi = document.getElementById("mouthHi");
|
|
|
|
// 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");
|
|
}
|
|
|
|
function applyHighlightStyle(el) {
|
|
// Inlay/Highlight wie Vorlage (heller, ohne Stroke)
|
|
el.setAttribute("fill", "rgba(230,255,255,0.35)");
|
|
el.setAttribute("stroke", "none");
|
|
}
|
|
|
|
// 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: {
|
|
// Augen: Außen-U minus Innen-U (evenodd) -> echtes "U-Modul" wie Vorlage
|
|
eyeL: `M 345,230
|
|
Q 395,185 445,230
|
|
L 445,312
|
|
Q 395,285 345,312
|
|
Z
|
|
M 368,240
|
|
Q 395,214 422,240
|
|
L 422,296
|
|
Q 395,276 368,296
|
|
Z`,
|
|
|
|
eyeR: `M 555,230
|
|
Q 605,185 655,230
|
|
L 655,312
|
|
Q 605,285 555,312
|
|
Z
|
|
M 578,240
|
|
Q 605,214 632,240
|
|
L 632,296
|
|
Q 605,276 578,296
|
|
Z`,
|
|
|
|
// Mund: Banana-Band (Außen) wie Vorlage (kleiner, bis etwa Augenmitte)
|
|
mouth: `M 395,360
|
|
Q 500,344 605,360
|
|
Q 640,410 610,468
|
|
Q 500,520 390,468
|
|
Q 360,410 395,360
|
|
Z`,
|
|
|
|
// Mund-Highlight (Inlay): kleiner, etwas höher
|
|
mouthHi: `M 415,382
|
|
Q 500,368 585,382
|
|
Q 610,412 592,446
|
|
Q 500,476 408,446
|
|
Q 390,412 415,382
|
|
Z`,
|
|
|
|
// Sprechen: gleiche Familie, nur etwas "tiefer" öffnen
|
|
visemes: [
|
|
`M 395,360
|
|
Q 500,344 605,360
|
|
Q 640,410 610,468
|
|
Q 500,520 390,468
|
|
Q 360,410 395,360
|
|
Z`,
|
|
|
|
`M 388,354
|
|
Q 500,336 612,354
|
|
Q 652,414 618,484
|
|
Q 500,546 382,484
|
|
Q 348,414 388,354
|
|
Z`,
|
|
|
|
`M 380,346
|
|
Q 500,328 620,346
|
|
Q 665,420 625,505
|
|
Q 500,575 375,505
|
|
Q 335,420 380,346
|
|
Z`,
|
|
],
|
|
|
|
visemesHi: [
|
|
`M 415,382
|
|
Q 500,368 585,382
|
|
Q 610,412 592,446
|
|
Q 500,476 408,446
|
|
Q 390,412 415,382
|
|
Z`,
|
|
|
|
`M 410,378
|
|
Q 500,362 590,378
|
|
Q 620,414 600,456
|
|
Q 500,492 400,456
|
|
Q 380,414 410,378
|
|
Z`,
|
|
|
|
`M 405,372
|
|
Q 500,354 595,372
|
|
Q 632,418 608,470
|
|
Q 500,510 392,470
|
|
Q 368,418 405,372
|
|
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: {
|
|
// Eyes: sharp, inward pointing "evil" shapes (closer to template)
|
|
eyeL: `M 325,255
|
|
Q 360,205 435,225
|
|
Q 455,230 470,245
|
|
Q 415,330 340,305
|
|
Q 315,295 325,255 Z`,
|
|
|
|
eyeR: `M 675,255
|
|
Q 640,205 565,225
|
|
Q 545,230 530,245
|
|
Q 585,330 660,305
|
|
Q 685,295 675,255 Z`,
|
|
|
|
// Mouth: smaller, angled trapezoid (not huge)
|
|
mouth: `M 405,410
|
|
L 600,445
|
|
L 565,500
|
|
L 360,468 Z`,
|
|
|
|
// Speaking visemes: same "shout" family but not growing absurdly
|
|
visemes: [
|
|
`M 405,410 L 600,445 L 565,500 L 360,468 Z`,
|
|
`M 395,405 L 610,448 L 575,515 L 350,480 Z`,
|
|
`M 385,398 L 620,452 L 590,530 L 340,495 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;
|
|
|
|
// evenodd erlaubt "Aussparungen" (Innenpfade) in Augen/Mund
|
|
eyeL.setAttribute("fill-rule", "evenodd");
|
|
eyeR.setAttribute("fill-rule", "evenodd");
|
|
mouth.setAttribute("fill-rule", "evenodd");
|
|
mouthHi.setAttribute("fill-rule", "evenodd");>
|
|
|
|
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);
|
|
|
|
// Highlight/Inlay
|
|
if (cfg.mouthHi) {
|
|
const mouthHiPath = (state.speaking && cfg.visemesHi) ? cfg.visemesHi[visemeIndex] : cfg.mouthHi;
|
|
mouthHi.style.display = "";
|
|
applyHighlightStyle(mouthHi);
|
|
mouthHi.setAttribute("d", mouthHiPath);
|
|
} else {
|
|
mouthHi.style.display = "none";
|
|
}
|
|
|
|
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();
|
|
})();
|
|
|
|
|
|
|