helva-robot/face/var/www/html/index.html

274 lines
8.4 KiB
HTML
Executable File

<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<title>Face</title>
<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>
<body>
<div id="stage">
<div id="overlay" aria-hidden="true">
<a class="btn" id="btnControl" href="/control">neutral</a>
<a class="btn" id="btnDrive" href="/drive">/drive</a>
</div>
<div id="face" aria-label="Robot Face">
<!-- 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>
<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 },
};
// --- UI refs ---
const overlay = document.getElementById("overlay");
const btnControl = document.getElementById("btnControl");
const pupilL = document.getElementById("pupilL");
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>
</html>