(() => { 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: { // Eyes: thick "caps" (template-like) eyeL: `M 360,320 Q 395,258 430,320 Q 420,295 395,295 Q 370,295 360,320 Z`, eyeR: `M 570,320 Q 605,258 640,320 Q 630,295 605,295 Q 580,295 570,320 Z`, // Speaking visemes: keep same smile family (slightly “open” but still a smile) visemes: [ `M 345,360 Q 500,520 655,360 Q 610,500 500,500 Q 390,500 345,360 Z`, `M 332,350 Q 500,540 668,350 Q 615,515 500,515 Q 385,515 332,350 Z`, `M 318,338 Q 500,565 682,338 Q 620,532 500,532 Q 380,532 318,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: { // 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; 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(); })();