| <!DOCTYPE html>
|
| <html lang="en">
|
| <head>
|
| <meta charset="utf-8" />
|
| <meta name="viewport" content="width=device-width,initial-scale=1.0" />
|
| <title>Cosmic Gesture Blocks</title>
|
| <style>
|
| :root {
|
| font-family: "Space Grotesk", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| color: #f3f0ff;
|
| background-color: #04020b;
|
| }
|
|
|
| body, html {
|
| margin: 0;
|
| padding: 0;
|
| overflow: hidden;
|
| height: 100%;
|
| background: radial-gradient(circle at top, rgba(77, 32, 138, 0.35), transparent 55%),
|
| radial-gradient(circle at bottom, rgba(25, 105, 172, 0.25), transparent 50%),
|
| #04020b;
|
| }
|
|
|
| #three-root {
|
| position: fixed;
|
| inset: 0;
|
| }
|
|
|
| #overlay {
|
| position: fixed;
|
| top: 1.5rem;
|
| left: 1.5rem;
|
| max-width: 320px;
|
| padding: 1rem 1.25rem;
|
| background: rgba(6, 2, 17, 0.6);
|
| border: 1px solid rgba(255, 255, 255, 0.08);
|
| border-radius: 16px;
|
| backdrop-filter: blur(12px);
|
| box-shadow: 0 15px 45px rgba(7, 0, 18, 0.65);
|
| }
|
|
|
| #overlay h1 {
|
| margin: 0 0 0.35rem;
|
| font-size: 1.25rem;
|
| letter-spacing: 0.06em;
|
| text-transform: uppercase;
|
| }
|
|
|
| #overlay p, #overlay li {
|
| margin: 0 0 0.35rem;
|
| font-size: 0.95rem;
|
| line-height: 1.4;
|
| color: rgba(243, 240, 255, 0.9);
|
| }
|
|
|
| #overlay ul {
|
| padding-left: 1.05rem;
|
| margin: 0.5rem 0 0;
|
| }
|
|
|
| #status {
|
| position: fixed;
|
| bottom: 1.5rem;
|
| left: 50%;
|
| transform: translateX(-50%);
|
| padding: 0.65rem 1.25rem;
|
| font-size: 0.9rem;
|
| background: rgba(3, 4, 19, 0.65);
|
| border-radius: 999px;
|
| border: 1px solid rgba(255, 255, 255, 0.15);
|
| backdrop-filter: blur(8px);
|
| transition: opacity 0.35s ease;
|
| }
|
|
|
| #fallback {
|
| position: fixed;
|
| inset: 0;
|
| display: grid;
|
| place-items: center;
|
| text-align: center;
|
| padding: 2rem;
|
| background: rgba(4, 2, 11, 0.9);
|
| color: #fff5fe;
|
| z-index: 10;
|
| }
|
|
|
| #fallback button {
|
| margin-top: 1rem;
|
| padding: 0.5rem 1.4rem;
|
| border-radius: 999px;
|
| border: none;
|
| background: linear-gradient(120deg, #9a65ff, #ff63ae);
|
| color: #fff;
|
| cursor: pointer;
|
| }
|
|
|
| #inputVideo {
|
| position: fixed;
|
| width: 160px;
|
| height: 120px;
|
| bottom: 1rem;
|
| right: 1rem;
|
| opacity: 0.25;
|
| border-radius: 8px;
|
| border: 1px solid rgba(255,255,255,0.1);
|
| object-fit: cover;
|
| pointer-events: none;
|
| }
|
|
|
| #inputVideo.hidden {
|
| opacity: 0;
|
| pointer-events: none;
|
| }
|
|
|
| @media (max-width: 768px) {
|
| #overlay {
|
| top: 0.75rem;
|
| left: 50%;
|
| transform: translateX(-50%);
|
| width: calc(100% - 1.5rem);
|
| max-width: none;
|
| }
|
| #status {
|
| width: calc(100% - 2rem);
|
| }
|
| #inputVideo {
|
| width: 120px;
|
| height: 90px;
|
| }
|
| }
|
| </style>
|
| </head>
|
| <body>
|
| <div id="three-root"></div>
|
|
|
| <div id="overlay">
|
| <h1>Cosmic Gesture Blocks</h1>
|
| <p>Float through a constellation of iridescent blocks and sculpt them with simple hand poses:</p>
|
| <ul>
|
| <li><strong>Open palm</strong> – select & highlight nearest block</li>
|
| <li><strong>Pinch</strong> – grab & drag the block in midair</li>
|
| <li><strong>Rotate wrist</strong> – spin a grabbed block</li>
|
| <li><strong>Fist</strong> – shatter block into sparkling shards</li>
|
| <li><strong>Wave</strong> – pan the camera left/right</li>
|
| <li><strong>Thumbs up</strong> – duplicate the current block</li>
|
| </ul>
|
| </div>
|
|
|
| <video id="inputVideo" class="hidden" autoplay playsinline muted></video>
|
|
|
|
|
| <script type="module">
|
| import * as THREE from "https://unpkg.com/three@0.152.2/build/three.module.js";
|
| import { OrbitControls } from "https://unpkg.com/three@0.152.2/examples/jsm/controls/OrbitControls.js";
|
| </script>
|
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script>
|
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script>
|
|
|
| <script type="module">
|
| import * as THREE from "https://unpkg.com/three@0.152.2/build/three.module.js";
|
| import { OrbitControls } from "https://unpkg.com/three@0.152.2/examples/jsm/controls/OrbitControls.js";
|
|
|
| const root = document.getElementById("three-root");
|
| const statusEl = document.getElementById("status");
|
| const fallbackEl = document.getElementById("fallback");
|
| const videoEl = document.getElementById("inputVideo");
|
|
|
| const scene = new THREE.Scene();
|
| scene.fog = new THREE.FogExp2(0x04020b, 0.022);
|
|
|
| const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
| renderer.setSize(window.innerWidth, window.innerHeight);
|
| renderer.outputEncoding = THREE.sRGBEncoding;
|
| root.appendChild(renderer.domElement);
|
|
|
| const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.1, 200);
|
| camera.position.set(0, 1.2, 8);
|
|
|
| const fallbackControls = new OrbitControls(camera, renderer.domElement);
|
| fallbackControls.enabled = false;
|
| fallbackControls.enableDamping = true;
|
|
|
| const ambient = new THREE.AmbientLight(0xffffff, 0.6);
|
| scene.add(ambient);
|
| const keyLight = new THREE.DirectionalLight(0xb0d2ff, 0.85);
|
| keyLight.position.set(10, 12, 8);
|
| scene.add(keyLight);
|
|
|
| const raycaster = new THREE.Raycaster();
|
| const pointer = new THREE.Vector2();
|
| const clock = new THREE.Clock();
|
|
|
| const blocks = [];
|
| let selectedBlock = null;
|
| let highlightPulse = 0;
|
| let grabbing = false;
|
| let grabbedDistance = 0;
|
| let lastAngle = 0;
|
| let lastGesture = "";
|
| let lastThumbsUp = 0;
|
| let lastExplosion = 0;
|
| let lastWave = 0;
|
| let lastWristX = null;
|
|
|
| const particles = [];
|
|
|
| function createStarField() {
|
| const count = 1200;
|
| const positions = new Float32Array(count * 3);
|
| for (let i = 0; i < count; i++) {
|
| const radius = 50 * Math.random() + 10;
|
| const theta = Math.random() * Math.PI * 2;
|
| const phi = Math.acos(2 * Math.random() - 1);
|
| positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta);
|
| positions[i * 3 + 1] = radius * Math.sin(phi) * Math.sin(theta);
|
| positions[i * 3 + 2] = radius * Math.cos(phi);
|
| }
|
| const geometry = new THREE.BufferGeometry();
|
| geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
| const material = new THREE.PointsMaterial({
|
| color: 0xffffff,
|
| size: 0.2,
|
| transparent: true,
|
| opacity: 0.7,
|
| blending: THREE.AdditiveBlending,
|
| depthWrite: false
|
| });
|
| const stars = new THREE.Points(geometry, material);
|
| scene.add(stars);
|
| }
|
| createStarField();
|
|
|
| function randomColor() {
|
| const hues = [210, 250, 290];
|
| const h = hues[Math.floor(Math.random() * hues.length)] + THREE.MathUtils.randFloatSpread(10);
|
| return new THREE.Color(`hsl(${h}, 75%, 65%)`);
|
| }
|
|
|
| function createBlock(color = null, position = null) {
|
| const geometry = new THREE.BoxGeometry(1, 1, 1);
|
| const baseColor = color || randomColor();
|
| const material = new THREE.MeshPhysicalMaterial({
|
| color: baseColor,
|
| transmission: 0.7,
|
| opacity: 0.8,
|
| transparent: true,
|
| roughness: 0.05,
|
| metalness: 0.85,
|
| clearcoat: 1,
|
| clearcoatRoughness: 0.1,
|
| emissive: baseColor.clone().multiplyScalar(0.15)
|
| });
|
| const mesh = new THREE.Mesh(geometry, material);
|
| mesh.position.copy(position || new THREE.Vector3(
|
| THREE.MathUtils.randFloatSpread(6),
|
| THREE.MathUtils.randFloat(0.4, 3.2),
|
| THREE.MathUtils.randFloatSpread(6)
|
| ));
|
| mesh.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
|
| mesh.userData.base = mesh.position.clone();
|
| mesh.userData.phase = Math.random() * Math.PI * 2;
|
|
|
|
|
| const haloMaterial = new THREE.MeshBasicMaterial({
|
| color: baseColor,
|
| transparent: true,
|
| opacity: 0,
|
| side: THREE.BackSide,
|
| blending: THREE.AdditiveBlending
|
| });
|
| const halo = new THREE.Mesh(geometry.clone().scale(1.1, 1.1, 1.1), haloMaterial);
|
| halo.visible = false;
|
| mesh.add(halo);
|
| mesh.userData.halo = halo;
|
|
|
| scene.add(mesh);
|
| blocks.push(mesh);
|
| return mesh;
|
| }
|
|
|
| const initialCount = THREE.MathUtils.randInt(10, 15);
|
| for (let i = 0; i < initialCount; i++) createBlock();
|
|
|
| function selectBlockFromPointer() {
|
| raycaster.setFromCamera(pointer, camera);
|
| const intersects = raycaster.intersectObjects(blocks);
|
| if (intersects.length > 0) {
|
| highlightBlock(intersects[0].object);
|
| }
|
| }
|
|
|
| function highlightBlock(mesh) {
|
| if (selectedBlock === mesh) return;
|
| if (selectedBlock) selectedBlock.userData.halo.visible = false;
|
| selectedBlock = mesh;
|
| if (selectedBlock) selectedBlock.userData.halo.visible = true;
|
| }
|
|
|
| function updateHaloPulses(delta) {
|
| if (!selectedBlock) return;
|
| highlightPulse += delta * 3;
|
| const intensity = (Math.sin(highlightPulse) + 1) * 0.35 + 0.2;
|
| selectedBlock.userData.halo.material.opacity = intensity;
|
| }
|
|
|
| function beginGrab(angle) {
|
| grabbing = true;
|
| grabbedDistance = camera.position.distanceTo(selectedBlock.position);
|
| lastAngle = angle;
|
| }
|
|
|
| function endGrab() {
|
| grabbing = false;
|
| }
|
|
|
| function moveGrabbedBlock() {
|
| raycaster.setFromCamera(pointer, camera);
|
| const target = raycaster.ray.origin.clone().add(raycaster.ray.direction.clone().multiplyScalar(grabbedDistance));
|
| selectedBlock.position.lerp(target, 0.6);
|
| selectedBlock.userData.base.copy(selectedBlock.position);
|
| }
|
|
|
| function rotateGrabbedBlock(angle) {
|
| const delta = angle - lastAngle;
|
| selectedBlock.rotation.y += delta * 5;
|
| lastAngle = angle;
|
| }
|
|
|
| function explodeBlock(block) {
|
| const now = performance.now();
|
| if (!block || now - lastExplosion < 600) return;
|
| lastExplosion = now;
|
|
|
| const particleCount = 120;
|
| const positions = new Float32Array(particleCount * 3);
|
| const velocities = [];
|
| for (let i = 0; i < particleCount; i++) {
|
| const dir = new THREE.Vector3(
|
| THREE.MathUtils.randFloatSpread(1),
|
| THREE.MathUtils.randFloatSpread(1),
|
| THREE.MathUtils.randFloatSpread(1)
|
| ).normalize().multiplyScalar(THREE.MathUtils.randFloat(0.5, 2));
|
| velocities.push(dir);
|
| positions[i * 3] = block.position.x;
|
| positions[i * 3 + 1] = block.position.y;
|
| positions[i * 3 + 2] = block.position.z;
|
| }
|
| const pGeometry = new THREE.BufferGeometry();
|
| pGeometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
| const pMaterial = new THREE.PointsMaterial({
|
| size: 0.08,
|
| vertexColors: false,
|
| color: block.material.color,
|
| transparent: true,
|
| opacity: 0.9,
|
| blending: THREE.AdditiveBlending,
|
| depthWrite: false
|
| });
|
| const bundle = new THREE.Points(pGeometry, pMaterial);
|
| bundle.userData = { velocities, life: 1.2 };
|
| scene.add(bundle);
|
| particles.push(bundle);
|
|
|
| scene.remove(block);
|
| const idx = blocks.indexOf(block);
|
| if (idx !== -1) blocks.splice(idx, 1);
|
| selectedBlock = null;
|
| }
|
|
|
| function duplicateBlock() {
|
| const now = performance.now();
|
| if (!selectedBlock || now - lastThumbsUp < 900) return;
|
| lastThumbsUp = now;
|
| const clone = createBlock(selectedBlock.material.color.clone(), selectedBlock.position.clone().add(new THREE.Vector3(0.4, 0.4, -0.4)));
|
| highlightBlock(clone);
|
| }
|
|
|
| function panCamera(delta) {
|
| camera.position.x -= delta * 8;
|
| camera.lookAt(0, 1, 0);
|
| }
|
|
|
| function updateParticles(delta) {
|
| for (let i = particles.length - 1; i >= 0; i--) {
|
| const bundle = particles[i];
|
| const { velocities } = bundle.userData;
|
| bundle.userData.life -= delta;
|
| const positions = bundle.geometry.attributes.position.array;
|
| for (let j = 0; j < velocities.length; j++) {
|
| positions[j * 3] += velocities[j].x * delta * 3;
|
| positions[j * 3 + 1] += velocities[j].y * delta * 3;
|
| positions[j * 3 + 2] += velocities[j].z * delta * 3;
|
| velocities[j].multiplyScalar(0.98);
|
| }
|
| bundle.geometry.attributes.position.needsUpdate = true;
|
| bundle.material.opacity = Math.max(bundle.userData.life / 1.2, 0);
|
| if (bundle.userData.life <= 0) {
|
| scene.remove(bundle);
|
| particles.splice(i, 1);
|
| }
|
| }
|
| }
|
|
|
| function animateBlocks(time) {
|
| blocks.forEach((mesh) => {
|
| const t = time + mesh.userData.phase;
|
| mesh.position.y = mesh.userData.base.y + Math.sin(t) * 0.3;
|
| mesh.rotation.x += 0.002;
|
| mesh.rotation.z += 0.0015;
|
| });
|
| }
|
|
|
| function render() {
|
| const delta = clock.getDelta();
|
| const elapsed = clock.elapsedTime;
|
|
|
| animateBlocks(elapsed);
|
| updateHaloPulses(delta);
|
| updateParticles(delta);
|
|
|
| if (!handActive) {
|
| fallbackControls.enabled = true;
|
| fallbackControls.update();
|
| } else {
|
| fallbackControls.enabled = false;
|
| }
|
|
|
| renderer.render(scene, camera);
|
| requestAnimationFrame(render);
|
| }
|
| render();
|
|
|
|
|
|
|
| let handActive = false;
|
|
|
| function setPointerFromLandmark(lm) {
|
| pointer.x = lm.x * 2 - 1;
|
| pointer.y = -(lm.y * 2 - 1);
|
| }
|
|
|
| function distance(a, b) {
|
| return Math.hypot(a.x - b.x, a.y - b.y, (a.z - b.z) || 0);
|
| }
|
|
|
| function isFingerExtended(lm, tip, pip) {
|
| return lm[tip].y < lm[pip].y - 0.01;
|
| }
|
|
|
| function gestureClassifier(lm) {
|
| const pinch = distance(lm[4], lm[8]) < 0.035;
|
| const openPalm = [8, 12, 16, 20].every((tip, idx) => isFingerExtended(lm, tip, tip - 2));
|
| const fist = [8, 12, 16, 20].every((tip) => lm[tip].y > lm[tip - 2].y - 0.005);
|
| const thumbsUp =
|
| lm[4].y < lm[0].y - 0.05 &&
|
| [8, 12, 16, 20].every((tip) => lm[tip].y > lm[tip - 2].y + 0.015);
|
|
|
| return { pinch, openPalm, fist, thumbsUp };
|
| }
|
|
|
| function wristAngle(lm) {
|
| const index = lm[8];
|
| const pinky = lm[20];
|
| return Math.atan2(index.y - pinky.y, index.x - pinky.x);
|
| }
|
|
|
| function handleResults(results) {
|
| if (!results.multiHandLandmarks || !results.multiHandLandmarks.length) {
|
| handActive = false;
|
| statusEl.textContent = "Show your hand to control the blocks ✋";
|
| lastWristX = null;
|
| return;
|
| }
|
| handActive = true;
|
| statusEl.textContent = "";
|
| videoEl.classList.remove("hidden");
|
|
|
| const lm = results.multiHandLandmarks[0];
|
| setPointerFromLandmark(lm[8]);
|
|
|
| const { pinch, openPalm, fist, thumbsUp } = gestureClassifier(lm);
|
|
|
|
|
| if (lastWristX !== null) {
|
| const deltaX = lm[0].x - lastWristX;
|
| if (Math.abs(deltaX) > 0.07 && performance.now() - lastWave > 700) {
|
| lastWave = performance.now();
|
| panCamera(deltaX);
|
| }
|
| }
|
| lastWristX = lm[0].x;
|
|
|
| if (thumbsUp) {
|
| duplicateBlock();
|
| lastGesture = "thumbsup";
|
| } else if (fist) {
|
| explodeBlock(selectedBlock);
|
| lastGesture = "fist";
|
| } else if (pinch && selectedBlock) {
|
| const angle = wristAngle(lm);
|
| if (!grabbing) beginGrab(angle);
|
| moveGrabbedBlock();
|
| rotateGrabbedBlock(angle);
|
| lastGesture = "pinch";
|
| } else {
|
| if (grabbing) endGrab();
|
| if (openPalm && lastGesture !== "open") {
|
| selectBlockFromPointer();
|
| lastGesture = "open";
|
| } else if (!openPalm) {
|
| lastGesture = "";
|
| }
|
| }
|
| }
|
|
|
| const hands = new Hands({
|
| locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`,
|
| });
|
| hands.setOptions({
|
| maxNumHands: 1,
|
| minDetectionConfidence: 0.6,
|
| minTrackingConfidence: 0.5,
|
| modelComplexity: 1,
|
| });
|
| hands.onResults(handleResults);
|
|
|
| const mpCamera = new Camera(videoEl, {
|
| onFrame: async () => {
|
| await hands.send({ image: videoEl });
|
| },
|
| width: 640,
|
| height: 480,
|
| });
|
|
|
| mpCamera
|
| .start()
|
| .then(() => {
|
| statusEl.textContent = "Gesture controls ready – wave to explore.";
|
| })
|
| .catch((err) => {
|
| console.error(err);
|
| fallbackEl.hidden = false;
|
| statusEl.textContent = "";
|
| });
|
|
|
| window.addEventListener("resize", () => {
|
| camera.aspect = window.innerWidth / window.innerHeight;
|
| camera.updateProjectionMatrix();
|
| renderer.setSize(window.innerWidth, window.innerHeight);
|
| });
|
| </script>
|
| </body>
|
| </html> |