add new face
This commit is contained in:
parent
cd8569318f
commit
a36981da66
|
|
@ -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();
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
@ -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();
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,300 +1,138 @@
|
||||||
:root{
|
:root{
|
||||||
--bg: #0b0f14;
|
--bg:#000;
|
||||||
--panel: #121925;
|
--panel:#0b1020cc;
|
||||||
--fg: #d7e3f2;
|
--text:#e8f0ff;
|
||||||
|
--muted:#a8b6d8;
|
||||||
/* Glow / Stimmung */
|
--accent:#7cffc9;
|
||||||
--glow: rgba(0, 255, 180, 0.22);
|
--accent2:#7cb7ff;
|
||||||
|
--danger:#ff6b6b;
|
||||||
/* Pupillen-Offset (wird via JS verändert) */
|
--shadow: 0 12px 40px rgba(0,0,0,.55);
|
||||||
--pupil-x: 0px;
|
--r: 18px;
|
||||||
--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{
|
html,body{
|
||||||
height:100%;
|
height:100%;
|
||||||
margin:0;
|
margin:0;
|
||||||
background:var(--bg);
|
background:var(--bg);
|
||||||
overflow: hidden;
|
color:var(--text);
|
||||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
font-family:system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
|
||||||
}
|
}
|
||||||
|
|
||||||
#face {
|
.stage{
|
||||||
|
position:fixed; inset:0;
|
||||||
|
display:grid; place-items:center;
|
||||||
|
overflow:hidden;
|
||||||
|
touch-action:manipulation;
|
||||||
|
user-select:none;
|
||||||
|
background:#000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.displayWrap{
|
||||||
|
width:min(96vw, 720px);
|
||||||
|
aspect-ratio: 520 / 360;
|
||||||
|
position:relative;
|
||||||
|
display:grid; place-items:center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display{
|
||||||
|
width:100%;
|
||||||
height:100%;
|
height:100%;
|
||||||
display: grid;
|
display:block;
|
||||||
place-items: center;
|
filter: drop-shadow(0 18px 40px rgba(0,0,0,.70));
|
||||||
gap: 3.5vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.eyes {
|
/* 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;
|
display:flex;
|
||||||
gap: 8vw;
|
gap:10px;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
}
|
justify-content:space-between;
|
||||||
|
|
||||||
.eye {
|
|
||||||
width: 14vw;
|
|
||||||
height: 14vw;
|
|
||||||
max-width: 220px;
|
|
||||||
max-height: 220px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background:var(--panel);
|
background:var(--panel);
|
||||||
box-shadow: 0 0 40px var(--glow);
|
border:1px solid rgba(255,255,255,.10);
|
||||||
position: relative;
|
border-radius: var(--r);
|
||||||
overflow: hidden;
|
padding:10px 12px;
|
||||||
transition: border-radius 220ms ease, transform 220ms ease, height 220ms ease;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pupille */
|
.overlay.show{
|
||||||
.eye::after {
|
opacity:1;
|
||||||
content: "";
|
transform: translateY(0);
|
||||||
position: absolute;
|
pointer-events:auto;
|
||||||
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 */
|
.overlay .left,
|
||||||
.eye.blink {
|
.overlay .right{
|
||||||
transform: scaleY(0.12);
|
display:flex;
|
||||||
|
gap:10px;
|
||||||
|
align-items:center;
|
||||||
|
flex-wrap:wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mund-Container */
|
a.btn, button.btn{
|
||||||
.mouth {
|
appearance:none;
|
||||||
width: var(--mouth-w);
|
border:1px solid rgba(255,255,255,.14);
|
||||||
height: var(--mouth-h);
|
background: rgba(255,255,255,.06);
|
||||||
max-width: 600px;
|
color:var(--text);
|
||||||
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;
|
border-radius: 14px;
|
||||||
backdrop-filter: blur(8px);
|
padding:10px 12px;
|
||||||
border: 1px solid rgba(255,255,255,0.08);
|
font-size:14px;
|
||||||
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 */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glow stärker je nach Intensität */
|
|
||||||
.eye, .mouth {
|
|
||||||
box-shadow: 0 0 calc(26px + 30px * var(--intensity)) var(--glow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 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));
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.control-link{
|
|
||||||
position: fixed;
|
|
||||||
right: 16px;
|
|
||||||
bottom: 16px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: rgba(0,0,0,0.6);
|
|
||||||
color: white;
|
|
||||||
text-decoration:none;
|
text-decoration:none;
|
||||||
border-radius: 12px;
|
line-height:1;
|
||||||
font-family: system-ui, sans-serif;
|
display:inline-flex;
|
||||||
font-size: 16px;
|
align-items:center;
|
||||||
|
gap:8px;
|
||||||
|
cursor:pointer;
|
||||||
}
|
}
|
||||||
.control-link:active{ transform: scale(0.98); }
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue