add new face

This commit is contained in:
max 2026-02-08 20:26:14 +01:00
parent cd8569318f
commit a36981da66
4 changed files with 701 additions and 501 deletions

View File

@ -1,194 +0,0 @@
const label = document.getElementById("label");
const eyes = Array.from(document.querySelectorAll(".eye"));
const mouthShape = document.querySelector(".mouth-shape");
let current = "neutral";
let wanderEnabled = true;
let wanderTimer = null;
let talkEnabled = false;
let talkCfg = { rate_hz: 3.2, amount: 0.9, jitter: 0.25 };
let talkTimer = null;
function clamp(v, lo, hi){ return Math.max(lo, Math.min(hi, v)); }
function setEmotion(name) {
const safe = String(name || "neutral")
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "");
current = safe;
document.body.className = `emotion-${safe}`;
label.textContent = safe;
// surprised -> O-mouth
if (safe === "surprised") document.body.classList.add("has-omouth");
else document.body.classList.remove("has-omouth");
// frown for sad/angry
if (safe === "sad" || safe === "angry") mouthShape.classList.add("frown");
else mouthShape.classList.remove("frown");
}
function blinkOnce() {
eyes.forEach(e => e.classList.add("blink"));
setTimeout(() => eyes.forEach(e => e.classList.remove("blink")), 120);
}
function setLook(x, y) {
// x/y in -1..1 -> px offsets
const pxX = Math.round(clamp(x, -1, 1) * 12);
const pxY = Math.round(clamp(y, -1, 1) * 10);
document.documentElement.style.setProperty("--pupil-x", `${pxX}px`);
document.documentElement.style.setProperty("--pupil-y", `${pxY}px`);
}
function setIntensity(v) {
const val = clamp(Number(v ?? 0.7), 0, 1);
document.documentElement.style.setProperty("--intensity", String(val));
}
function setMouthOpen(v) {
const val = clamp(Number(v ?? 0), 0, 1);
document.documentElement.style.setProperty("--mouth-open", String(val));
}
function speak(amount = 0.8, durationMs = 600) {
if (talkEnabled) return; // Talk-Mode steuert Mund
setMouthOpen(amount);
setTimeout(() => setMouthOpen(0), Math.max(0, durationMs));
}
function stopTalk() {
talkEnabled = false;
if (talkTimer) {
clearTimeout(talkTimer);
talkTimer = null;
}
setMouthOpen(0);
}
function startTalk(cfg) {
talkEnabled = true;
talkCfg = {
rate_hz: clamp(Number(cfg?.rate_hz ?? 3.2), 0.5, 10),
amount: clamp(Number(cfg?.amount ?? 0.9), 0, 1),
jitter: clamp(Number(cfg?.jitter ?? 0.25), 0, 1),
};
if (talkTimer) clearTimeout(talkTimer);
// “sprech”-Animation: Mund öffnet/schließt schnell, mit bisschen Zufall
const tick = () => {
if (!talkEnabled) return;
const base = talkCfg.amount;
const j = talkCfg.jitter;
// random-ish open amount between ~0.2..1.0, scaled
const r = (0.35 + Math.random() * 0.65);
const open = clamp(base * r * (1 - j + Math.random() * j), 0, 1);
setMouthOpen(open);
// timing from rate_hz (Hz -> ms)
const interval = Math.max(60, Math.round(1000 / talkCfg.rate_hz));
// add a bit of jitter to cadence
const next = interval + Math.round((Math.random() * 2 - 1) * interval * 0.25);
talkTimer = setTimeout(tick, next);
};
tick();
}
/* Pupillen-Wandern nur wenn kein look gesetzt ist */
function startWander() {
if (wanderTimer) clearTimeout(wanderTimer);
const tick = () => {
if (!wanderEnabled) return;
const x = (Math.random() * 2 - 1) * 0.7;
const y = (Math.random() * 2 - 1) * 0.6;
setLook(x, y);
const next = 600 + Math.random() * 900;
wanderTimer = setTimeout(tick, next);
};
tick();
}
function applyState(s) {
if (!s || typeof s !== "object") return;
if (s.emotion) setEmotion(s.emotion);
if (s.intensity !== undefined) setIntensity(s.intensity);
// one-shot blink
if (s.blink) blinkOnce();
// look: if null -> enable wander. if object -> fixed look
if ("look" in s) {
if (s.look === null) {
wanderEnabled = true;
startWander();
} else if (typeof s.look === "object") {
wanderEnabled = false;
setLook(s.look.x ?? 0, s.look.y ?? 0);
}
}
// mouth command
if (s.mouth && typeof s.mouth === "object") {
const open = !!s.mouth.open;
const amount = clamp(Number(s.mouth.amount ?? 0.8), 0, 1);
const dur = Number(s.mouth.duration_ms ?? 600);
if (open) speak(amount, dur);
else setMouthOpen(0);
}
// talk mode
if (s.talk && typeof s.talk === "object") {
const enabled = !!s.talk.enabled;
if (enabled) startTalk(s.talk);
else stopTalk();
}
}
function connect() {
const es = new EventSource("/events");
es.addEventListener("state", (e) => {
try { applyState(JSON.parse(e.data)); } catch {}
});
es.onmessage = (e) => {
// fallback: treat as state json
try { applyState(JSON.parse(e.data)); } catch {}
};
es.onerror = () => {
es.close();
setTimeout(connect, 1000);
};
}
connect();
startWander();
/* zufälliges Blinzeln unabhängig vom Push */
(function startBlinkLoop(){
const loop = () => {
let base = 3500;
if (current === "sleepy") base = 2200;
if (current === "surprised") base = 4200;
const next = base + Math.random() * 2200;
setTimeout(() => {
blinkOnce();
if (Math.random() < 0.12) setTimeout(blinkOnce, 220);
loop();
}, next);
};
loop();
})();

312
face/var/www/html/face.js Normal file
View File

@ -0,0 +1,312 @@
// Helva Display Face (ONLY visor/display) - state driven
// Supports payload like:
// {"emotion":"angry","intensity":0.9,"look":{"x":0.9,"y":0.0}}
(() => {
const OVERLAY_TIMEOUT_MS = 3500;
const state = {
emotion: "neutral",
intensity: 0.7, // 0..1
look: { x: 0, y: 0 }, // -1..1
lookLock: false, // if true: no drift (stare)
speaking: false,
eyesMoving: true,
connected: true,
};
// DOM
const stage = document.getElementById("stage");
const overlay = document.getElementById("overlay");
const dotConn = document.getElementById("dotConn");
const pillText = document.getElementById("pillText");
const emotionLabel = document.getElementById("emotionLabel");
const emotionControlLink = document.getElementById("emotionControlLink");
const btnSpeak = document.getElementById("btnSpeak");
const btnEyes = document.getElementById("btnEyes");
const speakState = document.getElementById("speakState");
const eyesState = document.getElementById("eyesState");
const mouthShape = document.getElementById("mouthShape");
const mouthInner = document.getElementById("mouthInner");
const statusLed = document.getElementById("statusLed");
const cheekL = document.getElementById("cheekL");
const cheekR = document.getElementById("cheekR");
const eyeLIris = document.querySelector("#eyeL .iris");
const eyeRIris = document.querySelector("#eyeR .iris");
const pupilL = document.querySelector("#eyeL .pupil");
const pupilR = document.querySelector("#eyeR .pupil");
const lidL = document.getElementById("lidL");
const lidR = document.getElementById("lidR");
const mouthGroup = document.getElementById("mouth");
// Overlay on touch
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 });
btnSpeak.addEventListener("click", (e) => { e.stopPropagation(); window.setSpeaking(!state.speaking); showOverlay(); });
btnEyes.addEventListener("click", (e) => { e.stopPropagation(); window.setEyesMoving(!state.eyesMoving); showOverlay(); });
// Helpers
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
function clamp01(v) {
const n = Number(v);
if (Number.isNaN(n)) return 0;
return clamp(n, 0, 1);
}
// Emotions
const EMOTIONS = new Set(["neutral","happy","sad","angry","sleepy","surprised","excited"]);
function updateControlLink() {
emotionLabel.textContent = state.emotion;
emotionControlLink.href = `/control?current=${encodeURIComponent(state.emotion)}`;
}
function setEyeOffset(dx, dy) {
const max = 10;
const x = clamp(dx, -max, max);
const y = clamp(dy, -max, max);
// base positions: L(200,170), R(320,170)
eyeLIris.setAttribute("cx", 200 + x);
eyeLIris.setAttribute("cy", 170 + y);
eyeRIris.setAttribute("cx", 320 + x);
eyeRIris.setAttribute("cy", 170 + y);
pupilL.setAttribute("cx", 200 + x);
pupilL.setAttribute("cy", 170 + y);
pupilR.setAttribute("cx", 320 + x);
pupilR.setAttribute("cy", 170 + y);
}
function applyLook(look, lock = true) {
if (!look) return;
const x = clamp(Number(look.x ?? 0), -1, 1);
const y = clamp(Number(look.y ?? 0), -1, 1);
state.look = { x, y };
state.lookLock = !!lock;
setEyeOffset(x * 10, y * 10);
}
function applyIntensity(intensity) {
state.intensity = clamp01(intensity);
// LED brightness
statusLed.setAttribute("opacity", String(0.35 + state.intensity * 0.65));
// cheeks baseline
cheekL.style.opacity = String(0.12 + 0.55 * state.intensity);
cheekR.style.opacity = String(0.12 + 0.55 * state.intensity);
}
function setEmotionUI(em) {
const emotion = EMOTIONS.has(em) ? em : "neutral";
state.emotion = emotion;
updateControlLink();
const mouths = {
neutral: {
outer: "M225,250 C245,262 275,262 295,250 C290,275 230,275 225,250Z",
inner: "M238,258 C250,267 270,267 282,258 C275,272 245,272 238,258Z"
},
happy: {
outer: "M215,242 C240,292 280,292 305,242 C295,310 225,310 215,242Z",
inner: "M232,265 C250,288 270,288 288,265 C280,298 240,298 232,265Z"
},
sad: {
outer: "M225,282 C245,260 275,260 295,282 C285,245 235,245 225,282Z",
inner: "M240,276 C250,265 270,265 280,276 C272,258 248,258 240,276Z"
},
angry: {
outer: "M220,270 C255,255 265,255 300,270 C292,292 228,292 220,270Z",
inner: "M240,272 C255,264 265,264 280,272 C274,283 246,283 240,272Z"
},
sleepy: {
outer: "M230,280 C250,286 270,286 290,280 C283,292 237,292 230,280Z",
inner: "M244,282 C252,286 268,286 276,282 C270,290 250,290 244,282Z"
},
surprised: {
outer: "M240,238 C260,228 260,228 280,238 C305,265 305,298 260,305 C215,298 215,265 240,238Z",
inner: "M248,254 C260,246 260,246 272,254 C288,270 288,286 260,291 C232,286 232,270 248,254Z"
},
excited: {
outer: "M210,242 C240,322 280,322 310,242 C300,336 220,336 210,242Z",
inner: "M228,274 C250,308 270,308 292,274 C284,322 236,322 228,274Z"
}
};
const m = mouths[emotion] ?? mouths.neutral;
mouthShape.setAttribute("d", m.outer);
mouthInner.setAttribute("d", m.inner);
// LED color hint per emotion
switch (emotion) {
case "sad":
case "sleepy":
statusLed.setAttribute("fill", "rgba(124,183,255,.90)");
break;
case "angry":
statusLed.setAttribute("fill", "rgba(255,107,107,.92)");
break;
default:
statusLed.setAttribute("fill", "rgba(124,255,201,.92)");
}
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
}
function setConnectedUI(on) {
state.connected = !!on;
dotConn.classList.toggle("on", state.connected);
dotConn.classList.toggle("off", !state.connected);
pillText.textContent = state.connected ? (state.speaking ? (state.emotion + " • speaking") : state.emotion) : "offline";
}
function setSpeakingUI(on) {
state.speaking = !!on;
speakState.textContent = state.speaking ? "an" : "aus";
pillText.textContent = state.speaking ? (state.emotion + " • speaking") : state.emotion;
}
function setEyesMovingUI(on) {
state.eyesMoving = !!on;
eyesState.textContent = state.eyesMoving ? "an" : "aus";
}
// Animations: blink + drift + speaking flap
let blinkTimer = null;
let driftTimer = null;
let speakTimer = null;
function doBlink() {
const down = 70, up = 90;
lidL.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
lidR.animate([{ transform: "scaleY(0)" }, { transform: "scaleY(1)" }], { duration: down, fill: "forwards" });
setTimeout(() => {
lidL.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
lidR.animate([{ transform: "scaleY(1)" }, { transform: "scaleY(0)" }], { duration: up, fill: "forwards" });
}, down + 25);
}
function scheduleBlink() {
clearTimeout(blinkTimer);
const base = state.emotion === "sleepy" ? 4500 : 2600;
blinkTimer = setTimeout(() => { doBlink(); scheduleBlink(); }, base + Math.random() * 2200);
}
function scheduleEyeDrift() {
clearTimeout(driftTimer);
if (state.lookLock) {
setEyeOffset(state.look.x * 10, state.look.y * 10);
return;
}
if (!state.eyesMoving) {
setEyeOffset(0,0);
return;
}
const dx = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
const dy = (Math.random() * 2 - 1) * (state.emotion === "surprised" ? 6 : 4);
const startX = (parseFloat(eyeLIris.getAttribute("cx")) - 200) || 0;
const startY = (parseFloat(eyeLIris.getAttribute("cy")) - 170) || 0;
const steps = 18;
let i = 0;
const tick = () => {
i++;
const t = i / steps;
const ease = t < 0.5 ? 2*t*t : 1 - Math.pow(-2*t+2,2)/2;
setEyeOffset(startX + (dx - startX)*ease, startY + (dy - startY)*ease);
if (i < steps) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
driftTimer = setTimeout(scheduleEyeDrift, 700 + Math.random() * 700);
}
function startSpeakingFlap() {
stopSpeakingFlap();
let open = false;
const interval = (state.emotion === "excited") ? 90 : (state.emotion === "sleepy" ? 170 : 120);
speakTimer = setInterval(() => {
if (!state.speaking) return;
open = !open;
const s = open ? 1.12 : 0.96;
mouthGroup.setAttribute("transform", `translate(0, ${open ? -2 : 0}) scale(1, ${s})`);
}, interval);
}
function stopSpeakingFlap() {
if (speakTimer) clearInterval(speakTimer);
speakTimer = null;
mouthGroup.setAttribute("transform", "translate(0,0)");
}
// Public API
window.setEmotion = (emotion) => { setEmotionUI(emotion); scheduleBlink(); scheduleEyeDrift(); };
window.setSpeaking = (on) => { setSpeakingUI(on); if (state.speaking) startSpeakingFlap(); else stopSpeakingFlap(); };
window.setEyesMoving = (on) => { setEyesMovingUI(on); scheduleEyeDrift(); };
window.setConnected = (on) => { setConnectedUI(on); };
window.applyFaceState = (payload) => {
if (!payload || typeof payload !== "object") return;
if (payload.intensity !== undefined) applyIntensity(payload.intensity);
if (typeof payload.emotion === "string") setEmotionUI(payload.emotion);
if (payload.look && typeof payload.look === "object") {
applyLook(payload.look, true); // lock stare
} else {
state.lookLock = false;
scheduleEyeDrift();
}
if (typeof payload.speaking === "boolean") window.setSpeaking(payload.speaking);
if (typeof payload.eyesMoving === "boolean") window.setEyesMoving(payload.eyesMoving);
if (typeof payload.connected === "boolean") window.setConnected(payload.connected);
scheduleBlink();
if (state.speaking) startSpeakingFlap();
};
// Optional WebSocket to /ws (if present)
(function connectWS(){
const proto = (location.protocol === "https:") ? "wss" : "ws";
const wsUrl = `${proto}://${location.host}/ws`;
let ws;
function open() {
ws = new WebSocket(wsUrl);
ws.onopen = () => window.setConnected(true);
ws.onmessage = (ev) => {
try { window.applyFaceState(JSON.parse(ev.data)); } catch (_) {}
};
ws.onclose = () => { window.setConnected(false); setTimeout(open, 800); };
ws.onerror = () => { try { ws.close(); } catch (_) {} };
}
open();
})();
// Init
updateControlLink();
applyIntensity(state.intensity);
setConnectedUI(true);
setEmotionUI("neutral");
setSpeakingUI(false);
setEyesMovingUI(true);
scheduleBlink();
scheduleEyeDrift();
})();

View File

@ -2,27 +2,271 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" /> <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Robot Face</title> <title>Face</title>
<link rel="stylesheet" href="/style.css?v=2" /> <style>
:root {
--bg: #000;
--fg: #fff;
}
html, body {
height: 100%;
margin: 0;
background: var(--bg);
overflow: hidden;
touch-action: manipulation;
user-select: none;
-webkit-tap-highlight-color: transparent;
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
/* Fullscreen stage */
#stage {
position: fixed;
inset: 0;
background: var(--bg);
display: grid;
place-items: center;
}
/* Face canvas-ish container */
#face {
width: min(92vw, 92vh);
height: min(92vw, 92vh);
position: relative;
}
/* Controls: hidden until touch/move */
#overlay {
position: fixed;
left: 12px;
top: 12px;
display: flex;
gap: 10px;
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
z-index: 10;
}
#overlay.visible {
opacity: 1;
pointer-events: auto;
}
.btn {
color: var(--fg);
text-decoration: none;
font-size: 14px;
padding: 10px 12px;
border: 1px solid rgba(255,255,255,0.25);
border-radius: 12px;
background: rgba(0,0,0,0.35);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
line-height: 1;
white-space: nowrap;
}
.btn:active { transform: translateY(1px); }
/* --- Simple face elements (SVG) --- */
svg { width: 100%; height: 100%; display: block; }
/* Keep strokes crisp */
.stroke {
fill: none;
stroke: #fff;
stroke-linecap: round;
stroke-linejoin: round;
}
</style>
</head> </head>
<body class="emotion-neutral"> <body>
<div id="face" aria-label="Robot face"> <div id="stage">
<div class="eyes"> <div id="overlay" aria-hidden="true">
<div class="eye"></div> <a class="btn" id="btnControl" href="/control">neutral</a>
<div class="eye"></div> <a class="btn" id="btnDrive" href="/drive">/drive</a>
</div> </div>
<div class="mouth"> <div id="face" aria-label="Robot Face">
<div class="mouth-shape"></div> <!-- Minimal clean face in SVG (eyes + mouth); driven by state below -->
<svg viewBox="0 0 100 100" role="img" aria-hidden="true">
<!-- Eyes -->
<g id="eyes" class="stroke" stroke-width="6">
<!-- left eye -->
<path id="eyeL" d="M22 40 C 30 30, 40 30, 48 40" />
<!-- right eye -->
<path id="eyeR" d="M52 40 C 60 30, 70 30, 78 40" />
</g>
<!-- Pupils (small dots) -->
<g id="pupils" fill="#fff">
<circle id="pupilL" cx="35" cy="40" r="2.2" />
<circle id="pupilR" cx="65" cy="40" r="2.2" />
</g>
<!-- Mouth -->
<g class="stroke" stroke-width="6">
<path id="mouth" d="M30 68 C 40 76, 60 76, 70 68" />
</g>
</svg>
</div>
</div> </div>
<div class="label" id="label">neutral</div> <script>
// --- State (matches your face-server structure) ---
const state = {
emotion: "neutral",
intensity: 0.7, // 0..1
look: null, // {"x": -1..1, "y": -1..1} or null
mouth: { open: false, amount: 0.0, duration_ms: 0 },
talk: { enabled: false, rate_hz: 3.2, amount: 0.9, jitter: 0.25 },
};
</div> // --- UI refs ---
const overlay = document.getElementById("overlay");
const btnControl = document.getElementById("btnControl");
<a href="/drive" class="control-link">🎮 Steuerung öffnen</a> const pupilL = document.getElementById("pupilL");
<script src="/app.js?v=2"></script> const pupilR = document.getElementById("pupilR");
const mouth = document.getElementById("mouth");
const eyeL = document.getElementById("eyeL");
const eyeR = document.getElementById("eyeR");
// --- Overlay show-on-touch/move ---
let overlayTimer = null;
function showOverlayBriefly() {
overlay.classList.add("visible");
overlay.setAttribute("aria-hidden", "false");
if (overlayTimer) clearTimeout(overlayTimer);
overlayTimer = setTimeout(() => {
overlay.classList.remove("visible");
overlay.setAttribute("aria-hidden", "true");
}, 2500);
}
window.addEventListener("touchstart", showOverlayBriefly, { passive: true });
window.addEventListener("mousemove", showOverlayBriefly, { passive: true });
window.addEventListener("keydown", showOverlayBriefly);
// --- Face rendering helpers ---
function clamp(v, a, b) { return Math.max(a, Math.min(b, v)); }
function applyLook(look) {
// Pupils range in SVG coords
const maxX = 3.2, maxY = 2.2;
const x = look ? clamp(look.x, -1, 1) * maxX : 0;
const y = look ? clamp(look.y, -1, 1) * maxY : 0;
pupilL.setAttribute("cx", (35 + x).toFixed(2));
pupilL.setAttribute("cy", (40 + y).toFixed(2));
pupilR.setAttribute("cx", (65 + x).toFixed(2));
pupilR.setAttribute("cy", (40 + y).toFixed(2));
}
function applyMouth(emotion, mouthState) {
// mouthState.amount: 0..1, open toggles
const amt = clamp(mouthState?.amount ?? 0, 0, 1);
const open = !!mouthState?.open;
// Base mouth by emotion (simple, clean)
// neutral: slight smile, happy: bigger smile, sad: frown, angry: flat, surprised: open O
if (emotion === "sad") {
mouth.setAttribute("d", "M30 72 C 42 62, 58 62, 70 72");
} else if (emotion === "angry") {
mouth.setAttribute("d", "M32 70 C 44 70, 56 70, 68 70");
} else if (emotion === "happy") {
mouth.setAttribute("d", "M28 66 C 40 80, 60 80, 72 66");
} else if (emotion === "surprised") {
// fake an "O" using a short curve; (keeps it minimal)
const r = 6 + amt * 6;
mouth.setAttribute("d", `M50 ${70-r} C ${50+r} ${70-r}, ${50+r} ${70+r}, 50 ${70+r} C ${50-r} ${70+r}, ${50-r} ${70-r}, 50 ${70-r}`);
} else {
// neutral
mouth.setAttribute("d", "M30 68 C 40 76, 60 76, 70 68");
}
// If mouth.open => exaggerate a bit (except surprised which is already open-ish)
if (open && emotion !== "surprised") {
// slightly "deeper" smile/frown/line
// We just scale stroke width a touch and nudge curve visually by intensity
// (simple, avoids layout thrash)
const sw = 6 + amt * 2.5;
mouth.parentElement.setAttribute("stroke-width", sw.toFixed(2));
} else {
mouth.parentElement.setAttribute("stroke-width", "6");
}
}
function applyEyes(emotion) {
// optional: angry eyebrows effect via eye curve shape
if (emotion === "angry") {
eyeL.setAttribute("d", "M22 42 C 32 30, 42 34, 48 42");
eyeR.setAttribute("d", "M52 42 C 58 34, 68 30, 78 42");
} else if (emotion === "sad") {
eyeL.setAttribute("d", "M22 40 C 30 34, 40 30, 48 40");
eyeR.setAttribute("d", "M52 40 C 60 30, 70 34, 78 40");
} else {
// default
eyeL.setAttribute("d", "M22 40 C 30 30, 40 30, 48 40");
eyeR.setAttribute("d", "M52 40 C 60 30, 70 30, 78 40");
}
}
function render() {
btnControl.textContent = state.emotion; // /control link text = aktuelle Emotion
applyLook(state.look);
applyEyes(state.emotion);
applyMouth(state.emotion, state.mouth);
}
// --- WebSocket hookup (adjust WS_URL to your setup) ---
// Typical options:
// 1) Direct: ws://<pi-ip>:8001/ws
// 2) Via nginx: wss://<host>/face-ws
const WS_URL = (() => {
// Default: same host, path /ws
const proto = (location.protocol === "https:") ? "wss:" : "ws:";
return `${proto}//${location.host}/ws`;
})();
let ws = null;
let wsBackoffMs = 300;
function connectWs() {
try { ws?.close(); } catch {}
ws = new WebSocket(WS_URL);
ws.onopen = () => { wsBackoffMs = 300; };
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
// accept either:
// - {type:"state", state:{...}}
// - {state:{...}}
// - direct state object
const incoming = msg?.state ?? (msg?.type === "state" ? msg.state : msg);
if (incoming && typeof incoming === "object") {
Object.assign(state, incoming);
render();
}
} catch (e) {
// ignore malformed messages
}
};
ws.onclose = () => {
setTimeout(connectWs, wsBackoffMs);
wsBackoffMs = Math.min(wsBackoffMs * 1.7, 8000);
};
ws.onerror = () => {
try { ws.close(); } catch {}
};
}
render();
connectWs();
</script>
</body> </body>
</html> </html>

View File

@ -1,300 +1,138 @@
:root {
--bg: #0b0f14;
--panel: #121925;
--fg: #d7e3f2;
/* Glow / Stimmung */
--glow: rgba(0, 255, 180, 0.22);
/* Pupillen-Offset (wird via JS verändert) */
--pupil-x: 0px;
--pupil-y: 0px;
/* Mund-Parameter (Default = neutral) */
--mouth-w: 38vw;
--mouth-h: 10vh;
--mouth-radius: 999px;
--mouth-line-y: 50%;
--mouth-line-h: 10px;
--mouth-line-opacity: 0.85;
/* „Smile“-Bogen (0 = aus) */
--smile: 0;
/* „Frown“-Bogen (0 = aus) */
--frown: 0;
/* „O“-Mund (0 = aus, sonst Größe) */
--omouth: 0;
}
html, body {
height: 100%;
margin: 0;
background: var(--bg);
overflow: hidden;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
#face {
height: 100%;
display: grid;
place-items: center;
gap: 3.5vh;
}
.eyes {
display: flex;
gap: 8vw;
align-items: center;
}
.eye {
width: 14vw;
height: 14vw;
max-width: 220px;
max-height: 220px;
border-radius: 999px;
background: var(--panel);
box-shadow: 0 0 40px var(--glow);
position: relative;
overflow: hidden;
transition: border-radius 220ms ease, transform 220ms ease, height 220ms ease;
}
/* Pupille */
.eye::after {
content: "";
position: absolute;
inset: 28%;
border-radius: 999px;
background: var(--fg);
opacity: 0.9;
transform: translate(var(--pupil-x), var(--pupil-y));
transition: transform 180ms ease;
}
/* Blinzeln: wir "quetschen" das Auge kurz */
.eye.blink {
transform: scaleY(0.12);
}
/* Mund-Container */
.mouth {
width: var(--mouth-w);
height: var(--mouth-h);
max-width: 600px;
max-height: 120px;
border-radius: var(--mouth-radius);
background: var(--panel);
box-shadow: 0 0 40px var(--glow);
position: relative;
overflow: hidden;
transition: width 220ms ease, height 220ms ease, border-radius 220ms ease;
}
/* Mund-Shape (Linie + Bögen + O-Mund) */
.mouth-shape {
position: absolute;
inset: 0;
}
/* Mund-Linie */
.mouth-shape::after {
content: "";
position: absolute;
left: 12%;
right: 12%;
top: var(--mouth-line-y);
height: var(--mouth-line-h);
transform: translateY(-50%);
background: var(--fg);
border-radius: 999px;
opacity: var(--mouth-line-opacity);
transition: all 220ms ease;
}
/* Smile-Bogen */
.mouth-shape::before {
content: "";
position: absolute;
left: 16%;
right: 16%;
top: 38%;
height: 55%;
border: calc(6px + 6px * var(--smile)) solid rgba(215,227,242,0.85);
border-top: none;
border-left-color: transparent;
border-right-color: transparent;
border-bottom-left-radius: 999px;
border-bottom-right-radius: 999px;
opacity: calc(0.10 + 0.60 * var(--smile));
transition: opacity 220ms ease, border-width 220ms ease;
}
/* Frown-Bogen als extra Element über box-shadow Trick */
.mouth-shape {
filter: drop-shadow(0 0 0 rgba(0,0,0,0));
}
.mouth-shape.frown::before {
content: "";
position: absolute;
left: 16%;
right: 16%;
bottom: 38%;
height: 55%;
border: calc(6px + 6px * var(--frown)) solid rgba(215,227,242,0.85);
border-bottom: none;
border-left-color: transparent;
border-right-color: transparent;
border-top-left-radius: 999px;
border-top-right-radius: 999px;
opacity: calc(0.10 + 0.60 * var(--frown));
}
/* O-Mund: wir machen aus dem Mund-Container einen Kreis und verstecken Linie */
body.has-omouth .mouth {
width: calc(18vw + 8vw * var(--omouth));
height: calc(18vw + 8vw * var(--omouth));
max-width: 260px;
max-height: 260px;
border-radius: 999px;
}
body.has-omouth .mouth-shape::after {
left: 28%;
right: 28%;
top: 50%;
height: 42%;
border-radius: 999px;
opacity: 0.9;
}
body.has-omouth .mouth-shape::before {
opacity: 0; /* Smile aus */
}
/* Label */
.label {
position: fixed;
bottom: 18px;
left: 18px;
padding: 10px 14px;
background: rgba(18, 25, 37, 0.75);
color: var(--fg);
border-radius: 14px;
backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.08);
letter-spacing: 0.5px;
}
/* ===== Emotionen über Variablen ===== */
body.emotion-neutral {
--glow: rgba(0, 255, 180, 0.22);
--smile: 0;
--frown: 0;
--omouth: 0;
--mouth-line-opacity: 0.85;
--mouth-line-h: 10px;
}
body.emotion-neutral .mouth-shape { }
body.emotion-neutral .mouth-shape.frown { } /* no-op */
body.emotion-happy {
--glow: rgba(0, 255, 120, 0.32);
--smile: 1;
--frown: 0;
--omouth: 0;
--mouth-line-y: 58%;
--mouth-line-h: 12px;
--mouth-line-opacity: 0;
}
body.emotion-happy .mouth-shape { }
body.emotion-happy .mouth-shape.frown { } /* no-op */
body.emotion-happy .mouth-shape::before {
top: -20%; /* war 38% -> kleiner = weiter nach oben */
height: 62%; /* etwas größer, damit der Bogen schön wirkt */
}
body.emotion-sad {
--glow: rgba(120, 180, 255, 0.32);
--smile: 0;
--frown: 1;
--omouth: 0;
--mouth-line-y: 42%;
--mouth-line-h: 12px;
--mouth-line-opacity: 0;
}
body.emotion-sad .mouth-shape { }
body.emotion-sad .mouth-shape.frown { } /* no-op */
body.emotion-angry {
--glow: rgba(255, 70, 70, 0.32);
--smile: 0;
--frown: 0.35;
--omouth: 0;
--mouth-line-opacity: 0.95;
--mouth-line-h: 16px;
}
body.emotion-angry .eye {
border-radius: 26% 74% 60% 40% / 55% 45% 55% 45%;
transform: rotate(-2deg);
}
body.emotion-surprised {
--glow: rgba(255, 220, 90, 0.34);
--smile: 0;
--frown: 0;
--omouth: 1;
--mouth-line-opacity: 0.95;
}
body.emotion-surprised { }
body.emotion-surprised.has-omouth { } /* marker in JS */
body.emotion-sleepy {
--glow: rgba(180, 180, 255, 0.22);
--smile: 0;
--frown: 0;
--omouth: 0;
--mouth-line-opacity: 0.55;
--mouth-line-h: 8px;
}
body.emotion-sleepy .eye {
height: 6vw;
max-height: 90px;
}
/* Smooth transition for everything */
* { box-sizing: border-box; }
:root{ :root{
--intensity: 0.7; /* 0..1 */ --bg:#000;
--mouth-open: 0; /* 0..1 */ --panel:#0b1020cc;
--text:#e8f0ff;
--muted:#a8b6d8;
--accent:#7cffc9;
--accent2:#7cb7ff;
--danger:#ff6b6b;
--shadow: 0 12px 40px rgba(0,0,0,.55);
--r: 18px;
} }
/* Glow stärker je nach Intensität */ html,body{
.eye, .mouth { height:100%;
box-shadow: 0 0 calc(26px + 30px * var(--intensity)) var(--glow); margin:0;
background:var(--bg);
color:var(--text);
font-family:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
} }
/* Mund-Linie “öffnet”: wird dicker und etwas tiefer */ .stage{
.mouth-shape::after { position:fixed; inset:0;
height: calc(var(--mouth-line-h) + 26px * var(--mouth-open)); display:grid; place-items:center;
top: calc(var(--mouth-line-y) + 6% * var(--mouth-open)); overflow:hidden;
opacity: calc(var(--mouth-line-opacity) + 0.10 * var(--mouth-open)); touch-action:manipulation;
user-select:none;
background:#000;
} }
.hint{
.control-link{ position:fixed;
position: fixed; top: max(10px, env(safe-area-inset-top));
right: 16px; left: 0;
bottom: 16px; right: 0;
padding: 12px 16px; text-align:center;
background: rgba(0,0,0,0.6); opacity:.55;
color: white; font-size:12px;
text-decoration: none; color:var(--muted);
border-radius: 12px; pointer-events:none;
font-family: system-ui, sans-serif;
font-size: 16px;
} }
.control-link:active{ transform: scale(0.98); }
.displayWrap{
width:min(96vw, 720px);
aspect-ratio: 520 / 360;
position:relative;
display:grid; place-items:center;
}
.display{
width:100%;
height:100%;
display:block;
filter: drop-shadow(0 18px 40px rgba(0,0,0,.70));
}
/* Touch overlay */
.overlay{
position:absolute;
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;
justify-content:space-between;
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);
opacity:0;
transform: translateY(10px);
pointer-events:none;
transition: opacity .18s ease, transform .18s ease;
}
.overlay.show{
opacity:1;
transform: translateY(0);
pointer-events:auto;
}
.overlay .left,
.overlay .right{
display:flex;
gap:10px;
align-items:center;
flex-wrap:wrap;
}
a.btn, button.btn{
appearance:none;
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;
text-decoration:none;
line-height:1;
display:inline-flex;
align-items:center;
gap:8px;
cursor:pointer;
}
a.btn:active, button.btn:active{ transform: translateY(1px); }
.pill{
font-size:12px;
color:var(--muted);
padding:8px 10px;
border-radius:999px;
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
display:inline-flex;
align-items:center;
gap:8px;
}
.dot{
width:9px;
height:9px;
border-radius:99px;
background: var(--accent2);
box-shadow: 0 0 0 3px rgba(124,183,255,.15);
}
.dot.on{
background: var(--accent);
box-shadow: 0 0 0 3px rgba(124,255,201,.15);
}
.dot.off{
background: var(--danger);
box-shadow: 0 0 0 3px rgba(255,107,107,.12);
}