From 5283293dfb52f748641cb82c8abcbe6081762494 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:56:43 +0000 Subject: [PATCH] feat: add blink-to-jump mechanic - Add detectBlink() to tracker.js using MediaPipe eye landmarks (159/145, 386/374) normalized by inter-eye distance, with smoothing and 500ms cooldown - Add jump physics to physics.js with upward impulse (6.0), gravity arc, and landing - Skip obstacle/turtle collisions while airborne; allow edge falls and lateral steering - Integrate blink detection in game loop (main.js) Co-Authored-By: bot_apk --- js/main.js | 7 ++++--- js/physics.js | 34 ++++++++++++++++++++++++++++++---- js/tracker.js | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) diff --git a/js/main.js b/js/main.js index 7fe3de4..dbddd39 100644 --- a/js/main.js +++ b/js/main.js @@ -16,7 +16,7 @@ import { updateCoinRotation, } from './renderer.js'; -import { initTracker, calibrate, detectTilt, detectPitch, detectMouthOpen, resetTilt } from './tracker.js'; +import { initTracker, calibrate, detectTilt, detectPitch, detectMouthOpen, detectBlink, resetTilt } from './tracker.js'; import { initPhysics, updatePhysics, resetBall, updateLevelData } from './physics.js'; const overlay = document.getElementById('overlay'); @@ -273,10 +273,11 @@ function gameLoop(timestamp) { lastTime = timestamp; if (state === 'playing' || state === 'falling') { - // Get head tilt, pitch, and mouth-open state + // Get head tilt, pitch, mouth-open, and blink state const tiltAngle = detectTilt(timestamp); const pitch = detectPitch(); const mouthOpen = detectMouthOpen(); + const blinkDetected = detectBlink(); // Update rolling track chunks based on current ball position updateRollingTrack(currentBallZ); @@ -285,7 +286,7 @@ function gameLoop(timestamp) { updateLevelData(getActiveObstacles(), getActiveCoins(), getActiveTurtles()); // Update physics - const result = updatePhysics(dt, tiltAngle, pitch, mouthOpen); + const result = updatePhysics(dt, tiltAngle, pitch, mouthOpen, blinkDetected); currentBallZ = result.z; // Update renderer diff --git a/js/physics.js b/js/physics.js index 9360e94..963e31a 100644 --- a/js/physics.js +++ b/js/physics.js @@ -5,6 +5,7 @@ const FORWARD_SPEED = 4.5; const PITCH_SENSITIVITY = 3.0; const MAX_SPEED = 11.0; const MOUTH_BOOST_MULTIPLIER = 1.8; // Speed multiplier when mouth is open +const JUMP_IMPULSE = 6.0; // Upward velocity for jump (clears ~1.84 units) const MAX_DT = 1 / 30; // Cap delta time to prevent physics explosions const COIN_COLLECT_RADIUS = 0.8; const TURTLE_COLLECT_RADIUS = 0.8; @@ -41,6 +42,7 @@ export function resetBall() { vy: 0, vz: FORWARD_SPEED, falling: false, + jumping: false, }; collectedCoinIds = new Set(); collectedTurtleIds = new Set(); @@ -54,17 +56,17 @@ export function updateLevelData(newObstacles, newCoins, newTurtles) { turtles = newTurtles; } -export function updatePhysics(dt, tiltAngle, pitch, mouthOpen) { +export function updatePhysics(dt, tiltAngle, pitch, mouthOpen, blinkDetected) { dt = Math.min(dt, MAX_DT); if (ball.falling) { return updateFalling(dt); } - return updateOnTrack(dt, tiltAngle, pitch, mouthOpen); + return updateOnTrack(dt, tiltAngle, pitch, mouthOpen, blinkDetected); } -function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { +function updateOnTrack(dt, tiltAngle, pitch, mouthOpen, blinkDetected) { // Decrement slowdown timer if (slowdownActive) { slowdownTimer -= dt; @@ -93,20 +95,43 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { const pitchVal = pitch || 0; ball.vz = Math.max(0, Math.min(effectiveMax, effectiveForward * (1 + pitchVal * PITCH_SENSITIVITY))); + // Trigger jump on blink if ball is on track (not already jumping or falling) + if (blinkDetected && !ball.jumping && !ball.falling) { + ball.jumping = true; + ball.vy = JUMP_IMPULSE; + } + + // Apply jump physics (gravity and vertical movement) + const trackSurface = trackConfig.trackHeight / 2 + trackConfig.ballRadius; + if (ball.jumping) { + ball.vy -= GRAVITY * dt; + ball.y += ball.vy * dt; + + // Land when ball returns to track surface + if (ball.y <= trackSurface) { + ball.y = trackSurface; + ball.vy = 0; + ball.jumping = false; + } + } + // Update position ball.x += ball.vx * dt; ball.z += ball.vz * dt; // Track boundaries -- check if ball center has gone past track edge + // (can still fall off edges during a jump) const halfWidth = trackConfig.trackWidth / 2; if (Math.abs(ball.x) > halfWidth) { ball.falling = true; + ball.jumping = false; ball.vy = 0; } // Obstacle collision -- AABB check with ball radius margin + // Skip obstacle collisions while airborne from a jump let obstacleHit = false; - if (!ball.falling) { + if (!ball.falling && !ball.jumping) { const br = trackConfig.ballRadius; for (let i = 0; i < obstacles.length; i++) { const o = obstacles[i]; @@ -160,6 +185,7 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { vx: ball.vx, vz: ball.vz, falling: ball.falling, + jumping: ball.jumping, needsReset: false, obstacleHit, coinsCollected: newlyCollected, diff --git a/js/tracker.js b/js/tracker.js index cf50829..94ad151 100644 --- a/js/tracker.js +++ b/js/tracker.js @@ -11,6 +11,11 @@ const FOREHEAD = 10; // Landmark indices for mouth-open detection const UPPER_LIP = 13; const LOWER_LIP = 14; +// Landmark indices for blink detection (upper/lower eyelid) +const LEFT_EYE_UPPER = 159; +const LEFT_EYE_LOWER = 145; +const RIGHT_EYE_UPPER = 386; +const RIGHT_EYE_LOWER = 374; let faceLandmarker = null; let videoElement = null; @@ -20,7 +25,11 @@ let rawPitch = 0; let smoothedPitch = 0; let rawMouthOpen = 0; let smoothedMouthOpen = 0; +let rawEyeOpen = 1; +let smoothedEyeOpen = 1; +let lastBlinkTime = 0; const SMOOTHING_FACTOR = 0.7; +const BLINK_COOLDOWN_MS = 500; // Calibration offset: the face X position at neutral/center let calibrationOffset = 0.5; @@ -110,6 +119,18 @@ export function detectTilt(timestamp) { const eyeDist = Math.abs(rightEye.x - leftEye.x); rawMouthOpen = eyeDist > 0.01 ? mouthDist / eyeDist : 0; smoothedMouthOpen = smoothedMouthOpen * SMOOTHING_FACTOR + rawMouthOpen * (1 - SMOOTHING_FACTOR); + + // Compute eye openness from upper/lower eyelid distances, + // averaged across both eyes and normalized by inter-eye distance + const leftEyeUpper = landmarks[LEFT_EYE_UPPER]; + const leftEyeLower = landmarks[LEFT_EYE_LOWER]; + const rightEyeUpper = landmarks[RIGHT_EYE_UPPER]; + const rightEyeLower = landmarks[RIGHT_EYE_LOWER]; + const leftEyeDist = Math.abs(leftEyeLower.y - leftEyeUpper.y); + const rightEyeDist = Math.abs(rightEyeLower.y - rightEyeUpper.y); + const avgEyeDist = (leftEyeDist + rightEyeDist) / 2; + rawEyeOpen = eyeDist > 0.01 ? avgEyeDist / eyeDist : 1; + smoothedEyeOpen = smoothedEyeOpen * SMOOTHING_FACTOR + rawEyeOpen * (1 - SMOOTHING_FACTOR); } return smoothedTilt; @@ -126,6 +147,18 @@ export function detectMouthOpen() { return smoothedMouthOpen > MOUTH_OPEN_THRESHOLD; } +export function detectBlink() { + // Threshold for eye openness normalized by inter-eye distance; + // typical open eye ~0.06-0.08, closed eye ~0.01-0.02 + const BLINK_THRESHOLD = 0.04; + const now = performance.now(); + if (smoothedEyeOpen < BLINK_THRESHOLD && now - lastBlinkTime > BLINK_COOLDOWN_MS) { + lastBlinkTime = now; + return true; + } + return false; +} + export function resetTilt() { rawTilt = 0; smoothedTilt = 0; @@ -133,6 +166,9 @@ export function resetTilt() { smoothedPitch = 0; rawMouthOpen = 0; smoothedMouthOpen = 0; + rawEyeOpen = 1; + smoothedEyeOpen = 1; + lastBlinkTime = 0; calibrationOffset = 0.5; needsCalibration = true; }