Rally
Gemini 3.7 Flash · full
◈Preview unavailable — try playing the result
Browser check: Preview unavailable or timed out. This is a technical check, not a quality review.
Reviews & generation details
Reviews
No human review yet. Unreviewed does not mean unsuccessful.
Generation context
Imported historical artifact. Its exact original instructions and lineage are not known.
Generation prompt · exact text unavailable
See the current prompt for reference. It may differ from the instructions used to generate this artifact.
Inspect original source 84,676 bytes · SHA-256 b5db1b936e0d
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Apex Rally 3D - Procedural Rally Championship</title>
<!-- Three.js r128 from CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #0b0f19;
font-family: 'Segoe UI', -apple-system, BlinkMacSystemFont, Roboto, sans-serif;
color: #fff;
}
#game-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
canvas#webgl-canvas {
width: 100%;
height: 100%;
display: block;
}
/* HUD Styling */
#hud-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 20px;
z-index: 10;
}
.hud-top {
display: flex;
justify-content: space-between;
align-items: flex-start;
width: 100%;
}
.hud-card {
background: rgba(12, 18, 28, 0.75);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
padding: 12px 18px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.timer-box {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
}
.timer-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-family: 'Courier New', Courier, monospace;
}
.timer-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #94a3b8;
font-family: 'Segoe UI', sans-serif;
font-weight: 700;
}
.timer-val {
font-size: 20px;
font-weight: 900;
color: #f8fafc;
letter-spacing: 1px;
}
.timer-best {
color: #38bdf8;
font-size: 15px;
}
.timer-last {
color: #fbbf24;
font-size: 13px;
}
.lap-box {
text-align: center;
min-width: 110px;
}
.lap-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #94a3b8;
font-weight: 700;
}
.lap-counter {
font-size: 28px;
font-weight: 900;
color: #f43f5e;
font-style: italic;
}
.surface-tag {
margin-top: 4px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
background: #22c55e;
color: #000;
letter-spacing: 0.5px;
display: inline-block;
}
.surface-tag.dirt { background: #d97706; color: #fff; }
.surface-tag.offroad { background: #ef4444; color: #fff; }
/* Minimap */
.minimap-container {
position: relative;
width: 140px;
height: 140px;
background: rgba(12, 18, 28, 0.85);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 50%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
}
canvas#minimap-canvas {
width: 100%;
height: 100%;
}
/* Bottom HUD */
.hud-bottom {
display: flex;
justify-content: space-between;
align-items: flex-end;
width: 100%;
}
.gauge-cluster {
display: flex;
align-items: flex-end;
gap: 15px;
}
/* Speedometer Circular SVG / Canvas */
.tacho-box {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(12, 18, 28, 0.8);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 16px;
padding: 12px 18px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.speed-main {
display: flex;
align-items: baseline;
gap: 4px;
}
.speed-val {
font-size: 48px;
font-weight: 900;
font-family: 'Segoe UI', sans-serif;
color: #fff;
line-height: 1;
}
.speed-unit {
font-size: 14px;
font-weight: 800;
color: #94a3b8;
text-transform: uppercase;
}
.rpm-bar-wrap {
width: 140px;
height: 10px;
background: rgba(255, 255, 255, 0.1);
border-radius: 5px;
margin-top: 8px;
overflow: hidden;
position: relative;
}
.rpm-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #38bdf8 0%, #fbbf24 70%, #ef4444 95%);
transition: width 0.05s ease;
}
.gear-display {
margin-top: 6px;
font-size: 18px;
font-weight: 900;
color: #38bdf8;
letter-spacing: 1px;
}
/* Status message alert (Wrong way, checkpoints, etc) */
#center-banner {
position: absolute;
top: 25%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
pointer-events: none;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275), opacity 0.3s;
opacity: 0;
}
#center-banner.active {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
.banner-text {
font-size: 48px;
font-weight: 900;
font-style: italic;
text-transform: uppercase;
color: #facc15;
text-shadow: 0 0 20px rgba(250, 204, 21, 0.8), 0 4px 10px rgba(0,0,0,0.8);
letter-spacing: 2px;
}
.banner-sub {
font-size: 18px;
font-weight: 700;
color: #ffffff;
text-shadow: 0 2px 8px rgba(0,0,0,0.8);
margin-top: 4px;
}
/* Help hints on bottom right */
.hud-hints {
font-size: 11px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
text-align: right;
}
.hud-hints kbd {
background: rgba(255,255,255,0.2);
border-radius: 4px;
padding: 1px 5px;
font-weight: bold;
color: #fff;
}
/* Mobile Touch Controls Overlay */
#touch-controls {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: none;
z-index: 20;
}
.touch-zone-left {
position: absolute;
bottom: 25px;
left: 25px;
width: 160px;
height: 160px;
pointer-events: auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.touch-zone-right {
position: absolute;
bottom: 25px;
right: 25px;
width: 180px;
height: 160px;
pointer-events: auto;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: flex-end;
}
.touch-btn {
width: 70px;
height: 70px;
background: rgba(15, 23, 42, 0.65);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
font-weight: 900;
font-size: 20px;
color: #fff;
touch-action: none;
box-shadow: 0 4px 15px rgba(0,0,0,0.4);
user-select: none;
backdrop-filter: blur(4px);
}
.touch-btn:active, .touch-btn.active {
background: rgba(244, 63, 94, 0.7);
border-color: #fff;
transform: scale(0.92);
}
.touch-btn-gas {
background: rgba(34, 197, 94, 0.6);
width: 80px;
height: 80px;
}
.touch-btn-brake {
background: rgba(239, 68, 68, 0.6);
width: 70px;
height: 70px;
}
.touch-btn-handbrake {
background: rgba(234, 179, 8, 0.6);
font-size: 12px;
width: 60px;
height: 60px;
}
/* Modals (Menu, Finish, Countdown, Audio Prompt) */
.modal-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(5, 8, 15, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
justify-content: center;
align-items: center;
z-index: 50;
transition: opacity 0.3s ease;
}
.modal-box {
background: #0f172a;
border: 2px solid #38bdf8;
border-radius: 20px;
padding: 35px 45px;
max-width: 480px;
width: 90%;
text-align: center;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.8), 0 0 30px rgba(56, 189, 248, 0.2);
animation: popIn 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
@keyframes popIn {
0% { transform: scale(0.8); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
.modal-title {
font-size: 32px;
font-weight: 900;
font-style: italic;
letter-spacing: 1.5px;
color: #fff;
margin-bottom: 8px;
text-transform: uppercase;
}
.modal-subtitle {
font-size: 14px;
color: #94a3b8;
margin-bottom: 25px;
}
.modal-stats {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 16px;
margin-bottom: 25px;
display: flex;
flex-direction: column;
gap: 8px;
}
.stat-row {
display: flex;
justify-content: space-between;
font-size: 15px;
}
.stat-row .val {
font-weight: 800;
font-family: 'Courier New', Courier, monospace;
color: #38bdf8;
}
.btn-primary {
background: linear-gradient(135deg, #f43f5e 0%, #e11d48 100%);
color: #fff;
border: none;
border-radius: 12px;
padding: 14px 28px;
font-size: 16px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 1px;
cursor: pointer;
box-shadow: 0 6px 20px rgba(244, 63, 94, 0.4);
transition: all 0.2s;
outline: none;
width: 100%;
margin-bottom: 10px;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px rgba(244, 63, 94, 0.6);
}
.btn-secondary {
background: rgba(255,255,255,0.1);
color: #e2e8f0;
border: 1px solid rgba(255,255,255,0.2);
border-radius: 12px;
padding: 12px 24px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
width: 100%;
}
.btn-secondary:hover {
background: rgba(255,255,255,0.2);
}
/* Countdown big overlay */
#countdown-screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
display: flex;
justify-content: center;
align-items: center;
z-index: 40;
}
.countdown-num {
font-size: 120px;
font-weight: 900;
font-style: italic;
color: #fff;
text-shadow: 0 0 40px rgba(244, 63, 94, 0.8), 0 8px 20px rgba(0,0,0,0.9);
transform: scale(0.3);
opacity: 0;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.countdown-num.show {
transform: scale(1.2);
opacity: 1;
}
.countdown-num.go {
color: #22c55e;
text-shadow: 0 0 50px rgba(34, 197, 94, 0.9), 0 8px 20px rgba(0,0,0,0.9);
}
/* Top bar action buttons (Mute, Pause, Cam) */
.top-actions {
display: flex;
gap: 8px;
pointer-events: auto;
}
.icon-btn {
background: rgba(12, 18, 28, 0.8);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
width: 38px;
height: 38px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
}
.icon-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
@media (max-width: 768px) {
#touch-controls { display: block; }
.hud-hints { display: none; }
.gauge-cluster { transform: scale(0.85); transform-origin: bottom left; }
.minimap-container { width: 100px; height: 100px; }
}
</style>
</head>
<body>
<div id="game-container">
<canvas id="webgl-canvas"></canvas>
<!-- HUD Layer -->
<div id="hud-layer">
<div class="hud-top">
<div class="hud-card timer-box">
<div class="timer-row">
<span class="timer-label">TIME</span>
<span class="timer-val" id="time-current">00:00.000</span>
</div>
<div class="timer-row">
<span class="timer-label">BEST</span>
<span class="timer-val timer-best" id="time-best">--:--.---</span>
</div>
<div class="timer-row">
<span class="timer-label">LAST</span>
<span class="timer-val timer-last" id="time-last">--:--.---</span>
</div>
</div>
<div class="hud-card lap-box">
<div class="lap-label">LAP</div>
<div class="lap-counter" id="lap-current">1 / 3</div>
<div class="surface-tag" id="surface-tag">TARMAC</div>
</div>
<div class="top-actions">
<button class="icon-btn" id="btn-cam" title="Toggle Camera (C)">📷</button>
<button class="icon-btn" id="btn-audio" title="Mute/Unmute Audio (M)">🔊</button>
<button class="icon-btn" id="btn-pause" title="Pause Game (P)">⏸</button>
</div>
<div class="minimap-container">
<canvas id="minimap-canvas" width="140" height="140"></canvas>
</div>
</div>
<!-- Center Alerts -->
<div id="center-banner">
<div class="banner-text" id="banner-main">LAP 2</div>
<div class="banner-sub" id="banner-sub">Split: +0.42s</div>
</div>
<div class="hud-bottom">
<div class="gauge-cluster">
<div class="tacho-box">
<div class="speed-main">
<span class="speed-val" id="speed-val">0</span>
<span class="speed-unit">KM/H</span>
</div>
<div class="rpm-bar-wrap">
<div class="rpm-bar-fill" id="rpm-bar"></div>
</div>
<div class="gear-display" id="gear-val">N</div>
</div>
</div>
<div class="hud-hints">
<div><kbd>W</kbd> / <kbd>▲</kbd> Accelerate • <kbd>S</kbd> / <kbd>▼</kbd> Brake / Rev</div>
<div><kbd>A</kbd> <kbd>D</kbd> / <kbd>◄</kbd> <kbd>►</kbd> Steer • <kbd>SPACE</kbd> Drift</div>
<div><kbd>C</kbd> Camera • <kbd>R</kbd> Reset Car • <kbd>P</kbd> Pause</div>
</div>
</div>
</div>
<!-- Countdown Overlay -->
<div id="countdown-screen">
<div class="countdown-num" id="countdown-num">3</div>
</div>
<!-- Touch Controls for Mobile -->
<div id="touch-controls">
<div class="touch-zone-left">
<div class="touch-btn" id="t-left">◀</div>
<div class="touch-btn" id="t-right">▶</div>
</div>
<div class="touch-zone-right">
<div class="touch-btn touch-btn-gas" id="t-gas">GAS</div>
<div style="display: flex; gap: 10px;">
<div class="touch-btn touch-btn-handbrake" id="t-drift">DRIFT</div>
<div class="touch-btn touch-btn-brake" id="t-brake">BRAKE</div>
</div>
</div>
</div>
<!-- Pause Modal -->
<div class="modal-overlay" id="pause-modal" style="display: none;">
<div class="modal-box">
<div class="modal-title">RACE PAUSED</div>
<div class="modal-subtitle">Take a breather, driver!</div>
<button class="btn-primary" id="btn-resume">RESUME RACE</button>
<button class="btn-secondary" id="btn-restart-pause">RESTART RACE</button>
</div>
</div>
<!-- Finish Modal -->
<div class="modal-overlay" id="finish-modal" style="display: none;">
<div class="modal-box">
<div class="modal-title">RALLY COMPLETED! 🏁</div>
<div class="modal-subtitle" id="finish-grade">OUTSTANDING DRIVING</div>
<div class="modal-stats">
<div class="stat-row">
<span>Total Race Time:</span>
<span class="val" id="stat-total-time">02:14.530</span>
</div>
<div class="stat-row">
<span>Best Lap:</span>
<span class="val" id="stat-best-lap">00:43.120</span>
</div>
<div class="stat-row">
<span>Top Speed:</span>
<span class="val" id="stat-top-speed">168 km/h</span>
</div>
<div class="stat-row">
<span>Clean Driving:</span>
<span class="val" id="stat-clean-laps">3 / 3 Laps</span>
</div>
</div>
<button class="btn-primary" id="btn-restart-finish">RACE AGAIN</button>
</div>
</div>
<!-- Start Welcome / Audio Unlock Overlay -->
<div class="modal-overlay" id="start-modal">
<div class="modal-box">
<div class="modal-title">🌲 APEX RALLY 3D 🌲</div>
<div class="modal-subtitle">Procedural Alpine Dirt & Asphalt Track</div>
<div class="modal-stats" style="text-align: left; font-size: 13px; line-height: 1.6;">
<div>🏆 Complete 3 fast laps against the clock.</div>
<div>🏎️ Master throttle control & handbrake drifts on dirt corners.</div>
<div>⚠️ Avoid rocks, barriers & dense forest trees.</div>
</div>
<button class="btn-primary" id="btn-start-game">START ENGINE & RACE</button>
</div>
</div>
</div>
<script>
/**
* APEX RALLY 3D - Complete Single File Procedural WebGL Racing Experience
*/
// ==========================================
// 1. SOUND SYNTHESIZER (Web Audio API)
// ==========================================
class SoundController {
constructor() {
this.ctx = null;
this.muted = false;
this.initialized = false;
this.engineOsc1 = null;
this.engineOsc2 = null;
this.engineGain = null;
this.skidGain = null;
this.skidFilter = null;
this.skidSource = null;
}
init() {
if (this.initialized) return;
try {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
this.setupEngineSynth();
this.setupSkidSynth();
this.initialized = true;
} catch (e) {
console.warn("Web Audio API not supported", e);
}
}
setupEngineSynth() {
if (!this.ctx) return;
// Primary engine roar
this.engineOsc1 = this.ctx.createOscillator();
this.engineOsc1.type = 'sawtooth';
this.engineOsc1.frequency.setValueAtTime(60, this.ctx.currentTime);
// Sub rumble oscillator
this.engineOsc2 = this.ctx.createOscillator();
this.engineOsc2.type = 'triangle';
this.engineOsc2.frequency.setValueAtTime(30, this.ctx.currentTime);
// Lowpass filter for smooth throatiness
this.engineFilter = this.ctx.createBiquadFilter();
this.engineFilter.type = 'lowpass';
this.engineFilter.frequency.setValueAtTime(450, this.ctx.currentTime);
this.engineFilter.Q.setValueAtTime(2.5, this.ctx.currentTime);
this.engineGain = this.ctx.createGain();
this.engineGain.gain.setValueAtTime(0.0, this.ctx.currentTime);
this.engineOsc1.connect(this.engineFilter);
this.engineOsc2.connect(this.engineFilter);
this.engineFilter.connect(this.engineGain);
this.engineGain.connect(this.ctx.destination);
this.engineOsc1.start();
this.engineOsc2.start();
}
setupSkidSynth() {
if (!this.ctx) return;
// Pink / White noise generator for tire screech and gravel rumble
const bufferSize = this.ctx.sampleRate * 2;
const noiseBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = noiseBuffer.getChannelData(0);
let b0 = 0, b1 = 0, b2 = 0, b3 = 0, b4 = 0, b5 = 0, b6 = 0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
b0 = 0.99886 * b0 + white * 0.0555179;
b1 = 0.99332 * b1 + white * 0.0750759;
b2 = 0.96900 * b2 + white * 0.1538520;
b3 = 0.86650 * b3 + white * 0.3104856;
b4 = 0.55000 * b4 + white * 0.5329522;
b5 = -0.7616 * b5 - white * 0.0168980;
output[i] = (b0 + b1 + b2 + b3 + b4 + b5 + b6 + white * 0.5362) * 0.11;
b6 = white * 0.115926;
}
this.skidSource = this.ctx.createBufferSource();
this.skidSource.buffer = noiseBuffer;
this.skidSource.loop = true;
this.skidFilter = this.ctx.createBiquadFilter();
this.skidFilter.type = 'bandpass';
this.skidFilter.frequency.setValueAtTime(1100, this.ctx.currentTime);
this.skidFilter.Q.setValueAtTime(4.0, this.ctx.currentTime);
this.skidGain = this.ctx.createGain();
this.skidGain.gain.setValueAtTime(0.0, this.ctx.currentTime);
this.skidSource.connect(this.skidFilter);
this.skidFilter.connect(this.skidGain);
this.skidGain.connect(this.ctx.destination);
this.skidSource.start();
}
updateEngine(rpmRatio, throttle, speedRatio) {
if (!this.initialized || this.muted || !this.ctx) return;
const now = this.ctx.currentTime;
const baseFreq = 45 + rpmRatio * 180 + throttle * 40;
this.engineOsc1.frequency.setTargetAtTime(baseFreq, now, 0.05);
this.engineOsc2.frequency.setTargetAtTime(baseFreq * 0.5, now, 0.05);
this.engineFilter.frequency.setTargetAtTime(350 + rpmRatio * 1200 + throttle * 600, now, 0.05);
const targetGain = 0.08 + throttle * 0.14 + speedRatio * 0.06;
this.engineGain.gain.setTargetAtTime(targetGain, now, 0.05);
}
updateSkid(intensity, isDirt = false) {
if (!this.initialized || this.muted || !this.ctx) return;
const now = this.ctx.currentTime;
const clamped = Math.min(1.0, Math.max(0, intensity));
const targetFreq = isDirt ? 600 + clamped * 800 : 1200 + clamped * 1200;
this.skidFilter.frequency.setTargetAtTime(targetFreq, now, 0.04);
this.skidGain.gain.setTargetAtTime(clamped * 0.18, now, 0.05);
}
playBeep(isHigh = false) {
if (!this.initialized || this.muted || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
const freq = isHigh ? 880 : 440;
const duration = isHigh ? 0.35 : 0.18;
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(0.2, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
playImpact(strength = 1.0) {
if (!this.initialized || this.muted || !this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(140 * Math.min(strength, 1.5), this.ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(30, this.ctx.currentTime + 0.25);
gain.gain.setValueAtTime(Math.min(strength * 0.3, 0.4), this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.3);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.3);
}
playFanfare() {
if (!this.initialized || this.muted || !this.ctx) return;
const notes = [523.25, 659.25, 783.99, 1046.50];
notes.forEach((freq, idx) => {
setTimeout(() => {
if (!this.ctx) return;
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + 0.4);
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.start();
osc.stop(this.ctx.currentTime + 0.4);
}, idx * 120);
});
}
toggleMute() {
this.muted = !this.muted;
if (this.engineGain) {
this.engineGain.gain.setValueAtTime(this.muted ? 0 : 0.1, this.ctx.currentTime);
}
if (this.skidGain && this.muted) {
this.skidGain.gain.setValueAtTime(0, this.ctx.currentTime);
}
return !this.muted;
}
}
// ==========================================
// 2. PROCEDURAL TEXTURES (Pure Canvas Generation)
// ==========================================
const TextureGenerator = {
createTarmacTexture() {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#2b2d30';
ctx.fillRect(0, 0, 512, 512);
// High frequency asphalt noise
for (let i = 0; i < 45000; i++) {
const v = Math.floor(Math.random() * 40 + 30);
ctx.fillStyle = `rgba(${v},${v},${v}, 0.6)`;
ctx.fillRect(Math.random() * 512, Math.random() * 512, 2, 2);
}
// Tire grooves
ctx.strokeStyle = 'rgba(20,20,20,0.25)';
ctx.lineWidth = 14;
ctx.beginPath();
ctx.moveTo(128, 0); ctx.lineTo(128, 512);
ctx.moveTo(384, 0); ctx.lineTo(384, 512);
ctx.stroke();
// Road dashed center line
ctx.strokeStyle = '#f8fafc';
ctx.lineWidth = 6;
ctx.setLineDash([30, 24]);
ctx.beginPath();
ctx.moveTo(256, 0);
ctx.lineTo(256, 512);
ctx.stroke();
// Road side edges (solid white / yellow stripes)
ctx.setLineDash([]);
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(35, 0); ctx.lineTo(35, 512);
ctx.moveTo(477, 0); ctx.lineTo(477, 512);
ctx.stroke();
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
return texture;
},
createGrassTexture() {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#2d5a27';
ctx.fillRect(0, 0, 512, 512);
for (let i = 0; i < 30000; i++) {
const g = Math.floor(Math.random() * 60 + 60);
const r = Math.floor(g * 0.45);
ctx.fillStyle = `rgba(${r},${g},30, 0.4)`;
ctx.fillRect(Math.random() * 512, Math.random() * 512, 3, 3);
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.repeat.set(40, 40);
return texture;
},
createKerbTexture() {
const canvas = document.createElement('canvas');
canvas.width = 64;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#dc2626';
ctx.fillRect(0, 0, 64, 32);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 32, 64, 32);
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
return texture;
},
createSponsorSignTexture(text, color1, color2) {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 128;
const ctx = canvas.getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 512, 128);
grad.addColorStop(0, color1);
grad.addColorStop(1, color2);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, 512, 128);
ctx.strokeStyle = '#fff';
ctx.lineWidth = 8;
ctx.strokeRect(6, 6, 500, 116);
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 54px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, 256, 64);
return new THREE.CanvasTexture(canvas);
},
createCheckerTexture() {
const canvas = document.createElement('canvas');
canvas.width = 128;
canvas.height = 128;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#111827';
ctx.fillRect(0, 0, 128, 128);
ctx.fillStyle = '#ffffff';
const s = 32;
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
if ((r + c) % 2 === 0) {
ctx.fillRect(c * s, r * s, s, s);
}
}
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
return texture;
}
};
// ==========================================
// 3. TRACK & SPLINE GEOMETRY SYSTEM
// ==========================================
class TrackSystem {
constructor(scene) {
this.scene = scene;
this.trackWidth = 13.5;
this.shoulderWidth = 2.2;
this.trackLength = 0;
this.obstacles = []; // Array of { position, radius, type }
this.checkpoints = []; // Array of { index, position, forward, tangent }
this.surfaceTypes = []; // Array of surface zones along spline t
this.buildSplineTrack();
this.buildTrackGeometry();
this.buildScenery();
}
buildSplineTrack() {
// 3D Control Points: looping rally track with hills, tight hairpins, sweeping curves, and elevation dips
const rawPoints = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(60, 4, 120),
new THREE.Vector3(140, 12, 230),
new THREE.Vector3(260, 20, 270),
new THREE.Vector3(380, 26, 210),
new THREE.Vector3(450, 18, 90),
new THREE.Vector3(410, 8, -60),
new THREE.Vector3(310, -2, -180),
new THREE.Vector3(230, -8, -270),
new THREE.Vector3(120, -14, -320),
new THREE.Vector3(-40, -6, -350),
new THREE.Vector3(-180, 5, -310),
new THREE.Vector3(-290, 18, -210),
new THREE.Vector3(-360, 25, -70),
new THREE.Vector3(-340, 15, 80),
new THREE.Vector3(-240, 8, 200),
new THREE.Vector3(-120, 2, 100),
new THREE.Vector3(-50, 0, 40)
];
this.curve = new THREE.CatmullRomCurve3(rawPoints, true, 'centripetal', 0.5);
this.trackLength = this.curve.getLength();
// Define Checkpoints (10 checkpoints around the loop)
const numCheckpoints = 12;
for (let i = 0; i < numCheckpoints; i++) {
const u = i / numCheckpoints;
const pt = this.curve.getPointAt(u);
const tangent = this.curve.getTangentAt(u).normalize();
this.checkpoints.push({
id: i,
u: u,
position: pt,
tangent: tangent,
radius: this.trackWidth * 1.3
});
}
// Define Surface Types: Asphalt vs Dirt/Gravel sections
// E.g., u between 0.35 and 0.65 is Dirt rally segment
this.surfaceTypes = [
{ start: 0.00, end: 0.30, type: 'tarmac', friction: 1.0, topSpeed: 175 },
{ start: 0.30, end: 0.65, type: 'dirt', friction: 0.78, topSpeed: 145 },
{ start: 0.65, end: 0.85, type: 'tarmac', friction: 1.0, topSpeed: 175 },
{ start: 0.85, end: 1.00, type: 'tarmac', friction: 1.0, topSpeed: 175 }
];
}
getSurfaceAt(u) {
const normU = ((u % 1) + 1) % 1;
for (let s of this.surfaceTypes) {
if (normU >= s.start && normU <= s.end) {
return s;
}
}
return { type: 'tarmac', friction: 1.0, topSpeed: 175 };
}
buildTrackGeometry() {
const numSteps = 700;
const roadVertices = [];
const roadUvs = [];
const roadIndices = [];
const kerbLeftVertices = [];
const kerbLeftIndices = [];
const kerbRightVertices = [];
const kerbRightIndices = [];
const up = new THREE.Vector3(0, 1, 0);
for (let i = 0; i <= numSteps; i++) {
const u = (i / numSteps) % 1;
const point = this.curve.getPointAt(u);
const tangent = this.curve.getTangentAt(u).normalize();
const normal = new THREE.Vector3().crossVectors(up, tangent).normalize();
// Road width span
const halfW = this.trackWidth * 0.5;
const leftPt = new THREE.Vector3().addVectors(point, normal.clone().multiplyScalar(-halfW));
const rightPt = new THREE.Vector3().addVectors(point, normal.clone().multiplyScalar(halfW));
// Slight banking on turns based on curve curvature
leftPt.y += 0.05;
rightPt.y += 0.05;
roadVertices.push(leftPt.x, leftPt.y, leftPt.z);
roadVertices.push(rightPt.x, rightPt.y, rightPt.z);
const vProgress = (i / numSteps) * 60; // texture repetition along length
roadUvs.push(0, vProgress);
roadUvs.push(1, vProgress);
// Kerbs / Shoulders
const kerbW = this.shoulderWidth;
const outerLeft = new THREE.Vector3().addVectors(leftPt, normal.clone().multiplyScalar(-kerbW));
outerLeft.y += 0.1;
kerbLeftVertices.push(leftPt.x, leftPt.y, leftPt.z);
kerbLeftVertices.push(outerLeft.x, outerLeft.y, outerLeft.z);
const outerRight = new THREE.Vector3().addVectors(rightPt, normal.clone().multiplyScalar(kerbW));
outerRight.y += 0.1;
kerbRightVertices.push(rightPt.x, rightPt.y, rightPt.z);
kerbRightVertices.push(outerRight.x, outerRight.y, outerRight.z);
if (i < numSteps) {
const base = i * 2;
roadIndices.push(base, base + 1, base + 2);
roadIndices.push(base + 1, base + 3, base + 2);
kerbLeftIndices.push(base, base + 1, base + 2);
kerbLeftIndices.push(base + 1, base + 3, base + 2);
kerbRightIndices.push(base, base + 1, base + 2);
kerbRightIndices.push(base + 1, base + 3, base + 2);
}
}
// 1. Road Ribbon Mesh
const roadGeo = new THREE.BufferGeometry();
roadGeo.setAttribute('position', new THREE.Float32BufferAttribute(roadVertices, 3));
roadGeo.setAttribute('uv', new THREE.Float32BufferAttribute(roadUvs, 2));
roadGeo.setIndex(roadIndices);
roadGeo.computeVertexNormals();
const roadMat = new THREE.MeshStandardMaterial({
map: TextureGenerator.createTarmacTexture(),
roughness: 0.85,
metalness: 0.1,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -1
});
this.roadMesh = new THREE.Mesh(roadGeo, roadMat);
this.roadMesh.receiveShadow = true;
this.scene.add(this.roadMesh);
// 2. Kerb Meshes
const kerbMat = new THREE.MeshStandardMaterial({
map: TextureGenerator.createKerbTexture(),
roughness: 0.7
});
kerbMat.map.repeat.set(1, 120);
const kerbLeftGeo = new THREE.BufferGeometry();
kerbLeftGeo.setAttribute('position', new THREE.Float32BufferAttribute(kerbLeftVertices, 3));
kerbLeftGeo.setIndex(kerbLeftIndices);
kerbLeftGeo.computeVertexNormals();
this.scene.add(new THREE.Mesh(kerbLeftGeo, kerbMat));
const kerbRightGeo = new THREE.BufferGeometry();
kerbRightGeo.setAttribute('position', new THREE.Float32BufferAttribute(kerbRightVertices, 3));
kerbRightGeo.setIndex(kerbRightIndices);
kerbRightGeo.computeVertexNormals();
this.scene.add(new THREE.Mesh(kerbRightGeo, kerbMat));
// 3. Start / Finish Gantry
this.buildStartGantry();
// 4. Infinite Terrain Grid with Hills
this.buildTerrain();
}
buildStartGantry() {
const startPt = this.curve.getPointAt(0);
const startTangent = this.curve.getTangentAt(0).normalize();
const normal = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), startTangent).normalize();
const gantryGroup = new THREE.Group();
// Steel Pillars
const pillarGeo = new THREE.BoxGeometry(0.8, 8, 0.8);
const pillarMat = new THREE.MeshStandardMaterial({ color: 0x22262d, roughness: 0.4, metalness: 0.8 });
const pLeft = new THREE.Mesh(pillarGeo, pillarMat);
pLeft.position.copy(startPt).add(normal.clone().multiplyScalar(-this.trackWidth * 0.65));
pLeft.position.y += 4;
gantryGroup.add(pLeft);
const pRight = new THREE.Mesh(pillarGeo, pillarMat);
pRight.position.copy(startPt).add(normal.clone().multiplyScalar(this.trackWidth * 0.65));
pRight.position.y += 4;
gantryGroup.add(pRight);
// Cross Beam Banner
const beamGeo = new THREE.BoxGeometry(this.trackWidth * 1.4, 2.0, 1.2);
const beamMat = new THREE.MeshStandardMaterial({
map: TextureGenerator.createSponsorSignTexture('🏁 START / FINISH 🏁', '#dc2626', '#991b1b'),
roughness: 0.5
});
const beam = new THREE.Mesh(beamGeo, beamMat);
beam.position.copy(startPt);
beam.position.y += 7.5;
beam.lookAt(beam.position.clone().add(startTangent));
gantryGroup.add(beam);
// Start line checker stripe on asphalt
const startLineGeo = new THREE.PlaneGeometry(this.trackWidth, 2.5);
const startLineMat = new THREE.MeshStandardMaterial({
map: TextureGenerator.createCheckerTexture(),
roughness: 0.5,
polygonOffset: true,
polygonOffsetFactor: -3,
polygonOffsetUnits: -3
});
startLineMat.map.repeat.set(8, 2);
const startLine = new THREE.Mesh(startLineGeo, startLineMat);
startLine.position.copy(startPt).add(new THREE.Vector3(0, 0.08, 0));
startLine.rotation.x = -Math.PI / 2;
startLine.lookAt(startPt.clone().add(new THREE.Vector3(0, 1, 0)));
startLine.rotateZ(Math.atan2(startTangent.x, startTangent.z));
gantryGroup.add(startLine);
this.scene.add(gantryGroup);
}
buildTerrain() {
const size = 1600;
const segs = 128;
const terrainGeo = new THREE.PlaneGeometry(size, size, segs, segs);
terrainGeo.rotateX(-Math.PI / 2);
const pos = terrainGeo.attributes.position;
for (let i = 0; i < pos.count; i++) {
const x = pos.getX(i);
const z = pos.getZ(i);
// Find distance to track spline to carve track depression/smooth bed
const queryPt = new THREE.Vector3(x, 0, z);
const { dist, trackHeight } = this.getApproxDistanceToTrack(queryPt);
// Ambient rolling hills outside track
let hill = Math.sin(x * 0.008) * Math.cos(z * 0.008) * 35 +
Math.sin(x * 0.02 + z * 0.015) * 12 +
Math.sin(x * 0.05) * 4;
// Blend smoothly into track elevation
if (dist < 40) {
const factor = Math.max(0, (dist - this.trackWidth * 0.7) / (40 - this.trackWidth * 0.7));
// Smooth step
const smoothF = factor * factor * (3 - 2 * factor);
const finalY = (1 - smoothF) * (trackHeight - 0.25) + smoothF * hill;
pos.setY(i, finalY);
} else {
pos.setY(i, hill);
}
}
terrainGeo.computeVertexNormals();
const terrainMat = new THREE.MeshStandardMaterial({
map: TextureGenerator.createGrassTexture(),
roughness: 0.9,
metalness: 0.05
});
const terrain = new THREE.Mesh(terrainGeo, terrainMat);
terrain.position.y = -0.2;
terrain.receiveShadow = true;
this.scene.add(terrain);
this.terrainMesh = terrain;
}
getApproxDistanceToTrack(point) {
let minDistSq = Infinity;
let nearestHeight = 0;
const testSteps = 100;
for (let i = 0; i < testSteps; i++) {
const p = this.curve.getPointAt(i / testSteps);
const dx = p.x - point.x;
const dz = p.z - point.z;
const d2 = dx * dx + dz * dz;
if (d2 < minDistSq) {
minDistSq = d2;
nearestHeight = p.y;
}
}
return { dist: Math.sqrt(minDistSq), trackHeight: nearestHeight };
}
buildScenery() {
// Shared geometries for performance
const trunkGeo = new THREE.CylinderGeometry(0.3, 0.5, 3.5, 6);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x3d271d, roughness: 0.9 });
const foliageGeo = new THREE.ConeGeometry(2.4, 6.0, 7);
const foliageMat1 = new THREE.MeshStandardMaterial({ color: 0x1e3a1e, roughness: 0.8, flatShading: true });
const foliageMat2 = new THREE.MeshStandardMaterial({ color: 0x143414, roughness: 0.8, flatShading: true });
const foliageMat3 = new THREE.MeshStandardMaterial({ color: 0x2e4e1e, roughness: 0.8, flatShading: true });
const foliageMats = [foliageMat1, foliageMat2, foliageMat3];
const rockGeo = new THREE.DodecahedronGeometry(1.5, 1);
const rockMat = new THREE.MeshStandardMaterial({ color: 0x64748b, roughness: 0.9, flatShading: true });
// Place trees and rocks around track corridor
const numItems = 280;
for (let i = 0; i < numItems; i++) {
const u = Math.random();
const pt = this.curve.getPointAt(u);
const tangent = this.curve.getTangentAt(u).normalize();
const normal = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), tangent).normalize();
// Side: left (-1) or right (+1)
const side = Math.random() > 0.5 ? 1 : -1;
const offsetDist = this.trackWidth * 0.7 + 3.0 + Math.random() * 55;
const pos = pt.clone().add(normal.clone().multiplyScalar(side * offsetDist));
pos.y = this.getGroundHeight(pos.x, pos.z);
if (Math.random() < 0.75) {
// Place Low-Poly Pine Tree
const treeGroup = new THREE.Group();
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.y = 1.75;
trunk.castShadow = true;
treeGroup.add(trunk);
const folMat = foliageMats[Math.floor(Math.random() * foliageMats.length)];
const foliage1 = new THREE.Mesh(foliageGeo, folMat);
foliage1.position.y = 5.0;
foliage1.scale.set(1, 1, 1);
foliage1.castShadow = true;
treeGroup.add(foliage1);
const foliage2 = new THREE.Mesh(foliageGeo, folMat);
foliage2.position.y = 7.5;
foliage2.scale.set(0.75, 0.85, 0.75);
foliage2.castShadow = true;
treeGroup.add(foliage2);
const s = 0.8 + Math.random() * 0.7;
treeGroup.scale.set(s, s, s);
treeGroup.position.copy(pos);
treeGroup.rotation.y = Math.random() * Math.PI * 2;
this.scene.add(treeGroup);
this.obstacles.push({
position: pos,
radius: 1.2 * s,
type: 'tree',
elasticity: 0.2
});
} else {
// Place Low-Poly Boulder / Rock
const rock = new THREE.Mesh(rockGeo, rockMat);
const s = 1.0 + Math.random() * 1.5;
rock.scale.set(s, s * (0.6 + Math.random() * 0.4), s);
rock.position.copy(pos);
rock.position.y += s * 0.4;
rock.rotation.set(Math.random() * 3, Math.random() * 3, Math.random() * 3);
rock.castShadow = true;
this.scene.add(rock);
this.obstacles.push({
position: pos,
radius: 1.5 * s,
type: 'rock',
elasticity: 0.4
});
}
}
// Place Trackside Sponsor Billboards & Tire Barriers
this.buildTracksideProps();
}
buildTracksideProps() {
const bannerSponsors = [
{ text: 'PIRELLI RALLY', c1: '#eab308', c2: '#ca8a04' },
{ text: 'BREMBO BRAKES', c1: '#ef4444', c2: '#b91c1c' },
{ text: 'MOTUL SYNTH', c1: '#ef4444', c2: '#1e293b' },
{ text: 'SPARCO RACING', c1: '#3b82f6', c2: '#1d4ed8' },
{ text: 'TURBO BOOST', c1: '#10b981', c2: '#047857' }
];
const billboardGeo = new THREE.BoxGeometry(7, 2.4, 0.3);
const postGeo = new THREE.CylinderGeometry(0.12, 0.12, 3, 6);
const postMat = new THREE.MeshStandardMaterial({ color: 0x475569, metalness: 0.7 });
const numSigns = 14;
for (let i = 0; i < numSigns; i++) {
const u = (i / numSigns) + 0.03;
const pt = this.curve.getPointAt(u);
const tangent = this.curve.getTangentAt(u).normalize();
const normal = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), tangent).normalize();
const side = (i % 2 === 0) ? 1 : -1;
const pos = pt.clone().add(normal.clone().multiplyScalar(side * (this.trackWidth * 0.5 + 2.6)));
pos.y = pt.y;
const signGroup = new THREE.Group();
const sponsor = bannerSponsors[i % bannerSponsors.length];
const signMat = new THREE.MeshStandardMaterial({
map: TextureGenerator.createSponsorSignTexture(sponsor.text, sponsor.c1, sponsor.c2),
roughness: 0.4
});
const board = new THREE.Mesh(billboardGeo, signMat);
board.position.y = 2.2;
board.castShadow = true;
signGroup.add(board);
const p1 = new THREE.Mesh(postGeo, postMat);
p1.position.set(-2.5, 1.2, 0);
signGroup.add(p1);
const p2 = new THREE.Mesh(postGeo, postMat);
p2.position.set(2.5, 1.2, 0);
signGroup.add(p2);
signGroup.position.copy(pos);
signGroup.lookAt(pt.clone().add(new THREE.Vector3(0, 2, 0)));
this.scene.add(signGroup);
this.obstacles.push({
position: pos,
radius: 2.2,
type: 'billboard',
elasticity: 0.35
});
}
// Tire stack barriers on sharp curve apexes
const tireGeo = new THREE.CylinderGeometry(0.7, 0.7, 1.2, 10);
const tireMat = new THREE.MeshStandardMaterial({ color: 0x18181b, roughness: 0.9 });
for (let i = 0; i < 8; i++) {
const u = (i * 0.125) + 0.08;
const pt = this.curve.getPointAt(u);
const tangent = this.curve.getTangentAt(u).normalize();
const normal = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), tangent).normalize();
const pos = pt.clone().add(normal.clone().multiplyScalar(this.trackWidth * 0.5 + 1.2));
pos.y = pt.y + 0.6;
const tireStack = new THREE.Mesh(tireGeo, tireMat);
tireStack.position.copy(pos);
tireStack.castShadow = true;
this.scene.add(tireStack);
this.obstacles.push({
position: pos,
radius: 1.0,
type: 'barrier',
elasticity: 0.6
});
}
}
getGroundHeight(x, z) {
const queryPt = new THREE.Vector3(x, 0, z);
const { dist, trackHeight } = this.getApproxDistanceToTrack(queryPt);
let hill = Math.sin(x * 0.008) * Math.cos(z * 0.008) * 35 +
Math.sin(x * 0.02 + z * 0.015) * 12 +
Math.sin(x * 0.05) * 4;
if (dist < 40) {
const factor = Math.max(0, (dist - this.trackWidth * 0.7) / (40 - this.trackWidth * 0.7));
const smoothF = factor * factor * (3 - 2 * factor);
return (1 - smoothF) * (trackHeight - 0.25) + smoothF * hill;
}
return hill;
}
getTrackProjection(carPos) {
// Find closest point on spline to car position
const sampleSteps = 200;
let closestU = 0;
let minDistSq = Infinity;
for (let i = 0; i < sampleSteps; i++) {
const u = i / sampleSteps;
const p = this.curve.getPointAt(u);
const distSq = p.distanceToSquared(carPos);
if (distSq < minDistSq) {
minDistSq = distSq;
closestU = u;
}
}
// Refine search around closest sample
let bestU = closestU;
const delta = 1.0 / sampleSteps;
for (let i = -10; i <= 10; i++) {
const u = ((closestU + i * (delta * 0.1)) % 1 + 1) % 1;
const p = this.curve.getPointAt(u);
const dSq = p.distanceToSquared(carPos);
if (dSq < minDistSq) {
minDistSq = dSq;
bestU = u;
}
}
const trackCenter = this.curve.getPointAt(bestU);
const tangent = this.curve.getTangentAt(bestU).normalize();
const normal = new THREE.Vector3().crossVectors(new THREE.Vector3(0, 1, 0), tangent).normalize();
// Lateral distance from centerline
const offset = new THREE.Vector3().subVectors(carPos, trackCenter);
const lateralDist = offset.dot(normal);
const isOffTrack = Math.abs(lateralDist) > (this.trackWidth * 0.5 + this.shoulderWidth);
const isShoulder = Math.abs(lateralDist) > (this.trackWidth * 0.5) && !isOffTrack;
return {
u: bestU,
trackCenter: trackCenter,
tangent: tangent,
lateralDist: lateralDist,
isOffTrack: isOffTrack,
isShoulder: isShoulder,
surface: this.getSurfaceAt(bestU)
};
}
}
// ==========================================
// 4. PROCEDURAL RALLY CAR & PHYSICS
// ==========================================
class RallyCar {
constructor(scene) {
this.scene = scene;
// Physical state
this.position = new THREE.Vector3(0, 1, 0);
this.velocity = new THREE.Vector3();
this.speed = 0; // km/h
this.heading = 0; // radians yaw
this.steerAngle = 0; // current steering angle
this.targetSteer = 0;
this.angularVelocity = 0;
this.driftFactor = 0;
this.rpm = 1000;
this.gear = 1;
this.isBraking = false;
this.isHandbrake = false;
this.throttle = 0;
this.pitch = 0;
this.roll = 0;
// Dimensions
this.width = 2.0;
this.length = 4.4;
this.height = 1.45;
this.wheelBase = 2.6;
this.buildCarModel();
}
buildCarModel() {
this.mesh = new THREE.Group();
// Materials
const bodyPaintMat = new THREE.MeshStandardMaterial({
color: 0xef4444, // Racing Red Rally livery
roughness: 0.25,
metalness: 0.6
});
const liveryWhiteMat = new THREE.MeshStandardMaterial({
color: 0xf8fafc,
roughness: 0.3,
metalness: 0.3
});
const windowGlassMat = new THREE.MeshStandardMaterial({
color: 0x0f172a,
roughness: 0.1,
metalness: 0.9,
transparent: true,
opacity: 0.85
});
const trimDarkMat = new THREE.MeshStandardMaterial({
color: 0x1e293b,
roughness: 0.7,
metalness: 0.4
});
// 1. Lower Chassis Base
const lowerBodyGeo = new THREE.BoxGeometry(1.9, 0.55, 4.2);
const lowerBody = new THREE.Mesh(lowerBodyGeo, bodyPaintMat);
lowerBody.position.y = 0.5;
lowerBody.castShadow = true;
this.mesh.add(lowerBody);
// 2. Cabin / Cockpit Roof
const cabinGeo = new THREE.BoxGeometry(1.6, 0.65, 2.2);
const cabin = new THREE.Mesh(cabinGeo, windowGlassMat);
cabin.position.set(0, 1.05, -0.3);
cabin.castShadow = true;
this.mesh.add(cabin);
// Roof Livery Plate
const roofGeo = new THREE.BoxGeometry(1.62, 0.08, 2.1);
const roof = new THREE.Mesh(roofGeo, liveryWhiteMat);
roof.position.set(0, 1.4, -0.3);
this.mesh.add(roof);
// 3. Rally Hood Scoop
const hoodScoopGeo = new THREE.BoxGeometry(0.7, 0.15, 0.8);
const hoodScoop = new THREE.Mesh(hoodScoopGeo, trimDarkMat);
hoodScoop.position.set(0, 0.82, 1.1);
this.mesh.add(hoodScoop);
// Front Bumper / Splitter
const splitterGeo = new THREE.BoxGeometry(2.0, 0.25, 0.6);
const splitter = new THREE.Mesh(splitterGeo, trimDarkMat);
splitter.position.set(0, 0.25, 2.1);
splitter.castShadow = true;
this.mesh.add(splitter);
// 4. Large Rally Rear Spoiler / Wing
const wingGeo = new THREE.BoxGeometry(2.1, 0.08, 0.6);
const wing = new THREE.Mesh(wingGeo, liveryWhiteMat);
wing.position.set(0, 1.45, -2.05);
const wingPostGeo = new THREE.BoxGeometry(0.08, 0.45, 0.35);
const wingPostL = new THREE.Mesh(wingPostGeo, trimDarkMat);
wingPostL.position.set(-0.7, 1.2, -2.0);
const wingPostR = new THREE.Mesh(wingPostGeo, trimDarkMat);
wingPostR.position.set(0.7, 1.2, -2.0);
this.mesh.add(wing);
this.mesh.add(wingPostL);
this.mesh.add(wingPostR);
// 5. Headlights & Quad Rally Pods
const lightMat = new THREE.MeshStandardMaterial({
color: 0xffffff,
emissive: 0xffffff,
emissiveIntensity: 0.8
});
const headlightGeo = new THREE.BoxGeometry(0.35, 0.2, 0.1);
const hlLeft = new THREE.Mesh(headlightGeo, lightMat);
hlLeft.position.set(-0.75, 0.65, 2.12);
const hlRight = new THREE.Mesh(headlightGeo, lightMat);
hlRight.position.set(0.75, 0.65, 2.12);
this.mesh.add(hlLeft);
this.mesh.add(hlRight);
// Center Rally Spotlights (4 circular pods)
const podGeo = new THREE.CylinderGeometry(0.18, 0.18, 0.2, 8);
podGeo.rotateX(Math.PI / 2);
for (let i = 0; i < 4; i++) {
const pod = new THREE.Mesh(podGeo, lightMat);
pod.position.set(-0.45 + i * 0.3, 0.75, 2.15);
this.mesh.add(pod);
}
// 6. Taillights & Brake Glow
this.tailLightMat = new THREE.MeshStandardMaterial({
color: 0xef4444,
emissive: 0xef4444,
emissiveIntensity: 0.4
});
const tailLightGeo = new THREE.BoxGeometry(0.4, 0.2, 0.1);
const tlLeft = new THREE.Mesh(tailLightGeo, this.tailLightMat);
tlLeft.position.set(-0.75, 0.65, -2.12);
const tlRight = new THREE.Mesh(tailLightGeo, this.tailLightMat);
tlRight.position.set(0.75, 0.65, -2.12);
this.mesh.add(tlLeft);
this.mesh.add(tlRight);
// 7. Wheels & Rims
const tireGeo = new THREE.CylinderGeometry(0.42, 0.42, 0.38, 14);
tireGeo.rotateZ(Math.PI / 2);
const tireMat = new THREE.MeshStandardMaterial({ color: 0x1c1917, roughness: 0.9 });
const rimGeo = new THREE.CylinderGeometry(0.24, 0.24, 0.4, 8);
rimGeo.rotateZ(Math.PI / 2);
const rimMat = new THREE.MeshStandardMaterial({ color: 0xf59e0b, metalness: 0.8, roughness: 0.3 }); // Gold rally rims
this.wheels = [];
const wheelPositions = [
{ x: -0.95, y: 0.42, z: 1.3, isFront: true }, // Front Left
{ x: 0.95, y: 0.42, z: 1.3, isFront: true }, // Front Right
{ x: -0.95, y: 0.42, z: -1.3, isFront: false }, // Rear Left
{ x: 0.95, y: 0.42, z: -1.3, isFront: false } // Rear Right
];
wheelPositions.forEach((wp) => {
const wheelGroup = new THREE.Group();
const tire = new THREE.Mesh(tireGeo, tireMat);
tire.castShadow = true;
const rim = new THREE.Mesh(rimGeo, rimMat);
wheelGroup.add(tire);
wheelGroup.add(rim);
wheelGroup.position.set(wp.x, wp.y, wp.z);
this.mesh.add(wheelGroup);
this.wheels.push({ group: wheelGroup, isFront: wp.isFront, basePos: new THREE.Vector3(wp.x, wp.y, wp.z) });
});
this.scene.add(this.mesh);
}
reset(position, heading) {
this.position.copy(position);
this.position.y += 0.5;
this.heading = heading;
this.velocity.set(0, 0, 0);
this.speed = 0;
this.steerAngle = 0;
this.targetSteer = 0;
this.angularVelocity = 0;
this.driftFactor = 0;
this.rpm = 1000;
this.gear = 1;
this.pitch = 0;
this.roll = 0;
this.updateMeshTransform();
}
updatePhysics(dt, input, trackProjection, trackSystem) {
// 1. Surface Friction Modifiers
let surfaceGrip = 1.0;
let surfaceMaxSpeed = 175;
if (trackProjection.isOffTrack) {
surfaceGrip = 0.5;
surfaceMaxSpeed = 75; // Heavy grass penalty
} else if (trackProjection.isShoulder) {
surfaceGrip = 0.75;
surfaceMaxSpeed = 125;
} else {
surfaceGrip = trackProjection.surface.friction;
surfaceMaxSpeed = trackProjection.surface.topSpeed;
}
// 2. Throttle & Braking Dynamics
this.throttle = input.up ? 1 : 0;
this.isBraking = input.down;
this.isHandbrake = input.handbrake;
// Brake Light visual toggle
if (this.tailLightMat) {
this.tailLightMat.emissiveIntensity = (this.isBraking || this.isHandbrake) ? 1.6 : 0.4;
}
const accelPower = 58.0 * surfaceGrip; // m/s^2 equivalent acceleration force
const brakePower = 95.0;
const handbrakeBrake = 55.0;
const dragCoeff = 0.0016;
const rollingResistance = 12.0;
// Forward direction vector of car
const forward = new THREE.Vector3(Math.sin(this.heading), 0, Math.cos(this.heading));
const right = new THREE.Vector3(forward.z, 0, -forward.x);
// Decompose velocity into forward and lateral components
const forwardSpeed = this.velocity.dot(forward);
const lateralSpeed = this.velocity.dot(right);
// Apply engine drive force
if (input.up) {
if (forwardSpeed < (surfaceMaxSpeed / 3.6)) {
this.velocity.add(forward.clone().multiplyScalar(accelPower * dt));
}
} else if (input.down) {
if (forwardSpeed > 2.0) {
// Normal foot brake
this.velocity.sub(forward.clone().multiplyScalar(brakePower * dt));
} else {
// Reverse gear
if (forwardSpeed > -12.0) {
this.velocity.sub(forward.clone().multiplyScalar(22.0 * dt));
}
}
}
// Handbrake extra drag
if (this.isHandbrake) {
if (Math.abs(forwardSpeed) > 1.0) {
this.velocity.sub(forward.clone().multiplyScalar(Math.sign(forwardSpeed) * handbrakeBrake * dt));
}
}
// Air Resistance & Rolling Friction
const currentSpeed = this.velocity.length();
const drag = currentSpeed * currentSpeed * dragCoeff;
if (currentSpeed > 0.01) {
const frictionDir = this.velocity.clone().normalize();
this.velocity.sub(frictionDir.multiplyScalar((drag + rollingResistance) * dt));
if (this.velocity.length() < 0.1 && !input.up && !input.down) {
this.velocity.set(0, 0, 0);
}
}
// 3. Speed-dependent Steering
const maxSteer = 0.65; // ~37 degrees
const steerSpeedFactor = 1.0 / (1.0 + Math.abs(forwardSpeed) * 0.045);
const currentMaxSteer = maxSteer * Math.max(0.28, steerSpeedFactor);
let targetSteer = 0;
if (input.left) targetSteer += currentMaxSteer;
if (input.right) targetSteer -= currentMaxSteer;
// Smooth steering rack interpolation
this.steerAngle += (targetSteer - this.steerAngle) * Math.min(1.0, 14.0 * dt);
// 4. Turning & Drift Physics (Lateral grip & slip curve)
let lateralGripFactor = 16.0 * surfaceGrip;
if (this.isHandbrake) {
lateralGripFactor *= 0.22; // Loss of rear traction for initiating drift
} else if (trackProjection.surface.type === 'dirt') {
lateralGripFactor *= 0.72; // Loose dirt sliding
}
// Lateral grip restores car to pointing direction
const lateralDamping = Math.min(1.0, lateralGripFactor * dt);
const newLateralSpeed = lateralSpeed * (1.0 - lateralDamping);
// Yaw rotation from steering
const turnRate = (forwardSpeed / this.wheelBase) * Math.tan(this.steerAngle);
this.heading += turnRate * dt;
// Drift factor calculation (angle difference between car heading and movement vector)
if (currentSpeed > 4.0) {
const movingAngle = Math.atan2(this.velocity.x, this.velocity.z);
let angleDiff = Math.abs(movingAngle - this.heading);
while (angleDiff > Math.PI) angleDiff -= Math.PI * 2;
this.driftFactor = Math.min(1.0, Math.abs(angleDiff) * 2.5 + (this.isHandbrake ? 0.6 : 0.0));
} else {
this.driftFactor = 0;
}
// Reconstruct velocity from updated forward & lateral speeds
const updatedForward = new THREE.Vector3(Math.sin(this.heading), 0, Math.cos(this.heading));
const updatedRight = new THREE.Vector3(updatedForward.z, 0, -updatedForward.x);
this.velocity.copy(updatedForward.multiplyScalar(forwardSpeed)).add(updatedRight.multiplyScalar(newLateralSpeed));
// Update position
this.position.add(this.velocity.clone().multiplyScalar(dt));
// 5. Track Surface Height & Slope Following
const groundY = trackSystem.getGroundHeight(this.position.x, this.position.z);
this.position.y += (groundY + 0.35 - this.position.y) * Math.min(1.0, 18.0 * dt);
// Pitch and roll suspension movement
const targetPitch = -(forwardSpeed * 0.012) * (input.up ? 1 : 0) + (this.isBraking ? 0.08 : 0);
this.pitch += (targetPitch - this.pitch) * 8.0 * dt;
const targetRoll = -this.steerAngle * (forwardSpeed * 0.02) + (this.driftFactor * 0.08 * (input.left ? -1 : 1));
this.roll += (targetRoll - this.roll) * 8.0 * dt;
// Speed in KM/H
this.speed = Math.max(0, forwardSpeed * 3.6);
// 6. Gear & RPM Simulation
this.updateGears();
// 7. Update 3D Object Transforms
this.updateMeshTransform();
this.updateWheelVisuals(forwardSpeed, dt);
}
updateGears() {
const kmh = Math.abs(this.speed);
if (kmh < 1.0 && this.throttle === 0) {
this.rpm = 900;
this.gear = 1;
return;
}
// 6-speed rally gearbox ratios
const gearRatios = [0, 38, 72, 110, 145, 175, 210];
let g = 1;
for (let i = 1; i <= 6; i++) {
if (kmh >= gearRatios[i - 1] && kmh < gearRatios[i]) {
g = i;
break;
}
}
if (kmh >= gearRatios[6]) g = 6;
this.gear = g;
const prevGearSpeed = gearRatios[g - 1];
const nextGearSpeed = gearRatios[g];
const gearProgress = (kmh - prevGearSpeed) / Math.max(1, (nextGearSpeed - prevGearSpeed));
this.rpm = 2500 + gearProgress * 5500 + (this.throttle * 800);
this.rpm = Math.min(8500, Math.max(900, this.rpm));
}
updateMeshTransform() {
this.mesh.position.copy(this.position);
this.mesh.rotation.set(0, 0, 0);
this.mesh.rotation.y = this.heading;
this.mesh.rotation.x = this.pitch;
this.mesh.rotation.z = this.roll;
}
updateWheelVisuals(forwardSpeed, dt) {
const rollAngle = (forwardSpeed / 0.42) * dt;
this.wheels.forEach((w) => {
// Wheel rotation
w.group.children.forEach(c => c.rotation.x += rollAngle);
// Front wheel steer
if (w.isFront) {
w.group.rotation.y = this.steerAngle * 0.85;
}
});
}
checkObstacleCollisions(obstacles, soundController) {
let hitAny = false;
for (let obs of obstacles) {
const distSq = this.position.distanceToSquared(obs.position);
const combinedR = (obs.radius + 1.2);
if (distSq < combinedR * combinedR) {
// Resolve collision
const dist = Math.sqrt(distSq);
const normal = new THREE.Vector3().subVectors(this.position, obs.position).normalize();
normal.y = 0;
// Push car out of obstacle
const penetration = combinedR - dist;
this.position.add(normal.clone().multiplyScalar(penetration + 0.05));
// Bounce velocity
const speed = this.velocity.length();
this.velocity.reflect(normal).multiplyScalar(obs.elasticity || 0.4);
if (speed > 5.0) {
soundController.playImpact(speed / 15.0);
hitAny = true;
}
}
}
return hitAny;
}
}
// ==========================================
// 5. PARTICLE EFFECTS (Dust, Skids, Sparks)
// ==========================================
class ParticleSystem {
constructor(scene) {
this.scene = scene;
this.dustParticles = [];
this.skidLines = [];
// Shared Dust Particle Geometry & Material
const pGeo = new THREE.DodecahedronGeometry(0.28, 0);
const pMat = new THREE.MeshBasicMaterial({
color: 0xca9868,
transparent: true,
opacity: 0.65
});
this.dustMeshPool = [];
for (let i = 0; i < 120; i++) {
const m = new THREE.Mesh(pGeo, pMat.clone());
m.visible = false;
this.scene.add(m);
this.dustMeshPool.push(m);
}
}
spawnDust(position, isDirt = false) {
// Find inactive mesh from pool
const mesh = this.dustMeshPool.find(m => !m.visible);
if (!mesh) return;
mesh.visible = true;
mesh.position.copy(position);
mesh.position.y += 0.15;
mesh.scale.set(1, 1, 1);
mesh.material.color.setHex(isDirt ? 0x925b30 : 0xd8b488);
mesh.material.opacity = 0.7;
const vel = new THREE.Vector3(
(Math.random() - 0.5) * 2.0,
1.5 + Math.random() * 2.0,
(Math.random() - 0.5) * 2.0
);
this.dustParticles.push({
mesh: mesh,
velocity: vel,
life: 1.0,
maxLife: 0.6 + Math.random() * 0.4
});
}
update(dt) {
for (let i = this.dustParticles.length - 1; i >= 0; i--) {
const p = this.dustParticles[i];
p.life -= dt / p.maxLife;
if (p.life <= 0) {
p.mesh.visible = false;
this.dustParticles.splice(i, 1);
} else {
p.mesh.position.add(p.velocity.clone().multiplyScalar(dt));
p.velocity.y += 0.4 * dt; // slight upward drift
const currentScale = 1.0 + (1.0 - p.life) * 3.5;
p.mesh.scale.set(currentScale, currentScale, currentScale);
p.mesh.material.opacity = p.life * 0.6;
}
}
}
}
// ==========================================
// 6. CAMERA CONTROLLER
// ==========================================
class ChaseCamera {
constructor(camera) {
this.camera = camera;
this.mode = 0; // 0: Dynamic Chase, 1: Close Chase, 2: Bonnet / Hood, 3: High Helicopter
this.targetPos = new THREE.Vector3();
this.targetLook = new THREE.Vector3();
this.smoothPos = new THREE.Vector3();
this.smoothLook = new THREE.Vector3();
}
cycleMode() {
this.mode = (this.mode + 1) % 4;
return this.mode;
}
update(car, dt) {
const forward = new THREE.Vector3(Math.sin(car.heading), 0, Math.cos(car.heading));
const speedRatio = Math.min(1.0, car.speed / 180.0);
let offset = new THREE.Vector3();
let lookOffset = new THREE.Vector3(0, 1.2, 0);
let lagFactor = 12.0;
switch (this.mode) {
case 0: // Dynamic Behind Chase
offset = forward.clone().multiplyScalar(-6.8 - speedRatio * 2.2);
offset.y = 2.6 + speedRatio * 0.8;
lookOffset = forward.clone().multiplyScalar(4.0).add(new THREE.Vector3(0, 1.2, 0));
lagFactor = 8.0;
break;
case 1: // Tight Close Chase
offset = forward.clone().multiplyScalar(-4.8);
offset.y = 1.9;
lookOffset = forward.clone().multiplyScalar(5.0).add(new THREE.Vector3(0, 1.0, 0));
lagFactor = 14.0;
break;
case 2: // Bonnet / Hood Cam
offset = forward.clone().multiplyScalar(0.4);
offset.y = 1.2;
lookOffset = forward.clone().multiplyScalar(15.0).add(new THREE.Vector3(0, 0.9, 0));
lagFactor = 25.0;
break;
case 3: // High Helicopter Cam
offset = forward.clone().multiplyScalar(-14.0);
offset.y = 9.0;
lookOffset = forward.clone().multiplyScalar(2.0);
lagFactor = 5.0;
break;
}
this.targetPos.copy(car.position).add(offset);
this.targetLook.copy(car.position).add(lookOffset);
// Exponential smoothing for fluid cinematic chase feel
this.smoothPos.lerp(this.targetPos, Math.min(1.0, lagFactor * dt));
this.smoothLook.lerp(this.targetLook, Math.min(1.0, (lagFactor + 4.0) * dt));
this.camera.position.copy(this.smoothPos);
this.camera.lookAt(this.smoothLook);
}
}
// ==========================================
// 7. INPUT HANDLER (Keyboard + Mobile Touch)
// ==========================================
class InputManager {
constructor() {
this.up = false;
this.down = false;
this.left = false;
this.right = false;
this.handbrake = false;
this.keys = {};
this.setupKeyboard();
this.setupTouch();
}
setupKeyboard() {
window.addEventListener('keydown', (e) => {
this.keys[e.code] = true;
this.updateState();
});
window.addEventListener('keyup', (e) => {
this.keys[e.code] = false;
this.updateState();
});
}
updateState() {
this.up = !!(this.keys['KeyW'] || this.keys['ArrowUp']);
this.down = !!(this.keys['KeyS'] || this.keys['ArrowDown']);
this.left = !!(this.keys['KeyA'] || this.keys['ArrowLeft']);
this.right = !!(this.keys['KeyD'] || this.keys['ArrowRight']);
this.handbrake = !!(this.keys['Space']);
}
setupTouch() {
const bindBtn = (id, prop) => {
const el = document.getElementById(id);
if (!el) return;
const start = (e) => { e.preventDefault(); this[prop] = true; el.classList.add('active'); };
const end = (e) => { e.preventDefault(); this[prop] = false; el.classList.remove('active'); };
el.addEventListener('touchstart', start, { passive: false });
el.addEventListener('touchend', end, { passive: false });
el.addEventListener('touchcancel', end, { passive: false });
el.addEventListener('mousedown', start);
el.addEventListener('mouseup', end);
el.addEventListener('mouseleave', end);
};
bindBtn('t-left', 'left');
bindBtn('t-right', 'right');
bindBtn('t-gas', 'up');
bindBtn('t-brake', 'down');
bindBtn('t-drift', 'handbrake');
}
}
// ==========================================
// 8. MINIMAP OVERLAY
// ==========================================
class MinimapRenderer {
constructor(canvas, trackCurve) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.trackCurve = trackCurve;
this.points = [];
// Precalculate 2D points normalized to minimap canvas
this.calculateBounds();
}
calculateBounds() {
let minX = Infinity, maxX = -Infinity;
let minZ = Infinity, maxZ = -Infinity;
const sampleCount = 120;
for (let i = 0; i <= sampleCount; i++) {
const p = this.trackCurve.getPointAt(i / sampleCount);
minX = Math.min(minX, p.x);
maxX = Math.max(maxX, p.x);
minZ = Math.min(minZ, p.z);
maxZ = Math.max(maxZ, p.z);
}
const pad = 30;
const width = (maxX - minX) + pad * 2;
const height = (maxZ - minZ) + pad * 2;
this.mapTransform = {
minX: minX - pad,
minZ: minZ - pad,
scaleX: this.canvas.width / width,
scaleZ: this.canvas.height / height
};
}
render(carPosition, carHeading) {
const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw Track Path
ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const sampleCount = 80;
for (let i = 0; i <= sampleCount; i++) {
const p = this.trackCurve.getPointAt(i / sampleCount);
const mx = (p.x - this.mapTransform.minX) * this.mapTransform.scaleX;
const my = (p.z - this.mapTransform.minZ) * this.mapTransform.scaleZ;
if (i === 0) ctx.moveTo(mx, my);
else ctx.lineTo(mx, my);
}
ctx.closePath();
ctx.stroke();
// Start / Finish Line marker
const startPt = this.trackCurve.getPointAt(0);
const smx = (startPt.x - this.mapTransform.minX) * this.mapTransform.scaleX;
const smy = (startPt.z - this.mapTransform.minZ) * this.mapTransform.scaleZ;
ctx.fillStyle = '#22c55e';
ctx.beginPath();
ctx.arc(smx, smy, 4.5, 0, Math.PI * 2);
ctx.fill();
// Draw Player Dot & Direction Arrow
const px = (carPosition.x - this.mapTransform.minX) * this.mapTransform.scaleX;
const py = (carPosition.z - this.mapTransform.minZ) * this.mapTransform.scaleZ;
ctx.save();
ctx.translate(px, py);
ctx.rotate(-carHeading + Math.PI / 2);
// Pulsing outer dot
ctx.fillStyle = 'rgba(239, 68, 68, 0.4)';
ctx.beginPath();
ctx.arc(0, 0, 8, 0, Math.PI * 2);
ctx.fill();
// Arrow pointer
ctx.fillStyle = '#f43f5e';
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(0, -6);
ctx.lineTo(5, 5);
ctx.lineTo(-5, 5);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
// ==========================================
// 9. MAIN GAME ORCHESTRATOR
// ==========================================
class ApexRallyGame {
constructor() {
this.totalLaps = 3;
this.currentLap = 1;
this.nextCheckpointIndex = 1;
this.passedCheckpointsCount = 0;
this.lapStartTime = 0;
this.raceStartTime = 0;
this.currentLapTime = 0;
this.totalRaceTime = 0;
this.bestLapTime = null;
this.lastLapTime = null;
this.topSpeedRecord = 0;
this.cleanLaps = 3;
this.gameState = 'READY'; // READY, COUNTDOWN, RACING, PAUSED, FINISHED
this.initRenderer();
this.initScene();
this.initGameObjects();
this.initUI();
this.initEvents();
this.animate = this.animate.bind(this);
this.lastFrameTime = performance.now();
requestAnimationFrame(this.animate);
}
initRenderer() {
this.canvas = document.getElementById('webgl-canvas');
this.renderer = new THREE.WebGLRenderer({
canvas: this.canvas,
antialias: true,
powerPreference: 'high-performance'
});
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.1;
this.camera = new THREE.PerspectiveCamera(65, window.innerWidth / window.innerHeight, 0.5, 900);
}
initScene() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x7dd3fc); // Sky Blue
this.scene.fog = new THREE.FogExp2(0xb0e0f8, 0.0032); // Atmospheric mountain haze
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.65);
this.scene.add(ambientLight);
const sunLight = new THREE.DirectionalLight(0xfff7ed, 1.25);
sunLight.position.set(120, 200, 80);
sunLight.castShadow = true;
sunLight.shadow.mapSize.width = 2048;
sunLight.shadow.mapSize.height = 2048;
sunLight.shadow.camera.near = 10;
sunLight.shadow.camera.far = 500;
sunLight.shadow.camera.left = -100;
sunLight.shadow.camera.right = 100;
sunLight.shadow.camera.top = 100;
sunLight.shadow.camera.bottom = -100;
sunLight.shadow.bias = -0.0005;
this.scene.add(sunLight);
this.sunLight = sunLight;
}
initGameObjects() {
this.sound = new SoundController();
this.track = new TrackSystem(this.scene);
this.car = new RallyCar(this.scene);
this.particles = new ParticleSystem(this.scene);
this.chaseCam = new ChaseCamera(this.camera);
this.input = new InputManager();
// Minimap
this.minimap = new MinimapRenderer(
document.getElementById('minimap-canvas'),
this.track.curve
);
// Initial Car placement at start line pointing down track
const startPt = this.track.curve.getPointAt(0);
const startTangent = this.track.curve.getTangentAt(0).normalize();
const heading = Math.atan2(startTangent.x, startTangent.z);
this.car.reset(startPt, heading);
this.chaseCam.smoothPos.copy(this.car.position).add(new THREE.Vector3(0, 3, -8));
this.chaseCam.smoothLook.copy(this.car.position);
}
initUI() {
this.ui = {
timeCurrent: document.getElementById('time-current'),
timeBest: document.getElementById('time-best'),
timeLast: document.getElementById('time-last'),
lapCurrent: document.getElementById('lap-current'),
surfaceTag: document.getElementById('surface-tag'),
speedVal: document.getElementById('speed-val'),
rpmBar: document.getElementById('rpm-bar'),
gearVal: document.getElementById('gear-val'),
centerBanner: document.getElementById('center-banner'),
bannerMain: document.getElementById('banner-main'),
bannerSub: document.getElementById('banner-sub'),
countdownScreen: document.getElementById('countdown-screen'),
countdownNum: document.getElementById('countdown-num'),
pauseModal: document.getElementById('pause-modal'),
finishModal: document.getElementById('finish-modal'),
startModal: document.getElementById('start-modal'),
statTotalTime: document.getElementById('stat-total-time'),
statBestLap: document.getElementById('stat-best-lap'),
statTopSpeed: document.getElementById('stat-top-speed'),
statCleanLaps: document.getElementById('stat-clean-laps'),
finishGrade: document.getElementById('finish-grade')
};
}
initEvents() {
window.addEventListener('resize', () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
});
document.getElementById('btn-start-game').addEventListener('click', () => {
this.sound.init();
this.ui.startModal.style.display = 'none';
this.startCountdown();
});
document.getElementById('btn-cam').addEventListener('click', () => {
const mode = this.chaseCam.cycleMode();
const names = ['CHASE CAM', 'CLOSE CAM', 'BONNET CAM', 'HELI CAM'];
this.showCenterBanner(names[mode], '');
});
document.getElementById('btn-audio').addEventListener('click', (e) => {
const unmuted = this.sound.toggleMute();
e.target.innerText = unmuted ? '🔊' : '🔇';
});
document.getElementById('btn-pause').addEventListener('click', () => this.togglePause());
document.getElementById('btn-resume').addEventListener('click', () => this.togglePause());
document.getElementById('btn-restart-pause').addEventListener('click', () => {
this.ui.pauseModal.style.display = 'none';
this.restartRace();
});
document.getElementById('btn-restart-finish').addEventListener('click', () => {
this.ui.finishModal.style.display = 'none';
this.restartRace();
});
window.addEventListener('keydown', (e) => {
if (e.code === 'KeyP') {
this.togglePause();
} else if (e.code === 'KeyR') {
this.resetCarToTrack();
} else if (e.code === 'KeyC') {
const mode = this.chaseCam.cycleMode();
const names = ['CHASE CAM', 'CLOSE CAM', 'BONNET CAM', 'HELI CAM'];
this.showCenterBanner(names[mode], '');
} else if (e.code === 'KeyM') {
const unmuted = this.sound.toggleMute();
document.getElementById('btn-audio').innerText = unmuted ? '🔊' : '🔇';
}
});
}
startCountdown() {
this.gameState = 'COUNTDOWN';
let count = 3;
this.ui.countdownScreen.style.display = 'flex';
const tick = () => {
if (count > 0) {
this.ui.countdownNum.innerText = count;
this.ui.countdownNum.classList.remove('go');
this.ui.countdownNum.classList.add('show');
this.sound.playBeep(false);
setTimeout(() => {
this.ui.countdownNum.classList.remove('show');
}, 650);
count--;
setTimeout(tick, 1000);
} else {
this.ui.countdownNum.innerText = 'GO!';
this.ui.countdownNum.classList.add('go', 'show');
this.sound.playBeep(true);
this.gameState = 'RACING';
this.raceStartTime = performance.now();
this.lapStartTime = performance.now();
setTimeout(() => {
this.ui.countdownNum.classList.remove('show');
this.ui.countdownScreen.style.display = 'none';
}, 900);
}
};
tick();
}
togglePause() {
if (this.gameState === 'RACING') {
this.gameState = 'PAUSED';
this.ui.pauseModal.style.display = 'flex';
} else if (this.gameState === 'PAUSED') {
this.gameState = 'RACING';
this.ui.pauseModal.style.display = 'none';
}
}
resetCarToTrack() {
// Places car back safely onto track at current progress point
const proj = this.track.getTrackProjection(this.car.position);
const centerPt = proj.trackCenter;
const tangent = proj.tangent;
const heading = Math.atan2(tangent.x, tangent.z);
this.car.reset(centerPt, heading);
this.showCenterBanner('CAR RECOVERED', 'Penalty +2.0s');
this.cleanLaps = Math.max(0, this.cleanLaps - 1);
}
restartRace() {
this.currentLap = 1;
this.nextCheckpointIndex = 1;
this.passedCheckpointsCount = 0;
this.currentLapTime = 0;
this.totalRaceTime = 0;
this.bestLapTime = null;
this.lastLapTime = null;
this.topSpeedRecord = 0;
this.cleanLaps = 3;
this.ui.lapCurrent.innerText = `1 / ${this.totalLaps}`;
this.ui.timeCurrent.innerText = '00:00.000';
this.ui.timeBest.innerText = '--:--.---';
this.ui.timeLast.innerText = '--:--.---';
const startPt = this.track.curve.getPointAt(0);
const startTangent = this.track.curve.getTangentAt(0).normalize();
const heading = Math.atan2(startTangent.x, startTangent.z);
this.car.reset(startPt, heading);
this.startCountdown();
}
formatTime(ms) {
const totalSecs = Math.floor(ms / 1000);
const mins = Math.floor(totalSecs / 60);
const secs = totalSecs % 60;
const millis = Math.floor(ms % 1000);
return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
}
showCenterBanner(mainText, subText = '') {
this.ui.bannerMain.innerText = mainText;
this.ui.bannerSub.innerText = subText;
this.ui.centerBanner.classList.add('active');
clearTimeout(this.bannerTimer);
this.bannerTimer = setTimeout(() => {
this.ui.centerBanner.classList.remove('active');
}, 2200);
}
checkLapProgress(proj) {
const nextCp = this.track.checkpoints[this.nextCheckpointIndex];
const distToCp = this.car.position.distanceTo(nextCp.position);
if (distToCp < nextCp.radius) {
this.nextCheckpointIndex = (this.nextCheckpointIndex + 1) % this.track.checkpoints.length;
this.passedCheckpointsCount++;
// When crossing the finish line after hitting intermediate checkpoints
if (this.nextCheckpointIndex === 1 && this.passedCheckpointsCount >= (this.track.checkpoints.length - 1)) {
this.passedCheckpointsCount = 0;
this.completeLap();
}
}
}
completeLap() {
const now = performance.now();
const lapDuration = now - this.lapStartTime;
this.lastLapTime = lapDuration;
this.ui.timeLast.innerText = this.formatTime(lapDuration);
let isNewBest = false;
if (!this.bestLapTime || lapDuration < this.bestLapTime) {
this.bestLapTime = lapDuration;
this.ui.timeBest.innerText = this.formatTime(lapDuration);
isNewBest = true;
}
if (this.currentLap < this.totalLaps) {
this.currentLap++;
this.lapStartTime = now;
this.ui.lapCurrent.innerText = `${this.currentLap} / ${this.totalLaps}`;
this.showCenterBanner(`LAP ${this.currentLap}`, isNewBest ? '⭐ NEW BEST LAP!' : `Lap: ${this.formatTime(lapDuration)}`);
this.sound.playBeep(true);
} else {
// Race Finish!
this.gameState = 'FINISHED';
this.totalRaceTime = now - this.raceStartTime;
this.sound.playFanfare();
this.showFinishModal();
}
}
showFinishModal() {
this.ui.statTotalTime.innerText = this.formatTime(this.totalRaceTime);
this.ui.statBestLap.innerText = this.formatTime(this.bestLapTime);
this.ui.statTopSpeed.innerText = `${Math.round(this.topSpeedRecord)} km/h`;
this.ui.statCleanLaps.innerText = `${this.cleanLaps} / 3 Laps`;
if (this.totalRaceTime < 130000) {
this.ui.finishGrade.innerText = '🏆 GOLD TROPHY - LEGENDARY PACE!';
this.ui.finishGrade.style.color = '#fbbf24';
} else if (this.totalRaceTime < 160000) {
this.ui.finishGrade.innerText = '🥈 SILVER TROPHY - PRO RALLY DRIVER!';
this.ui.finishGrade.style.color = '#94a3b8';
} else {
this.ui.finishGrade.innerText = '🥉 BRONZE TROPHY - FINISHER!';
this.ui.finishGrade.style.color = '#d97706';
}
this.ui.finishModal.style.display = 'flex';
}
animate(currentTime) {
requestAnimationFrame(this.animate);
const dt = Math.min(0.05, (currentTime - this.lastFrameTime) / 1000.0);
this.lastFrameTime = currentTime;
if (this.gameState === 'RACING' || this.gameState === 'COUNTDOWN' || this.gameState === 'FINISHED') {
const activeInput = (this.gameState === 'RACING') ? this.input : { up: false, down: false, left: false, right: false, handbrake: false };
// 1. Projection on Track
const proj = this.track.getTrackProjection(this.car.position);
// 2. Update Car Physics
this.car.updatePhysics(dt, activeInput, proj, this.track);
// 3. Obstacle Collision Resolution
this.car.checkObstacleCollisions(this.track.obstacles, this.sound);
// 4. Lap Timing & Checkpoints
if (this.gameState === 'RACING') {
this.currentLapTime = currentTime - this.lapStartTime;
this.ui.timeCurrent.innerText = this.formatTime(this.currentLapTime);
this.topSpeedRecord = Math.max(this.topSpeedRecord, this.car.speed);
this.checkLapProgress(proj);
}
// 5. Particle Effects (Dust clouds on drift or offroad)
if (this.car.speed > 15.0) {
if (this.car.driftFactor > 0.2 || proj.isOffTrack || proj.surface.type === 'dirt') {
const spawnDirt = proj.isOffTrack || proj.surface.type === 'dirt';
this.particles.spawnDust(this.car.position, spawnDirt);
}
}
this.particles.update(dt);
// 6. Camera Follow
this.chaseCam.update(this.car, dt);
// 7. Dynamic Sun Light Follow Car for High Res Shadows
this.sunLight.position.set(this.car.position.x + 80, 160, this.car.position.z + 60);
this.sunLight.target.position.copy(this.car.position);
this.sunLight.target.updateMatrixWorld();
// 8. Sound Synthesizer Updates
const speedRatio = Math.min(1.0, this.car.speed / 180.0);
const rpmRatio = (this.car.rpm - 900) / 7600;
this.sound.updateEngine(rpmRatio, activeInput.up ? 1 : 0, speedRatio);
this.sound.updateSkid(this.car.driftFactor * (this.car.speed / 80), proj.surface.type === 'dirt' || proj.isOffTrack);
// 9. Update HUD Elements
this.updateHUD(proj);
}
// Render 3D Scene
this.renderer.render(this.scene, this.camera);
// Render Minimap
this.minimap.render(this.car.position, this.car.heading);
}
updateHUD(proj) {
this.ui.speedVal.innerText = Math.round(this.car.speed);
const rpmPercent = Math.min(100, Math.max(0, ((this.car.rpm - 900) / 7600) * 100));
this.ui.rpmBar.style.width = `${rpmPercent}%`;
this.ui.gearVal.innerText = this.car.speed < 1.0 && !this.input.up && this.input.down ? 'R' : `GEAR ${this.car.gear}`;
// Surface indicator
if (proj.isOffTrack) {
this.ui.surfaceTag.innerText = 'OFF-TRACK (SLOW)';
this.ui.surfaceTag.className = 'surface-tag offroad';
} else if (proj.surface.type === 'dirt') {
this.ui.surfaceTag.innerText = 'GRAVEL / DIRT';
this.ui.surfaceTag.className = 'surface-tag dirt';
} else {
this.ui.surfaceTag.innerText = 'TARMAC';
this.ui.surfaceTag.className = 'surface-tag';
}
}
}
// Instantiate Game on Page Load
window.addEventListener('DOMContentLoaded', () => {
window.game = new ApexRallyGame();
});
</script>
</body>
</html>