diff --git a/js/main.js b/js/main.js index 4cf7118..dec5dc1 100644 --- a/js/main.js +++ b/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'; @@ -57,7 +58,6 @@ let score = 0; let finalScore = 0; let currentLevel = 1; let gameStartTime = 0; -let finishTime = 0; function updateScore(value) { score = value; @@ -140,32 +140,6 @@ function renderLeaderboard() { function showLeaderboard() { renderLeaderboard(); leaderboardPanel.classList.add('visible'); } function hideLeaderboard() { leaderboardPanel.classList.remove('visible'); } -// --- Finish state --- - -function enterFinished(timestamp) { - finishTime = ((timestamp - gameStartTime) / 1000).toFixed(1); - finalScore = score; - state = 'finished'; - - gameoverTitle.textContent = 'FINISHED!'; - gameoverScore.textContent = 'Score: ' + finalScore + ' | Time: ' + finishTime + 's'; - - 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); - } - - levelEl.style.display = 'none'; - timerEl.style.display = 'none'; - gameoverOverlay.classList.add('visible'); -} - // --- Game over flow --- function enterGameOver() { @@ -303,6 +277,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); @@ -326,12 +302,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/js/physics.js b/js/physics.js index 7d987b8..7fd69f8 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, @@ -108,11 +107,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 +167,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 +176,6 @@ function updateOnTrack(dt, tiltAngle, pitch, mouthOpen) { vz: ball.vForward, falling: ball.falling, needsReset: false, - finished, obstacleHit, coinsCollected: newlyCollected, turtleCollected: turtleJustCollected, @@ -215,7 +205,6 @@ function updateFalling(dt) { vz: ball.vForward, falling: true, needsReset, - finished: false, obstacleHit: false, coinsCollected: [], turtleCollected: null, diff --git a/js/renderer.js b/js/renderer.js index 012f9cd..0ca14d7 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,114 @@ 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) { - e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.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; + // 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) { + if (!sharedGeometries.has(child.geometry)) child.geometry.dispose(); + if (!sharedMaterials.has(child.material)) 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 +370,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 +410,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 +436,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 +444,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 +455,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 +522,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..a6a9882 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 @@ -274,36 +274,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() { @@ -534,6 +504,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 +533,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..0ca14d7 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,114 @@ 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) { - e.mesh.traverse((child) => { if (child.isMesh) { child.geometry.dispose(); child.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; + // 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) { + if (!sharedGeometries.has(child.geometry)) child.geometry.dispose(); + if (!sharedMaterials.has(child.material)) 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 +370,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 +410,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 +436,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 +444,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 +455,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 +522,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; }