Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
34 changes: 30 additions & 4 deletions js/physics.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -41,6 +42,7 @@ export function resetBall() {
vy: 0,
vz: FORWARD_SPEED,
falling: false,
jumping: false,
};
collectedCoinIds = new Set();
collectedTurtleIds = new Set();
Expand All @@ -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;
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions js/tracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -126,13 +147,28 @@ 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;
rawPitch = 0;
smoothedPitch = 0;
rawMouthOpen = 0;
smoothedMouthOpen = 0;
rawEyeOpen = 1;
smoothedEyeOpen = 1;
lastBlinkTime = 0;
calibrationOffset = 0.5;
needsCalibration = true;
}
Loading