Spaces:
Running
Running
| <html> | |
| <head> | |
| <meta charset="utf-8"> | |
| <title>MediaPipe Wrecking Ball & Block Stacker</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| background-color: #222; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| height: 100vh; | |
| } | |
| #canvasContainer { | |
| position: relative; | |
| width: 800px; | |
| height: 600px; | |
| box-shadow: 0 0 15px rgba(0,0,0,0.6); | |
| overflow: hidden; | |
| background-color: #333; | |
| } | |
| #videoInput { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| object-fit: cover; | |
| z-index: 0; | |
| transform: scaleX(-1); | |
| } | |
| #handGestureOverlay { | |
| position: absolute; | |
| top: 0; | |
| left: 0; | |
| width: 100%; | |
| height: 100%; | |
| pointer-events: none; | |
| z-index: 2; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="canvasContainer"> | |
| <video id="videoInput" autoplay playsinline></video> | |
| <canvas id="handGestureOverlay"></canvas> | |
| </div> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script> | |
| <script> | |
| const videoElement = document.getElementById('videoInput'); | |
| const canvasContainer = document.getElementById('canvasContainer'); | |
| const handGestureOverlayCanvas = document.getElementById('handGestureOverlay'); | |
| const handGestureOverlayCtx = handGestureOverlayCanvas.getContext('2d'); | |
| const RENDER_WIDTH = 800; | |
| const RENDER_HEIGHT = 600; | |
| handGestureOverlayCanvas.width = RENDER_WIDTH; | |
| handGestureOverlayCanvas.height = RENDER_HEIGHT; | |
| const MIRROR_COORDS_FOR_DISPLAY_AND_PHYSICS = true; | |
| let handWorldX = 0, handWorldY = 0; | |
| let isPinching = false; | |
| let grabbedBody = null; | |
| let grabConstraint = null; | |
| let blockGrabOffset = {x: 0, y: 0}; | |
| const PINCH_THRESHOLD_PX = 45; | |
| const GRAB_RADIUS_BALL_PX = 70; | |
| const GRAB_RADIUS_BLOCK_PX = 45; | |
| const GRAB_STIFFNESS = 0.4; | |
| const LANDMARK_RADIUS = 12; | |
| const LANDMARK_COLOR = '#00BCD4'; | |
| const hands = new Hands({locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`}); | |
| hands.setOptions({ | |
| maxNumHands: 1, | |
| modelComplexity: 1, | |
| minDetectionConfidence: 0.65, | |
| minTrackingConfidence: 0.65 | |
| }); | |
| const { Engine, Render, Runner, Composites, Composite, Constraint, Bodies, Body, Events, Vector, Bounds } = Matter; | |
| var Example = Example || {}; | |
| Example.wreckingBallAndBlocks = function() { | |
| var engine = Engine.create(), world = engine.world; | |
| var render = Render.create({ | |
| element: canvasContainer, | |
| engine: engine, | |
| options: { | |
| width: RENDER_WIDTH, | |
| height: RENDER_HEIGHT, | |
| wireframes: false, | |
| background: 'transparent', | |
| showAngleIndicator: false | |
| } | |
| }); | |
| if (render.canvas) { | |
| render.canvas.style.position = 'absolute'; | |
| render.canvas.style.top = '0'; | |
| render.canvas.style.left = '0'; | |
| render.canvas.style.zIndex = '1'; | |
| } | |
| Render.run(render); | |
| var runner = Runner.create(); | |
| Runner.run(runner, engine); | |
| var rows = 12; // Taller tower | |
| var blockHeight = 45; | |
| var blockWidth = 45; | |
| var stackStartX = RENDER_WIDTH * 0.65; | |
| var yy = RENDER_HEIGHT - 15 - blockHeight * rows; | |
| var stack = Composites.stack(stackStartX, yy, 5, rows, 0, 0, function(x, y) { | |
| return Bodies.rectangle(x, y, blockWidth, blockHeight, { | |
| render: { | |
| fillStyle: '#27ae60', | |
| strokeStyle: '#fff', // White boundary | |
| lineWidth: 2 // Make boundary visible | |
| }, | |
| friction: 0.7, | |
| restitution: 0.05 | |
| }); | |
| }); | |
| var ball = Bodies.circle(RENDER_WIDTH * 0.25, RENDER_HEIGHT * 0.65, 40, { | |
| density: 0.04, | |
| frictionAir: 0.005, | |
| render: { fillStyle: '#c0392b' } | |
| }); | |
| Composite.add(world, [ | |
| stack, | |
| ball, | |
| Constraint.create({ | |
| pointA: { x: RENDER_WIDTH * 0.35, y: RENDER_HEIGHT * 0.1 }, | |
| bodyB: ball, | |
| stiffness: 0.04, | |
| damping: 0.05, | |
| length: RENDER_HEIGHT * 0.55, | |
| render: { strokeStyle: '#7f8c8d', lineWidth: 2 } | |
| }), | |
| Bodies.rectangle(RENDER_WIDTH/2, -25, RENDER_WIDTH, 50, { isStatic: true, render: { fillStyle: '#333'} }), | |
| Bodies.rectangle(RENDER_WIDTH/2, RENDER_HEIGHT + 25, RENDER_WIDTH, 50, { isStatic: true, render: { fillStyle: '#333'} }), | |
| Bodies.rectangle(RENDER_WIDTH + 25, RENDER_HEIGHT/2, 50, RENDER_HEIGHT, { isStatic: true, render: { fillStyle: '#333'} }), | |
| Bodies.rectangle(-25, RENDER_HEIGHT/2, 50, RENDER_HEIGHT, { isStatic: true, render: { fillStyle: '#333'} }) | |
| ]); | |
| Events.on(engine, 'beforeUpdate', function(event) { | |
| if (!ball || !stack || !stack.bodies) return; | |
| if (isPinching) { | |
| if (!grabbedBody) { | |
| const distToBall = Vector.magnitude(Vector.sub(ball.position, {x: handWorldX, y: handWorldY})); | |
| if (distToBall < ball.circleRadius + GRAB_RADIUS_BALL_PX / 2) { | |
| grabbedBody = ball; | |
| blockGrabOffset = {x:0, y:0}; | |
| } else { | |
| for (let i = stack.bodies.length - 1; i >= 0; i--) { | |
| const body = stack.bodies[i]; | |
| if (Bounds.contains(body.bounds, {x: handWorldX, y: handWorldY})) { | |
| const bodyCurrentWidth = body.bounds.max.x - body.bounds.min.x; | |
| const bodyCurrentHeight = body.bounds.max.y - body.bounds.min.y; | |
| if (Vector.magnitude(Vector.sub(body.position, {x: handWorldX, y: handWorldY})) < Math.max(bodyCurrentWidth, bodyCurrentHeight) / 1.5 + GRAB_RADIUS_BLOCK_PX) { | |
| grabbedBody = body; | |
| blockGrabOffset = Vector.sub({x: handWorldX, y: handWorldY}, body.position); | |
| Body.setAngularVelocity(grabbedBody, 0); | |
| Body.setVelocity(grabbedBody, {x:0, y:0}); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| if (grabbedBody) { | |
| grabConstraint = Constraint.create({ | |
| pointA: { x: handWorldX, y: handWorldY }, | |
| bodyB: grabbedBody, | |
| pointB: grabbedBody === ball ? {x:0, y:0} : blockGrabOffset, | |
| length: grabbedBody === ball ? 0 : Vector.magnitude(blockGrabOffset) * 0.1, | |
| stiffness: GRAB_STIFFNESS, | |
| damping: 0.15, | |
| render: { visible: false } | |
| }); | |
| Composite.add(world, grabConstraint); | |
| } | |
| } else { | |
| if (grabConstraint) { | |
| grabConstraint.pointA.x = handWorldX; | |
| grabConstraint.pointA.y = handWorldY; | |
| } | |
| } | |
| } else { | |
| if (grabbedBody && grabConstraint) { | |
| Composite.remove(world, grabConstraint); | |
| grabConstraint = null; | |
| grabbedBody = null; | |
| } | |
| } | |
| }); | |
| Render.lookAt(render, { min: { x: 0, y: 0 }, max: { x: RENDER_WIDTH, y: RENDER_HEIGHT }}); | |
| return { engine, runner, render, canvas: render.canvas, stop: () => { Render.stop(render); Runner.stop(runner); camera.stop(); } }; | |
| }; | |
| function onHandResults(results) { | |
| handGestureOverlayCtx.clearRect(0, 0, RENDER_WIDTH, RENDER_HEIGHT); | |
| if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) { | |
| const landmarks = results.multiHandLandmarks[0]; | |
| const thumbTipLandmark = landmarks[4]; | |
| const indexTipLandmark = landmarks[8]; | |
| if (thumbTipLandmark && indexTipLandmark) { | |
| let thumbDrawX = thumbTipLandmark.x * RENDER_WIDTH; | |
| let indexDrawX = indexTipLandmark.x * RENDER_WIDTH; | |
| let thumbPhysX = thumbTipLandmark.x * RENDER_WIDTH; | |
| let indexPhysX = indexTipLandmark.x * RENDER_WIDTH; | |
| if (MIRROR_COORDS_FOR_DISPLAY_AND_PHYSICS) { | |
| thumbDrawX = (1 - thumbTipLandmark.x) * RENDER_WIDTH; | |
| indexDrawX = (1 - indexTipLandmark.x) * RENDER_WIDTH; | |
| thumbPhysX = (1 - thumbTipLandmark.x) * RENDER_WIDTH; | |
| indexPhysX = (1 - indexTipLandmark.x) * RENDER_WIDTH; | |
| } | |
| const thumbDrawY = thumbTipLandmark.y * RENDER_HEIGHT; | |
| const indexDrawY = indexTipLandmark.y * RENDER_HEIGHT; | |
| const thumbPhysY = thumbTipLandmark.y * RENDER_HEIGHT; | |
| const indexPhysY = indexTipLandmark.y * RENDER_HEIGHT; | |
| [ {x: thumbDrawX, y: thumbDrawY}, {x: indexDrawX, y: indexDrawY} ].forEach(p => { | |
| handGestureOverlayCtx.beginPath(); | |
| handGestureOverlayCtx.arc(p.x, p.y, LANDMARK_RADIUS, 0, 2 * Math.PI); | |
| handGestureOverlayCtx.fillStyle = LANDMARK_COLOR; | |
| handGestureOverlayCtx.fill(); | |
| handGestureOverlayCtx.strokeStyle = 'rgba(255,255,255,0.3)'; | |
| handGestureOverlayCtx.lineWidth = 1.5; | |
| handGestureOverlayCtx.stroke(); | |
| }); | |
| const distance = Math.hypot(thumbPhysX - indexPhysX, thumbPhysY - indexPhysY); | |
| handWorldX = (thumbPhysX + indexPhysX) / 2; | |
| handWorldY = (thumbPhysY + indexPhysY) / 2; | |
| isPinching = (distance < PINCH_THRESHOLD_PX); | |
| } else { | |
| isPinching = false; | |
| } | |
| } else { | |
| isPinching = false; | |
| } | |
| } | |
| hands.onResults(onHandResults); | |
| const camera = new Camera(videoElement, { | |
| onFrame: async () => { | |
| if (videoElement.readyState >= HTMLMediaElement.HAVE_ENOUGH_DATA && videoElement.videoWidth > 0) { | |
| await hands.send({image: videoElement}); | |
| } | |
| }, | |
| width: 640, | |
| height: 480 | |
| }); | |
| camera.start(); | |
| Example.wreckingBallAndBlocks.title = 'MediaPipe Wrecking Ball & Blocks (Adjusted)'; | |
| const gameInstance = Example.wreckingBallAndBlocks(); | |
| window.addEventListener('beforeunload', () => { | |
| if (gameInstance && gameInstance.stop) gameInstance.stop(); | |
| }); | |
| </script> | |
| </body> | |
| </html> |