amitlal's picture
Live Conversation + Backyard AI submission: tags, demo videos, social link
1188e4b verified
Raw
History Blame Contribute Delete
17.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reachy Mini 3D Stage</title>
<style>
:root { color-scheme: dark; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #080b14; }
#scene { position: fixed; inset: 0; }
#scene canvas { display: block; }
/* Overlays drawn ON TOP of the (now opaque) WebGL canvas */
#scan {
position: fixed; inset: 0; pointer-events: none; z-index: 2; opacity: 0.16;
background: repeating-linear-gradient(180deg, rgba(255,255,255,0.08) 0 1px, transparent 1px 3px);
mix-blend-mode: overlay;
}
#vignette {
position: fixed; inset: 0; pointer-events: none; z-index: 2;
box-shadow: inset 0 0 130px 36px rgba(3,2,8,0.72);
}
/* tiny decorative HUD corners (cyberpunk-studio) */
.hud {
position: fixed; z-index: 3; pointer-events: none;
font-family: ui-monospace, "Cascadia Code", monospace; font-size: 10px; letter-spacing: .22em;
color: rgba(127,233,255,0.55); text-transform: uppercase; text-shadow: 0 0 10px rgba(34,227,255,0.45);
}
.hud.tl { top: 12px; left: 14px; }
.hud.br { bottom: 12px; right: 14px; }
.hud .kanji { font-family: "Noto Sans JP", system-ui, sans-serif; letter-spacing: .14em; }
#loading {
position: fixed; inset: 0; display: grid; place-items: center; z-index: 4;
font-family: ui-monospace, "Cascadia Code", monospace; letter-spacing: .18em;
color: #7fe9ff; text-transform: uppercase; font-size: 12px;
text-shadow: 0 0 12px rgba(34,227,255,0.7); transition: opacity .5s;
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="scene"></div>
<div id="scan"></div>
<div id="vignette"></div>
<div class="hud tl"><span class="kanji">通訳</span> · reachy.link</div>
<div class="hud br">tracking · <span class="kanji">音声</span></div>
<div id="loading">booting reachy&hellip;</div>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const mount = document.getElementById('scene');
const loading = document.getElementById('loading');
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const lerp = (a, b, t) => a + (b - a) * t;
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
mount.appendChild(renderer.domElement);
const scene = new THREE.Scene();
// ---- In-scene backdrop gradient (so bloom composites over a real bg) ----
function gradientBg() {
const c = document.createElement('canvas'); c.width = 4; c.height = 256;
const g = c.getContext('2d');
const grd = g.createLinearGradient(0, 0, 0, 256);
grd.addColorStop(0.0, '#11172a');
grd.addColorStop(0.55, '#0b0f1a');
grd.addColorStop(1.0, '#070912');
g.fillStyle = grd; g.fillRect(0, 0, 4, 256);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
scene.background = gradientBg();
scene.fog = new THREE.Fog(0x0a0e18, 6.5, 17);
const camera = new THREE.PerspectiveCamera(34, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0.1, 1.55, 4.7);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 1.28, 0);
controls.enablePan = false;
controls.enableZoom = false;
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.minPolarAngle = Math.PI * 0.28;
controls.maxPolarAngle = Math.PI * 0.60;
controls.autoRotate = false; // stay front-facing; user can still drag
// ---- Lighting: warm key + neon magenta/cyan rim (arcade) ----
scene.add(new THREE.HemisphereLight(0xbfd8ff, 0x140a22, 0.55));
const key = new THREE.DirectionalLight(0xfff3e0, 1.5); key.position.set(2.6, 4.2, 3.2); scene.add(key);
const fill = new THREE.DirectionalLight(0x9fc4ff, 0.5); fill.position.set(-3.0, 1.6, 2.0); scene.add(fill);
const rimViolet = new THREE.PointLight(0x8b5cf6, 1.5, 18); rimViolet.position.set(-2.6, 2.4, -2.4); scene.add(rimViolet);
const rimIndigo = new THREE.PointLight(0x6366f1, 1.4, 18); rimIndigo.position.set(2.8, 1.8, -2.2); scene.add(rimIndigo);
// ---- Neon floor grid (in-scene; fades into fog) ----
const grid = new THREE.GridHelper(46, 92, 0x818cf8, 0x2a2c66);
grid.material.transparent = true; grid.material.opacity = 0.22; grid.position.y = 0.001;
scene.add(grid);
// ---- Drifting particle/dust field (parallaxes with the mouse) ----
const PCOUNT = 700;
const pPos = new Float32Array(PCOUNT * 3);
for (let i = 0; i < PCOUNT; i++) {
pPos[i*3+0] = (Math.random() - 0.5) * 16;
pPos[i*3+1] = Math.random() * 7.5;
pPos[i*3+2] = (Math.random() - 0.5) * 14 - 2;
}
const pGeo = new THREE.BufferGeometry();
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos, 3));
const particles = new THREE.Points(pGeo, new THREE.PointsMaterial({
color: 0x9fb4ff, size: 0.035, transparent: true, opacity: 0.7,
depthWrite: false, blending: THREE.AdditiveBlending,
}));
scene.add(particles);
// ---- Glow sprite texture (neon bloom for eyes / antenna tips / ring) ----
function glowTexture(rgb) {
const c = document.createElement('canvas'); c.width = c.height = 128;
const g = c.getContext('2d');
const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64);
grd.addColorStop(0, `rgba(${rgb},1)`);
grd.addColorStop(0.25, `rgba(${rgb},0.85)`);
grd.addColorStop(1, `rgba(${rgb},0)`);
g.fillStyle = grd; g.fillRect(0, 0, 128, 128);
const t = new THREE.CanvasTexture(c); t.colorSpace = THREE.SRGBColorSpace; return t;
}
function makeGlow(rgb, size) {
const s = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTexture(rgb), transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
}));
s.scale.set(size, size, 1); return s;
}
const reachy = new THREE.Group();
scene.add(reachy);
// ---- Base / foot ----
const baseMat = new THREE.MeshStandardMaterial({ color: 0x201a2c, metalness: 0.5, roughness: 0.45 });
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.82, 0.92, 0.20, 48), baseMat);
base.position.y = 0.10; reachy.add(base);
const baseTrim = new THREE.Mesh(new THREE.TorusGeometry(0.83, 0.035, 16, 60),
new THREE.MeshStandardMaterial({ color: 0x818cf8, emissive: 0x4338ca, emissiveIntensity: 1.1, metalness: 0.3, roughness: 0.3 }));
baseTrim.rotation.x = Math.PI / 2; baseTrim.position.y = 0.205; reachy.add(baseTrim);
// ---- Body (rounded barrel via lathe), cream/off-white ----
const bodyProfile = [
[0.60, 0.20], [0.72, 0.30], [0.76, 0.55], [0.74, 0.86], [0.64, 1.06], [0.50, 1.16], [0.34, 1.20],
].map(p => new THREE.Vector2(p[0], p[1]));
const bodyMat = new THREE.MeshStandardMaterial({ color: 0xf1e7d2, metalness: 0.12, roughness: 0.55 });
const body = new THREE.Mesh(new THREE.LatheGeometry(bodyProfile, 64), bodyMat);
reachy.add(body);
// soft fabric/seam ring
const collar = new THREE.Mesh(new THREE.TorusGeometry(0.40, 0.05, 16, 48),
new THREE.MeshStandardMaterial({ color: 0x6d5cd6, emissive: 0x4c3aa8, emissiveIntensity: 0.5, roughness: 0.5 }));
collar.rotation.x = Math.PI / 2; collar.position.y = 1.16; reachy.add(collar);
// ---- Head group (moves: bob / tilt / nod / mouse-look) ----
const head = new THREE.Group(); head.position.set(0, 1.55, 0); reachy.add(head);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 40),
new THREE.MeshStandardMaterial({ color: 0xf4ecdb, metalness: 0.1, roughness: 0.5 }));
skull.scale.set(1.0, 0.86, 0.92); head.add(skull);
// dark face visor
const visor = new THREE.Mesh(new THREE.SphereGeometry(0.42, 48, 40, 0, Math.PI * 2, 0, Math.PI * 0.62),
new THREE.MeshStandardMaterial({ color: 0x14121d, metalness: 0.6, roughness: 0.25 }));
visor.rotation.x = Math.PI * 0.5; visor.position.set(0, -0.02, 0.16); visor.scale.set(1.0, 0.78, 0.7); head.add(visor);
// eyes (emissive + glow sprite) — base positions saved for mouse parallax
const eyeMat = new THREE.MeshStandardMaterial({ color: 0xffffff, emissive: 0x22e3ff, emissiveIntensity: 2.2, roughness: 0.2 });
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.105, 28, 24), eyeMat);
const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.105, 28, 24), eyeMat.clone());
const EYE_BASE = { lx: -0.18, rx: 0.18, y: 0.02, z: 0.40 };
eyeL.position.set(EYE_BASE.lx, EYE_BASE.y, EYE_BASE.z); eyeR.position.set(EYE_BASE.rx, EYE_BASE.y, EYE_BASE.z);
head.add(eyeL, eyeR);
const eyeGlowL = makeGlow('120,235,255', 0.5); eyeGlowL.position.copy(eyeL.position);
const eyeGlowR = makeGlow('120,235,255', 0.5); eyeGlowR.position.copy(eyeR.position);
head.add(eyeGlowL, eyeGlowR);
// little mouth/speaker bar (pulses while speaking)
const mouth = new THREE.Mesh(new THREE.CapsuleGeometry(0.05, 0.16, 4, 12),
new THREE.MeshStandardMaterial({ color: 0x241a2e, emissive: 0x9b8cff, emissiveIntensity: 0.5, roughness: 0.5 }));
mouth.rotation.z = Math.PI / 2; mouth.position.set(0, -0.20, 0.42); head.add(mouth);
// ---- Antennas (two, each a group pivoting at the head top) ----
function makeAntenna(side) {
const g = new THREE.Group();
const stalk = new THREE.Mesh(new THREE.CylinderGeometry(0.022, 0.03, 0.52, 16),
new THREE.MeshStandardMaterial({ color: 0x2a2436, metalness: 0.6, roughness: 0.35 }));
stalk.position.y = 0.26; g.add(stalk);
const tipColor = side < 0 ? 0x818cf8 : 0xa78bfa;
const tip = new THREE.Mesh(new THREE.SphereGeometry(0.075, 24, 20),
new THREE.MeshStandardMaterial({ color: 0xf2f0ff, emissive: tipColor, emissiveIntensity: 1.8, roughness: 0.3 }));
tip.position.y = 0.54; g.add(tip);
const tipGlow = makeGlow(side < 0 ? '129,140,248' : '167,139,250', 0.42);
tipGlow.position.copy(tip.position); g.add(tipGlow);
g.position.set(side * 0.22, 0.42, -0.02);
g.rotation.z = -side * 0.18;
return g;
}
const antL = makeAntenna(-1), antR = makeAntenna(1);
head.add(antL, antR);
// ---- Reactive ground ring ----
const ring = new THREE.Mesh(new THREE.TorusGeometry(1.05, 0.02, 16, 80),
new THREE.MeshBasicMaterial({ color: 0x22e3ff, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending }));
ring.rotation.x = Math.PI / 2; ring.position.y = 0.04; reachy.add(ring);
// ---------------------------------------------------------------
// Bloom post-processing (graceful fallback if it fails to load)
// ---------------------------------------------------------------
let composer = null;
try {
const { EffectComposer } = await import('three/addons/postprocessing/EffectComposer.js');
const { RenderPass } = await import('three/addons/postprocessing/RenderPass.js');
const { UnrealBloomPass } = await import('three/addons/postprocessing/UnrealBloomPass.js');
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.85, 0.6, 0.18);
composer.addPass(bloom);
} catch (err) {
console.warn('Reachy bloom unavailable, falling back to direct render:', err && err.message);
composer = null; // fall back to direct render — scene still works
}
// ---------------------------------------------------------------
// Pointer ("watch the mouse") — whole page via postMessage + local
// ---------------------------------------------------------------
const pointer = { x: 0, y: 0, tx: 0, ty: 0, lastMove: -10 };
const MAX_YAW = 0.55, MAX_PITCH = 0.26;
function setPointer(nx, ny) {
pointer.tx = clamp(nx, -1.4, 1.4);
pointer.ty = clamp(ny, -1.4, 1.4);
pointer.lastMove = clock.getElapsedTime();
}
// cursor directly over the 3D stage
window.addEventListener('mousemove', (e) => {
setPointer((e.clientX / window.innerWidth) * 2 - 1, (e.clientY / window.innerHeight) * 2 - 1);
});
// ---------------------------------------------------------------
// State machine (unchanged contract: reachy-state postMessage)
// ---------------------------------------------------------------
const PALETTE = {
cyan: new THREE.Color(0x4dd6ff), lime: new THREE.Color(0x6ee7b7),
amber: new THREE.Color(0xfbbf24), magenta: new THREE.Color(0x9b8cff),
};
const STATES = {
idle: { rotate: 0.9, antenna: 0.10, bob: 0.6, eye: 'cyan', eyeI: 1.9, mouth: 0.0, ring: 0.45 },
listening: { rotate: 0.25, antenna: 0.55, bob: 0.9, eye: 'cyan', eyeI: 2.8, mouth: 0.1, ring: 1.0 },
thinking: { rotate: 0.5, antenna: 0.22, bob: 0.4, eye: 'magenta', eyeI: 2.0, mouth: 0.0, ring: 0.6, tilt: 1 },
speaking: { rotate: 0.6, antenna: 0.40, bob: 1.4, eye: 'cyan', eyeI: 3.0, mouth: 1.0, ring: 0.9, nod: 1 },
text_only: { rotate: 0.5, antenna: 0.12, bob: 0.5, eye: 'amber', eyeI: 2.0, mouth: 0.2, ring: 0.55 },
success: { rotate: 1.1, antenna: 0.9, bob: 1.0, eye: 'lime', eyeI: 3.0, mouth: 0.4, ring: 1.0, hop: 1 },
encourage: { rotate: 0.5, antenna: 0.25, bob: 0.5, eye: 'amber', eyeI: 2.2, mouth: 0.1, ring: 0.6, nod: 1 },
nodding: { rotate: 0.6, antenna: 0.20, bob: 0.6, eye: 'cyan', eyeI: 2.2, mouth: 0.0, ring: 0.6, nod: 1 },
};
let target = STATES.idle;
const cur = { rotate: 0.9, antenna: 0.10, bob: 0.6, eyeI: 1.9, mouth: 0, ring: 0.45, tilt: 0, nod: 0, hop: 0 };
const eyeColor = new THREE.Color(0x22e3ff);
function setState(name) {
target = STATES[name] || STATES.idle;
}
window.addEventListener('message', (e) => {
const d = e.data || {};
if (d.type === 'reachy-state' && typeof d.state === 'string') setState(d.state);
else if (d.type === 'reachy-pointer' && typeof d.x === 'number') setPointer(d.x, d.y);
});
// initial state can also arrive via hash (#state=...)
if (location.hash.includes('state=')) setState(location.hash.split('state=')[1]);
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const t = clock.getElapsedTime();
const k = 0.06;
cur.rotate = lerp(cur.rotate, target.rotate, k);
cur.antenna = lerp(cur.antenna, target.antenna, k);
cur.bob = lerp(cur.bob, target.bob, k);
cur.eyeI = lerp(cur.eyeI, target.eyeI, k);
cur.mouth = lerp(cur.mouth, target.mouth, k);
cur.ring = lerp(cur.ring, target.ring, k);
cur.tilt = lerp(cur.tilt, target.tilt ? 1 : 0, k);
cur.nod = lerp(cur.nod, target.nod ? 1 : 0, k);
cur.hop = lerp(cur.hop, target.hop ? 1 : 0, k);
// mouse-look: after ~2.2s of no pointer, ease into gentle autonomous look-around
if (t - pointer.lastMove > 2.2) {
pointer.tx = Math.sin(t * 0.45) * 0.5;
pointer.ty = Math.sin(t * 0.33) * 0.22 - 0.05;
}
pointer.x = lerp(pointer.x, pointer.tx, 0.08);
pointer.y = lerp(pointer.y, pointer.ty, 0.08);
const prox = Math.max(0, 1 - Math.hypot(pointer.x, pointer.y)); // cursor near Reachy
// head: bob + tilt + nod + mouse-look (yaw/pitch added on top of state anim)
head.position.y = 1.55 + Math.sin(t * 1.8) * 0.02 * cur.bob + cur.hop * Math.abs(Math.sin(t * 4.0)) * 0.05;
head.rotation.y = pointer.x * MAX_YAW; // yaw toward cursor (pointer.x already smoothed)
const nodPitch = -cur.nod * Math.max(0, Math.sin(t * 3.2)) * 0.18;
head.rotation.x = nodPitch + pointer.y * MAX_PITCH; // nod (fast) + smooth mouse pitch
head.rotation.z = Math.sin(t * 1.2) * 0.12 * cur.tilt;
reachy.position.y = cur.hop * Math.abs(Math.sin(t * 4.0)) * 0.04;
reachy.rotation.y = pointer.x * 0.12; // subtle body lean toward cursor
// antenna wiggle (perks up when the cursor is near)
const aAmp = cur.antenna + prox * 0.22;
antL.rotation.z = 0.18 + Math.sin(t * 5.0) * aAmp;
antR.rotation.z = -0.18 - Math.sin(t * 5.0 + 0.6) * aAmp;
antL.rotation.x = Math.sin(t * 3.3) * aAmp * 0.4;
antR.rotation.x = Math.sin(t * 3.3 + 1.0) * aAmp * 0.4;
// eyes: color + intensity (+ blink) + mouse parallax
const tgtCol = PALETTE[target.eye] || PALETTE.cyan;
eyeColor.lerp(tgtCol, 0.05);
const blink = (Math.sin(t * 0.7) > 0.985) ? 0.15 : 1.0;
const ex = pointer.x * 0.03, ey = -pointer.y * 0.02;
eyeL.position.set(EYE_BASE.lx + ex, EYE_BASE.y + ey, EYE_BASE.z);
eyeR.position.set(EYE_BASE.rx + ex, EYE_BASE.y + ey, EYE_BASE.z);
eyeGlowL.position.copy(eyeL.position); eyeGlowR.position.copy(eyeR.position);
[eyeL, eyeR].forEach(e => { e.material.emissive.copy(eyeColor); e.material.emissiveIntensity = cur.eyeI * blink; });
[eyeGlowL, eyeGlowR].forEach(s => { s.material.opacity = 0.5 * blink; });
eyeL.scale.y = eyeR.scale.y = blink < 1 ? 0.2 : 1;
// mouth: a thin closed line at rest; opens/closes only while speaking
const talk = Math.max(0, Math.sin(t * 11.0));
mouth.scale.set(1 + cur.mouth * talk * 5.0, 1 + cur.mouth * 0.15, 1);
// ground ring pulse (brightens with cursor proximity)
const rp = 0.85 + 0.18 * Math.sin(t * 2.4);
ring.scale.set(rp, rp, 1);
ring.material.opacity = 0.18 + (cur.ring + prox * 0.4) * (0.35 + 0.2 * Math.sin(t * 2.4));
ring.material.color.copy(eyeColor);
// particles drift + parallax with the mouse
particles.rotation.y += 0.0008;
particles.position.x = lerp(particles.position.x, -pointer.x * 0.35, 0.05);
particles.position.y = lerp(particles.position.y, -pointer.y * 0.20, 0.05);
controls.update();
if (composer) composer.render(); else renderer.render(scene, camera);
}
function onResize() {
const w = window.innerWidth, h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
if (composer) composer.setSize(w, h);
}
window.addEventListener('resize', onResize);
loading.style.opacity = '0';
setTimeout(() => loading.remove(), 600);
animate();
</script>
</body>
</html>