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 = `
+
-
-
Robot Face Control
-
+
-
+
Emotion
-
-
-
-
-
-
-
-
+
+
+
+
+
+ Look
+ Zieh im Feld: links/rechts/oben/unten. Doppeltipp = zentrieren.
+
-
-
0.70
+
+
-
-
-
+
-
-
Blick (fix)
-
-
-
0.00
-
-
+
+
-
-
-
0.00
-
-
-
-
-
-
-
-
-
- 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 @@
-
+
-
+