From a36981da662d9a03060d73c58f4c1062bee80885 Mon Sep 17 00:00:00 2001 From: max Date: Sun, 8 Feb 2026 20:26:14 +0100 Subject: [PATCH] add new face --- face/var/www/html/app.js | 194 ---------------- face/var/www/html/face.js | 312 ++++++++++++++++++++++++++ face/var/www/html/index.html | 274 +++++++++++++++++++++-- face/var/www/html/style.css | 422 +++++++++++------------------------ 4 files changed, 701 insertions(+), 501 deletions(-) delete mode 100755 face/var/www/html/app.js create mode 100644 face/var/www/html/face.js diff --git a/face/var/www/html/app.js b/face/var/www/html/app.js deleted file mode 100755 index 4a112cc..0000000 --- a/face/var/www/html/app.js +++ /dev/null @@ -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(); -})(); - diff --git a/face/var/www/html/face.js b/face/var/www/html/face.js new file mode 100644 index 0000000..d929b1d --- /dev/null +++ b/face/var/www/html/face.js @@ -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(); +})(); + diff --git a/face/var/www/html/index.html b/face/var/www/html/index.html index bacabd8..53759f8 100755 --- a/face/var/www/html/index.html +++ b/face/var/www/html/index.html @@ -2,27 +2,271 @@ - - Robot Face - + + Face + - -
-
-
-
+ +
+ -
-
+
+ +
- -
neutral
-
- 🎮 Steuerung öffnen - + diff --git a/face/var/www/html/style.css b/face/var/www/html/style.css index cec81fd..139d422 100755 --- a/face/var/www/html/style.css +++ b/face/var/www/html/style.css @@ -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{ - --intensity: 0.7; /* 0..1 */ - --mouth-open: 0; /* 0..1 */ + --bg:#000; + --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 */ -.eye, .mouth { - box-shadow: 0 0 calc(26px + 30px * var(--intensity)) var(--glow); +html,body{ + height:100%; + 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 */ -.mouth-shape::after { - height: calc(var(--mouth-line-h) + 26px * var(--mouth-open)); - top: calc(var(--mouth-line-y) + 6% * var(--mouth-open)); - opacity: calc(var(--mouth-line-opacity) + 0.10 * var(--mouth-open)); +.stage{ + position:fixed; inset:0; + display:grid; place-items:center; + overflow:hidden; + touch-action:manipulation; + user-select:none; + background:#000; } - -.control-link{ - position: fixed; - right: 16px; - bottom: 16px; - padding: 12px 16px; - background: rgba(0,0,0,0.6); - color: white; - text-decoration: none; - border-radius: 12px; - font-family: system-ui, sans-serif; - font-size: 16px; +.hint{ + position:fixed; + top: max(10px, env(safe-area-inset-top)); + left: 0; + right: 0; + text-align:center; + opacity:.55; + font-size:12px; + color:var(--muted); + pointer-events:none; } -.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); +} +