🎮ArcadeLab

Gravity Hands

by GoldenBear80
2171 lines33.6 KB
▶ Play
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

<title>Gravity Hands</title>

<style>

* {
    box-sizing: border-box;
}

body {
    margin: 0;
    min-height: 100vh;

    background:
        radial-gradient(circle at 50% 20%, #17132d, #07070d 50%, #020204);

    color: white;
    font-family: Arial, sans-serif;

    display: flex;
    flex-direction: column;
    align-items: center;
}

header {
    width: min(900px, 92vw);

    display: flex;
    justify-content: space-between;
    align-items: center;

    padding: 22px 0 10px;
}

.title {
    font-size: 32px;
    font-weight: 800;
    letter-spacing: 2px;

    background: linear-gradient(
        90deg,
        #a855f7,
        #6366f1,
        #00e5ff
    );

    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
}

.status {
    padding: 8px 14px;

    border-radius: 20px;

    background: rgba(0,255,255,.05);

    border: 1px solid rgba(0,255,255,.25);

    font-size: 13px;
}

.subtitle {
    color: #9999aa;
    margin: 4px 0 18px;
}

.subtitle span {
    color: #00e5ff;
}

#restartButton {
    display: none;

    position: absolute;

    left: 50%;
    top: 60%;

    transform: translate(-50%, -50%);

    z-index: 100;
    pointer-events: auto;

    padding: 14px 28px;

    border: 1px solid rgba(255,255,255,0.25);
    border-radius: 12px;

    background: rgba(20, 20, 30, 0.9);

    color: white;

    font-size: 16px;
    font-weight: 600;

    cursor: pointer;

    backdrop-filter: blur(10px);

    transition:
        transform 0.2s,
        background 0.2s;
}

#restartButton:hover {
    transform:
        translate(-50%, -50%)
        scale(1.05);

    background: rgba(40, 40, 55, 0.95);
}

#gameArea {
    position: relative;

    width: min(900px, 92vw);
    aspect-ratio: 16 / 10;

    overflow: hidden;

    border-radius: 22px;

    border: 1px solid rgba(0,220,255,.35);

    background: #030307;

    box-shadow:
        0 0 50px rgba(0,100,255,.15),
        inset 0 0 60px rgba(80,0,255,.08);
}

#webcam {
    position: absolute;

    width: 100%;
    height: 100%;

    object-fit: cover;

    transform: scaleX(-1);

    opacity: .35;
}

#canvas {
    position: absolute;

    inset: 0;

    width: 100%;
    height: 100%;

    z-index: 5;
}

#message {
    position: absolute;

    left: 50%;
    top: 50%;

    transform: translate(-50%, -50%);

    z-index: 20;

    text-align: center;

    pointer-events: none;

    font-size: 28px;
    font-weight: bold;

    text-shadow: 0 0 20px #00e5ff;

    opacity: 0;

    transition: opacity .3s;
}

#hud {
    width: min(900px, 92vw);

    display: grid;

    grid-template-columns: repeat(4, 1fr);

    gap: 10px;

    margin-top: 15px;
}

.card {
    background: rgba(255,255,255,.035);

    border: 1px solid rgba(255,255,255,.08);

    border-radius: 14px;

    padding: 12px 15px;
}

.label {
    font-size: 10px;

    color: #77778a;

    text-transform: uppercase;

    letter-spacing: 1px;

    margin-bottom: 5px;
}

.value {
    color: #00e5ff;

    font-weight: bold;
}

#gravityState.active {
    color: #c084fc;

    text-shadow: 0 0 12px #a855f7;
}

@media(max-width:650px) {

    #hud {
        grid-template-columns: repeat(2,1fr);
    }

    .title {
        font-size: 23px;
    }
}
/* Screen shake effect */
#gameArea.shake {
    animation: screenShake 0.35s ease;
}

@keyframes screenShake {

    0%   { transform: translate(0, 0); }
    15%  { transform: translate(-8px, 5px); }
    30%  { transform: translate(7px, -6px); }
    45%  { transform: translate(-6px, -4px); }
    60%  { transform: translate(5px, 6px); }
    75%  { transform: translate(-3px, -3px); }
    100% { transform: translate(0, 0); }

}
</style>
</head>


<body>

<header>

    <div class="title">
        🪐 GRAVITY HANDS
    </div>

    <div class="status">
        🟢 HAND TRACKER
    </div>

</header>

<div class="subtitle">
    Move your <span>finger</span> • Pinch to control gravity
</div>


<div id="gameArea">

    <video
        id="webcam"
        autoplay
        playsinline>
    </video>

    <canvas id="canvas"></canvas>

    <div id="message"></div>
    <button id="restartButton" >
    🔄 Restart Game
</button>

</div>


<div id="hud">

    <div class="card">

        <div class="label">
            Tracking
        </div>

        <div
            class="value"
            id="trackingState">
            SEARCHING
        </div>

    </div>


    <div class="card">

        <div class="label">
            Gravity
        </div>

        <div
            class="value"
            id="gravityState">
            READY
        </div>

    </div>

    <div class="card">

    <div class="label">
        Level
    </div>

    <div
        class="value"
        id="level">
        1
    </div>

</div>

<div class="card">

    <div class="label">
        Lives
    </div>

    <div
        class="value"
        id="lives">
        ❤️❤️❤️
    </div>

</div>

<div class="card">

    <div class="label">
        Time
    </div>

    <div
        class="value"
        id="timer">
        20
    </div>

</div>

    <div class="card">

        <div class="label">
            Score
        </div>

        <div
            class="value"
            id="score">
            0
        </div>

    </div>


    <div class="card">

        <div class="label">
            Goal
        </div>

        <div class="value">
            Reach Portal
        </div>

    </div>

</div>



<script type="module">

import {
    HandLandmarker,
    FilesetResolver
}
from
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/vision_bundle.mjs";


// =====================================================
// ELEMENTS
// =====================================================

const video =
    document.getElementById("webcam");

const canvas =
    document.getElementById("canvas");

const ctx =
    canvas.getContext("2d");

const trackingState =
    document.getElementById("trackingState");

const gravityState =
    document.getElementById("gravityState");

const scoreElement =
    document.getElementById("score");

const message =
    document.getElementById("message");


// =====================================================
// CANVAS
// =====================================================

function resizeCanvas() {

    const rect =
        canvas.getBoundingClientRect();

    canvas.width =
        rect.width;

    canvas.height =
        rect.height;

}

window.addEventListener(
    "resize",
    resizeCanvas
);

resizeCanvas();


// =====================================================
// GAME VARIABLES
// =====================================================

let handLandmarker;

let gravityActive = false;

let score = 0;

let level = 1;
let lives = 3;
let gameOver = false;
let mineCooldown = false;
let timeLeft = 20;
let timerRunning = false;
let timerInterval = null;

let gameWon = false;


// =====================================================
// BALL
// =====================================================

const ball = {

    x: 100,

    y: 100,

    vx: 0,

    vy: 0,

    radius: 15

};


// =====================================================
// PORTAL
// =====================================================

const portal = {

    x: 700,

    y: 300,

    radius: 42

};


// =====================================================
// PARTICLES
// =====================================================

const particles = [];

const mines = [];

// =====================================================
// RANDOM PORTAL
// =====================================================

function movePortal() {

    portal.x =
        80 +
        Math.random() *
        (canvas.width - 160);

    portal.y =
        80 +
        Math.random() *
        (canvas.height - 160);

}


function createMines(amount) {

    mines.length = 0;

    for (let i = 0; i < amount; i++) {

        let mine;

        // Keep trying until we find a reasonable position
        do {

            mine = {
                x: 70 + Math.random() * (canvas.width - 140),
                y: 70 + Math.random() * (canvas.height - 140),
                radius: 18
            };

        } while (
            Math.hypot(
                mine.x - ball.x,
                mine.y - ball.y
            ) < 120 ||

            Math.hypot(
                mine.x - portal.x,
                mine.y - portal.y
            ) < 120
        );

        mines.push(mine);
    }
}


//    Draw the mines 🔴

function drawMines() {

    const time =
        performance.now() * 0.003;

    for (const mine of mines) {

        const pulse =
            1 + Math.sin(
                time + mine.x
            ) * 0.12;


        // Outer glow

        const glow =
            ctx.createRadialGradient(
                mine.x,
                mine.y,
                0,
                mine.x,
                mine.y,
                45
            );

        glow.addColorStop(
            0,
            "rgba(255, 40, 70, 0.35)"
        );

        glow.addColorStop(
            0.5,
            "rgba(255, 0, 40, 0.12)"
        );

        glow.addColorStop(
            1,
            "rgba(255, 0, 0, 0)"
        );


        ctx.fillStyle = glow;

        ctx.beginPath();

        ctx.arc(
            mine.x,
            mine.y,
            45,
            0,
            Math.PI * 2
        );

        ctx.fill();


        // Mine body

        ctx.fillStyle = "#26050b";

        ctx.strokeStyle = "#ff304f";

        ctx.lineWidth = 2;

        ctx.shadowColor = "#ff1744";

        ctx.shadowBlur = 15;


        ctx.beginPath();

        ctx.arc(
            mine.x,
            mine.y,
            mine.radius * pulse,
            0,
            Math.PI * 2
        );

        ctx.fill();

        ctx.stroke();


        ctx.shadowBlur = 0;


        // Core

        ctx.fillStyle = "#ff304f";

        ctx.beginPath();

        ctx.arc(
            mine.x,
            mine.y,
            5,
            0,
            Math.PI * 2
        );

        ctx.fill();


        // Little warning spikes

        ctx.strokeStyle =
            "rgba(255,80,100,.7)";

        ctx.lineWidth = 2;

        for (let i = 0; i < 8; i++) {

            const angle =
                i * Math.PI / 4;

            const inner = 22;
            const outer = 28;

            ctx.beginPath();

            ctx.moveTo(
                mine.x +
                Math.cos(angle) * inner,

                mine.y +
                Math.sin(angle) * inner
            );

            ctx.lineTo(
                mine.x +
                Math.cos(angle) * outer,

                mine.y +
                Math.sin(angle) * outer
            );

            ctx.stroke();
        }
    }
}



// =====================================================
// RESET BALL
// =====================================================

function resetBall() {

    ball.x = 80;

    ball.y =
        80 +
        Math.random() *
        (canvas.height - 160);

    ball.vx = 0;

    ball.vy = 0;

    gameWon = false;

    message.style.opacity = 0;

}


// =====================================================
// PARTICLES
// =====================================================

function spawnParticle(
    x,
    y,
    color = "#00e5ff"
) {

    particles.push({

        x,

        y,

        vx:
            (Math.random() - .5) *
            2,

        vy:
            (Math.random() - .5) *
            2,

        life: 1,

        color

    });


    if (particles.length > 200) {

        particles.shift();

    }

}


function updateParticles() {

    for (
        let i = particles.length - 1;
        i >= 0;
        i--
    ) {

        const p =
            particles[i];

        p.x += p.vx;

        p.y += p.vy;

        p.life -= .025;


        ctx.globalAlpha =
            p.life;

        ctx.fillStyle =
            p.color;

        ctx.beginPath();

        ctx.arc(
            p.x,
            p.y,
            2,
            0,
            Math.PI * 2
        );

        ctx.fill();


        if (p.life <= 0) {

            particles.splice(i,1);

        }

    }

    ctx.globalAlpha = 1;

}


// =====================================================
// DRAW BALL
// =====================================================

function drawBall() {

    const glow =
        ctx.createRadialGradient(
            ball.x,
            ball.y,
            0,
            ball.x,
            ball.y,
            35
        );


    glow.addColorStop(
        0,
        "white"
    );

    glow.addColorStop(
        .25,
        "#00e5ff"
    );

    glow.addColorStop(
        1,
        "rgba(0,229,255,0)"
    );


    ctx.fillStyle =
        glow;


    ctx.beginPath();

    ctx.arc(
        ball.x,
        ball.y,
        35,
        0,
        Math.PI * 2
    );

    ctx.fill();


    ctx.fillStyle =
        "white";


    ctx.beginPath();

    ctx.arc(
        ball.x,
        ball.y,
        ball.radius,
        0,
        Math.PI * 2
    );

    ctx.fill();

}


// =====================================================
// DRAW PORTAL
// =====================================================

function drawPortal() {

    const time =
        performance.now() *
        .001;


    ctx.save();

    ctx.translate(
        portal.x,
        portal.y
    );


    ctx.rotate(
        time
    );


    ctx.strokeStyle =
        "#a855f7";

    ctx.lineWidth = 5;

    ctx.shadowColor =
        "#a855f7";

    ctx.shadowBlur = 25;


    ctx.beginPath();

    ctx.arc(
        0,
        0,
        portal.radius,
        0,
        Math.PI * 2
    );

    ctx.stroke();


    ctx.rotate(
        -time * 2
    );


    ctx.strokeStyle =
        "#00e5ff";

    ctx.lineWidth = 2;


    ctx.beginPath();

    ctx.arc(
        0,
        0,
        portal.radius - 10,
        0,
        Math.PI * 2
    );

    ctx.stroke();


    ctx.restore();


    // center

    const glow =
        ctx.createRadialGradient(
            portal.x,
            portal.y,
            0,
            portal.x,
            portal.y,
            portal.radius
        );


    glow.addColorStop(
        0,
        "rgba(168,85,247,.25)"
    );

    glow.addColorStop(
        1,
        "rgba(168,85,247,0)"
    );


    ctx.fillStyle =
        glow;


    ctx.beginPath();

    ctx.arc(
        portal.x,
        portal.y,
        portal.radius,
        0,
        Math.PI * 2
    );

    ctx.fill();

}


// =====================================================
// GRAVITY PHYSICS
// =====================================================

function updatePhysics(gravityX, gravityY) {
         if (gameOver) return;
    // ==========================================
    // MAGNETIC GRAVITY
    // ==========================================

    if (gravityActive) {

        const dx = gravityX - ball.x;
        const dy = gravityY - ball.y;

        const distance = Math.hypot(dx, dy);


        if (distance > 5) {

            // ----------------------------------
            // STRONG MAGNETIC PULL
            // ----------------------------------

            const strength =
                Math.min(
                    0.055,
                    0.018 + distance * 0.00012
                );


            ball.vx += dx * strength;
            ball.vy += dy * strength;


            // ----------------------------------
            // QUICKLY REMOVE OLD MOMENTUM
            // ----------------------------------

            ball.vx *= 0.86;
            ball.vy *= 0.86;

        }

    }

    else {

        // --------------------------------------
        // WHEN GRAVITY IS OFF
        // SLOW THE BALL DOWN
        // --------------------------------------

        ball.vx *= 0.94;
        ball.vy *= 0.94;

    }


    // ==========================================
    // SPEED LIMIT
    // ==========================================

    const maxSpeed = gravityActive
        ? 12
        : 7;


    const speed = Math.hypot(
        ball.vx,
        ball.vy
    );


    if (speed > maxSpeed) {

        ball.vx =
            (ball.vx / speed) *
            maxSpeed;

        ball.vy =
            (ball.vy / speed) *
            maxSpeed;

    }


    // ==========================================
    // MOVE BALL
    // ==========================================

    ball.x += ball.vx;
    ball.y += ball.vy;


    // ==========================================
    // WALL COLLISION
    // ==========================================

    if (
        ball.x <
        ball.radius
    ) {

        ball.x =
            ball.radius;

        ball.vx *= -0.65;

    }


    if (
        ball.x >
        canvas.width -
        ball.radius
    ) {

        ball.x =
            canvas.width -
            ball.radius;

        ball.vx *= -0.65;

    }


    if (
        ball.y <
        ball.radius
    ) {

        ball.y =
            ball.radius;

        ball.vy *= -0.65;

    }


    if (
        ball.y >
        canvas.height -
        ball.radius
    ) {

        ball.y =
            canvas.height -
            ball.radius;

        ball.vy *= -0.65;

    }


    // ==========================================
    // PORTAL COLLISION
    // ==========================================

    const portalDX =
        ball.x - portal.x;

    const portalDY =
        ball.y - portal.y;


    const portalDistance =
        Math.hypot(
            portalDX,
            portalDY
        );

    // ==========================================
// MINE COLLISION
// ==========================================

for (const mine of mines) {

    const dx =
        ball.x - mine.x;

    const dy =
        ball.y - mine.y;

    const distance =
        Math.hypot(dx, dy);


    if (
        distance <
        ball.radius + mine.radius
    ) {

        hitMine(mine);

        return;

    }
}
    if (
        portalDistance <
        portal.radius
    ) {

        winLevel();

    }

}


//  ================================================= 
function hitMine(mine) {

    if (mineCooldown) return;

mineCooldown = true;


    // Lose one life
    lives--;

    document.getElementById("lives").textContent =
        "❤️".repeat(lives) + "🖤".repeat(3 - lives);


    // ==========================================
    // SCREEN SHAKE
    // ==========================================

   // ==========================================
// SCREEN SHAKE
// ==========================================

const gameArea =
    document.getElementById("gameArea") ||
    document.querySelector("canvas")?.parentElement;

if (gameArea) {

    gameArea.classList.remove("shake");

    void gameArea.offsetWidth;

    gameArea.classList.add("shake");

    setTimeout(() => {
        gameArea.classList.remove("shake");
    }, 400);
}


    // ==========================================
    // RED FLASH
    // ==========================================

    const flash =
        document.createElement("div");

    flash.style.position = "absolute";
    flash.style.inset = "0";
    flash.style.background =
        "rgba(255, 30, 50, 0.28)";
    flash.style.pointerEvents = "none";
    flash.style.zIndex = "15";

    gameArea.appendChild(flash);


    setTimeout(() => {

        flash.style.transition =
            "opacity .3s";

        flash.style.opacity = "0";

        setTimeout(() => {
            flash.remove();
        }, 300);

    }, 50);


    // ==========================================
    // EXPLOSION PARTICLES
    // ==========================================

    for (let i = 0; i < 80; i++) {

        const angle =
            Math.random() * Math.PI * 2;

        const speed =
            2 + Math.random() * 8;

        particles.push({

            x: mine.x,
            y: mine.y,

            vx:
                Math.cos(angle) * speed,

            vy:
                Math.sin(angle) * speed,

            life:
                0.7 + Math.random() * 0.5,

            color:
                i % 4 === 0
                    ? "#ffffff"
                    : i % 4 === 1
                    ? "#ffb000"
                    : i % 4 === 2
                    ? "#ff304f"
                    : "#ff6b35"
        });
    }


    // ==========================================
    // STOP BALL
    // ==========================================

    ball.vx = 0;
    ball.vy = 0;


    // ==========================================
    // GAME OVER
    // ==========================================

    if (lives <= 0) {

    gameOver = true;

    timerRunning = false;

    clearInterval(timerInterval);

    ball.vx = 0;
    ball.vy = 0;

    message.innerHTML =
        "💀 GAME OVER<br>" +
        "<small>All lives lost</small>";

    message.style.opacity = 1;

       document.getElementById("restartButton")
    .style.display = "block";

    return;
}


    // ==========================================
    // RESET AFTER HIT
    // ==========================================

    message.innerHTML =
        "💥 MINE HIT!<br>" +
        "<small>❤️ " + lives +
        " lives remaining</small>";

    message.style.opacity = 1;


    setTimeout(() => {

    resetBall();

    message.style.opacity = 0;

    mineCooldown = false;

}, 900);
}
 
///////////////////////////////////////////


function startTimer() {

    if (gameOver) return;

    clearInterval(timerInterval);

    timeLeft = Math.max(
        20 - (level - 1) * 2,
        8
    );

    timerRunning = true;

    document.getElementById("timer").textContent =
        timeLeft;

    timerInterval = setInterval(() => {

        if (!timerRunning) return;

        timeLeft--;

        document.getElementById("timer").textContent =
            timeLeft;

        if (timeLeft <= 0) {

            timerRunning = false;

            clearInterval(timerInterval);

            timeUp();
        }

    }, 1000);
}

////////////////////////////////

function restartGame() {

    console.log("RESTART CLICKED");

    // Stop current timer
    clearInterval(timerInterval);

    // Reset game state
    lives = 3;
    level = 1;
    score = 0;

    gameOver = false;
    mineCooldown = false;
    timerRunning = false;

    // Reset HUD
    document.getElementById("lives").textContent =
        "❤️❤️❤️";

    document.getElementById("level").textContent =
        "1";

    scoreElement.textContent =
        "0";

    // Hide game-over UI
    message.style.opacity = 0;

    document.getElementById("restartButton")
        .style.display = "none";

    // Reset game objects
    resetBall();

    movePortal();

    createMines(3);

    // Start fresh timer
    startTimer();

    console.log("GAME RESTARTED");
}

const restartButton =
    document.getElementById("restartButton");

restartButton.addEventListener(
    "click",
    restartGame
);

////////////////////////////////
function timeUp() {

    message.innerHTML =
        "⏱️ TIME'S UP!<br><small>Try again...</small>";

    message.style.opacity = 1;

    ball.vx = 0;
    ball.vy = 0;


    setTimeout(() => {

        resetBall();

        startTimer();

    }, 1000);
}

// =====================================================
// WIN
// =====================================================

function winLevel() {
    
    if (gameOver) return;
    if (gameWon) return;

    gameWon = true;

    score++;

level++;

scoreElement.textContent =
    score;

document.getElementById("level")
    .textContent = level;

timerRunning = false;

clearInterval(timerInterval);

    scoreElement.textContent =
        score;


    message.innerHTML =
        "🌀 PORTAL REACHED!<br><small>Next level...</small>";

    message.style.opacity = 1;


    for (
        let i = 0;
        i < 50;
        i++
    ) {

        spawnParticle(
            portal.x,
            portal.y,
            i % 2
                ? "#00e5ff"
                : "#a855f7"
        );

    }


    setTimeout(() => {

    movePortal();

    resetBall();

    createMines(
        Math.min(3 + score, 8)
    );

     startTimer();

}, 1200);

}


// =====================================================
// GRAVITY ORB
// =====================================================

function drawGravityOrb(
    x,
    y,
    active
) {

    const time =
        performance.now() *
        .001;


    const outer =
        active
            ? 105
            : 75;


    const glow =
        ctx.createRadialGradient(
            x,
            y,
            0,
            x,
            y,
            outer
        );


    glow.addColorStop(
        0,
        active
            ? "rgba(168,85,247,.40)"
            : "rgba(0,229,255,.25)"
    );


    glow.addColorStop(
        .4,
        "rgba(99,102,241,.12)"
    );


    glow.addColorStop(
        1,
        "rgba(0,0,0,0)"
    );


    ctx.fillStyle =
        glow;


    ctx.beginPath();

    ctx.arc(
        x,
        y,
        outer,
        0,
        Math.PI * 2
    );

    ctx.fill();


    // target ring

    ctx.save();

    ctx.translate(
        x,
        y
    );

    ctx.rotate(
        time * .5
    );


    ctx.strokeStyle =
        active
            ? "#c084fc"
            : "#00e5ff";


    ctx.lineWidth = 2;


    ctx.beginPath();

    ctx.arc(
        0,
        0,
        active ? 62 : 52,
        0,
        Math.PI * 2
    );

    ctx.stroke();


    // ticks

    for (
        let i = 0;
        i < 12;
        i++
    ) {

        const a =
            i *
            Math.PI *
            2 /
            12;


        const r1 =
            active ? 62 : 52;

        const r2 =
            active ? 70 : 60;


        ctx.beginPath();

        ctx.moveTo(
            Math.cos(a) * r1,
            Math.sin(a) * r1
        );

        ctx.lineTo(
            Math.cos(a) * r2,
            Math.sin(a) * r2
        );

        ctx.stroke();

    }


    ctx.restore();


    // rotating ellipse

    ctx.save();

    ctx.translate(
        x,
        y
    );

    ctx.rotate(
        time
    );


    ctx.strokeStyle =
        "#a855f7";

    ctx.lineWidth = 3;

    ctx.shadowColor =
        "#a855f7";

    ctx.shadowBlur = 15;


    ctx.beginPath();

    ctx.ellipse(
        0,
        0,
        active ? 48 : 40,
        active ? 15 : 12,
        0,
        0,
        Math.PI * 2
    );

    ctx.stroke();

    ctx.restore();


    // second ring

    ctx.save();

    ctx.translate(
        x,
        y
    );

    ctx.rotate(
        -time * 1.5
    );


    ctx.strokeStyle =
        "#00e5ff";

    ctx.lineWidth = 2;


    ctx.beginPath();

    ctx.ellipse(
        0,
        0,
        active ? 34 : 29,
        active ? 10 : 8,
        0,
        0,
        Math.PI * 2
    );

    ctx.stroke();

    ctx.restore();


    // central orb

    const pulse =
        (active ? 18 : 14) +
        Math.sin(time * 5) *
        3;


    const orb =
        ctx.createRadialGradient(
            x - 4,
            y - 4,
            1,
            x,
            y,
            pulse
        );


    orb.addColorStop(
        0,
        "#fff"
    );

    orb.addColorStop(
        .25,
        "#00ffff"
    );

    orb.addColorStop(
        .6,
        active
            ? "#a855f7"
            : "#6366f1"
    );

    orb.addColorStop(
        1,
        "rgba(100,0,255,0)"
    );


    ctx.fillStyle =
        orb;


    ctx.shadowColor =
        active
            ? "#a855f7"
            : "#00e5ff";

    ctx.shadowBlur =
        active ? 35 : 25;


    ctx.beginPath();

    ctx.arc(
        x,
        y,
        pulse,
        0,
        Math.PI * 2
    );

    ctx.fill();

    ctx.shadowBlur = 0;


    ctx.fillStyle =
        "white";


    ctx.beginPath();

    ctx.arc(
        x,
        y,
        3,
        0,
        Math.PI * 2
    );

    ctx.fill();

}


// =====================================================
// HAND TRACKING
// =====================================================

let lastVideoTime = -1;

let handX = null;

let handY = null;


function detectHands() {

    ctx.clearRect(
        0,
        0,
        canvas.width,
        canvas.height
    );


    updateParticles();


    if (
        video.readyState >= 2 &&
        video.currentTime !==
        lastVideoTime
    ) {

        lastVideoTime =
            video.currentTime;


        const results =
            handLandmarker
                .detectForVideo(
                    video,
                    performance.now()
                );


        if (
            results.landmarks &&
            results.landmarks.length
        ) {

            const landmarks =
                results.landmarks[0];


            const index =
                landmarks[8];

            const thumb =
                landmarks[4];


            handX =
                (1 - index.x) *
                canvas.width;

            handY =
                index.y *
                canvas.height;


            const distance =
                Math.hypot(
                    thumb.x - index.x,
                    thumb.y - index.y
                );


            gravityActive =
                distance < .07;


            trackingState.textContent =
                "ACTIVE";


            if (gravityActive) {

                gravityState.textContent =
                    "ACTIVE";

                gravityState.classList
                    .add("active");

            }

            else {

                gravityState.textContent =
                    "READY";

                gravityState.classList
                    .remove("active");

            }


            drawGravityOrb(
                handX,
                handY,
                gravityActive
            );


            updatePhysics(
                handX,
                handY
            );

        }

        else {

            trackingState.textContent =
                "SEARCHING";


            gravityActive = false;


            gravityState.textContent =
                "READY";


            gravityState.classList
                .remove("active");


            updatePhysics(
                canvas.width / 2,
                canvas.height / 2
            );

        }

    }


    drawMines();

    drawPortal();

    drawBall();


    requestAnimationFrame(
        detectHands
    );

}


// =====================================================
// MEDIAPIPE
// =====================================================

async function createHandTracker() {

    console.log(
        "Loading MediaPipe..."
    );


    const vision =
        await FilesetResolver
            .forVisionTasks(
                "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm"
            );


    handLandmarker =
        await HandLandmarker
            .createFromOptions(
                vision,
                {

                    baseOptions: {

                        modelAssetPath:
                            "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task"

                    },

                    runningMode:
                        "VIDEO",

                    numHands: 1

                }
            );


    console.log(
        "🖐️ Hand tracker ready!"
    );


    try {

        const stream =
            await navigator.mediaDevices
                .getUserMedia({
                    video: true
                });


        video.srcObject =
            stream;


        console.log(
            "Camera is working!"
        );


        video.addEventListener(
            "loadeddata",
            () => {

                detectHands();

            },
            { once: true }
        );


    }

    catch (error) {

        console.error(
            "Camera error:",
            error
        );

    }

}


// =====================================================
// START
// =====================================================

movePortal();

resetBall();

createMines(3);

startTimer();

createHandTracker();

</script>

</body>
</html>

Game Source: Gravity Hands

Creator: GoldenBear80

Libraries: none

Complexity: complex (2171 lines, 33.6 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: gravity-hands-goldenbear80" to link back to the original. Then publish at arcadelab.ai/publish.