diff --git a/face/var/www/html/face.js b/face/var/www/html/face.js index a0a5811..a6978d6 100644 --- a/face/var/www/html/face.js +++ b/face/var/www/html/face.js @@ -1,10 +1,3 @@ -// /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; @@ -15,7 +8,7 @@ return clamp(n, 0, 1); }; - const EMOTIONS = new Set(["neutral", "happy", "sad", "angry", "sleepy", "surprised", "excited"]); + const EMOTIONS = new Set(["neutral","happy","sad","angry","sleepy","surprised","excited"]); const state = { emotion: "neutral", @@ -26,7 +19,6 @@ eyesMoving: true, }; - // DOM const stage = document.getElementById("stage"); const overlay = document.getElementById("overlay"); @@ -37,260 +29,258 @@ // Touch overlay show let overlayTimer = null; - function showOverlay() { - if (!overlay) return; + function showOverlay(){ overlay.classList.add("show"); clearTimeout(overlayTimer); overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS); } - if (stage) stage.addEventListener("pointerdown", () => showOverlay(), { passive: true }); + 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; + // 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.55 + 0.35 * state.intensity})`); - el.setAttribute("stroke-width", String(edge)); + 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"); } - // ----- 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`; + // 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`; } - 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`; + // 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})`); } - 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) + // Speaking: use viseme cycling instead of scaling (keeps shapes “sticker clean”) let speakTimer = null; + let visemeIndex = 0; + function startSpeaking() { stopSpeaking(); - let open = false; - const base = state.emotion === "excited" ? 85 : state.emotion === "sleepy" ? 170 : 115; + visemeIndex = 0; + const base = state.emotion === "excited" ? 90 : (state.emotion === "sleepy" ? 180 : 120); 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})`); + visemeIndex = (visemeIndex + 1) % 3; + render(); // will pick viseme }, base); } function stopSpeaking() { if (speakTimer) clearInterval(speakTimer); speakTimer = null; + visemeIndex = 0; mouth.setAttribute("transform", ""); } - // Eye drift when no locked look and eyesMoving enabled + // Optional drift (only if allowLook and not locked) let driftTimer = null; function scheduleDrift() { clearTimeout(driftTimer); - if (state.lookLock || !state.eyesMoving) return; + const cfg = ICONS[state.emotion] ?? ICONS.neutral; - // do not drift for icon-locked emotions - const iconLocked = state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"; - if (iconLocked) return; + 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() { - if (!eyeL || !eyeR || !mouth || !eyesGroup) return; + const cfg = ICONS[state.emotion] ?? ICONS.neutral; - // subtle look shift (eyesGroup translate) - const lookX = clamp(state.look.x, -1, 1); - const lookY = clamp(state.look.y, -1, 1); + applyStyle(eyeL); + applyStyle(eyeR); + applyStyle(mouth); - const iconLocked = state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"; - const scale = iconLocked ? 0.18 : 1.0; + eyeL.setAttribute("d", cfg.eyeL); + eyeR.setAttribute("d", cfg.eyeR); - eyesGroup.setAttribute( - "transform", - `translate(${lookX * BASE.lookMaxPx * scale}, ${lookY * BASE.lookMaxPx * scale})` - ); + const mouthPath = (state.speaking && cfg.visemes) ? cfg.visemes[visemeIndex] : cfg.mouth; + mouth.setAttribute("d", mouthPath); - // 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; - } + applyLook(); } - // Public API for incoming state + // Public API window.applyFaceState = (payload) => { if (!payload || typeof payload !== "object") return; @@ -314,41 +304,38 @@ 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() { + // SSE /events + (function connectSSE(){ let es; - - function open() { + function open(){ es = new EventSource("/events"); const handle = (ev) => { - try { - window.applyFaceState(JSON.parse(ev.data)); - } catch (_) {} + try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} }; es.addEventListener("state", handle); es.onmessage = handle; es.onerror = () => { - try { - es.close(); - } catch (_) {} + try { es.close(); } catch (_) {} setTimeout(open, 1200); }; } - open(); })(); - // Init + // init render(); scheduleDrift(); })(); + +