diff --git a/face/var/www/html/face.js b/face/var/www/html/face.js index b0c4f58..a0a5811 100644 --- a/face/var/www/html/face.js +++ b/face/var/www/html/face.js @@ -1,19 +1,21 @@ -// Minimal: black background, cyan face elements only. -// Input via SSE /events (nginx -> :8001/events) +// /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 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 EMOTIONS = new Set(["neutral", "happy", "sad", "angry", "sleepy", "surprised", "excited"]); const state = { emotion: "neutral", @@ -35,104 +37,158 @@ // Touch overlay show let overlayTimer = null; - function showOverlay(){ + function showOverlay() { + if (!overlay) return; overlay.classList.add("show"); clearTimeout(overlayTimer); 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 = { - L: { x: 380, y: 270 }, - R: { x: 620, y: 270 }, - lookMaxPx: 22, + L: { x: 385, y: 265 }, + R: { x: 615, y: 265 }, + lookMaxPx: 18, mouthCx: 500, - mouthY: 400, + mouthY: 388, }; - function setStickerStyle(el, edge = 6) { - const a = 0.80 + 0.20 * state.intensity; - el.setAttribute("fill", `rgba(120,220,255,${a})`); - el.setAttribute("stroke", `rgba(200,255,255,${0.55 + 0.35 * state.intensity})`); + // 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"); } - // --- Shapes (filled cyan like your reference) --- + // ----- EYES (filled) ----- function eyeOval(cx, cy) { - const rx=34, ry=58; - 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) { - 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} - 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`; + 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=36, h=14; - 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`; + 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) { - const w=320, h=150; - const x1=cx-w/2, x2=cx+w/2, y1=y-55; - return `M ${x1},${y1} - Q ${cx},${y1+h} ${x2},${y1} - Q ${cx},${y1+h*0.55} ${x1},${y1} Z`; + // 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) { - const w=260, h=140; - const x1=cx-w/2, x2=cx+w/2, y1=y-40; - return `M ${x1},${y1} - Q ${cx},${y1+h} ${x2},${y1} - Q ${cx},${y1+h*0.55} ${x1},${y1} Z`; + // 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=260, h=130; - const x1=cx-w/2, x2=cx+w/2, y1=y+45; - return `M ${x1},${y1} - Q ${cx},${y1-130} ${x2},${y1} - Q ${cx},${y1-72} ${x1},${y1} Z`; + 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) { - const wTop=140, wBot=260, h=120; - const x1=cx-wTop/2, x2=cx+wTop/2, x3=cx+wBot/2, x4=cx-wBot/2; - const y1=y-35, y2=y+h-35; + // 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) + // 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); + const base = state.emotion === "excited" ? 85 : state.emotion === "sleepy" ? 170 : 115; + speakTimer = setInterval(() => { if (!state.speaking) return; open = !open; @@ -142,34 +198,40 @@ mouth.setAttribute("transform", `translate(0,${ty}) scale(1,${sy})`); }, base); } + function stopSpeaking() { if (speakTimer) clearInterval(speakTimer); speakTimer = null; 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; - function scheduleDrift(){ + function scheduleDrift() { clearTimeout(driftTimer); 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; state.look = { - x: (Math.random()*2-1) * 0.5, - y: (Math.random()*2-1) * 0.35 + x: (Math.random() * 2 - 1) * 0.5, + y: (Math.random() * 2 - 1) * 0.35, }; + render(); - driftTimer = setTimeout(scheduleDrift, 900 + Math.random()*900); + driftTimer = setTimeout(scheduleDrift, 900 + Math.random() * 900); } - function render(){ - // subtle look shift of eye group + 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 iconLocked = state.emotion === "angry" || state.emotion === "happy" || state.emotion === "sleepy"; const scale = iconLocked ? 0.18 : 1.0; eyesGroup.setAttribute( @@ -177,54 +239,66 @@ `translate(${lookX * BASE.lookMaxPx * scale}, ${lookY * BASE.lookMaxPx * scale})` ); - setStickerStyle(eyeL, 6); - setStickerStyle(eyeR, 6); - setStickerStyle(mouth, 6); + // style + setStickerStyle(eyeL, 5); + setStickerStyle(eyeR, 5); + setStickerStyle(mouth, 5); - switch(state.emotion){ + // 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 + 40)); + 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 + 10)); - 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)); + mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY + 14)); break; + case "excited": - eyeL.setAttribute("d", eyeSurprised(BASE.L.x, BASE.L.y)); - eyeR.setAttribute("d", eyeSurprised(BASE.R.x, BASE.R.y)); - mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY)); + 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 + 35)); + mouth.setAttribute("d", mouthDot(BASE.mouthCx, BASE.mouthY + 42)); + break; } } - // Public API + // 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 (typeof payload.emotion === "string") { + state.emotion = EMOTIONS.has(payload.emotion) ? payload.emotion : "neutral"; + } if (payload.look && typeof payload.look === "object") { state.look = { @@ -240,26 +314,36 @@ if (typeof payload.eyesMoving === "boolean") state.eyesMoving = payload.eyesMoving; render(); - if (state.speaking) startSpeaking(); else stopSpeaking(); + if (state.speaking) startSpeaking(); + else stopSpeaking(); + if (!state.lookLock) scheduleDrift(); }; - // SSE /events - (function connectSSE(){ + // SSE /events (supports event: state and plain message) + (function connectSSE() { let es; - function open(){ + + function open() { es = new EventSource("/events"); - es.addEventListener("state", (ev) => { - try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} - }); - es.onmessage = (ev) => { - try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} + + const handle = (ev) => { + 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(); })();