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
24 changes: 24 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@
#gameover-box .go-score {
font-size: 1.4em;
opacity: 0.8;
margin-bottom: 8px;
}
#gameover-box .go-time {
font-size: 1.2em;
opacity: 0.7;
margin-bottom: 24px;
}
#gameover-box .go-message {
Expand Down Expand Up @@ -224,6 +229,23 @@
#leaderboard-close:hover {
background: rgba(255,255,255,0.1);
}
#timer {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 1.4em;
font-weight: 700;
z-index: 10;
pointer-events: none;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
background: rgba(0,0,0,0.3);
padding: 6px 14px;
border-radius: 8px;
display: none;
}
#slowdown-indicator {
position: fixed;
bottom: 50px;
Expand Down Expand Up @@ -267,6 +289,7 @@
</head>
<body>
<div id="score">Score: 0</div>
<div id="timer">0.0s</div>
<button id="leaderboard-btn">Leaderboard</button>
<div id="overlay">
<div class="title">TEETER</div>
Expand All @@ -276,6 +299,7 @@
<div id="gameover-box">
<div class="go-title">GAME OVER</div>
<div class="go-score"></div>
<div class="go-time"></div>
<div class="go-message"></div>
<div id="name-entry">
<label for="name-input">Enter your name:</label>
Expand Down
95 changes: 75 additions & 20 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
getObstacles,
getCoins,
hideCoin,
showAllCoins,
updateCoinRotation,
regenerateLevel,
getTurtle,
Expand All @@ -22,10 +21,13 @@ import { initPhysics, updatePhysics, resetBall, refreshLevel } from './physics.j
const overlay = document.getElementById('overlay');
const subtitle = overlay.querySelector('.subtitle');
const scoreEl = document.getElementById('score');
const timerEl = document.getElementById('timer');
const leaderboardBtn = document.getElementById('leaderboard-btn');
const gameoverOverlay = document.getElementById('gameover-overlay');
const gameoverTitle = gameoverOverlay.querySelector('.go-title');
const gameoverScore = gameoverOverlay.querySelector('.go-score');
const gameoverMessage = gameoverOverlay.querySelector('.go-message');
const gameoverTime = gameoverOverlay.querySelector('.go-time');
const nameEntry = document.getElementById('name-entry');
const nameInput = document.getElementById('name-input');
const nameSubmit = document.getElementById('name-submit');
Expand All @@ -37,18 +39,35 @@ const slowdownIndicator = document.getElementById('slowdown-indicator');
const STORAGE_KEY = 'teeter_highscores';
const MAX_SCORES = 10;
const NON_QUALIFYING_DELAY = 2000;
const FINISH_DISPLAY_DELAY = 3000;

let state = 'loading'; // loading | permission | playing | falling | gameover
let state = 'loading'; // loading | permission | playing | falling | finished | gameover
let lastTime = 0;
let resetTimer = null;
let score = 0;
let finalScore = 0;
let runStartTime = 0;
let runElapsed = 0;

function updateScore(value) {
score = value;
scoreEl.textContent = 'Score: ' + score;
}

function formatTime(seconds) {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
const ms = Math.floor((seconds % 1) * 10);
if (mins > 0) {
return mins + ':' + String(secs).padStart(2, '0') + '.' + ms;
}
return secs + '.' + ms + 's';
}

function updateTimerDisplay() {
timerEl.textContent = formatTime(runElapsed);
}

// --- localStorage leaderboard ---

function loadScores() {
Expand All @@ -70,7 +89,7 @@ function saveScores(scores) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(scores));
} catch {
// storage unavailable — silently fail
// storage unavailable
}
}

Expand Down Expand Up @@ -125,13 +144,39 @@ function hideLeaderboard() {
leaderboardPanel.classList.remove('visible');
}

// --- Game over flow ---
// --- Finish & Game over flow ---

function enterFinished() {
finalScore = score;
state = 'finished';

gameoverTitle.textContent = 'COURSE COMPLETE!';
gameoverScore.textContent = 'Score: ' + finalScore;
gameoverTime.textContent = 'Time: ' + formatTime(runElapsed);

if (scoreQualifies(finalScore)) {
gameoverMessage.textContent = 'New high score!';
nameEntry.classList.add('visible');
nameInput.value = '';
nameInput.focus();
} else {
gameoverMessage.textContent = 'Well done!';
nameEntry.classList.remove('visible');
resetTimer = setTimeout(() => {
exitGameOver();
}, FINISH_DISPLAY_DELAY);
}

gameoverOverlay.classList.add('visible');
}

function enterGameOver() {
finalScore = score;
state = 'gameover';

gameoverTitle.textContent = 'GAME OVER';
gameoverScore.textContent = 'Score: ' + finalScore;
gameoverTime.textContent = 'Time: ' + formatTime(runElapsed);

if (scoreQualifies(finalScore)) {
gameoverMessage.textContent = 'New high score!';
Expand All @@ -141,7 +186,6 @@ function enterGameOver() {
} else {
gameoverMessage.textContent = '';
nameEntry.classList.remove('visible');
// Auto-dismiss after delay
resetTimer = setTimeout(() => {
exitGameOver();
}, NON_QUALIFYING_DELAY);
Expand Down Expand Up @@ -178,8 +222,15 @@ function exitGameOver() {
calibrate(performance.now());
resetBallRotation();
updateScore(0);
updateBallPosition(0, config.trackHeight / 2 + config.ballRadius, config.ballStartZ);
updateCamera(config.ballStartZ);

// Reset ball to start of curve
const startPos = config.curveLocalToWorld(0, 0, config.ballRadius);
updateBallPosition(startPos.x, startPos.y, startPos.z);
updateCamera(0, startPos);

runStartTime = performance.now();
runElapsed = 0;
updateTimerDisplay();
state = 'playing';
}

Expand All @@ -203,7 +254,6 @@ leaderboardClose.addEventListener('click', () => {
hideLeaderboard();
});

// Close leaderboard on backdrop click
leaderboardPanel.addEventListener('click', (e) => {
if (e.target === leaderboardPanel) {
hideLeaderboard();
Expand All @@ -214,22 +264,18 @@ leaderboardPanel.addEventListener('click', (e) => {

async function init() {
try {
// Initialize Three.js renderer
initRenderer();
const config = getTrackConfig();

// Attach obstacle, coin, and turtle data to config for physics
config.obstacles = getObstacles();
config.coins = getCoins();
config.turtle = getTurtle();
initPhysics(config);

// Initial render so the scene is visible during loading
render();

subtitle.textContent = 'Requesting camera access...';

// Request camera
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({
Expand All @@ -242,7 +288,6 @@ async function init() {

subtitle.textContent = 'Loading head tracking model...';

// Initialize head tracker
await initTracker(stream);

// Calibrate neutral head position
Expand All @@ -251,8 +296,12 @@ async function init() {
// Hide overlay, show score and leaderboard button, and start game
overlay.classList.add('hidden');
scoreEl.style.display = 'block';
timerEl.style.display = 'block';
leaderboardBtn.style.display = 'block';
updateScore(0);
runStartTime = performance.now();
runElapsed = 0;
updateTimerDisplay();
state = 'playing';
lastTime = performance.now();
requestAnimationFrame(gameLoop);
Expand All @@ -276,30 +325,31 @@ function gameLoop(timestamp) {
lastTime = timestamp;

if (state === 'playing' || state === 'falling') {
// Get head tilt and pitch
// Update run timer
runElapsed = (timestamp - runStartTime) / 1000;
updateTimerDisplay();

const tiltAngle = detectTilt(timestamp);
const pitch = detectPitch();

// Update physics
const result = updatePhysics(dt, tiltAngle, pitch);

// Update renderer
updateBallPosition(result.x, result.y, result.z);
updateBallRotation(result.vx, result.vz, dt);
updateCamera(result.z);

// Animate coins
// Camera follows curve tangent at ball's t position
const ballWorldPos = { x: result.x, y: result.y, z: result.z };
updateCamera(result.t, ballWorldPos);

updateCoinRotation(dt);

// Handle coin collection
if (result.coinsCollected && result.coinsCollected.length > 0) {
for (const idx of result.coinsCollected) {
hideCoin(idx);
updateScore(score + 1);
}
}

// Handle turtle collection
if (result.turtleCollected) {
hideTurtle();
}
Expand All @@ -325,6 +375,11 @@ function gameLoop(timestamp) {
updateCamera(config.ballStartZ);
}

// Handle finish line crossing
if (result.finished && state === 'playing') {
enterFinished();
}

// Handle state transitions
if (result.falling && state === 'playing') {
state = 'falling';
Expand Down
Loading
Loading