diff --git a/index.html b/index.html index 2c8fbac..06b3a4e 100644 --- a/index.html +++ b/index.html @@ -103,6 +103,11 @@ #gameover-box .go-score { font-size: 1.4em; opacity: 0.8; + margin-bottom: 8px; + } + #gameover-box .go-time { + font-size: 1.2em; + opacity: 0.7; margin-bottom: 24px; } #gameover-box .go-message { @@ -224,6 +229,23 @@ #leaderboard-close:hover { background: rgba(255,255,255,0.1); } + #timer { + position: fixed; + top: 16px; + left: 50%; + transform: translateX(-50%); + color: #fff; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 1.4em; + font-weight: 700; + z-index: 10; + pointer-events: none; + text-shadow: 0 2px 4px rgba(0,0,0,0.5); + background: rgba(0,0,0,0.3); + padding: 6px 14px; + border-radius: 8px; + display: none; + } #slowdown-indicator { position: fixed; bottom: 50px; @@ -267,6 +289,7 @@
Score: 0
+
0.0s
TEETER
@@ -276,6 +299,7 @@
GAME OVER
+
diff --git a/js/main.js b/js/main.js index c84fdd2..fdd4e48 100644 --- a/js/main.js +++ b/js/main.js @@ -9,7 +9,6 @@ import { getObstacles, getCoins, hideCoin, - showAllCoins, updateCoinRotation, regenerateLevel, getTurtle, @@ -22,10 +21,13 @@ import { initPhysics, updatePhysics, resetBall, refreshLevel } from './physics.j const overlay = document.getElementById('overlay'); const subtitle = overlay.querySelector('.subtitle'); const scoreEl = document.getElementById('score'); +const timerEl = document.getElementById('timer'); const leaderboardBtn = document.getElementById('leaderboard-btn'); const gameoverOverlay = document.getElementById('gameover-overlay'); +const gameoverTitle = gameoverOverlay.querySelector('.go-title'); const gameoverScore = gameoverOverlay.querySelector('.go-score'); const gameoverMessage = gameoverOverlay.querySelector('.go-message'); +const gameoverTime = gameoverOverlay.querySelector('.go-time'); const nameEntry = document.getElementById('name-entry'); const nameInput = document.getElementById('name-input'); const nameSubmit = document.getElementById('name-submit'); @@ -37,18 +39,35 @@ const slowdownIndicator = document.getElementById('slowdown-indicator'); const STORAGE_KEY = 'teeter_highscores'; const MAX_SCORES = 10; const NON_QUALIFYING_DELAY = 2000; +const FINISH_DISPLAY_DELAY = 3000; -let state = 'loading'; // loading | permission | playing | falling | gameover +let state = 'loading'; // loading | permission | playing | falling | finished | gameover let lastTime = 0; let resetTimer = null; let score = 0; let finalScore = 0; +let runStartTime = 0; +let runElapsed = 0; function updateScore(value) { score = value; scoreEl.textContent = 'Score: ' + score; } +function formatTime(seconds) { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const ms = Math.floor((seconds % 1) * 10); + if (mins > 0) { + return mins + ':' + String(secs).padStart(2, '0') + '.' + ms; + } + return secs + '.' + ms + 's'; +} + +function updateTimerDisplay() { + timerEl.textContent = formatTime(runElapsed); +} + // --- localStorage leaderboard --- function loadScores() { @@ -70,7 +89,7 @@ function saveScores(scores) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(scores)); } catch { - // storage unavailable — silently fail + // storage unavailable } } @@ -125,13 +144,39 @@ function hideLeaderboard() { leaderboardPanel.classList.remove('visible'); } -// --- Game over flow --- +// --- Finish & Game over flow --- + +function enterFinished() { + finalScore = score; + state = 'finished'; + + gameoverTitle.textContent = 'COURSE COMPLETE!'; + gameoverScore.textContent = 'Score: ' + finalScore; + gameoverTime.textContent = 'Time: ' + formatTime(runElapsed); + + if (scoreQualifies(finalScore)) { + gameoverMessage.textContent = 'New high score!'; + nameEntry.classList.add('visible'); + nameInput.value = ''; + nameInput.focus(); + } else { + gameoverMessage.textContent = 'Well done!'; + nameEntry.classList.remove('visible'); + resetTimer = setTimeout(() => { + exitGameOver(); + }, FINISH_DISPLAY_DELAY); + } + + gameoverOverlay.classList.add('visible'); +} function enterGameOver() { finalScore = score; state = 'gameover'; + gameoverTitle.textContent = 'GAME OVER'; gameoverScore.textContent = 'Score: ' + finalScore; + gameoverTime.textContent = 'Time: ' + formatTime(runElapsed); if (scoreQualifies(finalScore)) { gameoverMessage.textContent = 'New high score!'; @@ -141,7 +186,6 @@ function enterGameOver() { } else { gameoverMessage.textContent = ''; nameEntry.classList.remove('visible'); - // Auto-dismiss after delay resetTimer = setTimeout(() => { exitGameOver(); }, NON_QUALIFYING_DELAY); @@ -178,8 +222,15 @@ function exitGameOver() { calibrate(performance.now()); resetBallRotation(); updateScore(0); - updateBallPosition(0, config.trackHeight / 2 + config.ballRadius, config.ballStartZ); - updateCamera(config.ballStartZ); + + // Reset ball to start of curve + const startPos = config.curveLocalToWorld(0, 0, config.ballRadius); + updateBallPosition(startPos.x, startPos.y, startPos.z); + updateCamera(0, startPos); + + runStartTime = performance.now(); + runElapsed = 0; + updateTimerDisplay(); state = 'playing'; } @@ -203,7 +254,6 @@ leaderboardClose.addEventListener('click', () => { hideLeaderboard(); }); -// Close leaderboard on backdrop click leaderboardPanel.addEventListener('click', (e) => { if (e.target === leaderboardPanel) { hideLeaderboard(); @@ -214,22 +264,18 @@ leaderboardPanel.addEventListener('click', (e) => { async function init() { try { - // Initialize Three.js renderer initRenderer(); const config = getTrackConfig(); - // Attach obstacle, coin, and turtle data to config for physics config.obstacles = getObstacles(); config.coins = getCoins(); config.turtle = getTurtle(); initPhysics(config); - // Initial render so the scene is visible during loading render(); subtitle.textContent = 'Requesting camera access...'; - // Request camera let stream; try { stream = await navigator.mediaDevices.getUserMedia({ @@ -242,7 +288,6 @@ async function init() { subtitle.textContent = 'Loading head tracking model...'; - // Initialize head tracker await initTracker(stream); // Calibrate neutral head position @@ -251,8 +296,12 @@ async function init() { // Hide overlay, show score and leaderboard button, and start game overlay.classList.add('hidden'); scoreEl.style.display = 'block'; + timerEl.style.display = 'block'; leaderboardBtn.style.display = 'block'; updateScore(0); + runStartTime = performance.now(); + runElapsed = 0; + updateTimerDisplay(); state = 'playing'; lastTime = performance.now(); requestAnimationFrame(gameLoop); @@ -276,22 +325,24 @@ function gameLoop(timestamp) { lastTime = timestamp; if (state === 'playing' || state === 'falling') { - // Get head tilt and pitch + // Update run timer + runElapsed = (timestamp - runStartTime) / 1000; + updateTimerDisplay(); + const tiltAngle = detectTilt(timestamp); const pitch = detectPitch(); - // Update physics const result = updatePhysics(dt, tiltAngle, pitch); - // Update renderer updateBallPosition(result.x, result.y, result.z); updateBallRotation(result.vx, result.vz, dt); - updateCamera(result.z); - // Animate coins + // Camera follows curve tangent at ball's t position + const ballWorldPos = { x: result.x, y: result.y, z: result.z }; + updateCamera(result.t, ballWorldPos); + updateCoinRotation(dt); - // Handle coin collection if (result.coinsCollected && result.coinsCollected.length > 0) { for (const idx of result.coinsCollected) { hideCoin(idx); @@ -299,7 +350,6 @@ function gameLoop(timestamp) { } } - // Handle turtle collection if (result.turtleCollected) { hideTurtle(); } @@ -325,6 +375,11 @@ function gameLoop(timestamp) { updateCamera(config.ballStartZ); } + // Handle finish line crossing + if (result.finished && state === 'playing') { + enterFinished(); + } + // Handle state transitions if (result.falling && state === 'playing') { state = 'falling'; diff --git a/js/physics.js b/js/physics.js index b3777cc..8e89fd9 100644 --- a/js/physics.js +++ b/js/physics.js @@ -4,10 +4,13 @@ const RESPONSE_RATE = 6.0; const FORWARD_SPEED = 2.0; const PITCH_SENSITIVITY = 3.0; const MAX_SPEED = 6.0; -const MAX_DT = 1 / 30; // Cap delta time to prevent physics explosions -const COIN_COLLECT_RADIUS = 0.8; -const TURTLE_COLLECT_RADIUS = 0.8; +const MAX_DT = 1 / 30; +const COIN_COLLECT_RADIUS = 0.6; // In lateral-distance space +const COIN_COLLECT_T_RADIUS = 0.005; // In t-space +const TURTLE_COLLECT_RADIUS = 0.6; +const TURTLE_COLLECT_T_RADIUS = 0.005; const SLOWDOWN_DURATION = 4; +const OBSTACLE_COLLISION_T_RADIUS = 0.004; let ball = {}; let trackConfig = {}; @@ -33,14 +36,25 @@ export function initPhysics(config) { export function resetBall() { ball = { - x: 0, - y: trackConfig.trackHeight / 2 + trackConfig.ballRadius, - z: trackConfig.ballStartZ, - vx: 0, - vy: 0, - vz: FORWARD_SPEED, + t: 0, // Position along curve (0-1) + d: 0, // Lateral offset from centerline + speed: FORWARD_SPEED, // Forward speed in world units/sec + lateralSpeed: 0, // Lateral speed falling: false, + vy: 0, // Vertical velocity when falling + worldX: 0, + worldY: 0, + worldZ: 0, }; + + // Compute initial world position + if (trackConfig.curveLocalToWorld) { + const pos = trackConfig.curveLocalToWorld(0, 0, trackConfig.ballRadius); + ball.worldX = pos.x; + ball.worldY = pos.y; + ball.worldZ = pos.z; + } + coinsCollected = new Array(coins.length).fill(false); turtleCollected = false; slowdownActive = false; @@ -58,6 +72,9 @@ export function updatePhysics(dt, tiltAngle, pitch) { } function updateOnTrack(dt, tiltAngle, pitch) { + const { curve, curveLength, curveLocalToWorld, trackWidth, trackHeight, ballRadius } = trackConfig; + if (!curve) return getFallbackResult(); + // Decrement slowdown timer if (slowdownActive) { slowdownTimer -= dt; @@ -67,7 +84,6 @@ function updateOnTrack(dt, tiltAngle, pitch) { } } - // Effective speeds (halved when slowed) const effectiveForward = slowdownActive ? FORWARD_SPEED / 2 : FORWARD_SPEED; const effectiveMax = slowdownActive ? MAX_SPEED / 2 : MAX_SPEED; @@ -75,33 +91,42 @@ function updateOnTrack(dt, tiltAngle, pitch) { const targetVx = -tiltAngle * DIRECT_SENSITIVITY; ball.vx += (targetVx - ball.vx) * RESPONSE_RATE * dt; - // Forward motion modulated by pitch (forward tilt speeds up, backward slows down) + // Get tangent at current position for slope calculation + const clampedT = Math.max(0, Math.min(1, ball.t)); + const tangent = curve.getTangentAt(clampedT); + + // Gravity slope boost — tangent.y < 0 means going downhill + const gravityBoost = -GRAVITY * tangent.y * 0.3; + + // Forward motion: base speed + gravity + pitch modulation const pitchVal = pitch || 0; - ball.vz = Math.max(0, Math.min(effectiveMax, effectiveForward * (1 + pitchVal * PITCH_SENSITIVITY))); + const baseSpeed = effectiveForward * (1 + pitchVal * PITCH_SENSITIVITY); + ball.speed = Math.max(0.5, Math.min(effectiveMax, baseSpeed + gravityBoost)); + + // Lateral movement from head tilt + const targetLateral = tiltAngle * DIRECT_SENSITIVITY; + ball.lateralSpeed += (targetLateral - ball.lateralSpeed) * RESPONSE_RATE * dt; - // Update position - ball.x += ball.vx * dt; - ball.z += ball.vz * dt; + // Update curve-local position + ball.t += (ball.speed * dt) / curveLength; + ball.d += ball.lateralSpeed * dt; - // Track boundaries — check if ball center has gone past track edge - const halfWidth = trackConfig.trackWidth / 2; - if (Math.abs(ball.x) > halfWidth) { + // Edge detection — fall off if past track edge + const halfWidth = trackWidth / 2; + if (Math.abs(ball.d) > halfWidth) { ball.falling = true; ball.vy = 0; } - // Obstacle collision — AABB check with ball radius margin + // Obstacle collision in curve-local space let obstacleHit = false; if (!ball.falling) { - const br = trackConfig.ballRadius; for (let i = 0; i < obstacles.length; i++) { const o = obstacles[i]; - if ( - ball.x + br > o.x - o.halfW && - ball.x - br < o.x + o.halfW && - ball.z + br > o.z - o.halfD && - ball.z - br < o.z + o.halfD - ) { + const tDist = Math.abs(ball.t - o.t); + const dDist = Math.abs(ball.d - o.d); + + if (tDist < OBSTACLE_COLLISION_T_RADIUS && dDist < o.halfW + ballRadius * 0.5) { ball.falling = true; ball.vy = 0; obstacleHit = true; @@ -110,26 +135,24 @@ function updateOnTrack(dt, tiltAngle, pitch) { } } - // Coin collection — distance check in XZ plane + // Coin collection in curve-local space const newlyCollected = []; for (let i = 0; i < coins.length; i++) { if (coinsCollected[i]) continue; - const dx = ball.x - coins[i].x; - const dz = ball.z - coins[i].z; - const dist = Math.sqrt(dx * dx + dz * dz); - if (dist < COIN_COLLECT_RADIUS) { + const tDist = Math.abs(ball.t - coins[i].t); + const dDist = Math.abs(ball.d - coins[i].d); + if (tDist < COIN_COLLECT_T_RADIUS && dDist < COIN_COLLECT_RADIUS) { coinsCollected[i] = true; newlyCollected.push(i); } } - // Turtle collection — distance check in XZ plane + // Turtle collection let turtleJustCollected = false; if (turtle && !turtleCollected) { - const dx = ball.x - turtle.x; - const dz = ball.z - turtle.z; - const dist = Math.sqrt(dx * dx + dz * dz); - if (dist < TURTLE_COLLECT_RADIUS) { + const tDist = Math.abs(ball.t - turtle.t); + const dDist = Math.abs(ball.d - turtle.d); + if (tDist < TURTLE_COLLECT_T_RADIUS && dDist < TURTLE_COLLECT_RADIUS) { turtleCollected = true; turtleJustCollected = true; slowdownActive = true; @@ -139,21 +162,32 @@ function updateOnTrack(dt, tiltAngle, pitch) { // Track end — wrap back to start if ball reaches the end let trackCompleted = false; - const halfLength = trackConfig.trackLength / 2; - if (ball.z > halfLength) { - ball.z = -halfLength + 1; + let wrapped = false; + if (ball.t >= 1.0) { + ball.t = ball.t - 1.0; + wrapped = true; trackCompleted = true; } + // Convert curve-local to world position + const safeT = Math.max(0, Math.min(0.9999, ball.t)); + const worldPos = curveLocalToWorld(safeT, ball.d, ballRadius); + ball.worldX = worldPos.x; + ball.worldY = worldPos.y; + ball.worldZ = worldPos.z; + return { - x: ball.x, - y: ball.y, - z: ball.z, - vx: ball.vx, - vz: ball.vz, + x: ball.worldX, + y: ball.worldY, + z: ball.worldZ, + vx: ball.lateralSpeed, + vz: ball.speed, + t: ball.t, + d: ball.d, falling: ball.falling, needsReset: false, obstacleHit, + wrapped, coinsCollected: newlyCollected, turtleCollected: turtleJustCollected, slowdownActive, @@ -163,23 +197,26 @@ function updateOnTrack(dt, tiltAngle, pitch) { function updateFalling(dt) { ball.vy -= GRAVITY * dt; - ball.y += ball.vy * dt; + ball.worldY += ball.vy * dt; - // Also continue lateral and forward motion slightly - ball.x += ball.vx * dt * 0.5; - ball.z += ball.vz * dt * 0.3; + // Continue lateral and forward drift + ball.worldX += ball.lateralSpeed * dt * 0.5; + ball.worldZ += ball.speed * dt * 0.3; - const needsReset = ball.y < -10; + const needsReset = ball.worldY < -10; return { - x: ball.x, - y: ball.y, - z: ball.z, - vx: ball.vx, - vz: ball.vz, + x: ball.worldX, + y: ball.worldY, + z: ball.worldZ, + vx: ball.lateralSpeed, + vz: ball.speed, + t: ball.t, + d: ball.d, falling: true, needsReset, obstacleHit: false, + wrapped: false, coinsCollected: [], turtleCollected: false, slowdownActive, @@ -195,6 +232,17 @@ export function refreshLevel(config) { turtleCollected = false; } +function getFallbackResult() { + return { + x: 0, y: 0, z: 0, + vx: 0, vz: 0, + t: 0, d: 0, + falling: false, needsReset: false, + obstacleHit: false, wrapped: false, coinsCollected: [], turtleCollected: false, + slowdownActive: false, + }; +} + export function getBallState() { return { ...ball }; } diff --git a/js/renderer.js b/js/renderer.js index 7a027aa..519d91c 100644 --- a/js/renderer.js +++ b/js/renderer.js @@ -2,36 +2,70 @@ import * as THREE from 'three'; const TRACK_WIDTH = 4.5; const TRACK_HEIGHT = 0.2; -const TRACK_LENGTH = 50; const BALL_RADIUS = 0.3; -const BALL_START_Z = -20; // Obstacle config const OBSTACLE_WIDTH = 1.5; const OBSTACLE_HEIGHT = 1.0; const OBSTACLE_DEPTH = 0.4; -const OBSTACLE_MIN_SPACING = 7; -const OBSTACLE_MAX_SPACING = 9; -const SAFE_ZONE_Z = BALL_START_Z + 5; // No obstacles/coins before Z = -15 -const MIN_GAP = 1.5; // Minimum passable gap beside obstacle +const OBSTACLE_MIN_SPACING = 0.04; // In t-space (~5.6 world units on 140-unit curve) +const OBSTACLE_MAX_SPACING = 0.06; +const SAFE_ZONE_T = 0.05; // No obstacles before 5% of curve +const MIN_GAP = 1.5; // Coin config const COIN_RADIUS = 0.25; const COIN_TUBE = 0.08; -const COIN_Y = TRACK_HEIGHT / 2 + 0.35; + +const NUM_TRACK_SAMPLES = 300; + +// Curve control points — winding, gently downhill path +const CONTROL_POINTS = [ + new THREE.Vector3(0, 10, 0), + new THREE.Vector3(0, 9.5, 10), + new THREE.Vector3(3, 8.5, 25), + new THREE.Vector3(5, 7.5, 40), + new THREE.Vector3(3, 6.5, 55), + new THREE.Vector3(-3, 5.5, 70), + new THREE.Vector3(-5, 4.5, 85), + new THREE.Vector3(-2, 3.0, 100), + new THREE.Vector3(2, 1.5, 115), + new THREE.Vector3(2, 0.5, 130), + new THREE.Vector3(0, 0, 140), +]; + +let curve = null; +let curveLength = 0; let scene, camera, renderer; -let trackMesh, ballMesh; -let edgeLeft, edgeRight; +let ballMesh; +let trackGroup; +let finishLineMesh; let obstacleMeshes = []; -let obstacleData = []; // { x, z, halfW, halfD } +let obstacleData = []; let coinMeshes = []; -let coinData = []; // { x, z } +let coinData = []; let turtleMesh = null; -let turtleData = null; // { x, z } or null +let turtleData = null; -// Simple seeded RNG for deterministic placement +// Shared geometry and materials +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 coinGeo = new THREE.TorusGeometry(COIN_RADIUS, COIN_TUBE, 12, 24); +const coinMat = new THREE.MeshStandardMaterial({ + color: 0xFFD700, + metalness: 0.8, + roughness: 0.2, + emissive: 0x554400, + emissiveIntensity: 0.3, +}); + +// Simple seeded RNG function seededRandom(seed) { let s = seed; return function () { @@ -40,29 +74,21 @@ function seededRandom(seed) { }; } -function generateObstacles(rng) { - const obstacles = []; - const halfTrack = TRACK_WIDTH / 2; - const halfLength = TRACK_LENGTH / 2; - - let z = SAFE_ZONE_Z; - while (z < halfLength - 2) { - const spacing = OBSTACLE_MIN_SPACING + rng() * (OBSTACLE_MAX_SPACING - OBSTACLE_MIN_SPACING); - z += spacing; - if (z >= halfLength - 1) break; - - // Place obstacle so there's at least MIN_GAP on one side - const maxOffset = halfTrack - OBSTACLE_WIDTH / 2 - 0.1; - const x = (rng() * 2 - 1) * maxOffset; +function buildCurve() { + curve = new THREE.CatmullRomCurve3(CONTROL_POINTS, false, 'centripetal', 0.5); + curveLength = curve.getLength(); +} - obstacles.push({ - x, - z, - halfW: OBSTACLE_WIDTH / 2, - halfD: OBSTACLE_DEPTH / 2, - }); +// Get lateral vector at a point on the curve (perpendicular to tangent, in the horizontal-ish plane) +function getLateral(t) { + const tangent = curve.getTangentAt(t); + const up = new THREE.Vector3(0, 1, 0); + const lateral = new THREE.Vector3().crossVectors(tangent, up).normalize(); + // If tangent is nearly vertical, fallback + if (lateral.lengthSq() < 0.001) { + lateral.set(1, 0, 0); } - return obstacles; + return lateral; } function generateCoins(rng, obstacles) { @@ -70,20 +96,50 @@ function generateCoins(rng, obstacles) { const halfTrack = TRACK_WIDTH / 2; const halfLength = TRACK_LENGTH / 2; - // Place 2-3 coins between each pair of obstacles - for (let i = 0; i < obstacles.length; i++) { - const startZ = i === 0 ? SAFE_ZONE_Z : obstacles[i - 1].z + 1; - const endZ = obstacles[i].z - 1; - const gap = endZ - startZ; - if (gap < 2) continue; +function getTrackUp(t) { + const tangent = curve.getTangentAt(t); + const lateral = getLateral(t); + return new THREE.Vector3().crossVectors(lateral, tangent).normalize(); +} - const count = gap >= 5 ? 3 : 2; - const step = gap / (count + 1); +function buildTrackMesh() { + trackGroup = new THREE.Group(); - for (let j = 1; j <= count; j++) { - const cz = startZ + step * j; - const cx = (rng() * 2 - 1) * (halfTrack - 0.5); - coins.push({ x: cx, z: cz }); + const positions = []; + const normals = []; + const indices = []; + const uvs = []; + + const halfWidth = TRACK_WIDTH / 2; + + // Build ribbon geometry + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + + const left = point.clone().add(lateral.clone().multiplyScalar(-halfWidth)); + const right = point.clone().add(lateral.clone().multiplyScalar(halfWidth)); + + // Raise by track height/2 so surface is on top + const yOffset = trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2); + left.add(yOffset); + right.add(yOffset); + + positions.push(left.x, left.y, left.z); + positions.push(right.x, right.y, right.z); + + normals.push(trackUp.x, trackUp.y, trackUp.z); + normals.push(trackUp.x, trackUp.y, trackUp.z); + + uvs.push(0, t); + uvs.push(1, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = i * 2; + indices.push(base, base + 1, base + 2); + indices.push(base + 1, base + 3, base + 2); } } @@ -119,33 +175,269 @@ function generateCoins(rng, obstacles) { return coins; } + + // Also build underside for thickness + const topVertCount = (NUM_TRACK_SAMPLES + 1) * 2; + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + + const left = point.clone().add(lateral.clone().multiplyScalar(-halfWidth)); + const right = point.clone().add(lateral.clone().multiplyScalar(halfWidth)); + + const yOffset = trackUp.clone().multiplyScalar(-TRACK_HEIGHT / 2); + left.add(yOffset); + right.add(yOffset); + + positions.push(left.x, left.y, left.z); + positions.push(right.x, right.y, right.z); + + const downNorm = trackUp.clone().negate(); + normals.push(downNorm.x, downNorm.y, downNorm.z); + normals.push(downNorm.x, downNorm.y, downNorm.z); + + uvs.push(0, t); + uvs.push(1, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = topVertCount + i * 2; + indices.push(base, base + 2, base + 1); + indices.push(base + 1, base + 2, base + 3); + } + } + + // Side faces (left edge and right edge) + const sideStart = positions.length / 3; + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + + const halfH = TRACK_HEIGHT / 2; + // Left edge + const leftTop = point.clone() + .add(lateral.clone().multiplyScalar(-halfWidth)) + .add(trackUp.clone().multiplyScalar(halfH)); + const leftBot = point.clone() + .add(lateral.clone().multiplyScalar(-halfWidth)) + .add(trackUp.clone().multiplyScalar(-halfH)); + + const leftNorm = lateral.clone().negate(); + + positions.push(leftTop.x, leftTop.y, leftTop.z); + positions.push(leftBot.x, leftBot.y, leftBot.z); + normals.push(leftNorm.x, leftNorm.y, leftNorm.z); + normals.push(leftNorm.x, leftNorm.y, leftNorm.z); + uvs.push(0, t); + uvs.push(0, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = sideStart + i * 2; + indices.push(base, base + 2, base + 1); + indices.push(base + 1, base + 2, base + 3); + } + } + + const rightStart = positions.length / 3; + for (let i = 0; i <= NUM_TRACK_SAMPLES; i++) { + const t = i / NUM_TRACK_SAMPLES; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + + const halfH = TRACK_HEIGHT / 2; + const rightTop = point.clone() + .add(lateral.clone().multiplyScalar(halfWidth)) + .add(trackUp.clone().multiplyScalar(halfH)); + const rightBot = point.clone() + .add(lateral.clone().multiplyScalar(halfWidth)) + .add(trackUp.clone().multiplyScalar(-halfH)); + + positions.push(rightTop.x, rightTop.y, rightTop.z); + positions.push(rightBot.x, rightBot.y, rightBot.z); + normals.push(lateral.x, lateral.y, lateral.z); + normals.push(lateral.x, lateral.y, lateral.z); + uvs.push(1, t); + uvs.push(1, t); + + if (i < NUM_TRACK_SAMPLES) { + const base = rightStart + i * 2; + indices.push(base, base + 1, base + 2); + indices.push(base + 1, base + 3, base + 2); + } + } + + const geo = new THREE.BufferGeometry(); + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); + geo.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)); + geo.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); + geo.setIndex(indices); + + const trackMat = new THREE.MeshStandardMaterial({ + color: 0x8B7355, + roughness: 0.7, + metalness: 0.1, + side: THREE.DoubleSide, + }); + + const trackMesh = new THREE.Mesh(geo, trackMat); + trackMesh.receiveShadow = true; + trackGroup.add(trackMesh); + + // Edge lines + const edgeMat = new THREE.MeshStandardMaterial({ color: 0x5a4a3a, roughness: 0.6 }); + const edgeRadius = 0.04; + const edgeSegments = NUM_TRACK_SAMPLES; + + // Build edge line as a tube along left and right edges + const leftPoints = []; + const rightPoints = []; + for (let i = 0; i <= edgeSegments; i++) { + const t = i / edgeSegments; + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + const yOff = trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + edgeRadius); + + leftPoints.push(point.clone().add(lateral.clone().multiplyScalar(-halfWidth)).add(yOff)); + rightPoints.push(point.clone().add(lateral.clone().multiplyScalar(halfWidth)).add(yOff)); + } + + const leftCurve = new THREE.CatmullRomCurve3(leftPoints); + const rightCurve = new THREE.CatmullRomCurve3(rightPoints); + + const edgeGeoL = new THREE.TubeGeometry(leftCurve, edgeSegments, edgeRadius, 6, false); + const edgeGeoR = new THREE.TubeGeometry(rightCurve, edgeSegments, edgeRadius, 6, false); + + const edgeLeft = new THREE.Mesh(edgeGeoL, edgeMat); + const edgeRight = new THREE.Mesh(edgeGeoR, edgeMat); + trackGroup.add(edgeLeft); + trackGroup.add(edgeRight); + + scene.add(trackGroup); +} + +function buildFinishLine() { + // Create a checkerboard texture via canvas + const canvas = document.createElement('canvas'); + canvas.width = 128; + canvas.height = 32; + const ctx = canvas.getContext('2d'); + const numChecks = 8; + const checkW = canvas.width / numChecks; + const checkH = canvas.height / 2; + for (let row = 0; row < 2; row++) { + for (let col = 0; col < numChecks; col++) { + ctx.fillStyle = (row + col) % 2 === 0 ? '#ffffff' : '#111111'; + ctx.fillRect(col * checkW, row * checkH, checkW, checkH); + } + } + const texture = new THREE.CanvasTexture(canvas); + texture.wrapS = THREE.RepeatWrapping; + texture.wrapT = THREE.RepeatWrapping; + + const finishGeo = new THREE.PlaneGeometry(TRACK_WIDTH, 1.5); + const finishMat = new THREE.MeshStandardMaterial({ + map: texture, + roughness: 0.4, + metalness: 0.1, + side: THREE.DoubleSide, + }); + finishLineMesh = new THREE.Mesh(finishGeo, finishMat); + + // Position at end of curve + const endPoint = curve.getPointAt(1.0); + const tangent = curve.getTangentAt(1.0); + const lateral = getLateral(1.0); + const trackUp = getTrackUp(1.0); + + finishLineMesh.position.copy(endPoint); + finishLineMesh.position.add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + 0.01)); + + // Orient to face along tangent, lying on track surface + const lookTarget = endPoint.clone().add(trackUp); + finishLineMesh.lookAt(lookTarget); + // Rotate to align width with lateral direction + const quat = new THREE.Quaternion(); + const mat4 = new THREE.Matrix4(); + mat4.makeBasis(lateral, trackUp, tangent); + quat.setFromRotationMatrix(mat4); + finishLineMesh.quaternion.copy(quat); + // Shift slightly up off surface + finishLineMesh.position.add(trackUp.clone().multiplyScalar(0.02)); + + scene.add(finishLineMesh); + + // Add vertical finish banner poles + const poleMat = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.5 }); + const poleGeo = new THREE.CylinderGeometry(0.05, 0.05, 2.5, 8); + const poleLeft = new THREE.Mesh(poleGeo, poleMat); + const poleRight = new THREE.Mesh(poleGeo, poleMat); + + const poleBase = endPoint.clone().add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + 1.25)); + poleLeft.position.copy(poleBase.clone().add(lateral.clone().multiplyScalar(-TRACK_WIDTH / 2))); + poleRight.position.copy(poleBase.clone().add(lateral.clone().multiplyScalar(TRACK_WIDTH / 2))); + + // Align poles with track up direction + const poleQuat = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), trackUp); + poleLeft.quaternion.copy(poleQuat); + poleRight.quaternion.copy(poleQuat); + + scene.add(poleLeft); + scene.add(poleRight); + + // Banner across top + const bannerGeo = new THREE.PlaneGeometry(TRACK_WIDTH, 0.4); + const bannerCanvas = document.createElement('canvas'); + bannerCanvas.width = 256; + bannerCanvas.height = 32; + const bctx = bannerCanvas.getContext('2d'); + // Checkerboard banner + for (let col = 0; col < 16; col++) { + bctx.fillStyle = col % 2 === 0 ? '#ffffff' : '#111111'; + bctx.fillRect(col * 16, 0, 16, 32); + } + const bannerTex = new THREE.CanvasTexture(bannerCanvas); + const bannerMat = new THREE.MeshStandardMaterial({ + map: bannerTex, + side: THREE.DoubleSide, + roughness: 0.4, + }); + const bannerMesh = new THREE.Mesh(bannerGeo, bannerMat); + bannerMesh.position.copy(poleBase.clone().add(trackUp.clone().multiplyScalar(1.25))); + const bannerQuat = new THREE.Quaternion(); + const bannerBasis = new THREE.Matrix4().makeBasis(lateral, trackUp, tangent); + bannerQuat.setFromRotationMatrix(bannerBasis); + bannerMesh.quaternion.copy(bannerQuat); + scene.add(bannerMesh); +} + 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 }); - // Shell (flattened sphere) const shellGeo = new THREE.SphereGeometry(0.4, 16, 12); const shell = new THREE.Mesh(shellGeo, shellMat); shell.scale.set(1, 0.5, 1.1); shell.position.y = 0.1; group.add(shell); - // Body (slightly smaller, underneath shell) const bodyGeo = new THREE.SphereGeometry(0.35, 12, 10); const body = new THREE.Mesh(bodyGeo, bodyMat); body.scale.set(1, 0.35, 1.05); body.position.y = -0.02; group.add(body); - // Head (small sphere at front) const headGeo = new THREE.SphereGeometry(0.12, 10, 8); const head = new THREE.Mesh(headGeo, headMat); head.position.set(0, 0.05, 0.42); group.add(head); - // Legs (4 flattened cylinders) const legGeo = new THREE.CylinderGeometry(0.06, 0.06, 0.12, 6); const legPositions = [ { x: -0.22, z: 0.2 }, @@ -162,52 +454,120 @@ function createTurtleMesh() { return group; } +function generateObstacles(rng) { + const obstacles = []; + const halfTrack = TRACK_WIDTH / 2; + + let t = SAFE_ZONE_T; + const endT = 0.95; // Stop before finish line + while (t < endT) { + const spacing = OBSTACLE_MIN_SPACING + rng() * (OBSTACLE_MAX_SPACING - OBSTACLE_MIN_SPACING); + t += spacing; + if (t >= endT) break; + + // Place obstacle with lateral offset + const maxOffset = halfTrack - OBSTACLE_WIDTH / 2 - 0.1; + const d = (rng() * 2 - 1) * maxOffset; + + // Convert to world position for mesh placement + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + const tangent = curve.getTangentAt(t); + + const worldPos = point.clone() + .add(lateral.clone().multiplyScalar(d)) + .add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + OBSTACLE_HEIGHT / 2)); + + obstacles.push({ + t, + d, + halfW: OBSTACLE_WIDTH / 2, + halfD: OBSTACLE_DEPTH / 2, + worldPos, + tangent: tangent.clone(), + lateral: lateral.clone(), + trackUp: trackUp.clone(), + }); + } + return obstacles; +} + +function generateCoins(rng, obstacles) { + const coins = []; + const halfTrack = TRACK_WIDTH / 2; + + for (let i = 0; i < obstacles.length; i++) { + const startT = i === 0 ? SAFE_ZONE_T : obstacles[i - 1].t + 0.005; + const endT = obstacles[i].t - 0.005; + const gap = endT - startT; + if (gap < 0.01) continue; + + const count = gap >= 0.03 ? 3 : 2; + const step = gap / (count + 1); + + for (let j = 1; j <= count; j++) { + const ct = startT + step * j; + const cd = (rng() * 2 - 1) * (halfTrack - 0.5); + coins.push({ t: ct, d: cd }); + } + } + + // Coins after last obstacle + if (obstacles.length > 0) { + const lastT = obstacles[obstacles.length - 1].t + 0.005; + const gap = 0.95 - lastT; + if (gap >= 0.015) { + const count = 2; + const step = gap / (count + 1); + for (let j = 1; j <= count; j++) { + const ct = lastT + step * j; + const cd = (rng() * 2 - 1) * (halfTrack - 0.5); + coins.push({ t: ct, d: cd }); + } + } + } + + return coins; +} + function generateTurtle(rng, obstacles) { const halfTrack = TRACK_WIDTH / 2; - const halfLength = TRACK_LENGTH / 2; - const minZ = SAFE_ZONE_Z + 5; - const maxZ = halfLength - 3; + const minT = SAFE_ZONE_T + 0.05; + const maxT = 0.90; - if (maxZ <= minZ) return null; + if (maxT <= minT) return null; - // Pick a random Z, avoiding obstacle zones let attempts = 0; while (attempts < 20) { - const z = minZ + rng() * (maxZ - minZ); + const t = minT + rng() * (maxT - minT); let clear = true; for (const o of obstacles) { - if (Math.abs(z - o.z) < 2) { + if (Math.abs(t - o.t) < 0.02) { clear = false; break; } } if (clear) { - const x = (rng() * 2 - 1) * (halfTrack - 0.5); - return { x, z }; + const d = (rng() * 2 - 1) * (halfTrack - 0.5); + return { t, d }; } attempts++; } - // Fallback: place in safe zone area - const x = (rng() * 2 - 1) * (halfTrack - 0.5); - return { x, z: minZ + 2 }; + const d = (rng() * 2 - 1) * (halfTrack - 0.5); + return { t: minT + 0.02, d }; } -// Shared geometry and materials for obstacles and coins -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 coinGeo = new THREE.TorusGeometry(COIN_RADIUS, COIN_TUBE, 12, 24); -const coinMat = new THREE.MeshStandardMaterial({ - color: 0xFFD700, - metalness: 0.8, - roughness: 0.2, - emissive: 0x554400, - emissiveIntensity: 0.3, -}); +// Convert curve-local (t, d) to world position on the track surface +function curveLocalToWorld(t, d, yOffset) { + const point = curve.getPointAt(t); + const lateral = getLateral(t); + const trackUp = getTrackUp(t); + return point.clone() + .add(lateral.clone().multiplyScalar(d)) + .add(trackUp.clone().multiplyScalar(TRACK_HEIGHT / 2 + (yOffset || 0))); +} function generateLevel() { let rng = seededRandom(Date.now()); @@ -225,65 +585,78 @@ function generateLevel() { obstacleMeshes = obstacleData.map((o) => { const mesh = new THREE.Mesh(obstGeo, obstMat); - mesh.position.set(o.x, TRACK_HEIGHT / 2 + OBSTACLE_HEIGHT / 2, o.z); + mesh.position.copy(o.worldPos); + + // Orient obstacle to align with track + const quat = new THREE.Quaternion(); + const basis = new THREE.Matrix4().makeBasis(o.lateral, o.trackUp, o.tangent); + quat.setFromRotationMatrix(basis); + mesh.quaternion.copy(quat); + mesh.castShadow = true; mesh.receiveShadow = true; scene.add(mesh); return mesh; }); - coinMeshes = coinData.map((c) => { + const rawCoins = generateCoins(rng, rawObstacles); + coinData = rawCoins; + + const coinY = 0.35; // Height above track surface + coinMeshes = rawCoins.map((c) => { + const worldPos = curveLocalToWorld(c.t, c.d, coinY); const mesh = new THREE.Mesh(coinGeo, coinMat); - mesh.position.set(c.x, COIN_Y, c.z); + mesh.position.copy(worldPos); mesh.rotation.x = Math.PI / 2; scene.add(mesh); return mesh; }); - // Generate turtle powerup - turtleData = generateTurtle(rng, obstacleData); + // Turtle powerup + turtleData = generateTurtle(rng, rawObstacles); if (turtleData) { turtleMesh = createTurtleMesh(); - turtleMesh.position.set(turtleData.x, COIN_Y, turtleData.z); + const turtleWorldPos = curveLocalToWorld(turtleData.t, turtleData.d, 0.35); + turtleMesh.position.copy(turtleWorldPos); scene.add(turtleMesh); } } export function regenerateLevel() { - // Remove old obstacle meshes from scene for (const mesh of obstacleMeshes) { scene.remove(mesh); } obstacleMeshes = []; obstacleData = []; - // Remove old coin meshes from scene for (const mesh of coinMeshes) { scene.remove(mesh); } coinMeshes = []; coinData = []; - // Remove old turtle mesh from scene if (turtleMesh) { scene.remove(turtleMesh); turtleMesh = null; turtleData = null; } - // Generate fresh layout generateLevel(); } export function initRenderer() { scene = new THREE.Scene(); scene.background = new THREE.Color(0x87CEEB); - scene.fog = new THREE.Fog(0x87CEEB, 30, 80); + scene.fog = new THREE.Fog(0x87CEEB, 40, 120); + + // Build curve + buildCurve(); // Camera - camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 200); - camera.position.set(0, 4, BALL_START_Z - 8); - camera.lookAt(0, 0, BALL_START_Z); + const startPoint = curve.getPointAt(0); + camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 300); + camera.position.set(startPoint.x, startPoint.y + 4, startPoint.z - 8); + camera.lookAt(startPoint); // Renderer renderer = new THREE.WebGLRenderer({ antialias: true }); @@ -298,40 +671,37 @@ export function initRenderer() { scene.add(ambient); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); - dirLight.position.set(5, 10, 5); + dirLight.position.set(5, 20, 5); dirLight.castShadow = true; - dirLight.shadow.mapSize.width = 1024; - dirLight.shadow.mapSize.height = 1024; + dirLight.shadow.mapSize.width = 2048; + dirLight.shadow.mapSize.height = 2048; dirLight.shadow.camera.near = 0.5; - dirLight.shadow.camera.far = 60; - dirLight.shadow.camera.left = -10; - dirLight.shadow.camera.right = 10; - dirLight.shadow.camera.top = 30; - dirLight.shadow.camera.bottom = -30; + dirLight.shadow.camera.far = 100; + dirLight.shadow.camera.left = -20; + dirLight.shadow.camera.right = 20; + dirLight.shadow.camera.top = 40; + dirLight.shadow.camera.bottom = -40; scene.add(dirLight); - // Track (fixed, never rotates) - const trackGeo = new THREE.BoxGeometry(TRACK_WIDTH, TRACK_HEIGHT, TRACK_LENGTH); - const trackMat = new THREE.MeshStandardMaterial({ - color: 0x8B7355, - roughness: 0.7, - metalness: 0.1, - }); - trackMesh = new THREE.Mesh(trackGeo, trackMat); - trackMesh.position.set(0, 0, 0); - trackMesh.receiveShadow = true; - scene.add(trackMesh); + // A second directional light for better illumination along the course + const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.4); + dirLight2.position.set(-5, 15, 70); + scene.add(dirLight2); - // Edge lines for visibility - const edgeMat = new THREE.MeshStandardMaterial({ color: 0x5a4a3a, roughness: 0.6 }); - const edgeGeo = new THREE.BoxGeometry(0.06, 0.08, TRACK_LENGTH); - edgeLeft = new THREE.Mesh(edgeGeo, edgeMat); - edgeLeft.position.set(-TRACK_WIDTH / 2, TRACK_HEIGHT / 2 + 0.04, 0); - scene.add(edgeLeft); + // Ground plane (far below track, for visual reference) + const groundGeo = new THREE.PlaneGeometry(300, 300); + const groundMat = new THREE.MeshStandardMaterial({ color: 0x3a7d3a, roughness: 0.9 }); + const ground = new THREE.Mesh(groundGeo, groundMat); + ground.rotation.x = -Math.PI / 2; + ground.position.y = -5; + ground.receiveShadow = true; + scene.add(ground); + + // Build track mesh + buildTrackMesh(); - edgeRight = new THREE.Mesh(edgeGeo, edgeMat); - edgeRight.position.set(TRACK_WIDTH / 2, TRACK_HEIGHT / 2 + 0.04, 0); - scene.add(edgeRight); + // Build finish line + buildFinishLine(); // Ball const ballGeo = new THREE.SphereGeometry(BALL_RADIUS, 32, 32); @@ -342,10 +712,11 @@ export function initRenderer() { }); ballMesh = new THREE.Mesh(ballGeo, ballMat); ballMesh.castShadow = true; - ballMesh.position.set(0, TRACK_HEIGHT / 2 + BALL_RADIUS, BALL_START_Z); + const ballStart = curveLocalToWorld(0, 0, BALL_RADIUS); + ballMesh.position.copy(ballStart); scene.add(ballMesh); - // Generate initial level layout + // Generate level generateLevel(); // Handle resize @@ -369,15 +740,30 @@ export function resetBallRotation() { } export function updateBallRotation(vx, vz, dt) { - // Rolling rotation: x-axis for forward motion, z-axis for lateral ballMesh.rotation.x -= (vz / BALL_RADIUS) * dt; ballMesh.rotation.z += (vx / BALL_RADIUS) * dt; } -export function updateCamera(ballZ) { - camera.position.z = ballZ - 8; - camera.position.y = 4; - camera.lookAt(0, 0, ballZ); +// Camera smoothly follows the ball along the curve +const _cameraTarget = new THREE.Vector3(); +const _cameraPos = new THREE.Vector3(); + +export function updateCamera(ballT, ballWorldPos) { + if (!curve) return; + + const clampedT = Math.max(0, Math.min(1, ballT)); + const tangent = curve.getTangentAt(clampedT); + + // Camera positioned behind the ball along the tangent + _cameraPos.copy(ballWorldPos) + .sub(tangent.clone().multiplyScalar(8)) + .add(new THREE.Vector3(0, 4, 0)); + + // Smooth follow + camera.position.lerp(_cameraPos, 0.08); + + _cameraTarget.copy(ballWorldPos).add(new THREE.Vector3(0, 0.5, 0)); + camera.lookAt(_cameraTarget); } export function render() { @@ -388,16 +774,21 @@ export function getTrackConfig() { return { trackWidth: TRACK_WIDTH, trackHeight: TRACK_HEIGHT, - trackLength: TRACK_LENGTH, + trackLength: curveLength, ballRadius: BALL_RADIUS, - ballStartZ: BALL_START_Z, + ballStartT: 0, + curve, + curveLength, + getLateral, + getTrackUp, + curveLocalToWorld, }; } export function getObstacles() { return obstacleData.map((o) => ({ - x: o.x, - z: o.z, + t: o.t, + d: o.d, halfW: o.halfW, halfD: o.halfD, height: OBSTACLE_HEIGHT, @@ -405,7 +796,7 @@ export function getObstacles() { } export function getCoins() { - return coinData.map((c) => ({ x: c.x, z: c.z })); + return coinData.map((c) => ({ t: c.t, d: c.d })); } export function hideCoin(index) { @@ -414,24 +805,19 @@ export function hideCoin(index) { } } -export function showAllCoins() { - coinMeshes.forEach((m) => { m.visible = true; }); -} - export function updateCoinRotation(dt) { coinMeshes.forEach((m) => { if (m.visible) { m.rotation.y += 2.0 * dt; } }); - // Rotate turtle powerup too if (turtleMesh && turtleMesh.visible) { turtleMesh.rotation.y += 1.5 * dt; } } export function getTurtle() { - return turtleData ? { x: turtleData.x, z: turtleData.z } : null; + return turtleData ? { t: turtleData.t, d: turtleData.d } : null; } export function hideTurtle() {