From 2c7334de8ff022f60c3d838c53b092b70ad6e6a5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:31:52 +0000 Subject: [PATCH 1/5] feat: implement infinite endless mode with procedural track generation - Replace fixed-length track with procedurally generated infinite track - Add chunk-based rendering system for dynamic track generation/culling - Remove finish line and finish state entirely - Game over only triggers when ball falls off track - Track segments, obstacles, coins, turtles, and moving walls spawn procedurally - Old chunks culled behind ball to prevent unbounded memory growth - Score/distance/level/timer continue incrementing indefinitely - Level colors cycle based on distance milestones - Increase MAX_REASONABLE_SCORE to 1000000 for infinite mode - Seeded RNG ensures deterministic chunk generation Co-Authored-By: bot_apk --- js/main.js | 369 ++++++++++++++++++++++++++++--------- js/physics.js | 26 +-- js/renderer.js | 412 ++++++++++++++++++++---------------------- js/track.js | 133 +++++++++----- public/js/main.js | 44 +---- public/js/physics.js | 11 -- public/js/renderer.js | 412 ++++++++++++++++++++---------------------- public/js/track.js | 133 +++++++++----- server.js | 2 +- 9 files changed, 864 insertions(+), 678 deletions(-) diff --git a/js/main.js b/js/main.js index 4cf7118..624039b 100644 --- a/js/main.js +++ b/js/main.js @@ -15,10 +15,11 @@ import { updateCoinRotation, updateSceneColors, updateMovingWalls, + updateChunks, } from './renderer.js'; import { initTracker, calibrate, detectTilt, detectPitch, detectMouthOpen, resetTilt } from './tracker.js'; -import { initPhysics, updatePhysics, resetBall, updateLevelData } from './physics.js'; +import { initPhysics, updatePhysics, resetBall, updateLevelData, setSensitivity, getSensitivity, DEFAULT_SENSITIVITY } from './physics.js'; import { getPointAtDistance } from './track.js'; const overlay = document.getElementById('overlay'); @@ -39,11 +40,22 @@ const slowdownIndicator = document.getElementById('slowdown-indicator'); const boostIndicator = document.getElementById('boost-indicator'); const levelEl = document.getElementById('level'); const timerEl = document.getElementById('timer'); - -const STORAGE_KEY = 'teeter_highscores'; +const retryBtn = document.getElementById('retry-btn'); +const speedEl = document.getElementById('speed'); +const settingsBtn = document.getElementById('settings-btn'); +const settingsPanel = document.getElementById('settings-panel'); +const settingsClose = document.getElementById('settings-close'); +const sensitivitySlider = document.getElementById('sensitivity-slider'); +const sensitivityValue = document.getElementById('sensitivity-value'); +const sensitivityReset = document.getElementById('sensitivity-reset'); + +const INIT_TIMEOUT_MS = 15000; + +const SENSITIVITY_STORAGE_KEY = 'teeter_sensitivity'; const MAX_SCORES = 10; const NON_QUALIFYING_DELAY = 2000; const CHUNK_LENGTH = 20; +const API_BASE = '/api'; const LEVEL_COLORS = [ 0x87CEEB, 0xFFB347, 0x77DD77, 0xCB99C9, 0xFF6961, @@ -57,7 +69,10 @@ let score = 0; let finalScore = 0; let currentLevel = 1; let gameStartTime = 0; -let finishTime = 0; +let rendererInitialized = false; + +// Cached leaderboard scores for rendering +let cachedScores = []; function updateScore(value) { score = value; @@ -88,94 +103,194 @@ function resetLevel() { updateSceneColors(LEVEL_COLORS[0]); } -// --- localStorage leaderboard --- +// --- Sensitivity settings --- -function loadScores() { - try { - const raw = localStorage.getItem(STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - return parsed.filter((e) => typeof e.name === 'string' && typeof e.score === 'number') - .sort((a, b) => b.score - a.score).slice(0, MAX_SCORES); - } catch { return []; } +function getSensitivityLabel(val) { + if (val <= 11.5) return 'Low'; + if (val <= 19.5) return 'Medium'; + return 'High'; } -function saveScores(scores) { - try { localStorage.setItem(STORAGE_KEY, JSON.stringify(scores)); } catch {} +function updateSensitivityDisplay(val) { + sensitivityValue.textContent = parseFloat(val).toFixed(1) + ' (' + getSensitivityLabel(val) + ')'; + sensitivitySlider.value = val; } -function scoreQualifies(value) { - if (value <= 0) return false; - const scores = loadScores(); - if (scores.length < MAX_SCORES) return true; - return value > scores[scores.length - 1].score; +function loadSensitivity() { + try { + const stored = localStorage.getItem(SENSITIVITY_STORAGE_KEY); + if (stored !== null) { + const val = parseFloat(stored); + if (!isNaN(val) && val >= 5 && val <= 30) { + setSensitivity(val); + updateSensitivityDisplay(val); + return; + } + } + } catch {} + setSensitivity(DEFAULT_SENSITIVITY); + updateSensitivityDisplay(DEFAULT_SENSITIVITY); } -function addScore(name, value) { - const scores = loadScores(); - scores.push({ name, score: value }); - scores.sort((a, b) => b.score - a.score); - const trimmed = scores.slice(0, MAX_SCORES); - saveScores(trimmed); - return trimmed; +function saveSensitivity(val) { + try { localStorage.setItem(SENSITIVITY_STORAGE_KEY, String(val)); } catch {} } -function renderLeaderboard() { - const scores = loadScores(); - if (scores.length === 0) { - leaderboardList.innerHTML = '

No scores yet.

'; - return; +function showSettings() { settingsPanel.classList.add('visible'); } +function hideSettings() { settingsPanel.classList.remove('visible'); } + +sensitivitySlider.addEventListener('input', () => { + const val = parseFloat(sensitivitySlider.value); + setSensitivity(val); + updateSensitivityDisplay(val); + saveSensitivity(val); +}); + +sensitivityReset.addEventListener('click', () => { + setSensitivity(DEFAULT_SENSITIVITY); + updateSensitivityDisplay(DEFAULT_SENSITIVITY); + saveSensitivity(DEFAULT_SENSITIVITY); +}); + +settingsBtn.addEventListener('click', () => { showSettings(); }); +settingsClose.addEventListener('click', () => { hideSettings(); }); +settingsPanel.addEventListener('click', (e) => { if (e.target === settingsPanel) hideSettings(); }); + +// --- API-based leaderboard --- + +async function fetchScores() { + try { + const res = await fetch(API_BASE + '/scores'); + if (!res.ok) throw new Error('Server error: ' + res.status); + const data = await res.json(); + cachedScores = data.scores || []; + return { scores: cachedScores, offline: false }; + } catch (err) { + console.error('Failed to fetch scores:', err); + return { scores: cachedScores, offline: true }; } - let html = ''; - for (let i = 0; i < scores.length; i++) { - const e = scores[i]; - const escapedName = e.name.replace(/&/g, '&').replace(//g, '>'); - html += ''; +} + +async function scoreQualifies(value) { + if (value <= 0) return false; + try { + const res = await fetch(API_BASE + '/scores/qualifies?score=' + encodeURIComponent(value)); + if (!res.ok) throw new Error('Server error: ' + res.status); + const data = await res.json(); + return data.qualifies; + } catch (err) { + console.error('Failed to check score qualification:', err); + if (cachedScores.length < MAX_SCORES) return true; + return value > cachedScores[cachedScores.length - 1].score; } - html += '
#NameScore
' + (i + 1) + '' + escapedName + '' + e.score + '
'; - leaderboardList.innerHTML = html; } -function showLeaderboard() { renderLeaderboard(); leaderboardPanel.classList.add('visible'); } -function hideLeaderboard() { leaderboardPanel.classList.remove('visible'); } +async function addScore(name, value) { + try { + const res = await fetch(API_BASE + '/scores', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, score: value }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || 'Server error: ' + res.status); + } + const data = await res.json(); + if (data.scores) { + cachedScores = data.scores; + } + return { success: true }; + } catch (err) { + console.error('Failed to submit score:', err); + return { success: false, error: err.message }; + } +} -// --- Finish state --- +function renderLeaderboard(scores, offline) { + leaderboardList.textContent = ''; -function enterFinished(timestamp) { - finishTime = ((timestamp - gameStartTime) / 1000).toFixed(1); - finalScore = score; - state = 'finished'; + if (!scores || scores.length === 0) { + const p = document.createElement('p'); + p.className = 'lb-empty'; + p.textContent = offline + ? 'Could not reach server. Please try again later.' + : 'No scores yet.'; + leaderboardList.appendChild(p); + return; + } - gameoverTitle.textContent = 'FINISHED!'; - gameoverScore.textContent = 'Score: ' + finalScore + ' | Time: ' + finishTime + 's'; + if (offline) { + const p = document.createElement('p'); + p.className = 'lb-empty'; + p.textContent = 'Could not reach server. Showing cached scores.'; + leaderboardList.appendChild(p); + } - if (scoreQualifies(finalScore)) { - gameoverMessage.textContent = 'New high score!'; - nameEntry.classList.add('visible'); - nameInput.value = ''; - nameInput.focus(); - } else { - gameoverMessage.textContent = 'Great run!'; - nameEntry.classList.remove('visible'); - resetTimer = setTimeout(() => { exitGameOver(); }, NON_QUALIFYING_DELAY); + const table = document.createElement('table'); + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + for (const [cls, label] of [['lb-rank', '#'], ['lb-name', 'Name'], ['lb-score', 'Score']]) { + const th = document.createElement('th'); + th.className = cls; + th.textContent = label; + headRow.appendChild(th); } + thead.appendChild(headRow); + table.appendChild(thead); - levelEl.style.display = 'none'; - timerEl.style.display = 'none'; - gameoverOverlay.classList.add('visible'); + const tbody = document.createElement('tbody'); + for (let i = 0; i < scores.length; i++) { + const row = document.createElement('tr'); + const rankTd = document.createElement('td'); + rankTd.className = 'lb-rank'; + rankTd.textContent = String(i + 1); + const nameTd = document.createElement('td'); + nameTd.className = 'lb-name'; + nameTd.textContent = scores[i].name; + const scoreTd = document.createElement('td'); + scoreTd.className = 'lb-score'; + scoreTd.textContent = String(scores[i].score); + row.appendChild(rankTd); + row.appendChild(nameTd); + row.appendChild(scoreTd); + tbody.appendChild(row); + } + table.appendChild(tbody); + leaderboardList.appendChild(table); +} + +async function showLeaderboard() { + leaderboardList.textContent = ''; + const loadingP = document.createElement('p'); + loadingP.className = 'lb-empty'; + loadingP.textContent = 'Loading scores...'; + leaderboardList.appendChild(loadingP); + leaderboardPanel.classList.add('visible'); + const { scores, offline } = await fetchScores(); + renderLeaderboard(scores, offline); } +function hideLeaderboard() { leaderboardPanel.classList.remove('visible'); } + // --- Game over flow --- -function enterGameOver() { +async function enterGameOver() { finalScore = score; state = 'gameover'; gameoverTitle.textContent = 'GAME OVER'; gameoverScore.textContent = 'Score: ' + finalScore; + gameoverMessage.textContent = 'Checking score...'; + nameEntry.classList.remove('visible'); + + levelEl.style.display = 'none'; + timerEl.style.display = 'none'; + speedEl.style.display = 'none'; + gameoverOverlay.classList.add('visible'); - if (scoreQualifies(finalScore)) { + const qualifies = await scoreQualifies(finalScore); + if (qualifies) { gameoverMessage.textContent = 'New high score!'; nameEntry.classList.add('visible'); nameInput.value = ''; @@ -185,16 +300,20 @@ function enterGameOver() { nameEntry.classList.remove('visible'); resetTimer = setTimeout(() => { exitGameOver(); }, NON_QUALIFYING_DELAY); } - - levelEl.style.display = 'none'; - timerEl.style.display = 'none'; - gameoverOverlay.classList.add('visible'); } -function submitScore() { +async function submitScore() { let name = nameInput.value.trim(); if (!name) name = 'Anonymous'; - addScore(name, finalScore); + nameSubmit.disabled = true; + nameSubmit.textContent = 'Submitting...'; + const result = await addScore(name, finalScore); + nameSubmit.disabled = false; + nameSubmit.textContent = 'Submit'; + if (!result.success) { + gameoverMessage.textContent = 'Failed to submit score. Please try again.'; + return; + } exitGameOver(); } @@ -215,6 +334,7 @@ function exitGameOver() { updateScore(0); levelEl.style.display = 'block'; timerEl.style.display = 'block'; + speedEl.style.display = 'block'; const startPos = getStartBallPosition(config); updateBallPosition(startPos.x, startPos.y, startPos.z); @@ -238,53 +358,131 @@ leaderboardPanel.addEventListener('click', (e) => { if (e.target === leaderboard // --- Init & game loop --- +function createInitTimeout() { + let timeoutId; + const promise = new Promise(function(_, reject) { + timeoutId = setTimeout(function() { + reject(new Error('INIT_TIMEOUT')); + }, INIT_TIMEOUT_MS); + }); + return { promise: promise, cancel: function() { clearTimeout(timeoutId); } }; +} + async function init() { + // Check WebGL support before anything else + var testCanvas = document.createElement('canvas'); + var gl = testCanvas.getContext('webgl2') || testCanvas.getContext('webgl'); + if (!gl) { + showError('WebGL is not supported by your browser.\nPlease use a modern browser with WebGL enabled.', true); + return; + } + + var timeout = createInitTimeout(); + var stream; + try { - initRenderer(); - const config = getTrackConfig(); + if (!rendererInitialized) { + initRenderer(); + rendererInitialized = true; + } + var config = getTrackConfig(); initPhysics(config); render(); + // Pre-fetch leaderboard scores + fetchScores(); + + // Request camera access subtitle.textContent = 'Requesting camera access...'; - let stream; try { - stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, - }); + stream = await Promise.race([ + navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, + }), + timeout.promise, + ]); } catch (err) { - showError('Camera access is required to play.\nPlease allow camera access and reload.'); + timeout.cancel(); + if (err.message === 'INIT_TIMEOUT') { + showError('Loading timed out.\nPlease check your connection and try again.', true); + } else { + showError('Camera access is required to play.\nPlease allow camera access and reload.', true); + } return; } + // Load MediaPipe face landmark model subtitle.textContent = 'Loading head tracking model...'; - await initTracker(stream); + try { + await Promise.race([ + initTracker(stream), + timeout.promise, + ]); + } catch (err) { + timeout.cancel(); + stream.getTracks().forEach(function(t) { t.stop(); }); + if (err.message === 'INIT_TIMEOUT') { + showError('Loading timed out.\nPlease check your connection and try again.', true); + } else { + console.error('Tracker initialization error:', err); + showError('Failed to load face tracking model.\nPlease check your connection and try again.', true); + } + return; + } + + timeout.cancel(); calibrate(performance.now()); + loadSensitivity(); + overlay.classList.add('hidden'); + retryBtn.classList.remove('visible'); scoreEl.style.display = 'block'; levelEl.style.display = 'block'; timerEl.style.display = 'block'; + speedEl.style.display = 'block'; leaderboardBtn.style.display = 'block'; + settingsBtn.style.display = 'block'; updateScore(0); gameStartTime = performance.now(); state = 'playing'; lastTime = performance.now(); requestAnimationFrame(gameLoop); } catch (err) { + timeout.cancel(); + if (stream) { + stream.getTracks().forEach(function(t) { t.stop(); }); + } console.error('Initialization error:', err); - showError('Failed to initialize. Please reload and try again.'); + showError('Failed to initialize.\nPlease reload and try again.', true); + return; } } -function showError(message) { +function showError(message, showRetry) { state = 'error'; + overlay.classList.remove('hidden'); overlay.classList.add('error'); subtitle.textContent = message; overlay.querySelector('.title').textContent = ''; + if (showRetry) { + retryBtn.classList.add('visible'); + } else { + retryBtn.classList.remove('visible'); + } } +retryBtn.addEventListener('click', function() { + retryBtn.classList.remove('visible'); + overlay.classList.remove('error'); + overlay.querySelector('.title').textContent = 'TEETER'; + subtitle.textContent = 'Loading...'; + state = 'loading'; + init(); +}); + function gameLoop(timestamp) { requestAnimationFrame(gameLoop); @@ -303,6 +501,8 @@ function gameLoop(timestamp) { if (state === 'playing') { updateLevel(result.distance); updateTimer(timestamp); + // Generate new track chunks and cull old ones + updateChunks(result.distance); } updateBallPosition(result.x, result.y, result.z); @@ -320,18 +520,17 @@ function gameLoop(timestamp) { if (result.turtleCollected) { hideTurtleById(result.turtleCollected); } + // Update speed indicator + const speed = Math.sqrt(result.vx * result.vx + result.vz * result.vz); + speedEl.textContent = 'Speed: ' + speed.toFixed(1) + ' m/s'; + if (result.slowdownActive) { slowdownIndicator.classList.add('visible'); } else { slowdownIndicator.classList.remove('visible'); } if (result.boostActive) { boostIndicator.classList.add('visible'); } else { boostIndicator.classList.remove('visible'); } - // Handle finish - if (result.finished && state === 'playing') { - enterFinished(timestamp); - } - - // Handle falling + // Handle falling — game over only when ball falls off track if (result.falling && state === 'playing') { state = 'falling'; } if (result.needsReset && state === 'falling') { enterGameOver(); } } diff --git a/js/physics.js b/js/physics.js index 7d987b8..7f3a0e2 100644 --- a/js/physics.js +++ b/js/physics.js @@ -3,7 +3,6 @@ import { TRACK_HEIGHT, BALL_RADIUS, BALL_START_DISTANCE, - FINISH_LINE_DISTANCE, getPointAtDistance, getTangentAtDistance, getRightAtDistance, @@ -14,7 +13,8 @@ import { } from './track.js'; const GRAVITY = 9.8; -const DIRECT_SENSITIVITY = 15.0; +const DEFAULT_SENSITIVITY = 15.0; +let directSensitivity = DEFAULT_SENSITIVITY; const RESPONSE_RATE = 6.0; const FORWARD_SPEED = 4.5; const PITCH_SENSITIVITY = 3.0; @@ -95,7 +95,7 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { } // Lateral velocity from head tilt - const targetVLateral = tiltAngle * DIRECT_SENSITIVITY; + const targetVLateral = tiltAngle * directSensitivity; ball.vLateral += (targetVLateral - ball.vLateral) * RESPONSE_RATE * dt; // Forward motion modulated by pitch + gravity slope contribution @@ -108,11 +108,6 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { ball.distance += ball.vForward * dt; ball.lateral += ball.vLateral * dt; - // Clamp distance to track length - if (ball.distance > getTrackLength()) { - ball.distance = getTrackLength(); - } - // Compute world position from track coordinates const centerPoint = getPointAtDistance(ball.distance); const right = getRightAtDistance(ball.distance); @@ -173,9 +168,6 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { } } - // Check finish line - const finished = ball.distance >= trackConfig.finishLineDistance; - return { x: ball.worldX, y: ball.worldY, @@ -185,7 +177,6 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { vz: ball.vForward, falling: ball.falling, needsReset: false, - finished, obstacleHit, coinsCollected: newlyCollected, turtleCollected: turtleJustCollected, @@ -215,7 +206,6 @@ function updateFalling(dt) { vz: ball.vForward, falling: true, needsReset, - finished: false, obstacleHit: false, coinsCollected: [], turtleCollected: null, @@ -227,3 +217,13 @@ function updateFalling(dt) { export function getBallState() { return { ...ball }; } + +export function setSensitivity(value) { + directSensitivity = value; +} + +export function getSensitivity() { + return directSensitivity; +} + +export { DEFAULT_SENSITIVITY }; diff --git a/js/renderer.js b/js/renderer.js index 012f9cd..e95accc 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -4,12 +4,13 @@ import { TRACK_HEIGHT, BALL_RADIUS, BALL_START_DISTANCE, - FINISH_LINE_DISTANCE, getPointAtDistance, getTangentAtDistance, getRightAtDistance, trackToWorld, getTrackLength, + initTrack, + ensureTrackTo, } from './track.js'; const SEGMENT_LENGTH = 1.5; @@ -32,17 +33,17 @@ const MOVING_WALL_START_DISTANCE = 40; const MOVING_WALL_MIN_SPACING = 15; const MOVING_WALL_MAX_SPACING = 25; +// Chunk system constants +const CHUNK_SIZE = 30; +const CHUNKS_AHEAD = 4; +const CHUNKS_BEHIND = 2; + let scene, camera, renderer, dirLight; let ballMesh; -let trackMeshes = []; -let edgeMeshes = []; -let obstacleMeshes = []; -let coinEntries = []; -let turtleEntries = []; -let movingWallEntries = []; -let finishLineMeshes = []; - +// Shared geometries and materials +const segGeo = new THREE.BoxGeometry(TRACK_WIDTH, TRACK_HEIGHT, SEGMENT_LENGTH * 1.05); +const edgeGeo = new THREE.BoxGeometry(0.06, 0.12, SEGMENT_LENGTH * 1.05); const obstGeo = new THREE.BoxGeometry(OBSTACLE_WIDTH, OBSTACLE_HEIGHT, OBSTACLE_DEPTH); const obstMat = new THREE.MeshStandardMaterial({ color: 0x8B2222, roughness: 0.5, metalness: 0.2 }); const movingWallGeo = new THREE.BoxGeometry(MOVING_WALL_WIDTH, MOVING_WALL_HEIGHT, MOVING_WALL_DEPTH); @@ -62,109 +63,56 @@ function seededRandom(seed) { let globalSeed = Date.now(); -function buildTrackMesh() { - const trackLength = getTrackLength(); - const numSegments = Math.ceil(trackLength / SEGMENT_LENGTH); - const segGeo = new THREE.BoxGeometry(TRACK_WIDTH, TRACK_HEIGHT, SEGMENT_LENGTH * 1.05); - const edgeGeo = new THREE.BoxGeometry(0.06, 0.12, SEGMENT_LENGTH * 1.05); +// Chunk storage: Map +let chunks = new Map(); - for (let i = 0; i < numSegments; i++) { - const d = (i + 0.5) * SEGMENT_LENGTH; - if (d > trackLength) break; +function createChunkData() { + return { + trackMeshes: [], + edgeMeshes: [], + obstacleMeshes: [], + obstacleData: [], + coinEntries: [], + turtleEntries: [], + movingWallEntries: [], + }; +} +function buildChunkTrackMesh(chunk, startD, endD) { + const numSegments = Math.ceil((endD - startD) / SEGMENT_LENGTH); + for (let i = 0; i < numSegments; i++) { + const d = startD + (i + 0.5) * SEGMENT_LENGTH; + if (d > endD) break; const pos = getPointAtDistance(d); const tangent = getTangentAtDistance(d); - const tMesh = new THREE.Mesh(segGeo, trackMat); tMesh.position.copy(pos); const forward = new THREE.Vector3(0, 0, 1); tMesh.quaternion.setFromUnitVectors(forward, tangent); tMesh.receiveShadow = true; scene.add(tMesh); - trackMeshes.push(tMesh); - + chunk.trackMeshes.push(tMesh); const right = getRightAtDistance(d); const halfW = TRACK_WIDTH / 2; - const eLeft = new THREE.Mesh(edgeGeo, edgeMat); eLeft.position.set(pos.x - right.x * halfW, pos.y + TRACK_HEIGHT / 2 + 0.06, pos.z - right.z * halfW); eLeft.quaternion.copy(tMesh.quaternion); scene.add(eLeft); - edgeMeshes.push(eLeft); - + chunk.edgeMeshes.push(eLeft); const eRight = new THREE.Mesh(edgeGeo, edgeMat); eRight.position.set(pos.x + right.x * halfW, pos.y + TRACK_HEIGHT / 2 + 0.06, pos.z + right.z * halfW); eRight.quaternion.copy(tMesh.quaternion); scene.add(eRight); - edgeMeshes.push(eRight); - } -} - -function buildFinishLine() { - const d = FINISH_LINE_DISTANCE; - const pos = getPointAtDistance(d); - const tangent = getTangentAtDistance(d); - const right = getRightAtDistance(d); - const halfW = TRACK_WIDTH / 2; - const trackY = pos.y + TRACK_HEIGHT / 2; - - const bannerWidth = TRACK_WIDTH + 0.5; - const bannerHeight = 2.0; - const bannerGeo = new THREE.PlaneGeometry(bannerWidth, bannerHeight); - - const canvas = document.createElement('canvas'); - canvas.width = 64; - canvas.height = 16; - const ctx = canvas.getContext('2d'); - for (let row = 0; row < 2; row++) { - for (let col = 0; col < 8; col++) { - ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#000000'; - ctx.fillRect(col * 8, row * 8, 8, 8); - } + chunk.edgeMeshes.push(eRight); } - const texture = new THREE.CanvasTexture(canvas); - texture.magFilter = THREE.NearestFilter; - texture.minFilter = THREE.NearestFilter; - - const bannerMat = new THREE.MeshStandardMaterial({ map: texture, side: THREE.DoubleSide, roughness: 0.5, metalness: 0.1 }); - - const banner = new THREE.Mesh(bannerGeo, bannerMat); - banner.position.set(pos.x, trackY + bannerHeight / 2 + 0.5, pos.z); - banner.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), tangent); - scene.add(banner); - finishLineMeshes.push(banner); - - const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, bannerHeight + 1.2, 8); - const poleMat = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.4, metalness: 0.6 }); - - const poleLeft = new THREE.Mesh(poleGeo, poleMat); - poleLeft.position.set(pos.x - right.x * (halfW + 0.1), trackY + (bannerHeight + 1.2) / 2 - 0.1, pos.z - right.z * (halfW + 0.1)); - scene.add(poleLeft); - finishLineMeshes.push(poleLeft); - - const poleRight = new THREE.Mesh(poleGeo, poleMat); - poleRight.position.set(pos.x + right.x * (halfW + 0.1), trackY + (bannerHeight + 1.2) / 2 - 0.1, pos.z + right.z * (halfW + 0.1)); - scene.add(poleRight); - finishLineMeshes.push(poleRight); - - const lineGeo = new THREE.PlaneGeometry(TRACK_WIDTH, 0.3); - const lineMat = new THREE.MeshStandardMaterial({ color: 0xffffff, side: THREE.DoubleSide, roughness: 0.3 }); - const line = new THREE.Mesh(lineGeo, lineMat); - line.position.set(pos.x, trackY + 0.01, pos.z); - line.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0)); - const yaw = Math.atan2(tangent.x, tangent.z); - line.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), yaw); - scene.add(line); - finishLineMeshes.push(line); } -function generateObstacles() { - const rng = seededRandom(globalSeed); +function generateChunkObstacles(chunkIndex, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000); const obstacles = []; const halfTrack = TRACK_WIDTH / 2; - let d = SAFE_ZONE_DISTANCE; - const endD = FINISH_LINE_DISTANCE - 5; - + let d = startD; + if (d < SAFE_ZONE_DISTANCE) d = SAFE_ZONE_DISTANCE; while (d < endD) { const spacing = OBSTACLE_MIN_SPACING + rng() * (OBSTACLE_MAX_SPACING - OBSTACLE_MIN_SPACING); d += spacing; @@ -176,14 +124,13 @@ function generateObstacles() { return obstacles; } -function generateCoins(obstacles) { - const rng = seededRandom(globalSeed + 9973); +function generateChunkCoins(chunkIndex, obstacles, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000 + 9973); const coins = []; const halfTrack = TRACK_WIDTH / 2; - const endD = FINISH_LINE_DISTANCE - 3; - + const safeStart = Math.max(startD, SAFE_ZONE_DISTANCE + 1); for (let i = 0; i < obstacles.length; i++) { - const sD = i === 0 ? SAFE_ZONE_DISTANCE + 1 : obstacles[i - 1].distance + 1; + const sD = i === 0 ? safeStart : obstacles[i - 1].distance + 1; const eD = obstacles[i].distance - 1; const gap = eD - sD; if (gap < 2) continue; @@ -193,8 +140,7 @@ function generateCoins(obstacles) { coins.push({ distance: sD + step * j, lateral: (rng() * 2 - 1) * (halfTrack - 0.5) }); } } - - const lastD = obstacles.length > 0 ? obstacles[obstacles.length - 1].distance + 1 : SAFE_ZONE_DISTANCE + 1; + const lastD = obstacles.length > 0 ? obstacles[obstacles.length - 1].distance + 1 : safeStart; const gap = endD - lastD; if (gap >= 3) { const count = Math.min(3, Math.max(2, Math.floor(gap / 4))); @@ -206,13 +152,13 @@ function generateCoins(obstacles) { return coins; } -function generateTurtles(obstacles) { - const rng = seededRandom(globalSeed + 4201); +function generateChunkTurtles(chunkIndex, obstacles, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000 + 4201); const turtles = []; const halfTrack = TRACK_WIDTH / 2; - const endD = FINISH_LINE_DISTANCE - 5; - - for (let segStart = SAFE_ZONE_DISTANCE + 10; segStart < endD; segStart += TURTLE_SEGMENT_LENGTH) { + const safeStart = Math.max(startD, SAFE_ZONE_DISTANCE + 10); + if (safeStart >= endD) return turtles; + for (let segStart = safeStart; segStart < endD; segStart += TURTLE_SEGMENT_LENGTH) { if (rng() >= TURTLE_SPAWN_CHANCE) continue; const segEnd = Math.min(segStart + TURTLE_SEGMENT_LENGTH, endD); let attempts = 0; @@ -232,86 +178,60 @@ function generateTurtles(obstacles) { return turtles; } -function createTurtleMesh() { - const group = new THREE.Group(); - const bodyMat = new THREE.MeshStandardMaterial({ color: 0x228B22, roughness: 0.6, metalness: 0.1 }); - const shellMat = new THREE.MeshStandardMaterial({ color: 0x185818, roughness: 0.5, metalness: 0.15 }); - const headMat = new THREE.MeshStandardMaterial({ color: 0x2EA52E, roughness: 0.5, metalness: 0.1 }); - - const shell = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), shellMat); - shell.scale.set(1, 0.5, 1.1); - shell.position.y = 0.1; - group.add(shell); - - const body = new THREE.Mesh(new THREE.SphereGeometry(0.35, 12, 10), bodyMat); - body.scale.set(1, 0.35, 1.05); - body.position.y = -0.02; - group.add(body); - - const head = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), headMat); - head.position.set(0, 0.05, 0.42); - group.add(head); - - const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.12, 6); - for (const p of [{ x: -0.22, z: 0.2 }, { x: 0.22, z: 0.2 }, { x: -0.22, z: -0.2 }, { x: 0.22, z: -0.2 }]) { - const leg = new THREE.Mesh(legGeo, bodyMat); - leg.position.set(p.x, -0.1, p.z); - group.add(leg); - } - return group; -} - -function generateMovingWalls(staticObstacles) { - const rng = seededRandom(globalSeed + 7777); +function generateChunkMovingWalls(chunkIndex, staticObstacles, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000 + 7777); const walls = []; - const endD = FINISH_LINE_DISTANCE - 5; - let d = MOVING_WALL_START_DISTANCE; - + let d = Math.max(startD, MOVING_WALL_START_DISTANCE); + if (d >= endD) return walls; while (d < endD) { const spacing = MOVING_WALL_MIN_SPACING + rng() * (MOVING_WALL_MAX_SPACING - MOVING_WALL_MIN_SPACING); d += spacing; if (d >= endD) break; - - // Avoid placing too close to static obstacles let tooClose = false; for (const o of staticObstacles) { if (Math.abs(d - o.distance) < 3) { tooClose = true; break; } } if (tooClose) continue; - const phase = rng() * Math.PI * 2; const speed = 1.2 + rng() * 0.8; const range = (TRACK_WIDTH / 2) - (MOVING_WALL_WIDTH / 2) - 0.3; - walls.push({ distance: d, halfW: MOVING_WALL_WIDTH / 2, halfD: MOVING_WALL_DEPTH / 2, - speed, - range, - phase, + speed, range, phase, currentLateral: 0, }); } return walls; } -function placeMovingWalls(wallData) { - for (let i = 0; i < wallData.length; i++) { - const w = wallData[i]; - const worldPos = trackToWorld(w.distance, 0); - const tangent = getTangentAtDistance(w.distance); - const mesh = new THREE.Mesh(movingWallGeo, movingWallMat); - mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, worldPos.z); - mesh.rotation.y = Math.atan2(tangent.x, tangent.z); - mesh.castShadow = true; - mesh.receiveShadow = true; - scene.add(mesh); - movingWallEntries.push({ mesh, data: { ...w, id: 'mw_' + i } }); +function createTurtleMesh() { + const group = new THREE.Group(); + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x228B22, roughness: 0.6, metalness: 0.1 }); + const shellMat = new THREE.MeshStandardMaterial({ color: 0x185818, roughness: 0.5, metalness: 0.15 }); + const headMat = new THREE.MeshStandardMaterial({ color: 0x2EA52E, roughness: 0.5, metalness: 0.1 }); + const shell = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), shellMat); + shell.scale.set(1, 0.5, 1.1); + shell.position.y = 0.1; + group.add(shell); + const body = new THREE.Mesh(new THREE.SphereGeometry(0.35, 12, 10), bodyMat); + body.scale.set(1, 0.35, 1.05); + body.position.y = -0.02; + group.add(body); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), headMat); + head.position.set(0, 0.05, 0.42); + group.add(head); + const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.12, 6); + for (const p of [{ x: -0.22, z: 0.2 }, { x: 0.22, z: 0.2 }, { x: -0.22, z: -0.2 }, { x: 0.22, z: -0.2 }]) { + const leg = new THREE.Mesh(legGeo, bodyMat); + leg.position.set(p.x, -0.1, p.z); + group.add(leg); } + return group; } -function placeObstacles(obstacleData) { +function placeChunkObstacles(chunk, obstacleData, chunkIndex) { for (let i = 0; i < obstacleData.length; i++) { const o = obstacleData[i]; const worldPos = trackToWorld(o.distance, o.lateral); @@ -322,11 +242,12 @@ function placeObstacles(obstacleData) { mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); - obstacleMeshes.push(mesh); + chunk.obstacleMeshes.push(mesh); + chunk.obstacleData.push(o); } } -function placeCoins(coinData) { +function placeChunkCoins(chunk, coinData, chunkIndex) { for (let i = 0; i < coinData.length; i++) { const c = coinData[i]; const worldPos = trackToWorld(c.distance, c.lateral); @@ -334,55 +255,88 @@ function placeCoins(coinData) { mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + 0.35, worldPos.z); mesh.rotation.x = Math.PI / 2; scene.add(mesh); - coinEntries.push({ mesh, data: { distance: c.distance, lateral: c.lateral, id: 'c_' + i } }); + chunk.coinEntries.push({ mesh, data: { distance: c.distance, lateral: c.lateral, id: 'c_' + chunkIndex + '_' + i } }); } } -function placeTurtles(turtleData) { +function placeChunkTurtles(chunk, turtleData, chunkIndex) { for (let i = 0; i < turtleData.length; i++) { const t = turtleData[i]; const worldPos = trackToWorld(t.distance, t.lateral); const mesh = createTurtleMesh(); mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + 0.35, worldPos.z); scene.add(mesh); - turtleEntries.push({ mesh, data: { distance: t.distance, lateral: t.lateral, id: 't_' + i } }); + chunk.turtleEntries.push({ mesh, data: { distance: t.distance, lateral: t.lateral, id: 't_' + chunkIndex + '_' + i } }); } } -let obstacleDataCache = []; - -function buildFullTrack() { - buildTrackMesh(); - buildFinishLine(); - obstacleDataCache = generateObstacles(); - const coinData = generateCoins(obstacleDataCache); - const turtleData = generateTurtles(obstacleDataCache); - const movingWallData = generateMovingWalls(obstacleDataCache); - placeObstacles(obstacleDataCache); - placeCoins(coinData); - placeTurtles(turtleData); - placeMovingWalls(movingWallData); +function placeChunkMovingWalls(chunk, wallData, chunkIndex) { + for (let i = 0; i < wallData.length; i++) { + const w = wallData[i]; + const worldPos = trackToWorld(w.distance, 0); + const tangent = getTangentAtDistance(w.distance); + const mesh = new THREE.Mesh(movingWallGeo, movingWallMat); + mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, worldPos.z); + mesh.rotation.y = Math.atan2(tangent.x, tangent.z); + mesh.castShadow = true; + mesh.receiveShadow = true; + scene.add(mesh); + chunk.movingWallEntries.push({ mesh, data: { ...w, id: 'mw_' + chunkIndex + '_' + i } }); + } +} + +function buildChunk(chunkIndex) { + if (chunks.has(chunkIndex)) return; + const startD = chunkIndex * CHUNK_SIZE; + const endD = startD + CHUNK_SIZE; + ensureTrackTo(endD + 10); + const chunk = createChunkData(); + buildChunkTrackMesh(chunk, startD, endD); + const obstacleData = generateChunkObstacles(chunkIndex, startD, endD); + placeChunkObstacles(chunk, obstacleData, chunkIndex); + const coinData = generateChunkCoins(chunkIndex, obstacleData, startD, endD); + placeChunkCoins(chunk, coinData, chunkIndex); + const turtleData = generateChunkTurtles(chunkIndex, obstacleData, startD, endD); + placeChunkTurtles(chunk, turtleData, chunkIndex); + const movingWallData = generateChunkMovingWalls(chunkIndex, obstacleData, startD, endD); + placeChunkMovingWalls(chunk, movingWallData, chunkIndex); + chunks.set(chunkIndex, chunk); } -function clearTrack() { - for (const m of trackMeshes) scene.remove(m); - for (const m of edgeMeshes) scene.remove(m); - for (const m of obstacleMeshes) scene.remove(m); - for (const e of coinEntries) scene.remove(e.mesh); - for (const e of turtleEntries) { +function destroyChunk(chunkIndex) { + const chunk = chunks.get(chunkIndex); + if (!chunk) return; + for (const m of chunk.trackMeshes) scene.remove(m); + for (const m of chunk.edgeMeshes) scene.remove(m); + for (const m of chunk.obstacleMeshes) scene.remove(m); + for (const e of chunk.coinEntries) scene.remove(e.mesh); + for (const e of chunk.turtleEntries) { e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.material.dispose(); } }); scene.remove(e.mesh); } - for (const e of movingWallEntries) scene.remove(e.mesh); - for (const m of finishLineMeshes) scene.remove(m); - trackMeshes = []; - edgeMeshes = []; - obstacleMeshes = []; - coinEntries = []; - turtleEntries = []; - movingWallEntries = []; - finishLineMeshes = []; - obstacleDataCache = []; + for (const e of chunk.movingWallEntries) scene.remove(e.mesh); + chunks.delete(chunkIndex); +} + +export function updateChunks(ballDistance) { + const ballChunk = Math.floor(ballDistance / CHUNK_SIZE); + const minChunk = Math.max(0, ballChunk - CHUNKS_BEHIND); + const maxChunk = ballChunk + CHUNKS_AHEAD; + for (let i = minChunk; i <= maxChunk; i++) { + buildChunk(i); + } + for (const [idx] of chunks) { + if (idx < minChunk || idx > maxChunk) { + destroyChunk(idx); + } + } +} + +function clearAllChunks() { + for (const [idx] of chunks) { + destroyChunk(idx); + } + chunks = new Map(); } export function initRenderer() { @@ -390,6 +344,9 @@ export function initRenderer() { scene.background = new THREE.Color(0x87CEEB); scene.fog = new THREE.Fog(0x87CEEB, 40, 120); + globalSeed = Date.now(); + initTrack(globalSeed); + const startPos = getPointAtDistance(BALL_START_DISTANCE); camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 300); camera.position.set(startPos.x, startPos.y + 4, startPos.z - 8); @@ -427,7 +384,7 @@ export function initRenderer() { ballMesh.position.y += TRACK_HEIGHT / 2 + BALL_RADIUS; scene.add(ballMesh); - buildFullTrack(); + updateChunks(BALL_START_DISTANCE); window.addEventListener('resize', onResize); return { scene, camera, renderer }; } @@ -453,7 +410,6 @@ export function updateCamera(ballDistance, ballWorldX, ballWorldY, ballWorldZ) { const tangent = getTangentAtDistance(ballDistance); const targetCamPos = new THREE.Vector3(ballWorldX - tangent.x * 10, ballWorldY + 5, ballWorldZ - tangent.z * 10); const targetLookAt = new THREE.Vector3(ballWorldX + tangent.x * 5, ballWorldY, ballWorldZ + tangent.z * 5); - if (!cameraInitialized) { smoothCamPos.copy(targetCamPos); smoothCamTarget.copy(targetLookAt); @@ -462,7 +418,6 @@ export function updateCamera(ballDistance, ballWorldX, ballWorldY, ballWorldZ) { smoothCamPos.lerp(targetCamPos, 0.04); smoothCamTarget.lerp(targetLookAt, 0.04); } - camera.position.copy(smoothCamPos); camera.lookAt(smoothCamTarget); dirLight.position.set(ballWorldX + 5, ballWorldY + 10, ballWorldZ + 5); @@ -474,51 +429,64 @@ export function render() { renderer.render(scene, camera); } export function getTrackConfig() { return { trackWidth: TRACK_WIDTH, trackHeight: TRACK_HEIGHT, ballRadius: BALL_RADIUS, - ballStartDistance: BALL_START_DISTANCE, finishLineDistance: FINISH_LINE_DISTANCE, - trackLength: getTrackLength(), + ballStartDistance: BALL_START_DISTANCE, }; } export function resetTrack() { - clearTrack(); + clearAllChunks(); globalSeed = Date.now(); - buildFullTrack(); + initTrack(globalSeed); + updateChunks(BALL_START_DISTANCE); cameraInitialized = false; } export function getActiveObstacles() { - const result = obstacleDataCache.map((o) => ({ distance: o.distance, lateral: o.lateral, halfW: o.halfW, halfD: o.halfD, height: OBSTACLE_HEIGHT })); - for (const entry of movingWallEntries) { - const w = entry.data; - result.push({ - distance: w.distance, - lateral: w.currentLateral, - halfW: w.halfW, - halfD: w.halfD, - height: MOVING_WALL_HEIGHT, - }); + const result = []; + for (const [, chunk] of chunks) { + for (const o of chunk.obstacleData) { + result.push({ distance: o.distance, lateral: o.lateral, halfW: o.halfW, halfD: o.halfD, height: OBSTACLE_HEIGHT }); + } + for (const entry of chunk.movingWallEntries) { + const w = entry.data; + result.push({ + distance: w.distance, + lateral: w.currentLateral, + halfW: w.halfW, + halfD: w.halfD, + height: MOVING_WALL_HEIGHT, + }); + } } return result; } export function getActiveCoins() { const result = []; - for (const entry of coinEntries) { if (entry.mesh.visible) result.push(entry.data); } + for (const [, chunk] of chunks) { + for (const entry of chunk.coinEntries) { if (entry.mesh.visible) result.push(entry.data); } + } return result; } export function getActiveTurtles() { const result = []; - for (const entry of turtleEntries) { if (entry.mesh.visible) result.push(entry.data); } + for (const [, chunk] of chunks) { + for (const entry of chunk.turtleEntries) { if (entry.mesh.visible) result.push(entry.data); } + } return result; } export function hideCoinById(coinId) { - for (const entry of coinEntries) { if (entry.data.id === coinId) { entry.mesh.visible = false; return; } } + for (const [, chunk] of chunks) { + for (const entry of chunk.coinEntries) { if (entry.data.id === coinId) { entry.mesh.visible = false; return; } } + } } export function hideTurtleById(turtleId) { - for (const entry of turtleEntries) { if (entry.data.id === turtleId) { entry.mesh.visible = false; return; } } + for (const [, chunk] of chunks) { + for (const entry of chunk.turtleEntries) { if (entry.data.id === turtleId) { entry.mesh.visible = false; return; } } + } } export function updateSceneColors(hexColor) { @@ -528,21 +496,25 @@ export function updateSceneColors(hexColor) { } export function updateCoinRotation(dt) { - for (const entry of coinEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 2.0 * dt; } - for (const entry of turtleEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 1.5 * dt; } + for (const [, chunk] of chunks) { + for (const entry of chunk.coinEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 2.0 * dt; } + for (const entry of chunk.turtleEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 1.5 * dt; } + } } export function updateMovingWalls(timestamp) { const time = timestamp / 1000; - for (const entry of movingWallEntries) { - const w = entry.data; - const lateral = Math.sin(time * w.speed + w.phase) * w.range; - w.currentLateral = lateral; - const worldPos = trackToWorld(w.distance, lateral); - entry.mesh.position.set( - worldPos.x, - worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, - worldPos.z - ); + for (const [, chunk] of chunks) { + for (const entry of chunk.movingWallEntries) { + const w = entry.data; + const lateral = Math.sin(time * w.speed + w.phase) * w.range; + w.currentLateral = lateral; + const worldPos = trackToWorld(w.distance, lateral); + entry.mesh.position.set( + worldPos.x, + worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, + worldPos.z + ); + } } } diff --git a/js/track.js b/js/track.js index da495c1..bee898e 100644 --- a/js/track.js +++ b/js/track.js @@ -5,46 +5,106 @@ export const TRACK_WIDTH = 4.5; export const TRACK_HEIGHT = 0.2; export const BALL_RADIUS = 0.3; -// Define waypoints for the curved downhill course -// The course has multiple turns and a steady downhill slope -const WAYPOINTS = [ - new THREE.Vector3(0, 14, 0), - new THREE.Vector3(0, 13.2, 35), - new THREE.Vector3(10, 12, 70), - new THREE.Vector3(20, 10.5, 110), - new THREE.Vector3(18, 9, 150), - new THREE.Vector3(5, 7.2, 185), - new THREE.Vector3(-10, 5.5, 220), - new THREE.Vector3(-18, 4, 255), - new THREE.Vector3(-10, 2.5, 285), - new THREE.Vector3(0, 1.2, 310), - new THREE.Vector3(0, 0.5, 330), -]; - -// Create the CatmullRom curve through waypoints -const curve = new THREE.CatmullRomCurve3(WAYPOINTS, false, 'catmullrom', 0.5); - -// Cache the total length -const TRACK_LENGTH = curve.getLength(); - // Ball start distance (slightly into the track so there's track behind the ball) export const BALL_START_DISTANCE = 8; -// Finish line distance (near end of track) -export const FINISH_LINE_DISTANCE = TRACK_LENGTH - 12; +// Waypoint generation parameters +const WAYPOINT_Z_MIN = 30; +const WAYPOINT_Z_MAX = 40; +const WAYPOINT_X_WANDER = 15; +const WAYPOINT_X_CLAMP = 25; +const WAYPOINT_Y_DROP_MIN = 1.0; +const WAYPOINT_Y_DROP_MAX = 2.5; +const EXTEND_BUFFER = 150; + +// Seeded RNG for deterministic generation +function seededRandom(seed) { + let s = Math.abs(Math.floor(seed)) || 1; + return function () { + s = (s * 16807 + 0) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +// Dynamic track state +let waypoints = []; +let curve = null; +let trackLength = 0; +let trackSeed = 1; +let waypointRng = null; + +// Initialize the track with a seed +export function initTrack(seed) { + trackSeed = seed || Date.now(); + waypointRng = seededRandom(trackSeed); + + // Start with initial waypoints for a good opening section + waypoints = [ + new THREE.Vector3(0, 14, 0), + new THREE.Vector3(0, 13.2, 35), + new THREE.Vector3(10, 12, 70), + new THREE.Vector3(20, 10.5, 110), + ]; + + // Generate enough track for initial play + extendTrackWaypoints(10); + rebuildCurve(); +} + +function extendTrackWaypoints(count) { + for (let i = 0; i < count; i++) { + const prev = waypoints[waypoints.length - 1]; + const prevPrev = waypoints[waypoints.length - 2]; + + const z = prev.z + WAYPOINT_Z_MIN + waypointRng() * (WAYPOINT_Z_MAX - WAYPOINT_Z_MIN); + + // X wanders with some momentum from previous direction + const prevDx = prev.x - prevPrev.x; + const newDx = prevDx * 0.3 + (waypointRng() * 2 - 1) * WAYPOINT_X_WANDER; + const x = Math.max(-WAYPOINT_X_CLAMP, Math.min(WAYPOINT_X_CLAMP, prev.x + newDx)); + + // Y gently descends + const yDrop = WAYPOINT_Y_DROP_MIN + waypointRng() * (WAYPOINT_Y_DROP_MAX - WAYPOINT_Y_DROP_MIN); + const y = prev.y - yDrop; + + waypoints.push(new THREE.Vector3(x, y, z)); + } +} + +function rebuildCurve() { + curve = new THREE.CatmullRomCurve3(waypoints, false, 'catmullrom', 0.5); + // Scale arc-length divisions with track length for accuracy + const lastZ = waypoints[waypoints.length - 1].z; + curve.arcLengthDivisions = Math.max(200, Math.ceil(lastZ / 1.5)); + trackLength = curve.getLength(); +} + +// Ensure the track extends at least to minDistance + buffer +export function ensureTrackTo(minDistance) { + if (trackLength >= minDistance + EXTEND_BUFFER) return; + + let attempts = 0; + while (trackLength < minDistance + EXTEND_BUFFER && attempts < 50) { + extendTrackWaypoints(5); + rebuildCurve(); + attempts++; + } +} // Helper: clamp t to [0, 1] function clampT(d) { - return Math.max(0, Math.min(1, d / TRACK_LENGTH)); + return Math.max(0, Math.min(1, d / trackLength)); } // Get point on curve at arc-length distance d export function getPointAtDistance(d) { + ensureTrackTo(d); return curve.getPointAt(clampT(d)); } // Get tangent (forward direction) at distance d — normalized export function getTangentAtDistance(d) { + ensureTrackTo(d); return curve.getTangentAt(clampT(d)).normalize(); } @@ -84,7 +144,7 @@ export function getSlopeAtDistance(d) { // Get total track length export function getTrackLength() { - return TRACK_LENGTH; + return trackLength; } // Get the underlying curve object (for visualization etc.) @@ -92,22 +152,7 @@ export function getCurve() { return curve; } -// Find the nearest distance on the curve to a world point (approximate) -// Used for converting world position back to track coordinates -export function worldToTrackDistance(worldPos) { - const steps = 200; - let bestD = 0; - let bestDistSq = Infinity; - for (let i = 0; i <= steps; i++) { - const d = (i / steps) * TRACK_LENGTH; - const p = getPointAtDistance(d); - const dx = worldPos.x - p.x; - const dz = worldPos.z - p.z; - const distSq = dx * dx + dz * dz; - if (distSq < bestDistSq) { - bestDistSq = distSq; - bestD = d; - } - } - return bestD; +// Get the seed for the current track +export function getTrackSeed() { + return trackSeed; } diff --git a/public/js/main.js b/public/js/main.js index 7158272..624039b 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -15,6 +15,7 @@ import { updateCoinRotation, updateSceneColors, updateMovingWalls, + updateChunks, } from './renderer.js'; import { initTracker, calibrate, detectTilt, detectPitch, detectMouthOpen, resetTilt } from './tracker.js'; @@ -68,7 +69,6 @@ let score = 0; let finalScore = 0; let currentLevel = 1; let gameStartTime = 0; -let finishTime = 0; let rendererInitialized = false; // Cached leaderboard scores for rendering @@ -180,7 +180,6 @@ async function scoreQualifies(value) { return data.qualifies; } catch (err) { console.error('Failed to check score qualification:', err); - // Fallback: use cached scores if (cachedScores.length < MAX_SCORES) return true; return value > cachedScores[cachedScores.length - 1].score; } @@ -274,36 +273,6 @@ async function showLeaderboard() { function hideLeaderboard() { leaderboardPanel.classList.remove('visible'); } -// --- Finish state --- - -async function enterFinished(timestamp) { - finishTime = ((timestamp - gameStartTime) / 1000).toFixed(1); - finalScore = score; - state = 'finished'; - - gameoverTitle.textContent = 'FINISHED!'; - gameoverScore.textContent = 'Score: ' + finalScore + ' | Time: ' + finishTime + 's'; - gameoverMessage.textContent = 'Checking score...'; - nameEntry.classList.remove('visible'); - - levelEl.style.display = 'none'; - timerEl.style.display = 'none'; - speedEl.style.display = 'none'; - gameoverOverlay.classList.add('visible'); - - const qualifies = await scoreQualifies(finalScore); - if (qualifies) { - gameoverMessage.textContent = 'New high score!'; - nameEntry.classList.add('visible'); - nameInput.value = ''; - nameInput.focus(); - } else { - gameoverMessage.textContent = 'Great run!'; - nameEntry.classList.remove('visible'); - resetTimer = setTimeout(() => { exitGameOver(); }, NON_QUALIFYING_DELAY); - } -} - // --- Game over flow --- async function enterGameOver() { @@ -453,7 +422,6 @@ async function init() { ]); } catch (err) { timeout.cancel(); - // Stop camera stream to free resources on failure stream.getTracks().forEach(function(t) { t.stop(); }); if (err.message === 'INIT_TIMEOUT') { showError('Loading timed out.\nPlease check your connection and try again.', true); @@ -484,7 +452,6 @@ async function init() { requestAnimationFrame(gameLoop); } catch (err) { timeout.cancel(); - // Stop camera stream if it was acquired before the error if (stream) { stream.getTracks().forEach(function(t) { t.stop(); }); } @@ -534,6 +501,8 @@ function gameLoop(timestamp) { if (state === 'playing') { updateLevel(result.distance); updateTimer(timestamp); + // Generate new track chunks and cull old ones + updateChunks(result.distance); } updateBallPosition(result.x, result.y, result.z); @@ -561,12 +530,7 @@ function gameLoop(timestamp) { if (result.boostActive) { boostIndicator.classList.add('visible'); } else { boostIndicator.classList.remove('visible'); } - // Handle finish - if (result.finished && state === 'playing') { - enterFinished(timestamp); - } - - // Handle falling + // Handle falling — game over only when ball falls off track if (result.falling && state === 'playing') { state = 'falling'; } if (result.needsReset && state === 'falling') { enterGameOver(); } } diff --git a/public/js/physics.js b/public/js/physics.js index 83810c3..7f3a0e2 100644 --- a/public/js/physics.js +++ b/public/js/physics.js @@ -3,7 +3,6 @@ import { TRACK_HEIGHT, BALL_RADIUS, BALL_START_DISTANCE, - FINISH_LINE_DISTANCE, getPointAtDistance, getTangentAtDistance, getRightAtDistance, @@ -109,11 +108,6 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { ball.distance += ball.vForward * dt; ball.lateral += ball.vLateral * dt; - // Clamp distance to track length - if (ball.distance > getTrackLength()) { - ball.distance = getTrackLength(); - } - // Compute world position from track coordinates const centerPoint = getPointAtDistance(ball.distance); const right = getRightAtDistance(ball.distance); @@ -174,9 +168,6 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { } } - // Check finish line - const finished = ball.distance >= trackConfig.finishLineDistance; - return { x: ball.worldX, y: ball.worldY, @@ -186,7 +177,6 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { vz: ball.vForward, falling: ball.falling, needsReset: false, - finished, obstacleHit, coinsCollected: newlyCollected, turtleCollected: turtleJustCollected, @@ -216,7 +206,6 @@ function updateFalling(dt) { vz: ball.vForward, falling: true, needsReset, - finished: false, obstacleHit: false, coinsCollected: [], turtleCollected: null, diff --git a/public/js/renderer.js b/public/js/renderer.js index 012f9cd..e95accc 100644 --- a/public/js/renderer.js +++ b/public/js/renderer.js @@ -4,12 +4,13 @@ import { TRACK_HEIGHT, BALL_RADIUS, BALL_START_DISTANCE, - FINISH_LINE_DISTANCE, getPointAtDistance, getTangentAtDistance, getRightAtDistance, trackToWorld, getTrackLength, + initTrack, + ensureTrackTo, } from './track.js'; const SEGMENT_LENGTH = 1.5; @@ -32,17 +33,17 @@ const MOVING_WALL_START_DISTANCE = 40; const MOVING_WALL_MIN_SPACING = 15; const MOVING_WALL_MAX_SPACING = 25; +// Chunk system constants +const CHUNK_SIZE = 30; +const CHUNKS_AHEAD = 4; +const CHUNKS_BEHIND = 2; + let scene, camera, renderer, dirLight; let ballMesh; -let trackMeshes = []; -let edgeMeshes = []; -let obstacleMeshes = []; -let coinEntries = []; -let turtleEntries = []; -let movingWallEntries = []; -let finishLineMeshes = []; - +// Shared geometries and materials +const segGeo = new THREE.BoxGeometry(TRACK_WIDTH, TRACK_HEIGHT, SEGMENT_LENGTH * 1.05); +const edgeGeo = new THREE.BoxGeometry(0.06, 0.12, SEGMENT_LENGTH * 1.05); const obstGeo = new THREE.BoxGeometry(OBSTACLE_WIDTH, OBSTACLE_HEIGHT, OBSTACLE_DEPTH); const obstMat = new THREE.MeshStandardMaterial({ color: 0x8B2222, roughness: 0.5, metalness: 0.2 }); const movingWallGeo = new THREE.BoxGeometry(MOVING_WALL_WIDTH, MOVING_WALL_HEIGHT, MOVING_WALL_DEPTH); @@ -62,109 +63,56 @@ function seededRandom(seed) { let globalSeed = Date.now(); -function buildTrackMesh() { - const trackLength = getTrackLength(); - const numSegments = Math.ceil(trackLength / SEGMENT_LENGTH); - const segGeo = new THREE.BoxGeometry(TRACK_WIDTH, TRACK_HEIGHT, SEGMENT_LENGTH * 1.05); - const edgeGeo = new THREE.BoxGeometry(0.06, 0.12, SEGMENT_LENGTH * 1.05); +// Chunk storage: Map +let chunks = new Map(); - for (let i = 0; i < numSegments; i++) { - const d = (i + 0.5) * SEGMENT_LENGTH; - if (d > trackLength) break; +function createChunkData() { + return { + trackMeshes: [], + edgeMeshes: [], + obstacleMeshes: [], + obstacleData: [], + coinEntries: [], + turtleEntries: [], + movingWallEntries: [], + }; +} +function buildChunkTrackMesh(chunk, startD, endD) { + const numSegments = Math.ceil((endD - startD) / SEGMENT_LENGTH); + for (let i = 0; i < numSegments; i++) { + const d = startD + (i + 0.5) * SEGMENT_LENGTH; + if (d > endD) break; const pos = getPointAtDistance(d); const tangent = getTangentAtDistance(d); - const tMesh = new THREE.Mesh(segGeo, trackMat); tMesh.position.copy(pos); const forward = new THREE.Vector3(0, 0, 1); tMesh.quaternion.setFromUnitVectors(forward, tangent); tMesh.receiveShadow = true; scene.add(tMesh); - trackMeshes.push(tMesh); - + chunk.trackMeshes.push(tMesh); const right = getRightAtDistance(d); const halfW = TRACK_WIDTH / 2; - const eLeft = new THREE.Mesh(edgeGeo, edgeMat); eLeft.position.set(pos.x - right.x * halfW, pos.y + TRACK_HEIGHT / 2 + 0.06, pos.z - right.z * halfW); eLeft.quaternion.copy(tMesh.quaternion); scene.add(eLeft); - edgeMeshes.push(eLeft); - + chunk.edgeMeshes.push(eLeft); const eRight = new THREE.Mesh(edgeGeo, edgeMat); eRight.position.set(pos.x + right.x * halfW, pos.y + TRACK_HEIGHT / 2 + 0.06, pos.z + right.z * halfW); eRight.quaternion.copy(tMesh.quaternion); scene.add(eRight); - edgeMeshes.push(eRight); - } -} - -function buildFinishLine() { - const d = FINISH_LINE_DISTANCE; - const pos = getPointAtDistance(d); - const tangent = getTangentAtDistance(d); - const right = getRightAtDistance(d); - const halfW = TRACK_WIDTH / 2; - const trackY = pos.y + TRACK_HEIGHT / 2; - - const bannerWidth = TRACK_WIDTH + 0.5; - const bannerHeight = 2.0; - const bannerGeo = new THREE.PlaneGeometry(bannerWidth, bannerHeight); - - const canvas = document.createElement('canvas'); - canvas.width = 64; - canvas.height = 16; - const ctx = canvas.getContext('2d'); - for (let row = 0; row < 2; row++) { - for (let col = 0; col < 8; col++) { - ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#000000'; - ctx.fillRect(col * 8, row * 8, 8, 8); - } + chunk.edgeMeshes.push(eRight); } - const texture = new THREE.CanvasTexture(canvas); - texture.magFilter = THREE.NearestFilter; - texture.minFilter = THREE.NearestFilter; - - const bannerMat = new THREE.MeshStandardMaterial({ map: texture, side: THREE.DoubleSide, roughness: 0.5, metalness: 0.1 }); - - const banner = new THREE.Mesh(bannerGeo, bannerMat); - banner.position.set(pos.x, trackY + bannerHeight / 2 + 0.5, pos.z); - banner.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), tangent); - scene.add(banner); - finishLineMeshes.push(banner); - - const poleGeo = new THREE.CylinderGeometry(0.08, 0.08, bannerHeight + 1.2, 8); - const poleMat = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.4, metalness: 0.6 }); - - const poleLeft = new THREE.Mesh(poleGeo, poleMat); - poleLeft.position.set(pos.x - right.x * (halfW + 0.1), trackY + (bannerHeight + 1.2) / 2 - 0.1, pos.z - right.z * (halfW + 0.1)); - scene.add(poleLeft); - finishLineMeshes.push(poleLeft); - - const poleRight = new THREE.Mesh(poleGeo, poleMat); - poleRight.position.set(pos.x + right.x * (halfW + 0.1), trackY + (bannerHeight + 1.2) / 2 - 0.1, pos.z + right.z * (halfW + 0.1)); - scene.add(poleRight); - finishLineMeshes.push(poleRight); - - const lineGeo = new THREE.PlaneGeometry(TRACK_WIDTH, 0.3); - const lineMat = new THREE.MeshStandardMaterial({ color: 0xffffff, side: THREE.DoubleSide, roughness: 0.3 }); - const line = new THREE.Mesh(lineGeo, lineMat); - line.position.set(pos.x, trackY + 0.01, pos.z); - line.quaternion.setFromUnitVectors(new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 1, 0)); - const yaw = Math.atan2(tangent.x, tangent.z); - line.rotateOnWorldAxis(new THREE.Vector3(0, 1, 0), yaw); - scene.add(line); - finishLineMeshes.push(line); } -function generateObstacles() { - const rng = seededRandom(globalSeed); +function generateChunkObstacles(chunkIndex, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000); const obstacles = []; const halfTrack = TRACK_WIDTH / 2; - let d = SAFE_ZONE_DISTANCE; - const endD = FINISH_LINE_DISTANCE - 5; - + let d = startD; + if (d < SAFE_ZONE_DISTANCE) d = SAFE_ZONE_DISTANCE; while (d < endD) { const spacing = OBSTACLE_MIN_SPACING + rng() * (OBSTACLE_MAX_SPACING - OBSTACLE_MIN_SPACING); d += spacing; @@ -176,14 +124,13 @@ function generateObstacles() { return obstacles; } -function generateCoins(obstacles) { - const rng = seededRandom(globalSeed + 9973); +function generateChunkCoins(chunkIndex, obstacles, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000 + 9973); const coins = []; const halfTrack = TRACK_WIDTH / 2; - const endD = FINISH_LINE_DISTANCE - 3; - + const safeStart = Math.max(startD, SAFE_ZONE_DISTANCE + 1); for (let i = 0; i < obstacles.length; i++) { - const sD = i === 0 ? SAFE_ZONE_DISTANCE + 1 : obstacles[i - 1].distance + 1; + const sD = i === 0 ? safeStart : obstacles[i - 1].distance + 1; const eD = obstacles[i].distance - 1; const gap = eD - sD; if (gap < 2) continue; @@ -193,8 +140,7 @@ function generateCoins(obstacles) { coins.push({ distance: sD + step * j, lateral: (rng() * 2 - 1) * (halfTrack - 0.5) }); } } - - const lastD = obstacles.length > 0 ? obstacles[obstacles.length - 1].distance + 1 : SAFE_ZONE_DISTANCE + 1; + const lastD = obstacles.length > 0 ? obstacles[obstacles.length - 1].distance + 1 : safeStart; const gap = endD - lastD; if (gap >= 3) { const count = Math.min(3, Math.max(2, Math.floor(gap / 4))); @@ -206,13 +152,13 @@ function generateCoins(obstacles) { return coins; } -function generateTurtles(obstacles) { - const rng = seededRandom(globalSeed + 4201); +function generateChunkTurtles(chunkIndex, obstacles, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000 + 4201); const turtles = []; const halfTrack = TRACK_WIDTH / 2; - const endD = FINISH_LINE_DISTANCE - 5; - - for (let segStart = SAFE_ZONE_DISTANCE + 10; segStart < endD; segStart += TURTLE_SEGMENT_LENGTH) { + const safeStart = Math.max(startD, SAFE_ZONE_DISTANCE + 10); + if (safeStart >= endD) return turtles; + for (let segStart = safeStart; segStart < endD; segStart += TURTLE_SEGMENT_LENGTH) { if (rng() >= TURTLE_SPAWN_CHANCE) continue; const segEnd = Math.min(segStart + TURTLE_SEGMENT_LENGTH, endD); let attempts = 0; @@ -232,86 +178,60 @@ function generateTurtles(obstacles) { return turtles; } -function createTurtleMesh() { - const group = new THREE.Group(); - const bodyMat = new THREE.MeshStandardMaterial({ color: 0x228B22, roughness: 0.6, metalness: 0.1 }); - const shellMat = new THREE.MeshStandardMaterial({ color: 0x185818, roughness: 0.5, metalness: 0.15 }); - const headMat = new THREE.MeshStandardMaterial({ color: 0x2EA52E, roughness: 0.5, metalness: 0.1 }); - - const shell = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), shellMat); - shell.scale.set(1, 0.5, 1.1); - shell.position.y = 0.1; - group.add(shell); - - const body = new THREE.Mesh(new THREE.SphereGeometry(0.35, 12, 10), bodyMat); - body.scale.set(1, 0.35, 1.05); - body.position.y = -0.02; - group.add(body); - - const head = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), headMat); - head.position.set(0, 0.05, 0.42); - group.add(head); - - const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.12, 6); - for (const p of [{ x: -0.22, z: 0.2 }, { x: 0.22, z: 0.2 }, { x: -0.22, z: -0.2 }, { x: 0.22, z: -0.2 }]) { - const leg = new THREE.Mesh(legGeo, bodyMat); - leg.position.set(p.x, -0.1, p.z); - group.add(leg); - } - return group; -} - -function generateMovingWalls(staticObstacles) { - const rng = seededRandom(globalSeed + 7777); +function generateChunkMovingWalls(chunkIndex, staticObstacles, startD, endD) { + const rng = seededRandom(globalSeed + chunkIndex * 1000 + 7777); const walls = []; - const endD = FINISH_LINE_DISTANCE - 5; - let d = MOVING_WALL_START_DISTANCE; - + let d = Math.max(startD, MOVING_WALL_START_DISTANCE); + if (d >= endD) return walls; while (d < endD) { const spacing = MOVING_WALL_MIN_SPACING + rng() * (MOVING_WALL_MAX_SPACING - MOVING_WALL_MIN_SPACING); d += spacing; if (d >= endD) break; - - // Avoid placing too close to static obstacles let tooClose = false; for (const o of staticObstacles) { if (Math.abs(d - o.distance) < 3) { tooClose = true; break; } } if (tooClose) continue; - const phase = rng() * Math.PI * 2; const speed = 1.2 + rng() * 0.8; const range = (TRACK_WIDTH / 2) - (MOVING_WALL_WIDTH / 2) - 0.3; - walls.push({ distance: d, halfW: MOVING_WALL_WIDTH / 2, halfD: MOVING_WALL_DEPTH / 2, - speed, - range, - phase, + speed, range, phase, currentLateral: 0, }); } return walls; } -function placeMovingWalls(wallData) { - for (let i = 0; i < wallData.length; i++) { - const w = wallData[i]; - const worldPos = trackToWorld(w.distance, 0); - const tangent = getTangentAtDistance(w.distance); - const mesh = new THREE.Mesh(movingWallGeo, movingWallMat); - mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, worldPos.z); - mesh.rotation.y = Math.atan2(tangent.x, tangent.z); - mesh.castShadow = true; - mesh.receiveShadow = true; - scene.add(mesh); - movingWallEntries.push({ mesh, data: { ...w, id: 'mw_' + i } }); +function createTurtleMesh() { + const group = new THREE.Group(); + const bodyMat = new THREE.MeshStandardMaterial({ color: 0x228B22, roughness: 0.6, metalness: 0.1 }); + const shellMat = new THREE.MeshStandardMaterial({ color: 0x185818, roughness: 0.5, metalness: 0.15 }); + const headMat = new THREE.MeshStandardMaterial({ color: 0x2EA52E, roughness: 0.5, metalness: 0.1 }); + const shell = new THREE.Mesh(new THREE.SphereGeometry(0.4, 16, 12), shellMat); + shell.scale.set(1, 0.5, 1.1); + shell.position.y = 0.1; + group.add(shell); + const body = new THREE.Mesh(new THREE.SphereGeometry(0.35, 12, 10), bodyMat); + body.scale.set(1, 0.35, 1.05); + body.position.y = -0.02; + group.add(body); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), headMat); + head.position.set(0, 0.05, 0.42); + group.add(head); + const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.12, 6); + for (const p of [{ x: -0.22, z: 0.2 }, { x: 0.22, z: 0.2 }, { x: -0.22, z: -0.2 }, { x: 0.22, z: -0.2 }]) { + const leg = new THREE.Mesh(legGeo, bodyMat); + leg.position.set(p.x, -0.1, p.z); + group.add(leg); } + return group; } -function placeObstacles(obstacleData) { +function placeChunkObstacles(chunk, obstacleData, chunkIndex) { for (let i = 0; i < obstacleData.length; i++) { const o = obstacleData[i]; const worldPos = trackToWorld(o.distance, o.lateral); @@ -322,11 +242,12 @@ function placeObstacles(obstacleData) { mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); - obstacleMeshes.push(mesh); + chunk.obstacleMeshes.push(mesh); + chunk.obstacleData.push(o); } } -function placeCoins(coinData) { +function placeChunkCoins(chunk, coinData, chunkIndex) { for (let i = 0; i < coinData.length; i++) { const c = coinData[i]; const worldPos = trackToWorld(c.distance, c.lateral); @@ -334,55 +255,88 @@ function placeCoins(coinData) { mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + 0.35, worldPos.z); mesh.rotation.x = Math.PI / 2; scene.add(mesh); - coinEntries.push({ mesh, data: { distance: c.distance, lateral: c.lateral, id: 'c_' + i } }); + chunk.coinEntries.push({ mesh, data: { distance: c.distance, lateral: c.lateral, id: 'c_' + chunkIndex + '_' + i } }); } } -function placeTurtles(turtleData) { +function placeChunkTurtles(chunk, turtleData, chunkIndex) { for (let i = 0; i < turtleData.length; i++) { const t = turtleData[i]; const worldPos = trackToWorld(t.distance, t.lateral); const mesh = createTurtleMesh(); mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + 0.35, worldPos.z); scene.add(mesh); - turtleEntries.push({ mesh, data: { distance: t.distance, lateral: t.lateral, id: 't_' + i } }); + chunk.turtleEntries.push({ mesh, data: { distance: t.distance, lateral: t.lateral, id: 't_' + chunkIndex + '_' + i } }); } } -let obstacleDataCache = []; - -function buildFullTrack() { - buildTrackMesh(); - buildFinishLine(); - obstacleDataCache = generateObstacles(); - const coinData = generateCoins(obstacleDataCache); - const turtleData = generateTurtles(obstacleDataCache); - const movingWallData = generateMovingWalls(obstacleDataCache); - placeObstacles(obstacleDataCache); - placeCoins(coinData); - placeTurtles(turtleData); - placeMovingWalls(movingWallData); +function placeChunkMovingWalls(chunk, wallData, chunkIndex) { + for (let i = 0; i < wallData.length; i++) { + const w = wallData[i]; + const worldPos = trackToWorld(w.distance, 0); + const tangent = getTangentAtDistance(w.distance); + const mesh = new THREE.Mesh(movingWallGeo, movingWallMat); + mesh.position.set(worldPos.x, worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, worldPos.z); + mesh.rotation.y = Math.atan2(tangent.x, tangent.z); + mesh.castShadow = true; + mesh.receiveShadow = true; + scene.add(mesh); + chunk.movingWallEntries.push({ mesh, data: { ...w, id: 'mw_' + chunkIndex + '_' + i } }); + } +} + +function buildChunk(chunkIndex) { + if (chunks.has(chunkIndex)) return; + const startD = chunkIndex * CHUNK_SIZE; + const endD = startD + CHUNK_SIZE; + ensureTrackTo(endD + 10); + const chunk = createChunkData(); + buildChunkTrackMesh(chunk, startD, endD); + const obstacleData = generateChunkObstacles(chunkIndex, startD, endD); + placeChunkObstacles(chunk, obstacleData, chunkIndex); + const coinData = generateChunkCoins(chunkIndex, obstacleData, startD, endD); + placeChunkCoins(chunk, coinData, chunkIndex); + const turtleData = generateChunkTurtles(chunkIndex, obstacleData, startD, endD); + placeChunkTurtles(chunk, turtleData, chunkIndex); + const movingWallData = generateChunkMovingWalls(chunkIndex, obstacleData, startD, endD); + placeChunkMovingWalls(chunk, movingWallData, chunkIndex); + chunks.set(chunkIndex, chunk); } -function clearTrack() { - for (const m of trackMeshes) scene.remove(m); - for (const m of edgeMeshes) scene.remove(m); - for (const m of obstacleMeshes) scene.remove(m); - for (const e of coinEntries) scene.remove(e.mesh); - for (const e of turtleEntries) { +function destroyChunk(chunkIndex) { + const chunk = chunks.get(chunkIndex); + if (!chunk) return; + for (const m of chunk.trackMeshes) scene.remove(m); + for (const m of chunk.edgeMeshes) scene.remove(m); + for (const m of chunk.obstacleMeshes) scene.remove(m); + for (const e of chunk.coinEntries) scene.remove(e.mesh); + for (const e of chunk.turtleEntries) { e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.material.dispose(); } }); scene.remove(e.mesh); } - for (const e of movingWallEntries) scene.remove(e.mesh); - for (const m of finishLineMeshes) scene.remove(m); - trackMeshes = []; - edgeMeshes = []; - obstacleMeshes = []; - coinEntries = []; - turtleEntries = []; - movingWallEntries = []; - finishLineMeshes = []; - obstacleDataCache = []; + for (const e of chunk.movingWallEntries) scene.remove(e.mesh); + chunks.delete(chunkIndex); +} + +export function updateChunks(ballDistance) { + const ballChunk = Math.floor(ballDistance / CHUNK_SIZE); + const minChunk = Math.max(0, ballChunk - CHUNKS_BEHIND); + const maxChunk = ballChunk + CHUNKS_AHEAD; + for (let i = minChunk; i <= maxChunk; i++) { + buildChunk(i); + } + for (const [idx] of chunks) { + if (idx < minChunk || idx > maxChunk) { + destroyChunk(idx); + } + } +} + +function clearAllChunks() { + for (const [idx] of chunks) { + destroyChunk(idx); + } + chunks = new Map(); } export function initRenderer() { @@ -390,6 +344,9 @@ export function initRenderer() { scene.background = new THREE.Color(0x87CEEB); scene.fog = new THREE.Fog(0x87CEEB, 40, 120); + globalSeed = Date.now(); + initTrack(globalSeed); + const startPos = getPointAtDistance(BALL_START_DISTANCE); camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 300); camera.position.set(startPos.x, startPos.y + 4, startPos.z - 8); @@ -427,7 +384,7 @@ export function initRenderer() { ballMesh.position.y += TRACK_HEIGHT / 2 + BALL_RADIUS; scene.add(ballMesh); - buildFullTrack(); + updateChunks(BALL_START_DISTANCE); window.addEventListener('resize', onResize); return { scene, camera, renderer }; } @@ -453,7 +410,6 @@ export function updateCamera(ballDistance, ballWorldX, ballWorldY, ballWorldZ) { const tangent = getTangentAtDistance(ballDistance); const targetCamPos = new THREE.Vector3(ballWorldX - tangent.x * 10, ballWorldY + 5, ballWorldZ - tangent.z * 10); const targetLookAt = new THREE.Vector3(ballWorldX + tangent.x * 5, ballWorldY, ballWorldZ + tangent.z * 5); - if (!cameraInitialized) { smoothCamPos.copy(targetCamPos); smoothCamTarget.copy(targetLookAt); @@ -462,7 +418,6 @@ export function updateCamera(ballDistance, ballWorldX, ballWorldY, ballWorldZ) { smoothCamPos.lerp(targetCamPos, 0.04); smoothCamTarget.lerp(targetLookAt, 0.04); } - camera.position.copy(smoothCamPos); camera.lookAt(smoothCamTarget); dirLight.position.set(ballWorldX + 5, ballWorldY + 10, ballWorldZ + 5); @@ -474,51 +429,64 @@ export function render() { renderer.render(scene, camera); } export function getTrackConfig() { return { trackWidth: TRACK_WIDTH, trackHeight: TRACK_HEIGHT, ballRadius: BALL_RADIUS, - ballStartDistance: BALL_START_DISTANCE, finishLineDistance: FINISH_LINE_DISTANCE, - trackLength: getTrackLength(), + ballStartDistance: BALL_START_DISTANCE, }; } export function resetTrack() { - clearTrack(); + clearAllChunks(); globalSeed = Date.now(); - buildFullTrack(); + initTrack(globalSeed); + updateChunks(BALL_START_DISTANCE); cameraInitialized = false; } export function getActiveObstacles() { - const result = obstacleDataCache.map((o) => ({ distance: o.distance, lateral: o.lateral, halfW: o.halfW, halfD: o.halfD, height: OBSTACLE_HEIGHT })); - for (const entry of movingWallEntries) { - const w = entry.data; - result.push({ - distance: w.distance, - lateral: w.currentLateral, - halfW: w.halfW, - halfD: w.halfD, - height: MOVING_WALL_HEIGHT, - }); + const result = []; + for (const [, chunk] of chunks) { + for (const o of chunk.obstacleData) { + result.push({ distance: o.distance, lateral: o.lateral, halfW: o.halfW, halfD: o.halfD, height: OBSTACLE_HEIGHT }); + } + for (const entry of chunk.movingWallEntries) { + const w = entry.data; + result.push({ + distance: w.distance, + lateral: w.currentLateral, + halfW: w.halfW, + halfD: w.halfD, + height: MOVING_WALL_HEIGHT, + }); + } } return result; } export function getActiveCoins() { const result = []; - for (const entry of coinEntries) { if (entry.mesh.visible) result.push(entry.data); } + for (const [, chunk] of chunks) { + for (const entry of chunk.coinEntries) { if (entry.mesh.visible) result.push(entry.data); } + } return result; } export function getActiveTurtles() { const result = []; - for (const entry of turtleEntries) { if (entry.mesh.visible) result.push(entry.data); } + for (const [, chunk] of chunks) { + for (const entry of chunk.turtleEntries) { if (entry.mesh.visible) result.push(entry.data); } + } return result; } export function hideCoinById(coinId) { - for (const entry of coinEntries) { if (entry.data.id === coinId) { entry.mesh.visible = false; return; } } + for (const [, chunk] of chunks) { + for (const entry of chunk.coinEntries) { if (entry.data.id === coinId) { entry.mesh.visible = false; return; } } + } } export function hideTurtleById(turtleId) { - for (const entry of turtleEntries) { if (entry.data.id === turtleId) { entry.mesh.visible = false; return; } } + for (const [, chunk] of chunks) { + for (const entry of chunk.turtleEntries) { if (entry.data.id === turtleId) { entry.mesh.visible = false; return; } } + } } export function updateSceneColors(hexColor) { @@ -528,21 +496,25 @@ export function updateSceneColors(hexColor) { } export function updateCoinRotation(dt) { - for (const entry of coinEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 2.0 * dt; } - for (const entry of turtleEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 1.5 * dt; } + for (const [, chunk] of chunks) { + for (const entry of chunk.coinEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 2.0 * dt; } + for (const entry of chunk.turtleEntries) { if (entry.mesh.visible) entry.mesh.rotation.y += 1.5 * dt; } + } } export function updateMovingWalls(timestamp) { const time = timestamp / 1000; - for (const entry of movingWallEntries) { - const w = entry.data; - const lateral = Math.sin(time * w.speed + w.phase) * w.range; - w.currentLateral = lateral; - const worldPos = trackToWorld(w.distance, lateral); - entry.mesh.position.set( - worldPos.x, - worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, - worldPos.z - ); + for (const [, chunk] of chunks) { + for (const entry of chunk.movingWallEntries) { + const w = entry.data; + const lateral = Math.sin(time * w.speed + w.phase) * w.range; + w.currentLateral = lateral; + const worldPos = trackToWorld(w.distance, lateral); + entry.mesh.position.set( + worldPos.x, + worldPos.y + TRACK_HEIGHT / 2 + MOVING_WALL_HEIGHT / 2, + worldPos.z + ); + } } } diff --git a/public/js/track.js b/public/js/track.js index da495c1..bee898e 100644 --- a/public/js/track.js +++ b/public/js/track.js @@ -5,46 +5,106 @@ export const TRACK_WIDTH = 4.5; export const TRACK_HEIGHT = 0.2; export const BALL_RADIUS = 0.3; -// Define waypoints for the curved downhill course -// The course has multiple turns and a steady downhill slope -const WAYPOINTS = [ - new THREE.Vector3(0, 14, 0), - new THREE.Vector3(0, 13.2, 35), - new THREE.Vector3(10, 12, 70), - new THREE.Vector3(20, 10.5, 110), - new THREE.Vector3(18, 9, 150), - new THREE.Vector3(5, 7.2, 185), - new THREE.Vector3(-10, 5.5, 220), - new THREE.Vector3(-18, 4, 255), - new THREE.Vector3(-10, 2.5, 285), - new THREE.Vector3(0, 1.2, 310), - new THREE.Vector3(0, 0.5, 330), -]; - -// Create the CatmullRom curve through waypoints -const curve = new THREE.CatmullRomCurve3(WAYPOINTS, false, 'catmullrom', 0.5); - -// Cache the total length -const TRACK_LENGTH = curve.getLength(); - // Ball start distance (slightly into the track so there's track behind the ball) export const BALL_START_DISTANCE = 8; -// Finish line distance (near end of track) -export const FINISH_LINE_DISTANCE = TRACK_LENGTH - 12; +// Waypoint generation parameters +const WAYPOINT_Z_MIN = 30; +const WAYPOINT_Z_MAX = 40; +const WAYPOINT_X_WANDER = 15; +const WAYPOINT_X_CLAMP = 25; +const WAYPOINT_Y_DROP_MIN = 1.0; +const WAYPOINT_Y_DROP_MAX = 2.5; +const EXTEND_BUFFER = 150; + +// Seeded RNG for deterministic generation +function seededRandom(seed) { + let s = Math.abs(Math.floor(seed)) || 1; + return function () { + s = (s * 16807 + 0) % 2147483647; + return (s - 1) / 2147483646; + }; +} + +// Dynamic track state +let waypoints = []; +let curve = null; +let trackLength = 0; +let trackSeed = 1; +let waypointRng = null; + +// Initialize the track with a seed +export function initTrack(seed) { + trackSeed = seed || Date.now(); + waypointRng = seededRandom(trackSeed); + + // Start with initial waypoints for a good opening section + waypoints = [ + new THREE.Vector3(0, 14, 0), + new THREE.Vector3(0, 13.2, 35), + new THREE.Vector3(10, 12, 70), + new THREE.Vector3(20, 10.5, 110), + ]; + + // Generate enough track for initial play + extendTrackWaypoints(10); + rebuildCurve(); +} + +function extendTrackWaypoints(count) { + for (let i = 0; i < count; i++) { + const prev = waypoints[waypoints.length - 1]; + const prevPrev = waypoints[waypoints.length - 2]; + + const z = prev.z + WAYPOINT_Z_MIN + waypointRng() * (WAYPOINT_Z_MAX - WAYPOINT_Z_MIN); + + // X wanders with some momentum from previous direction + const prevDx = prev.x - prevPrev.x; + const newDx = prevDx * 0.3 + (waypointRng() * 2 - 1) * WAYPOINT_X_WANDER; + const x = Math.max(-WAYPOINT_X_CLAMP, Math.min(WAYPOINT_X_CLAMP, prev.x + newDx)); + + // Y gently descends + const yDrop = WAYPOINT_Y_DROP_MIN + waypointRng() * (WAYPOINT_Y_DROP_MAX - WAYPOINT_Y_DROP_MIN); + const y = prev.y - yDrop; + + waypoints.push(new THREE.Vector3(x, y, z)); + } +} + +function rebuildCurve() { + curve = new THREE.CatmullRomCurve3(waypoints, false, 'catmullrom', 0.5); + // Scale arc-length divisions with track length for accuracy + const lastZ = waypoints[waypoints.length - 1].z; + curve.arcLengthDivisions = Math.max(200, Math.ceil(lastZ / 1.5)); + trackLength = curve.getLength(); +} + +// Ensure the track extends at least to minDistance + buffer +export function ensureTrackTo(minDistance) { + if (trackLength >= minDistance + EXTEND_BUFFER) return; + + let attempts = 0; + while (trackLength < minDistance + EXTEND_BUFFER && attempts < 50) { + extendTrackWaypoints(5); + rebuildCurve(); + attempts++; + } +} // Helper: clamp t to [0, 1] function clampT(d) { - return Math.max(0, Math.min(1, d / TRACK_LENGTH)); + return Math.max(0, Math.min(1, d / trackLength)); } // Get point on curve at arc-length distance d export function getPointAtDistance(d) { + ensureTrackTo(d); return curve.getPointAt(clampT(d)); } // Get tangent (forward direction) at distance d — normalized export function getTangentAtDistance(d) { + ensureTrackTo(d); return curve.getTangentAt(clampT(d)).normalize(); } @@ -84,7 +144,7 @@ export function getSlopeAtDistance(d) { // Get total track length export function getTrackLength() { - return TRACK_LENGTH; + return trackLength; } // Get the underlying curve object (for visualization etc.) @@ -92,22 +152,7 @@ export function getCurve() { return curve; } -// Find the nearest distance on the curve to a world point (approximate) -// Used for converting world position back to track coordinates -export function worldToTrackDistance(worldPos) { - const steps = 200; - let bestD = 0; - let bestDistSq = Infinity; - for (let i = 0; i <= steps; i++) { - const d = (i / steps) * TRACK_LENGTH; - const p = getPointAtDistance(d); - const dx = worldPos.x - p.x; - const dz = worldPos.z - p.z; - const distSq = dx * dx + dz * dz; - if (distSq < bestDistSq) { - bestDistSq = distSq; - bestD = d; - } - } - return bestD; +// Get the seed for the current track +export function getTrackSeed() { + return trackSeed; } diff --git a/server.js b/server.js index 6279052..1110445 100644 --- a/server.js +++ b/server.js @@ -18,7 +18,7 @@ const MAX_SCORES = 10; const MAX_RETAINED_SCORES = parseInt(process.env.MAX_RETAINED_SCORES, 10) || 1000; // Score validation constants -const MAX_REASONABLE_SCORE = 10000; +const MAX_REASONABLE_SCORE = 1000000; const MAX_NAME_LENGTH = 15; // Security headers — inline importmap allowed via hash; everything else in external files From 008747ad7ef5d645fc8f82d655a6132e556b3485 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:40:45 +0000 Subject: [PATCH 2/5] fix: address security review - revert server.js change, restore comments, minimize diff - Revert MAX_REASONABLE_SCORE back to original 10000 (out of scope) - Restore removed comments in main.js to minimize diff noise - All changes now strictly scoped to infinite endless mode feature Co-Authored-By: bot_apk --- js/main.js | 3 +++ public/js/main.js | 3 +++ server.js | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/js/main.js b/js/main.js index 624039b..a6a9882 100644 --- a/js/main.js +++ b/js/main.js @@ -180,6 +180,7 @@ async function scoreQualifies(value) { return data.qualifies; } catch (err) { console.error('Failed to check score qualification:', err); + // Fallback: use cached scores if (cachedScores.length < MAX_SCORES) return true; return value > cachedScores[cachedScores.length - 1].score; } @@ -422,6 +423,7 @@ async function init() { ]); } catch (err) { timeout.cancel(); + // Stop camera stream to free resources on failure stream.getTracks().forEach(function(t) { t.stop(); }); if (err.message === 'INIT_TIMEOUT') { showError('Loading timed out.\nPlease check your connection and try again.', true); @@ -452,6 +454,7 @@ async function init() { requestAnimationFrame(gameLoop); } catch (err) { timeout.cancel(); + // Stop camera stream if it was acquired before the error if (stream) { stream.getTracks().forEach(function(t) { t.stop(); }); } diff --git a/public/js/main.js b/public/js/main.js index 624039b..a6a9882 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -180,6 +180,7 @@ async function scoreQualifies(value) { return data.qualifies; } catch (err) { console.error('Failed to check score qualification:', err); + // Fallback: use cached scores if (cachedScores.length < MAX_SCORES) return true; return value > cachedScores[cachedScores.length - 1].score; } @@ -422,6 +423,7 @@ async function init() { ]); } catch (err) { timeout.cancel(); + // Stop camera stream to free resources on failure stream.getTracks().forEach(function(t) { t.stop(); }); if (err.message === 'INIT_TIMEOUT') { showError('Loading timed out.\nPlease check your connection and try again.', true); @@ -452,6 +454,7 @@ async function init() { requestAnimationFrame(gameLoop); } catch (err) { timeout.cancel(); + // Stop camera stream if it was acquired before the error if (stream) { stream.getTracks().forEach(function(t) { t.stop(); }); } diff --git a/server.js b/server.js index 1110445..6279052 100644 --- a/server.js +++ b/server.js @@ -18,7 +18,7 @@ const MAX_SCORES = 10; const MAX_RETAINED_SCORES = parseInt(process.env.MAX_RETAINED_SCORES, 10) || 1000; // Score validation constants -const MAX_REASONABLE_SCORE = 1000000; +const MAX_REASONABLE_SCORE = 10000; const MAX_NAME_LENGTH = 15; // Security headers — inline importmap allowed via hash; everything else in external files From 4f219f08aaad22cba318cabc50e020d5e4d87c85 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:47:36 +0000 Subject: [PATCH 3/5] fix: scope root js/ changes to only endless-mode edits - Restore original js/main.js and js/physics.js from main branch - Apply only task-scoped changes: remove finish line, add updateChunks - Root js/ files now preserve their original structure (localStorage leaderboard, simpler init) with only endless-mode modifications - No server.js changes (reverted in previous commit) Co-Authored-By: bot_apk --- js/main.js | 339 ++++++++------------------------------------------ js/physics.js | 15 +-- 2 files changed, 56 insertions(+), 298 deletions(-) diff --git a/js/main.js b/js/main.js index a6a9882..dec5dc1 100644 --- a/js/main.js +++ b/js/main.js @@ -19,7 +19,7 @@ import { } from './renderer.js'; import { initTracker, calibrate, detectTilt, detectPitch, detectMouthOpen, resetTilt } from './tracker.js'; -import { initPhysics, updatePhysics, resetBall, updateLevelData, setSensitivity, getSensitivity, DEFAULT_SENSITIVITY } from './physics.js'; +import { initPhysics, updatePhysics, resetBall, updateLevelData } from './physics.js'; import { getPointAtDistance } from './track.js'; const overlay = document.getElementById('overlay'); @@ -40,22 +40,11 @@ const slowdownIndicator = document.getElementById('slowdown-indicator'); const boostIndicator = document.getElementById('boost-indicator'); const levelEl = document.getElementById('level'); const timerEl = document.getElementById('timer'); -const retryBtn = document.getElementById('retry-btn'); -const speedEl = document.getElementById('speed'); -const settingsBtn = document.getElementById('settings-btn'); -const settingsPanel = document.getElementById('settings-panel'); -const settingsClose = document.getElementById('settings-close'); -const sensitivitySlider = document.getElementById('sensitivity-slider'); -const sensitivityValue = document.getElementById('sensitivity-value'); -const sensitivityReset = document.getElementById('sensitivity-reset'); - -const INIT_TIMEOUT_MS = 15000; - -const SENSITIVITY_STORAGE_KEY = 'teeter_sensitivity'; + +const STORAGE_KEY = 'teeter_highscores'; const MAX_SCORES = 10; const NON_QUALIFYING_DELAY = 2000; const CHUNK_LENGTH = 20; -const API_BASE = '/api'; const LEVEL_COLORS = [ 0x87CEEB, 0xFFB347, 0x77DD77, 0xCB99C9, 0xFF6961, @@ -69,10 +58,6 @@ let score = 0; let finalScore = 0; let currentLevel = 1; let gameStartTime = 0; -let rendererInitialized = false; - -// Cached leaderboard scores for rendering -let cachedScores = []; function updateScore(value) { score = value; @@ -103,195 +88,68 @@ function resetLevel() { updateSceneColors(LEVEL_COLORS[0]); } -// --- Sensitivity settings --- +// --- localStorage leaderboard --- -function getSensitivityLabel(val) { - if (val <= 11.5) return 'Low'; - if (val <= 19.5) return 'Medium'; - return 'High'; -} - -function updateSensitivityDisplay(val) { - sensitivityValue.textContent = parseFloat(val).toFixed(1) + ' (' + getSensitivityLabel(val) + ')'; - sensitivitySlider.value = val; -} - -function loadSensitivity() { +function loadScores() { try { - const stored = localStorage.getItem(SENSITIVITY_STORAGE_KEY); - if (stored !== null) { - const val = parseFloat(stored); - if (!isNaN(val) && val >= 5 && val <= 30) { - setSensitivity(val); - updateSensitivityDisplay(val); - return; - } - } - } catch {} - setSensitivity(DEFAULT_SENSITIVITY); - updateSensitivityDisplay(DEFAULT_SENSITIVITY); + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.filter((e) => typeof e.name === 'string' && typeof e.score === 'number') + .sort((a, b) => b.score - a.score).slice(0, MAX_SCORES); + } catch { return []; } } -function saveSensitivity(val) { - try { localStorage.setItem(SENSITIVITY_STORAGE_KEY, String(val)); } catch {} -} - -function showSettings() { settingsPanel.classList.add('visible'); } -function hideSettings() { settingsPanel.classList.remove('visible'); } - -sensitivitySlider.addEventListener('input', () => { - const val = parseFloat(sensitivitySlider.value); - setSensitivity(val); - updateSensitivityDisplay(val); - saveSensitivity(val); -}); - -sensitivityReset.addEventListener('click', () => { - setSensitivity(DEFAULT_SENSITIVITY); - updateSensitivityDisplay(DEFAULT_SENSITIVITY); - saveSensitivity(DEFAULT_SENSITIVITY); -}); - -settingsBtn.addEventListener('click', () => { showSettings(); }); -settingsClose.addEventListener('click', () => { hideSettings(); }); -settingsPanel.addEventListener('click', (e) => { if (e.target === settingsPanel) hideSettings(); }); - -// --- API-based leaderboard --- - -async function fetchScores() { - try { - const res = await fetch(API_BASE + '/scores'); - if (!res.ok) throw new Error('Server error: ' + res.status); - const data = await res.json(); - cachedScores = data.scores || []; - return { scores: cachedScores, offline: false }; - } catch (err) { - console.error('Failed to fetch scores:', err); - return { scores: cachedScores, offline: true }; - } +function saveScores(scores) { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(scores)); } catch {} } -async function scoreQualifies(value) { +function scoreQualifies(value) { if (value <= 0) return false; - try { - const res = await fetch(API_BASE + '/scores/qualifies?score=' + encodeURIComponent(value)); - if (!res.ok) throw new Error('Server error: ' + res.status); - const data = await res.json(); - return data.qualifies; - } catch (err) { - console.error('Failed to check score qualification:', err); - // Fallback: use cached scores - if (cachedScores.length < MAX_SCORES) return true; - return value > cachedScores[cachedScores.length - 1].score; - } + const scores = loadScores(); + if (scores.length < MAX_SCORES) return true; + return value > scores[scores.length - 1].score; } -async function addScore(name, value) { - try { - const res = await fetch(API_BASE + '/scores', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, score: value }), - }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error || 'Server error: ' + res.status); - } - const data = await res.json(); - if (data.scores) { - cachedScores = data.scores; - } - return { success: true }; - } catch (err) { - console.error('Failed to submit score:', err); - return { success: false, error: err.message }; - } +function addScore(name, value) { + const scores = loadScores(); + scores.push({ name, score: value }); + scores.sort((a, b) => b.score - a.score); + const trimmed = scores.slice(0, MAX_SCORES); + saveScores(trimmed); + return trimmed; } -function renderLeaderboard(scores, offline) { - leaderboardList.textContent = ''; - - if (!scores || scores.length === 0) { - const p = document.createElement('p'); - p.className = 'lb-empty'; - p.textContent = offline - ? 'Could not reach server. Please try again later.' - : 'No scores yet.'; - leaderboardList.appendChild(p); +function renderLeaderboard() { + const scores = loadScores(); + if (scores.length === 0) { + leaderboardList.innerHTML = '

No scores yet.

'; return; } - - if (offline) { - const p = document.createElement('p'); - p.className = 'lb-empty'; - p.textContent = 'Could not reach server. Showing cached scores.'; - leaderboardList.appendChild(p); - } - - const table = document.createElement('table'); - const thead = document.createElement('thead'); - const headRow = document.createElement('tr'); - for (const [cls, label] of [['lb-rank', '#'], ['lb-name', 'Name'], ['lb-score', 'Score']]) { - const th = document.createElement('th'); - th.className = cls; - th.textContent = label; - headRow.appendChild(th); - } - thead.appendChild(headRow); - table.appendChild(thead); - - const tbody = document.createElement('tbody'); + let html = ''; for (let i = 0; i < scores.length; i++) { - const row = document.createElement('tr'); - const rankTd = document.createElement('td'); - rankTd.className = 'lb-rank'; - rankTd.textContent = String(i + 1); - const nameTd = document.createElement('td'); - nameTd.className = 'lb-name'; - nameTd.textContent = scores[i].name; - const scoreTd = document.createElement('td'); - scoreTd.className = 'lb-score'; - scoreTd.textContent = String(scores[i].score); - row.appendChild(rankTd); - row.appendChild(nameTd); - row.appendChild(scoreTd); - tbody.appendChild(row); + const e = scores[i]; + const escapedName = e.name.replace(/&/g, '&').replace(//g, '>'); + html += ''; } - table.appendChild(tbody); - leaderboardList.appendChild(table); -} - -async function showLeaderboard() { - leaderboardList.textContent = ''; - const loadingP = document.createElement('p'); - loadingP.className = 'lb-empty'; - loadingP.textContent = 'Loading scores...'; - leaderboardList.appendChild(loadingP); - leaderboardPanel.classList.add('visible'); - const { scores, offline } = await fetchScores(); - renderLeaderboard(scores, offline); + html += '
#NameScore
' + (i + 1) + '' + escapedName + '' + e.score + '
'; + leaderboardList.innerHTML = html; } +function showLeaderboard() { renderLeaderboard(); leaderboardPanel.classList.add('visible'); } function hideLeaderboard() { leaderboardPanel.classList.remove('visible'); } // --- Game over flow --- -async function enterGameOver() { +function enterGameOver() { finalScore = score; state = 'gameover'; gameoverTitle.textContent = 'GAME OVER'; gameoverScore.textContent = 'Score: ' + finalScore; - gameoverMessage.textContent = 'Checking score...'; - nameEntry.classList.remove('visible'); - - levelEl.style.display = 'none'; - timerEl.style.display = 'none'; - speedEl.style.display = 'none'; - gameoverOverlay.classList.add('visible'); - const qualifies = await scoreQualifies(finalScore); - if (qualifies) { + if (scoreQualifies(finalScore)) { gameoverMessage.textContent = 'New high score!'; nameEntry.classList.add('visible'); nameInput.value = ''; @@ -301,20 +159,16 @@ async function enterGameOver() { nameEntry.classList.remove('visible'); resetTimer = setTimeout(() => { exitGameOver(); }, NON_QUALIFYING_DELAY); } + + levelEl.style.display = 'none'; + timerEl.style.display = 'none'; + gameoverOverlay.classList.add('visible'); } -async function submitScore() { +function submitScore() { let name = nameInput.value.trim(); if (!name) name = 'Anonymous'; - nameSubmit.disabled = true; - nameSubmit.textContent = 'Submitting...'; - const result = await addScore(name, finalScore); - nameSubmit.disabled = false; - nameSubmit.textContent = 'Submit'; - if (!result.success) { - gameoverMessage.textContent = 'Failed to submit score. Please try again.'; - return; - } + addScore(name, finalScore); exitGameOver(); } @@ -335,7 +189,6 @@ function exitGameOver() { updateScore(0); levelEl.style.display = 'block'; timerEl.style.display = 'block'; - speedEl.style.display = 'block'; const startPos = getStartBallPosition(config); updateBallPosition(startPos.x, startPos.y, startPos.z); @@ -359,133 +212,53 @@ leaderboardPanel.addEventListener('click', (e) => { if (e.target === leaderboard // --- Init & game loop --- -function createInitTimeout() { - let timeoutId; - const promise = new Promise(function(_, reject) { - timeoutId = setTimeout(function() { - reject(new Error('INIT_TIMEOUT')); - }, INIT_TIMEOUT_MS); - }); - return { promise: promise, cancel: function() { clearTimeout(timeoutId); } }; -} - async function init() { - // Check WebGL support before anything else - var testCanvas = document.createElement('canvas'); - var gl = testCanvas.getContext('webgl2') || testCanvas.getContext('webgl'); - if (!gl) { - showError('WebGL is not supported by your browser.\nPlease use a modern browser with WebGL enabled.', true); - return; - } - - var timeout = createInitTimeout(); - var stream; - try { - if (!rendererInitialized) { - initRenderer(); - rendererInitialized = true; - } - var config = getTrackConfig(); + initRenderer(); + const config = getTrackConfig(); initPhysics(config); render(); - // Pre-fetch leaderboard scores - fetchScores(); - - // Request camera access subtitle.textContent = 'Requesting camera access...'; + let stream; try { - stream = await Promise.race([ - navigator.mediaDevices.getUserMedia({ - video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, - }), - timeout.promise, - ]); + stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, + }); } catch (err) { - timeout.cancel(); - if (err.message === 'INIT_TIMEOUT') { - showError('Loading timed out.\nPlease check your connection and try again.', true); - } else { - showError('Camera access is required to play.\nPlease allow camera access and reload.', true); - } + showError('Camera access is required to play.\nPlease allow camera access and reload.'); return; } - // Load MediaPipe face landmark model subtitle.textContent = 'Loading head tracking model...'; - try { - await Promise.race([ - initTracker(stream), - timeout.promise, - ]); - } catch (err) { - timeout.cancel(); - // Stop camera stream to free resources on failure - stream.getTracks().forEach(function(t) { t.stop(); }); - if (err.message === 'INIT_TIMEOUT') { - showError('Loading timed out.\nPlease check your connection and try again.', true); - } else { - console.error('Tracker initialization error:', err); - showError('Failed to load face tracking model.\nPlease check your connection and try again.', true); - } - return; - } - - timeout.cancel(); + await initTracker(stream); calibrate(performance.now()); - loadSensitivity(); - overlay.classList.add('hidden'); - retryBtn.classList.remove('visible'); scoreEl.style.display = 'block'; levelEl.style.display = 'block'; timerEl.style.display = 'block'; - speedEl.style.display = 'block'; leaderboardBtn.style.display = 'block'; - settingsBtn.style.display = 'block'; updateScore(0); gameStartTime = performance.now(); state = 'playing'; lastTime = performance.now(); requestAnimationFrame(gameLoop); } catch (err) { - timeout.cancel(); - // Stop camera stream if it was acquired before the error - if (stream) { - stream.getTracks().forEach(function(t) { t.stop(); }); - } console.error('Initialization error:', err); - showError('Failed to initialize.\nPlease reload and try again.', true); - return; + showError('Failed to initialize. Please reload and try again.'); } } -function showError(message, showRetry) { +function showError(message) { state = 'error'; - overlay.classList.remove('hidden'); overlay.classList.add('error'); subtitle.textContent = message; overlay.querySelector('.title').textContent = ''; - if (showRetry) { - retryBtn.classList.add('visible'); - } else { - retryBtn.classList.remove('visible'); - } } -retryBtn.addEventListener('click', function() { - retryBtn.classList.remove('visible'); - overlay.classList.remove('error'); - overlay.querySelector('.title').textContent = 'TEETER'; - subtitle.textContent = 'Loading...'; - state = 'loading'; - init(); -}); - function gameLoop(timestamp) { requestAnimationFrame(gameLoop); @@ -523,10 +296,6 @@ function gameLoop(timestamp) { if (result.turtleCollected) { hideTurtleById(result.turtleCollected); } - // Update speed indicator - const speed = Math.sqrt(result.vx * result.vx + result.vz * result.vz); - speedEl.textContent = 'Speed: ' + speed.toFixed(1) + ' m/s'; - if (result.slowdownActive) { slowdownIndicator.classList.add('visible'); } else { slowdownIndicator.classList.remove('visible'); } diff --git a/js/physics.js b/js/physics.js index 7f3a0e2..7fd69f8 100644 --- a/js/physics.js +++ b/js/physics.js @@ -13,8 +13,7 @@ import { } from './track.js'; const GRAVITY = 9.8; -const DEFAULT_SENSITIVITY = 15.0; -let directSensitivity = DEFAULT_SENSITIVITY; +const DIRECT_SENSITIVITY = 15.0; const RESPONSE_RATE = 6.0; const FORWARD_SPEED = 4.5; const PITCH_SENSITIVITY = 3.0; @@ -95,7 +94,7 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { } // Lateral velocity from head tilt - const targetVLateral = tiltAngle * directSensitivity; + const targetVLateral = tiltAngle * DIRECT_SENSITIVITY; ball.vLateral += (targetVLateral - ball.vLateral) * RESPONSE_RATE * dt; // Forward motion modulated by pitch + gravity slope contribution @@ -217,13 +216,3 @@ function updateFalling(dt) { export function getBallState() { return { ...ball }; } - -export function setSensitivity(value) { - directSensitivity = value; -} - -export function getSensitivity() { - return directSensitivity; -} - -export { DEFAULT_SENSITIVITY }; From a75dc44354aa06d1eccc032cd4e8ffff5832e743 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:50:09 +0000 Subject: [PATCH 4/5] fix: add proper GPU resource disposal in chunk teardown - Add disposeMesh() helper to dispose geometry and materials - Call disposeMesh() for all mesh types in destroyChunk(): track, edge, obstacle, coin, and moving wall meshes - Prevents GPU memory accumulation during long endless-mode sessions - Sync js/renderer.js with public/js/renderer.js Co-Authored-By: bot_apk --- js/renderer.js | 18 +++++++++++++----- public/js/renderer.js | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/js/renderer.js b/js/renderer.js index e95accc..8b8a716 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -303,18 +303,26 @@ function buildChunk(chunkIndex) { chunks.set(chunkIndex, chunk); } +function disposeMesh(mesh) { + if (mesh.geometry) mesh.geometry.dispose(); + if (mesh.material) { + if (Array.isArray(mesh.material)) { mesh.material.forEach((m) => m.dispose()); } + else { mesh.material.dispose(); } + } +} + function destroyChunk(chunkIndex) { const chunk = chunks.get(chunkIndex); if (!chunk) return; - for (const m of chunk.trackMeshes) scene.remove(m); - for (const m of chunk.edgeMeshes) scene.remove(m); - for (const m of chunk.obstacleMeshes) scene.remove(m); - for (const e of chunk.coinEntries) scene.remove(e.mesh); + for (const m of chunk.trackMeshes) { disposeMesh(m); scene.remove(m); } + for (const m of chunk.edgeMeshes) { disposeMesh(m); scene.remove(m); } + for (const m of chunk.obstacleMeshes) { disposeMesh(m); scene.remove(m); } + for (const e of chunk.coinEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } for (const e of chunk.turtleEntries) { e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.material.dispose(); } }); scene.remove(e.mesh); } - for (const e of chunk.movingWallEntries) scene.remove(e.mesh); + for (const e of chunk.movingWallEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } chunks.delete(chunkIndex); } diff --git a/public/js/renderer.js b/public/js/renderer.js index e95accc..8b8a716 100644 --- a/public/js/renderer.js +++ b/public/js/renderer.js @@ -303,18 +303,26 @@ function buildChunk(chunkIndex) { chunks.set(chunkIndex, chunk); } +function disposeMesh(mesh) { + if (mesh.geometry) mesh.geometry.dispose(); + if (mesh.material) { + if (Array.isArray(mesh.material)) { mesh.material.forEach((m) => m.dispose()); } + else { mesh.material.dispose(); } + } +} + function destroyChunk(chunkIndex) { const chunk = chunks.get(chunkIndex); if (!chunk) return; - for (const m of chunk.trackMeshes) scene.remove(m); - for (const m of chunk.edgeMeshes) scene.remove(m); - for (const m of chunk.obstacleMeshes) scene.remove(m); - for (const e of chunk.coinEntries) scene.remove(e.mesh); + for (const m of chunk.trackMeshes) { disposeMesh(m); scene.remove(m); } + for (const m of chunk.edgeMeshes) { disposeMesh(m); scene.remove(m); } + for (const m of chunk.obstacleMeshes) { disposeMesh(m); scene.remove(m); } + for (const e of chunk.coinEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } for (const e of chunk.turtleEntries) { e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.material.dispose(); } }); scene.remove(e.mesh); } - for (const e of chunk.movingWallEntries) scene.remove(e.mesh); + for (const e of chunk.movingWallEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } chunks.delete(chunkIndex); } From 28cf0ff28f76e2efe62f7fcd54d96b4ef1556cd8 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 21:56:35 +0000 Subject: [PATCH 5/5] fix: protect shared geometries/materials from disposal during chunk teardown - Replace disposeMesh() with shared resource registry (sharedGeometries, sharedMaterials Sets) - destroyChunk() now only removes shared-geo meshes from scene (no dispose) - Turtle per-instance geometries/materials are still disposed via traverse, but with explicit shared-resource guard checks - Prevents render corruption and WebGL errors during long endless sessions Co-Authored-By: bot_apk --- js/renderer.js | 42 ++++++++++++++++++++++++++++++------------ public/js/renderer.js | 42 ++++++++++++++++++++++++++++++------------ 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/js/renderer.js b/js/renderer.js index 8b8a716..0ca14d7 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -303,26 +303,44 @@ function buildChunk(chunkIndex) { chunks.set(chunkIndex, chunk); } -function disposeMesh(mesh) { - if (mesh.geometry) mesh.geometry.dispose(); - if (mesh.material) { - if (Array.isArray(mesh.material)) { mesh.material.forEach((m) => m.dispose()); } - else { mesh.material.dispose(); } - } +// Shared geometry/material refs used across chunks — these must NOT be disposed +// during chunk teardown. Only per-instance resources (turtle meshes) are disposed. +const sharedGeometries = new Set(); +const sharedMaterials = new Set(); + +function registerSharedResources() { + sharedGeometries.add(segGeo); + sharedGeometries.add(edgeGeo); + sharedGeometries.add(obstGeo); + sharedGeometries.add(movingWallGeo); + sharedGeometries.add(coinGeo); + sharedMaterials.add(trackMat); + sharedMaterials.add(edgeMat); + sharedMaterials.add(obstMat); + sharedMaterials.add(movingWallMat); + sharedMaterials.add(coinMat); } +registerSharedResources(); function destroyChunk(chunkIndex) { const chunk = chunks.get(chunkIndex); if (!chunk) return; - for (const m of chunk.trackMeshes) { disposeMesh(m); scene.remove(m); } - for (const m of chunk.edgeMeshes) { disposeMesh(m); scene.remove(m); } - for (const m of chunk.obstacleMeshes) { disposeMesh(m); scene.remove(m); } - for (const e of chunk.coinEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } + // Track, edge, obstacle, coin, and moving wall meshes use shared geo/mat — just remove from scene + for (const m of chunk.trackMeshes) scene.remove(m); + for (const m of chunk.edgeMeshes) scene.remove(m); + for (const m of chunk.obstacleMeshes) scene.remove(m); + for (const e of chunk.coinEntries) scene.remove(e.mesh); + // Turtle meshes create per-instance geo/mat — dispose them to free GPU memory for (const e of chunk.turtleEntries) { - e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.material.dispose(); } }); + e.mesh.traverse((child) => { + if (child.isMesh) { + if (!sharedGeometries.has(child.geometry)) child.geometry.dispose(); + if (!sharedMaterials.has(child.material)) child.material.dispose(); + } + }); scene.remove(e.mesh); } - for (const e of chunk.movingWallEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } + for (const e of chunk.movingWallEntries) scene.remove(e.mesh); chunks.delete(chunkIndex); } diff --git a/public/js/renderer.js b/public/js/renderer.js index 8b8a716..0ca14d7 100644 --- a/public/js/renderer.js +++ b/public/js/renderer.js @@ -303,26 +303,44 @@ function buildChunk(chunkIndex) { chunks.set(chunkIndex, chunk); } -function disposeMesh(mesh) { - if (mesh.geometry) mesh.geometry.dispose(); - if (mesh.material) { - if (Array.isArray(mesh.material)) { mesh.material.forEach((m) => m.dispose()); } - else { mesh.material.dispose(); } - } +// Shared geometry/material refs used across chunks — these must NOT be disposed +// during chunk teardown. Only per-instance resources (turtle meshes) are disposed. +const sharedGeometries = new Set(); +const sharedMaterials = new Set(); + +function registerSharedResources() { + sharedGeometries.add(segGeo); + sharedGeometries.add(edgeGeo); + sharedGeometries.add(obstGeo); + sharedGeometries.add(movingWallGeo); + sharedGeometries.add(coinGeo); + sharedMaterials.add(trackMat); + sharedMaterials.add(edgeMat); + sharedMaterials.add(obstMat); + sharedMaterials.add(movingWallMat); + sharedMaterials.add(coinMat); } +registerSharedResources(); function destroyChunk(chunkIndex) { const chunk = chunks.get(chunkIndex); if (!chunk) return; - for (const m of chunk.trackMeshes) { disposeMesh(m); scene.remove(m); } - for (const m of chunk.edgeMeshes) { disposeMesh(m); scene.remove(m); } - for (const m of chunk.obstacleMeshes) { disposeMesh(m); scene.remove(m); } - for (const e of chunk.coinEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } + // Track, edge, obstacle, coin, and moving wall meshes use shared geo/mat — just remove from scene + for (const m of chunk.trackMeshes) scene.remove(m); + for (const m of chunk.edgeMeshes) scene.remove(m); + for (const m of chunk.obstacleMeshes) scene.remove(m); + for (const e of chunk.coinEntries) scene.remove(e.mesh); + // Turtle meshes create per-instance geo/mat — dispose them to free GPU memory for (const e of chunk.turtleEntries) { - e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.material.dispose(); } }); + e.mesh.traverse((child) => { + if (child.isMesh) { + if (!sharedGeometries.has(child.geometry)) child.geometry.dispose(); + if (!sharedMaterials.has(child.material)) child.material.dispose(); + } + }); scene.remove(e.mesh); } - for (const e of chunk.movingWallEntries) { disposeMesh(e.mesh); scene.remove(e.mesh); } + for (const e of chunk.movingWallEntries) scene.remove(e.mesh); chunks.delete(chunkIndex); }