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() {