diff --git a/face/var/www/html/control/control.css b/face/var/www/html/control/control.css new file mode 100644 index 0000000..a85d707 --- /dev/null +++ b/face/var/www/html/control/control.css @@ -0,0 +1,121 @@ +:root{ + --bg:#06070b; + --card:#0f1422; + --text:#e8f0ff; + --muted:#a8b6d8; + --border: rgba(255,255,255,.10); + --accent: rgba(120,220,255,1); + --shadow: 0 12px 40px rgba(0,0,0,.55); + --r: 18px; +} + +html,body{ height:100%; margin:0; background:var(--bg); color:var(--text); + font-family:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial; } + +.page{ max-width: 880px; margin:0 auto; padding: 18px; } +.top{ display:flex; align-items:flex-start; justify-content:space-between; gap:16px; flex-wrap:wrap; } +h1{ margin:0; font-size:22px; } +h2{ margin:0 0 12px 0; font-size:16px; } + +.card{ + background: rgba(255,255,255,.04); + border: 1px solid var(--border); + border-radius: var(--r); + padding: 14px; + margin-top: 14px; + box-shadow: var(--shadow); +} + +.row{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; } +.pill{ + font-size: 12px; + color: var(--muted); + border:1px solid var(--border); + border-radius: 999px; + padding: 8px 10px; + background: rgba(255,255,255,.03); +} + +.grid{ + display:grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.emobtn{ + border:1px solid var(--border); + background: rgba(255,255,255,.05); + border-radius: 14px; + padding: 12px; + color: var(--text); + cursor:pointer; + display:flex; + align-items:center; + gap:10px; + justify-content:flex-start; +} +.emobtn.active{ outline: 2px solid rgba(120,220,255,.55); } + +.icon{ + width:34px; height:34px; + border-radius: 10px; + display:grid; place-items:center; + background: rgba(120,220,255,.12); + border: 1px solid rgba(120,220,255,.25); + color: var(--accent); + font-size: 18px; +} + +.controls{ display:grid; gap: 12px; } +.toggle{ display:flex; gap:10px; align-items:center; color:var(--text); } +.toggle input{ transform: scale(1.2); } + +.slider{ display:grid; grid-template-columns: 110px 1fr 60px; gap:10px; align-items:center; } +.slider output{ text-align:right; color: var(--muted); } + +.lookpad{ + width:min(520px, 92vw); + aspect-ratio: 1 / 1; + margin-top: 10px; + border-radius: 20px; + border:1px solid var(--border); + background: rgba(0,0,0,.35); + position:relative; + overflow:hidden; + touch-action: none; +} +.cross::before, .cross::after{ + content:""; + position:absolute; + left:50%; top:0; bottom:0; + width:1px; + background: rgba(255,255,255,.08); +} +.cross::after{ + left:0; right:0; top:50%; bottom:auto; + height:1px; width:auto; +} +.dot{ + width:18px; height:18px; + border-radius: 99px; + background: rgba(120,220,255,.95); + box-shadow: 0 0 0 6px rgba(120,220,255,.18); + position:absolute; + left:50%; top:50%; + transform: translate(-50%,-50%); +} + +.btn{ + border:1px solid var(--border); + background: rgba(255,255,255,.06); + color: var(--text); + border-radius: 14px; + padding: 10px 12px; + cursor:pointer; +} +.btn:active{ transform: translateY(1px); } + +.muted{ margin: 0; color: var(--muted); font-size: 13px; } +.foot{ margin-top: 18px; display:flex; justify-content:space-between; } +.link{ color: rgba(120,220,255,.95); text-decoration:none; } + diff --git a/face/var/www/html/control/control.js b/face/var/www/html/control/control.js new file mode 100644 index 0000000..fb89227 --- /dev/null +++ b/face/var/www/html/control/control.js @@ -0,0 +1,229 @@ +// Control UI -> POST /api/state +// Also subscribes to /events to show current state/connection. + +(() => { + const EMOTIONS = [ + { id: "neutral", label: "Neutral", icon: "•" }, + { id: "happy", label: "Happy", icon: "😊" }, + { id: "sad", label: "Sad", icon: "☹️" }, + { id: "angry", label: "Angry", icon: "😠" }, + { id: "sleepy", label: "Sleepy", icon: "😴" }, + { id: "surprised", label: "Surprised", icon: "😲" }, + { id: "excited", label: "Excited", icon: "⚡" }, + ]; + + const clamp = (n,a,b) => Math.max(a, Math.min(b, n)); + const clamp01 = (v) => clamp(Number(v) || 0, 0, 1); + + const ui = { + grid: document.getElementById("emotionGrid"), + conn: document.getElementById("conn"), + current: document.getElementById("current"), + speaking: document.getElementById("speaking"), + eyesMoving: document.getElementById("eyesMoving"), + intensity: document.getElementById("intensity"), + intensityVal: document.getElementById("intensityVal"), + lookpad: document.getElementById("lookpad"), + lookdot: document.getElementById("lookdot"), + center: document.getElementById("center"), + stareOff: document.getElementById("stareOff"), + }; + + const state = { + emotion: "neutral", + intensity: 0.85, + speaking: false, + eyesMoving: true, + look: { x: 0, y: 0 }, + // stare/lock is represented by sending/omitting look. + stare: false, + }; + + function renderEmotionButtons() { + ui.grid.innerHTML = ""; + for (const e of EMOTIONS) { + const b = document.createElement("button"); + b.className = "emobtn"; + b.dataset.emotion = e.id; + b.innerHTML = ` +
${e.icon}
+
+
${e.label}
+
${e.id}
+
+ `; + b.addEventListener("click", () => { + state.emotion = e.id; + setActiveEmotion(); + sendState({ emotion: state.emotion }); + }); + ui.grid.appendChild(b); + } + setActiveEmotion(); + } + + function setActiveEmotion() { + for (const btn of ui.grid.querySelectorAll(".emobtn")) { + btn.classList.toggle("active", btn.dataset.emotion === state.emotion); + } + ui.current.textContent = state.emotion; + } + + async function sendState(partial) { + // Build payload. If stare=false -> do NOT send look (so face can drift). + const payload = { + emotion: state.emotion, + intensity: state.intensity, + speaking: state.speaking, + eyesMoving: state.eyesMoving, + ...partial, + }; + + if (state.stare) payload.look = { x: state.look.x, y: state.look.y }; + else delete payload.look; + + try { + await fetch("/api/state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + } catch (_) { + // ignore; SSE status will show disconnected + } + } + + // intensity + ui.intensity.addEventListener("input", () => { + state.intensity = clamp01(ui.intensity.value); + ui.intensityVal.textContent = state.intensity.toFixed(2); + sendState({ intensity: state.intensity }); + }); + + // toggles + ui.speaking.addEventListener("change", () => { + state.speaking = !!ui.speaking.checked; + sendState({ speaking: state.speaking }); + }); + + ui.eyesMoving.addEventListener("change", () => { + state.eyesMoving = !!ui.eyesMoving.checked; + sendState({ eyesMoving: state.eyesMoving }); + }); + + // Look pad (touch/mouse) + function setDotFromLook() { + const r = ui.lookpad.getBoundingClientRect(); + const px = (state.look.x + 1) / 2 * r.width; + const py = (state.look.y + 1) / 2 * r.height; + ui.lookdot.style.left = `${px}px`; + ui.lookdot.style.top = `${py}px`; + ui.lookdot.style.transform = "translate(-50%,-50%)"; + } + + function setLookFromEvent(ev) { + const r = ui.lookpad.getBoundingClientRect(); + const x = clamp((ev.clientX - r.left) / r.width, 0, 1); + const y = clamp((ev.clientY - r.top) / r.height, 0, 1); + + state.look.x = (x * 2) - 1; + state.look.y = (y * 2) - 1; + state.stare = true; // touching pad implies stare + setDotFromLook(); + sendState({}); // will include look because stare=true + } + + let pointerDown = false; + ui.lookpad.addEventListener("pointerdown", (ev) => { + pointerDown = true; + ui.lookpad.setPointerCapture(ev.pointerId); + setLookFromEvent(ev); + }); + ui.lookpad.addEventListener("pointermove", (ev) => { + if (!pointerDown) return; + setLookFromEvent(ev); + }); + ui.lookpad.addEventListener("pointerup", () => { pointerDown = false; }); + + // dblclick / double tap center + let lastTap = 0; + ui.lookpad.addEventListener("pointerdown", () => { + const now = Date.now(); + if (now - lastTap < 280) { + state.look = { x: 0, y: 0 }; + state.stare = true; + setDotFromLook(); + sendState({}); + } + lastTap = now; + }); + + ui.center.addEventListener("click", () => { + state.look = { x: 0, y: 0 }; + state.stare = true; + setDotFromLook(); + sendState({}); + }); + + ui.stareOff.addEventListener("click", () => { + state.stare = false; // omit look in next send + sendState({}); + }); + + // SSE subscribe to reflect current state + connection + (function connectSSE(){ + let es; + function open(){ + ui.conn.textContent = "connecting…"; + es = new EventSource("/events"); + + es.onopen = () => ui.conn.textContent = "online"; + + const apply = (msg) => { + if (!msg || typeof msg !== "object") return; + + if (typeof msg.emotion === "string") state.emotion = msg.emotion; + if (msg.intensity !== undefined) state.intensity = clamp01(msg.intensity); + if (typeof msg.speaking === "boolean") state.speaking = msg.speaking; + if (typeof msg.eyesMoving === "boolean") state.eyesMoving = msg.eyesMoving; + + if (msg.look && typeof msg.look === "object") { + state.look = { + x: clamp(Number(msg.look.x ?? 0), -1, 1), + y: clamp(Number(msg.look.y ?? 0), -1, 1), + }; + state.stare = true; + } + + ui.intensity.value = String(state.intensity); + ui.intensityVal.textContent = state.intensity.toFixed(2); + ui.speaking.checked = state.speaking; + ui.eyesMoving.checked = state.eyesMoving; + + setActiveEmotion(); + setDotFromLook(); + }; + + es.addEventListener("state", (ev) => { + try { apply(JSON.parse(ev.data)); } catch (_) {} + }); + + es.onmessage = (ev) => { + try { apply(JSON.parse(ev.data)); } catch (_) {} + }; + + es.onerror = () => { + ui.conn.textContent = "offline"; + try { es.close(); } catch (_) {} + setTimeout(open, 1200); + }; + } + open(); + })(); + + // init + renderEmotionButtons(); + ui.intensityVal.textContent = state.intensity.toFixed(2); + setDotFromLook(); +})(); + diff --git a/face/var/www/html/control/index.html b/face/var/www/html/control/index.html index c07f7ab..4f4fdb7 100644 --- a/face/var/www/html/control/index.html +++ b/face/var/www/html/control/index.html @@ -2,390 +2,67 @@ - - Robot Face Control - + + Face Control + -
-
-

Robot Face Control

-
- offline -
+
+
+

Face Control

+
+ Status: ? + Aktuell: neutral
-
+ -
+

Emotion

-
- - - - - - -
+
+
+
+

Parameter

+ +
+ + + + + +
+
+ +
+

Look

+

Zieh im Feld: links/rechts/oben/unten. Doppeltipp = zentrieren.

+
+
+
+
- -
0.70
+ +
- - -
+ -
-

Blick (fix)

-
- -
0.00
-
- + + -
- -
0.00
-
- - - -
- -
-

Sprechen

- - - -
- -
3.20
-
- - -
- -
0.90
-
- - -
- -
0.25
-
- -
- -
- Tipp: URL am Handy öffnen: /control/ (z. B. http://raspy/control/) -
-
- - + diff --git a/face/var/www/html/face.css b/face/var/www/html/face.css index 7818a42..e1d2355 100644 --- a/face/var/www/html/face.css +++ b/face/var/www/html/face.css @@ -5,44 +5,28 @@ --r: 18px; } -html, body { - height: 100%; - margin: 0; - background: #000; +html, body { height:100%; margin:0; background:#000; } +.stage{ + position:fixed; inset:0; + background:#000; + touch-action:manipulation; + user-select:none; } +.face{ width:100vw; height:100vh; display:block; } -.stage { - position: fixed; - inset: 0; - display: grid; - place-items: center; - background: #000; - touch-action: manipulation; - user-select: none; -} - -.face { - width: 100vw; - height: 100vh; - display: block; -} - -/* Touch-only overlay with two links */ +/* Touch overlay: 2 links only */ .overlay{ - position: fixed; - left: max(12px, env(safe-area-inset-left)); - right: max(12px, env(safe-area-inset-right)); - bottom: max(12px, env(safe-area-inset-bottom)); - display:flex; - gap:10px; - justify-content:flex-start; - align-items:center; + position:fixed; + left:max(12px, env(safe-area-inset-left)); + right:max(12px, env(safe-area-inset-right)); + bottom:max(12px, env(safe-area-inset-bottom)); + display:flex; gap:10px; align-items:center; - background: var(--panel); - border: 1px solid rgba(255,255,255,.10); - border-radius: var(--r); - padding: 10px 12px; - box-shadow: var(--shadow); + background:var(--panel); + border:1px solid rgba(255,255,255,.10); + border-radius:var(--r); + padding:10px 12px; + box-shadow:var(--shadow); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); @@ -51,27 +35,18 @@ html, body { pointer-events:none; transition: opacity .18s ease, transform .18s ease; } - -.overlay.show{ - opacity:1; - transform: translateY(0); - pointer-events:auto; -} +.overlay.show{ opacity:1; transform:translateY(0); pointer-events:auto; } a.btn{ - appearance:none; - border: 1px solid rgba(255,255,255,.14); + border:1px solid rgba(255,255,255,.14); background: rgba(255,255,255,.06); color: var(--text); - border-radius: 14px; - padding: 10px 12px; - font-size: 14px; + border-radius:14px; + padding:10px 12px; + font-size:14px; text-decoration:none; line-height:1; - display:inline-flex; - align-items:center; - gap:8px; + display:inline-flex; align-items:center; gap:8px; } - a.btn:active{ transform: translateY(1px); } diff --git a/face/var/www/html/face.js b/face/var/www/html/face.js index 050ad55..b0c4f58 100644 --- a/face/var/www/html/face.js +++ b/face/var/www/html/face.js @@ -1,11 +1,10 @@ -// Black fullscreen + cyan sticker-style face, plus touch-only 2 links. -// State via SSE /events from face_server (nginx proxies /events -> :8001/events) -// +// Minimal: black background, cyan face elements only. +// Input via SSE /events (nginx -> :8001/events) // Payload example: -// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0},"speaking":true} +// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0},"speaking":true,"eyesMoving":true} (() => { - const OVERLAY_TIMEOUT_MS = 3500; + const OVERLAY_TIMEOUT_MS = 2500; const clamp = (n,a,b) => Math.max(a, Math.min(b, n)); const clamp01 = (v) => { @@ -28,166 +27,112 @@ // DOM const stage = document.getElementById("stage"); const overlay = document.getElementById("overlay"); - const controlLink = document.getElementById("controlLink"); - const emotionLabel = document.getElementById("emotionLabel"); const eyeL = document.getElementById("eyeL"); const eyeR = document.getElementById("eyeR"); const eyesGroup = document.getElementById("eyesGroup"); const mouth = document.getElementById("mouth"); - const topbarLit = document.getElementById("topbarLit"); - const faceGroup = document.getElementById("faceGroup"); - // Touch overlay show let overlayTimer = null; - function showOverlay() { + function showOverlay(){ overlay.classList.add("show"); clearTimeout(overlayTimer); overlayTimer = setTimeout(() => overlay.classList.remove("show"), OVERLAY_TIMEOUT_MS); } - stage.addEventListener("pointerdown", () => showOverlay(), { passive: true }); + stage.addEventListener("pointerdown", () => showOverlay(), { passive:true }); - // Layout tuned to your reference composition + // Face layout (centered) const BASE = { - L: { x: 360, y: 300 }, - R: { x: 640, y: 300 }, - eyeW: 170, - eyeH: 180, - lookMaxPx: 28, + L: { x: 380, y: 270 }, + R: { x: 620, y: 270 }, + lookMaxPx: 22, mouthCx: 500, - mouthY: 415, + mouthY: 400, }; - function updateControlLink() { - emotionLabel.textContent = state.emotion; - controlLink.href = `/control?current=${encodeURIComponent(state.emotion)}`; - } - - function applyIntensity(intensity) { - state.intensity = clamp01(intensity); - - // bar brightness - topbarLit.setAttribute("opacity", String(0.35 + state.intensity * 0.65)); - - // stronger glow at high intensity - faceGroup.setAttribute("filter", state.intensity > 0.8 ? "url(#glowStrong)" : "url(#glow)"); - } - - function setStroke(el, width) { - el.setAttribute("fill", "none"); - el.setAttribute("stroke", `rgba(120,255,235,${0.86 + 0.14 * state.intensity})`); - el.setAttribute("stroke-width", String(width)); - el.setAttribute("stroke-linecap", "round"); + 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})`); + el.setAttribute("stroke-width", String(edge)); el.setAttribute("stroke-linejoin", "round"); + el.setAttribute("stroke-linecap", "round"); } - // ---- Eye shapes (icon-like) ---- - function eyeOval(cx, cy, w, h) { - const rx = w * 0.30; - const ry = h * 0.22; - return `M ${cx - rx},${cy} - C ${cx - rx},${cy - ry} ${cx - rx*0.35},${cy - ry} ${cx},${cy - ry} - C ${cx + rx*0.35},${cy - ry} ${cx + rx},${cy - ry} ${cx + rx},${cy} - C ${cx + rx},${cy + ry} ${cx + rx*0.35},${cy + ry} ${cx},${cy + ry} - C ${cx - rx*0.35},${cy + ry} ${cx - rx},${cy + ry} ${cx - rx},${cy}`; + // --- Shapes (filled cyan like your reference) --- + 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`; } - function eyeRound(cx, cy, r) { - return `M ${cx - r},${cy} - C ${cx - r},${cy - r} ${cx - r*0.2},${cy - r} ${cx},${cy - r} - C ${cx + r*0.2},${cy - r} ${cx + r},${cy - r} ${cx + r},${cy} - C ${cx + r},${cy + r} ${cx + r*0.2},${cy + r} ${cx},${cy + r} - C ${cx - r*0.2},${cy + r} ${cx - r},${cy + r} ${cx - r},${cy}`; - } - - function eyeSleepLine(cx, cy, w) { - const half = w * 0.20; - return `M ${cx - half},${cy} L ${cx + half},${cy}`; - } - - function eyeHappyArc(cx, cy, w) { - const half = w * 0.20; - const lift = 22; - return `M ${cx - half},${cy} Q ${cx},${cy + lift} ${cx + half},${cy}`; - } - - function eyeAngrySlashL(cx, cy) { return `M ${cx - 60},${cy - 18} L ${cx + 26},${cy + 26}`; } - function eyeAngrySlashR(cx, cy) { return `M ${cx + 60},${cy - 18} L ${cx - 26},${cy + 26}`; } - - function eyeSadSmall(cx, cy, w) { - const rx = w * 0.16; - const ry = 18; - const y = cy + 8; - return `M ${cx - rx},${y} - C ${cx - rx},${y - ry} ${cx - rx*0.35},${y - ry} ${cx},${y - ry} - C ${cx + rx*0.35},${y - ry} ${cx + rx},${y - ry} ${cx + rx},${y} - C ${cx + rx},${y + ry} ${cx + rx*0.35},${y + ry} ${cx},${y + ry} - C ${cx - rx*0.35},${y + ry} ${cx - rx},${y + ry} ${cx - rx},${y}`; - } - - // ---- Mouth shapes (sticker-like) ---- function mouthDot(cx, y) { - setStroke(mouth, 24); // thick capsule-dot - const half = 14; - return `M ${cx - half},${y} L ${cx + half},${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`; } - - function mouthLine(cx, y, w=170) { - setStroke(mouth, 18); - const half = w/2; - return `M ${cx - half},${y} L ${cx + half},${y}`; - } - - function mouthSmile(cx, y, w=190) { - setStroke(mouth, 18); - const half = w/2; - return `M ${cx - half},${y} Q ${cx},${y + 70} ${cx + half},${y}`; - } - function mouthGrin(cx, y) { - setStroke(mouth, 18); - const w = 220; - const half = w/2; - return `M ${cx - half},${y} - Q ${cx - half*0.15},${y + 70} ${cx},${y + 70} - Q ${cx + half*0.15},${y + 70} ${cx + half},${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`; } - - function mouthFrown(cx, y, w=190) { - setStroke(mouth, 18); - const half = w/2; - return `M ${cx - half},${y + 50} Q ${cx},${y - 25} ${cx + half},${y + 50}`; + 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`; } - - function mouthO(cx, y) { - setStroke(mouth, 18); - const r = 38; - return eyeRound(cx, y + 10, r); + 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`; } - function mouthShout(cx, y) { - // filled open mouth with neon outline - mouth.setAttribute("stroke", `rgba(120,255,235,${0.90 + 0.10 * state.intensity})`); - mouth.setAttribute("stroke-width", "18"); - mouth.setAttribute("stroke-linecap", "round"); - mouth.setAttribute("stroke-linejoin", "round"); - mouth.setAttribute("fill", "rgba(0,0,0,0.38)"); - - const wTop = 90, wBot = 150, h = 78; - const x1 = cx - wTop/2, x2 = cx + wTop/2; - const x3 = cx + wBot/2, x4 = cx - wBot/2; - const y1 = y - 10, y2 = y + h; + 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; return `M ${x1},${y1} L ${x2},${y1} L ${x3},${y2} L ${x4},${y2} Z`; } - // speaking animation overlays ANY mouth shape + // Speaking animation: scale mouth vertically (works for all) let speakTimer = null; function startSpeaking() { stopSpeaking(); let open = false; const base = state.emotion === "excited" ? 85 : (state.emotion === "sleepy" ? 170 : 115); - speakTimer = setInterval(() => { if (!state.speaking) return; open = !open; @@ -197,142 +142,88 @@ mouth.setAttribute("transform", `translate(0,${ty}) scale(1,${sy})`); }, base); } - function stopSpeaking() { if (speakTimer) clearInterval(speakTimer); speakTimer = null; mouth.setAttribute("transform", ""); } - // blink by temporarily switching to sleepy lines - let blinkTimer = null; - let blinking = false; - - function render() { - // look -> subtle move of the whole eyesGroup (keeps sticker look) - 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 lookScale = iconLocked ? 0.20 : 1.0; - - const dx = lookX * BASE.lookMaxPx * lookScale; - const dy = lookY * BASE.lookMaxPx * lookScale; - eyesGroup.setAttribute("transform", `translate(${dx},${dy})`); - - // eyes styling - setStroke(eyeL, state.emotion === "excited" ? 22 : 20); - setStroke(eyeR, state.emotion === "excited" ? 22 : 20); - - const emo = state.emotion; - const useBlink = blinking && emo !== "sleepy"; - - if (useBlink) { - eyeL.setAttribute("d", eyeSleepLine(BASE.L.x, BASE.L.y + 10, BASE.eyeW)); - eyeR.setAttribute("d", eyeSleepLine(BASE.R.x, BASE.R.y + 10, BASE.eyeW)); - } else { - switch (emo) { - case "happy": - eyeL.setAttribute("d", eyeHappyArc(BASE.L.x, BASE.L.y, BASE.eyeW)); - eyeR.setAttribute("d", eyeHappyArc(BASE.R.x, BASE.R.y, BASE.eyeW)); - break; - case "angry": - eyeL.setAttribute("d", eyeAngrySlashL(BASE.L.x, BASE.L.y)); - eyeR.setAttribute("d", eyeAngrySlashR(BASE.R.x, BASE.R.y)); - break; - case "sleepy": - eyeL.setAttribute("d", eyeSleepLine(BASE.L.x, BASE.L.y + 10, BASE.eyeW)); - eyeR.setAttribute("d", eyeSleepLine(BASE.R.x, BASE.R.y + 10, BASE.eyeW)); - break; - case "surprised": - eyeL.setAttribute("d", eyeRound(BASE.L.x, BASE.L.y, 40)); - eyeR.setAttribute("d", eyeRound(BASE.R.x, BASE.R.y, 40)); - break; - case "excited": - eyeL.setAttribute("d", eyeRound(BASE.L.x, BASE.L.y - 6, 46)); - eyeR.setAttribute("d", eyeRound(BASE.R.x, BASE.R.y - 6, 46)); - break; - case "sad": - eyeL.setAttribute("d", eyeSadSmall(BASE.L.x, BASE.L.y, BASE.eyeW)); - eyeR.setAttribute("d", eyeSadSmall(BASE.R.x, BASE.R.y, BASE.eyeW)); - break; - default: - eyeL.setAttribute("d", eyeOval(BASE.L.x, BASE.L.y, BASE.eyeW, BASE.eyeH)); - eyeR.setAttribute("d", eyeOval(BASE.R.x, BASE.R.y, BASE.eyeW, BASE.eyeH)); - } - } - - // mouth mapping - switch (emo) { - case "neutral": - mouth.setAttribute("d", mouthDot(BASE.mouthCx, BASE.mouthY + 20)); - break; - case "happy": - mouth.setAttribute("d", state.intensity > 0.75 ? mouthGrin(BASE.mouthCx, BASE.mouthY) : mouthSmile(BASE.mouthCx, BASE.mouthY, 190)); - break; - case "excited": - mouth.setAttribute("d", mouthGrin(BASE.mouthCx, BASE.mouthY - 6)); - break; - case "sad": - mouth.setAttribute("d", mouthFrown(BASE.mouthCx, BASE.mouthY - 6, 190)); - break; - case "angry": - mouth.setAttribute("d", mouthShout(BASE.mouthCx, BASE.mouthY - 12)); - break; - case "sleepy": - mouth.setAttribute("d", mouthLine(BASE.mouthCx, BASE.mouthY + 26, 170)); - break; - case "surprised": - mouth.setAttribute("d", mouthO(BASE.mouthCx, BASE.mouthY - 6)); - break; - default: - mouth.setAttribute("d", mouthLine(BASE.mouthCx, BASE.mouthY + 20, 170)); - } - - // top bar color hint - if (emo === "angry") topbarLit.setAttribute("stroke", "rgba(255,107,107,0.95)"); - else if (emo === "sad" || emo === "sleepy") topbarLit.setAttribute("stroke", "rgba(120,200,255,0.95)"); - else topbarLit.setAttribute("stroke", "rgba(120,255,235,0.95)"); - - updateControlLink(); - } - - function scheduleBlink() { - clearTimeout(blinkTimer); - const base = state.emotion === "sleepy" ? 5200 : (state.emotion === "excited" ? 2000 : 2800); - blinkTimer = setTimeout(() => { - blinking = true; - render(); - setTimeout(() => { - blinking = false; - render(); - scheduleBlink(); - }, 140); - }, base + Math.random() * 2200); - } - + // Optional eye drift when no look is locked and eyesMoving true let driftTimer = null; - function scheduleDrift() { + function scheduleDrift(){ clearTimeout(driftTimer); - if (state.lookLock) return; - if (!state.eyesMoving) return; + if (state.lookLock || !state.eyesMoving) return; 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 + 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 scale = iconLocked ? 0.18 : 1.0; + + eyesGroup.setAttribute( + "transform", + `translate(${lookX * BASE.lookMaxPx * scale}, ${lookY * BASE.lookMaxPx * scale})` + ); + + setStickerStyle(eyeL, 6); + setStickerStyle(eyeR, 6); + setStickerStyle(mouth, 6); + + 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)); + 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)); + 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)); + 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)); + } } // Public API window.applyFaceState = (payload) => { if (!payload || typeof payload !== "object") return; - if (payload.intensity !== undefined) applyIntensity(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 (payload.look && typeof payload.look === "object") { @@ -349,28 +240,21 @@ if (typeof payload.eyesMoving === "boolean") state.eyesMoving = payload.eyesMoving; render(); - scheduleBlink(); - if (!state.lookLock && state.eyesMoving) scheduleDrift(); - if (state.speaking) startSpeaking(); else stopSpeaking(); + if (!state.lookLock) scheduleDrift(); }; // SSE /events (function connectSSE(){ let es; - function open() { + function open(){ es = new EventSource("/events"); - - // server recommended: event: state es.addEventListener("state", (ev) => { try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} }); - - // fallback for plain data: es.onmessage = (ev) => { try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {} }; - es.onerror = () => { try { es.close(); } catch (_) {} setTimeout(open, 1200); @@ -379,11 +263,8 @@ open(); })(); - // Init default - applyIntensity(state.intensity); - updateControlLink(); + // Init render(); - scheduleBlink(); scheduleDrift(); })(); diff --git a/face/var/www/html/index.html b/face/var/www/html/index.html index 6e28269..fea1fdd 100755 --- a/face/var/www/html/index.html +++ b/face/var/www/html/index.html @@ -7,69 +7,18 @@ -
+
- - + + - - - - - - - - - - - - - - - - - - - - - @@ -79,10 +28,10 @@ - +