/** * SaltyFace Web App — Animated robot expression UI for Chromium kiosk * * Runs fullscreen in Cage Wayland compositor on MageDok 7" display * Replaces React desktop component with lightweight web app * * Features: * - 8 emotional states (happy, alert, confused, sleeping, excited, emergency, listening, talking) * - Canvas-based rendering (30fps target) * - ROS2 bridge integration via WebSocket (rosbridge) * - Touch-responsive status overlay * - 1024×600 fullscreen on MageDok display */ // Emotion configuration const EMOTIONS = { HAPPY: 'happy', ALERT: 'alert', CONFUSED: 'confused', SLEEPING: 'sleeping', EXCITED: 'excited', EMERGENCY: 'emergency', LISTENING: 'listening', TALKING: 'talking', }; const EMOTION_CONFIG = { [EMOTIONS.HAPPY]: { eyeScale: 1.0, pupilPos: { x: 0, y: 0 }, blink: true, blinkRate: 3000, color: '#10b981', }, [EMOTIONS.ALERT]: { eyeScale: 1.3, pupilPos: { x: 0, y: -3 }, blink: false, color: '#ef4444', }, [EMOTIONS.CONFUSED]: { eyeScale: 1.1, pupilPos: { x: 0, y: 0 }, blink: true, blinkRate: 1500, eyeWander: true, color: '#f59e0b', }, [EMOTIONS.SLEEPING]: { eyeScale: 0.3, pupilPos: { x: 0, y: 0 }, blink: false, isClosed: true, color: '#6b7280', }, [EMOTIONS.EXCITED]: { eyeScale: 1.2, pupilPos: { x: 0, y: 0 }, blink: true, blinkRate: 800, bounce: true, color: '#22c55e', }, [EMOTIONS.EMERGENCY]: { eyeScale: 1.4, pupilPos: { x: 0, y: -4 }, blink: false, color: '#dc2626', flash: true, }, [EMOTIONS.LISTENING]: { eyeScale: 1.0, pupilPos: { x: 0, y: -2 }, blink: true, blinkRate: 2000, color: '#0ea5e9', }, [EMOTIONS.TALKING]: { eyeScale: 1.0, pupilPos: { x: 0, y: 0 }, blink: true, blinkRate: 2500, color: '#06b6d4', }, }; // Mouth animation frames const MOUTH_FRAMES = [ { open: 0.0, shape: 'closed' }, { open: 0.3, shape: 'smile-closed' }, { open: 0.5, shape: 'smile-open' }, { open: 0.7, shape: 'oh' }, { open: 0.9, shape: 'ah' }, { open: 0.7, shape: 'ee' }, ]; // ROS2 bridge WebSocket let ros = null; const WS_URL = 'ws://localhost:9090'; let rosConnected = false; // Animation state let state = { emotion: EMOTIONS.HAPPY, isTalking: false, isListening: false, audioLevel: 0, showOverlay: true, botState: { battery: 85, state: 'IDLE', distance: 0.0, speed: 0.0, health: 90, hasTarget: false, obstacles: 0, }, frameCount: 0, isBlinking: false, mouthFrame: 0, eyeWanderOffset: { x: 0, y: 0 }, }; // Drawing helpers function drawEye(ctx, x, y, radius, config, isBlinking) { ctx.fillStyle = '#1f2937'; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); if (isBlinking) { ctx.strokeStyle = config.color; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(x - radius * 0.7, y); ctx.lineTo(x + radius * 0.7, y); ctx.stroke(); } else { const pupilRadius = radius * (config.eyeScale / 2); ctx.fillStyle = config.color; ctx.beginPath(); ctx.arc(x + config.pupilPos.x, y + config.pupilPos.y, pupilRadius, 0, Math.PI * 2); ctx.fill(); // Highlight ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'; ctx.beginPath(); ctx.arc(x + config.pupilPos.x + pupilRadius * 0.4, y + config.pupilPos.y - pupilRadius * 0.4, pupilRadius * 0.3, 0, Math.PI * 2); ctx.fill(); } } function drawMouth(ctx, x, y, width, frame) { ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 3; ctx.lineCap = 'round'; if (frame.shape === 'closed') { ctx.beginPath(); ctx.moveTo(x - width * 0.4, y); ctx.lineTo(x + width * 0.4, y); ctx.stroke(); } else if (frame.shape === 'smile-open' || frame.shape === 'smile-closed') { ctx.beginPath(); ctx.arc(x, y, width * 0.5, 0, Math.PI, false); ctx.stroke(); } else if (frame.shape === 'oh') { ctx.fillStyle = '#f59e0b'; ctx.beginPath(); ctx.arc(x, y, width * 0.35 * frame.open, 0, Math.PI * 2); ctx.fill(); } else if (frame.shape === 'ah') { ctx.beginPath(); ctx.moveTo(x - width * 0.3, y - width * 0.2 * frame.open); ctx.lineTo(x + width * 0.3, y - width * 0.2 * frame.open); ctx.lineTo(x + width * 0.2, y + width * 0.3 * frame.open); ctx.lineTo(x - width * 0.2, y + width * 0.3 * frame.open); ctx.closePath(); ctx.stroke(); } } function drawFace(ctx, W, H) { const config = EMOTION_CONFIG[state.emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY]; // Clear canvas ctx.fillStyle = 'rgba(5, 5, 16, 0.95)'; ctx.fillRect(0, 0, W, H); const centerX = W / 2; const centerY = H / 2.2; const eyeRadius = 40; const eyeSpacing = 80; // Eye wandering (confused state) let eyeOffX = 0, eyeOffY = 0; if (config.eyeWander) { eyeOffX = Math.sin(state.frameCount * 0.02) * 8; eyeOffY = Math.cos(state.frameCount * 0.015) * 8; } // Bounce (excited state) let bounceOffset = 0; if (config.bounce) { bounceOffset = Math.sin(state.frameCount * 0.08) * 6; } // Draw eyes const eyeY = centerY + bounceOffset; drawEye(ctx, centerX - eyeSpacing, eyeY + eyeOffY, eyeRadius, config, state.isBlinking); drawEye(ctx, centerX + eyeSpacing, eyeY + eyeOffY, eyeRadius, config, state.isBlinking); // Draw mouth (if talking) if (state.isTalking && !config.isClosed) { drawMouth(ctx, centerX, centerY + 80, 50, MOUTH_FRAMES[state.mouthFrame]); } // Flash (emergency state) if (config.flash && Math.sin(state.frameCount * 0.1) > 0.7) { ctx.fillStyle = 'rgba(220, 38, 38, 0.3)'; ctx.fillRect(0, 0, W, H); } state.frameCount++; } // ROS2 Bridge functions function initRosBridge() { console.log('[SaltyFace] Connecting to ROS bridge at', WS_URL); const ROSLIB = window.ROSLIB; if (!ROSLIB) { console.error('[SaltyFace] ROSLIB not loaded'); return false; } ros = new ROSLIB.Ros({ url: WS_URL, }); ros.on('connection', () => { console.log('[SaltyFace] Connected to ROS bridge'); rosConnected = true; updateWSStatus(true); subscribeToRosTopics(); }); ros.on('error', (error) => { console.error('[SaltyFace] ROS error:', error); rosConnected = false; updateWSStatus(false); }); ros.on('close', () => { console.log('[SaltyFace] ROS connection closed'); rosConnected = false; updateWSStatus(false); }); return true; } function subscribeToRosTopics() { if (!ros || !rosConnected) return; // Subscribe to robot state const stateTopic = new ROSLIB.Topic({ ros, name: '/saltybot/state', messageType: 'std_msgs/String', }); stateTopic.subscribe((msg) => { try { const data = JSON.parse(msg.data); state.botState.state = data.state || 'IDLE'; if (data.state === 'EMERGENCY') { state.emotion = EMOTIONS.EMERGENCY; } else if (data.state === 'TRACKING') { state.emotion = EMOTIONS.HAPPY; } else if (data.state === 'SEARCHING') { state.emotion = EMOTIONS.CONFUSED; } else if (data.state === 'IDLE') { state.emotion = EMOTIONS.HAPPY; } } catch (e) {} }); // Battery topic const batteryTopic = new ROSLIB.Topic({ ros, name: '/saltybot/battery', messageType: 'std_msgs/Float32', }); batteryTopic.subscribe((msg) => { state.botState.battery = Math.round(msg.data); updateUI(); }); // Target tracking const targetTopic = new ROSLIB.Topic({ ros, name: '/saltybot/target_track', messageType: 'geometry_msgs/Pose', }); targetTopic.subscribe((msg) => { state.botState.hasTarget = !!msg; if (msg) state.emotion = EMOTIONS.EXCITED; }); // Obstacles const obstacleTopic = new ROSLIB.Topic({ ros, name: '/saltybot/obstacles', messageType: 'sensor_msgs/LaserScan', }); obstacleTopic.subscribe((msg) => { const obstacleCount = msg?.ranges?.filter((r) => r < 0.5).length || 0; state.botState.obstacles = obstacleCount; if (obstacleCount > 0) state.emotion = EMOTIONS.ALERT; }); // Speech const speakingTopic = new ROSLIB.Topic({ ros, name: '/social/speech/is_speaking', messageType: 'std_msgs/Bool', }); speakingTopic.subscribe((msg) => { state.isTalking = msg.data || false; if (msg.data) state.emotion = EMOTIONS.TALKING; }); const listeningTopic = new ROSLIB.Topic({ ros, name: '/social/speech/is_listening', messageType: 'std_msgs/Bool', }); listeningTopic.subscribe((msg) => { state.isListening = msg.data || false; if (msg.data) state.emotion = EMOTIONS.LISTENING; }); // Audio level const audioTopic = new ROSLIB.Topic({ ros, name: '/saltybot/audio_level', messageType: 'std_msgs/Float32', }); audioTopic.subscribe((msg) => { state.audioLevel = msg.data || 0; }); } function updateWSStatus(connected) { const dot = document.getElementById('ws-dot'); const label = document.getElementById('ws-label'); const container = document.getElementById('ws-status'); if (connected) { dot.style.backgroundColor = '#10b981'; label.textContent = 'CONNECTED'; container.classList.add('connected'); container.classList.remove('disconnected'); } else { dot.style.backgroundColor = '#ef4444'; label.textContent = 'DISCONNECTED'; container.classList.remove('connected'); container.classList.add('disconnected'); } } function updateUI() { document.getElementById('battery').textContent = state.botState.battery; document.getElementById('state').textContent = state.botState.state; document.getElementById('distance').textContent = state.botState.distance.toFixed(1); document.getElementById('speed').textContent = state.botState.speed.toFixed(1) + ' m/s'; document.getElementById('health').textContent = state.botState.health; document.getElementById('emotion').textContent = state.emotion.toUpperCase(); document.getElementById('talking').textContent = state.isTalking ? 'YES' : 'NO'; document.getElementById('audio-level').textContent = Math.round(state.audioLevel * 100) + '%'; // Health color const healthIcon = document.getElementById('health-icon'); if (state.botState.health > 75) { healthIcon.style.color = '#10b981'; } else if (state.botState.health > 50) { healthIcon.style.color = '#f59e0b'; } else { healthIcon.style.color = '#ef4444'; } } // Animation loop function animationLoop(canvas, ctx) { drawFace(ctx, canvas.width, canvas.height); requestAnimationFrame(() => animationLoop(canvas, ctx)); } // Blinking animation function startBlinking() { const config = EMOTION_CONFIG[state.emotion] || EMOTION_CONFIG[EMOTIONS.HAPPY]; if (!config.blink || config.isClosed) return; setInterval(() => { state.isBlinking = true; setTimeout(() => { state.isBlinking = false; }, 150); }, config.blinkRate); } // Mouth animation function startMouthAnimation() { setInterval(() => { if (!state.isTalking) { state.mouthFrame = 0; return; } state.mouthFrame = (state.mouthFrame + 1) % MOUTH_FRAMES.length; }, 100); } // Initialize export function initSaltyFace() { const canvas = document.getElementById('canvas'); const ctx = canvas.getContext('2d'); const overlay = document.getElementById('overlay'); // Start animation loop animationLoop(canvas, ctx); // Start blinking startBlinking(); // Start mouth animation startMouthAnimation(); // Tap to toggle overlay canvas.addEventListener('click', () => { state.showOverlay = !state.showOverlay; overlay.classList.toggle('hidden', !state.showOverlay); }); // Prevent context menu on long press canvas.addEventListener('contextmenu', (e) => e.preventDefault()); // Initialize ROS bridge // Load ROSLIB dynamically const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/npm/roslib@1.3.0/build/roslib.min.js'; script.onload = () => { initRosBridge(); }; script.onerror = () => { console.warn('[SaltyFace] Failed to load ROSLIB from CDN, trying localhost'); setTimeout(() => { initRosBridge(); }, 2000); }; document.head.appendChild(script); // Update UI periodically setInterval(updateUI, 500); console.log('[SaltyFace] Web app initialized'); }