|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Starship Circuit Commander</title> |
|
<style> |
|
body { |
|
margin: 0; |
|
overflow: hidden; |
|
font-family: Arial, sans-serif; |
|
} |
|
canvas { |
|
display: block; |
|
} |
|
.ui-container { |
|
position: absolute; |
|
top: 10px; |
|
right: 10px; |
|
color: white; |
|
background-color: rgba(0, 0, 0, 0.5); |
|
padding: 10px; |
|
border-radius: 5px; |
|
user-select: none; |
|
} |
|
.sidebar { |
|
position: absolute; |
|
top: 0; |
|
left: 0; |
|
width: 250px; |
|
height: 100%; |
|
background-color: rgba(0, 0, 0, 0.8); |
|
color: white; |
|
padding: 10px; |
|
overflow-y: auto; |
|
border-radius: 0 5px 5px 0; |
|
} |
|
.sidebar h2, .sidebar h3 { |
|
margin: 0 0 10px; |
|
font-size: 18px; |
|
} |
|
.sidebar button { |
|
display: block; |
|
width: 100%; |
|
margin: 5px 0; |
|
padding: 8px; |
|
background: #4CAF50; |
|
color: white; |
|
border: none; |
|
border-radius: 5px; |
|
cursor: pointer; |
|
font-size: 14px; |
|
} |
|
.sidebar button:hover { |
|
background: #45a049; |
|
} |
|
.sidebar .character-sheet, .sidebar .skills-sheet, .sidebar .leaderboard { |
|
margin-top: 20px; |
|
font-size: 14px; |
|
} |
|
.sidebar .character-sheet div, .sidebar .skills-sheet div, .sidebar .leaderboard div { |
|
margin: 5px 0; |
|
} |
|
.gallery { |
|
position: absolute; |
|
top: 50%; |
|
left: 50%; |
|
transform: translate(-50%, -50%); |
|
background-color: rgba(0, 0, 0, 0.8); |
|
color: white; |
|
padding: 20px; |
|
border-radius: 10px; |
|
text-align: center; |
|
max-width: 600px; |
|
display: none; |
|
} |
|
.gallery img { |
|
max-width: 100%; |
|
margin: 10px 0; |
|
} |
|
.skill-meter { |
|
background: #333; |
|
height: 10px; |
|
border-radius: 5px; |
|
overflow: hidden; |
|
} |
|
.skill-meter div { |
|
background: #4CAF50; |
|
height: 100%; |
|
transition: width 0.3s; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div class="ui-container" id="race-ui"> |
|
<h2>Starship Circuit Commander 🏁</h2> |
|
<div id="time">Time: 0</div> |
|
<div id="score">Score: 0</div> |
|
<div id="status">Status: Active</div> |
|
</div> |
|
<div class="sidebar" id="sidebar"> |
|
<h2>Tracks</h2> |
|
<h3>Minnesota</h3> |
|
<button onclick="startRace('U of MN Twin Cities Track')">🎓 U of MN Twin Cities Track</button> |
|
<button onclick="startRace('Phelps Island Drift')">🏝️ Phelps Island Drift</button> |
|
<button onclick="startRace('Mound Overlook Circuit')">🏞️ Mound Overlook Circuit</button> |
|
<button onclick="startRace('Lake Minnetonka Sprint')">🚣 Lake Minnetonka Sprint</button> |
|
<button onclick="startRace('St. Paul Riverbend Run')">🛶 St. Paul Riverbend Run</button> |
|
<button onclick="startRace('Cathedral Hill Climb')">⛪ Cathedral Hill Climb</button> |
|
<button onclick="startRace('Downtown Minneapolis Skyway Loop')">🌆 Downtown Minneapolis Skyway Loop</button> |
|
<button onclick="startRace('Nicollet Mall Straightaway')">🛍️ Nicollet Mall Straightaway</button> |
|
<button onclick="startRace('Lake of the Isles Island Circuit')">🏝️ Lake of the Isles Island Circuit</button> |
|
<button onclick="startRace('Minnehaha Falls Meander')">🌳 Minnehaha Falls Meander</button> |
|
<h3>Wisconsin</h3> |
|
<button onclick="startRace('Milwaukee Loop')">🛶 Milwaukee Loop</button> |
|
<button onclick="startRace('Waukesha Speedway')">🏁 Waukesha Speedway</button> |
|
<button onclick="startRace('Manitowoc Marina Run')">🌊 Manitowoc Marina Run</button> |
|
<button onclick="startRace('Lake Winnebago Circuit')">🌾 Lake Winnebago Circuit</button> |
|
<button onclick="startRace('Door County Coastal Cruise')">🌲 Door County Coastal Cruise</button> |
|
<button onclick="startRace('Green Bay Bayfront Blitz')">🦌 Green Bay Bayfront Blitz</button> |
|
<button onclick="startRace('Sturgeon Bay Ship Canal Sprint')">🚢 Sturgeon Bay Ship Canal Sprint</button> |
|
<button onclick="startRace('Wisconsin Dells Rapids Run')">🎢 Wisconsin Dells Rapids Run</button> |
|
<button onclick="startRace('Madison Capitol Circuit')">🏛️ Madison Capitol Circuit</button> |
|
<button onclick="startRace('Superior North Shore Dash')">🌲 Superior North Shore Dash</button> |
|
<h3>Texas</h3> |
|
<button onclick="startRace('Houston Oil Refinery Rush')">⚙️ Houston Oil Refinery Rush</button> |
|
<button onclick="startRace('Dallas Finance District Dash')">💼 Dallas Finance District Dash</button> |
|
<button onclick="startRace('Austin Tech Corridor Cruise')">🎸 Austin Tech Corridor Cruise</button> |
|
<button onclick="startRace('San Antonio Riverwalk Run')">🎺 San Antonio Riverwalk Run</button> |
|
<button onclick="startRace('Corpus Christi Coastal Run')">⚓ Corpus Christi Coastal Run</button> |
|
<button onclick="startRace('Fort Worth Stockyards Loop')">🐎 Fort Worth Stockyards Loop</button> |
|
<button onclick="startRace('Galveston Port Channel Circuit')">🚢 Galveston Port Channel Circuit</button> |
|
<button onclick="startRace('El Paso Border Trade Boulevard')">🌵 El Paso Border Trade Boulevard</button> |
|
<button onclick="startRace('Lubbock Innovation Loop')">🌟 Lubbock Innovation Loop</button> |
|
<button onclick="startRace('Midland Permian Basin Sprint')">⛽ Midland Permian Basin Sprint</button> |
|
<h3>Florida</h3> |
|
<button onclick="startRace('Clearwater Curve')">🏖️ Clearwater Curve</button> |
|
<button onclick="startRace('Miami Beach Boulevard')">🌴 Miami Beach Boulevard</button> |
|
<button onclick="startRace('Florida Keys Canal Cruise')">🚤 Florida Keys Canal Cruise</button> |
|
<button onclick="startRace('Orlando Theme Park Tour')">🎢 Orlando Theme Park Tour</button> |
|
<button onclick="startRace('Tampa Bay Finance District Drift')">💰 Tampa Bay Finance District Drift</button> |
|
<button onclick="startRace('Jacksonville River City Run')">🏙️ Jacksonville River City Run</button> |
|
<button onclick="startRace('Fort Lauderdale Cruise Port Circuit')">🚢 Fort Lauderdale Cruise Port Circuit</button> |
|
<button onclick="startRace('Key West Sunset Sprint')">🌅 Key West Sunset Sprint</button> |
|
<button onclick="startRace('St. Augustine Colonial Run')">🏰 St. Augustine Colonial Run</button> |
|
<button onclick="startRace('Palm Beach Gold Coast Circuit')">💎 Palm Beach Gold Coast Circuit</button> |
|
<h3>California</h3> |
|
<button onclick="startRace('Venice Beach Boardwalk Circuit')">🏄 Venice Beach Boardwalk Circuit</button> |
|
<button onclick="startRace('Los Angeles Star-Studded Speedway')">🌟 Los Angeles Star-Studded Speedway</button> |
|
<button onclick="startRace('San Francisco Golden Gate Run')">🌉 San Francisco Golden Gate Run</button> |
|
<button onclick="startRace('San Diego Coastal Drift')">🚤 San Diego Coastal Drift</button> |
|
<button onclick="startRace('Anaheim Theme Park Loop')">🏰 Anaheim Theme Park Loop</button> |
|
<button onclick="startRace('Santa Barbara Vineyard Tour')">🍇 Santa Barbara Vineyard Tour</button> |
|
<button onclick="startRace('Palm Springs Desert Dash')">☀️ Palm Springs Desert Dash</button> |
|
<button onclick="startRace('Santa Cruz Boardwalk Sprint')">🎡 Santa Cruz Boardwalk Sprint</button> |
|
<button onclick="startRace('Monterey Bay Coastal Circuit')">🐋 Monterey Bay Coastal Circuit</button> |
|
<button onclick="startRace('Carmel-by-the-Sea Scenic Route')">🏞️ Carmel-by-the-Sea Scenic Route</button> |
|
<button onclick="startRace('Napa Valley Vineyard Ride')">🍷 Napa Valley Vineyard Ride</button> |
|
<button onclick="showGallery()">📷 View Image Gallery</button> |
|
<button onclick="restartRace()">🔄 Restart Race</button> |
|
<div class="character-sheet" id="character-sheet"> |
|
<h3>Ship Status</h3> |
|
<div id="ship-type">Type: Unknown</div> |
|
<div id="thruster-1">Thruster 1: 0/0</div> |
|
<div id="thruster-2">Thruster 2: 0/0</div> |
|
<div id="thruster-3">Thruster 3: 0/0</div> |
|
<div id="thruster-4">Thruster 4: 0/0</div> |
|
<div id="body-front">Body Front: 0/0</div> |
|
<div id="body-back">Body Back: 0/0</div> |
|
<div id="body-left">Body Left: 0/0</div> |
|
<div id="body-right">Body Right: 0/0</div> |
|
</div> |
|
<div class="skills-sheet" id="skills-sheet"> |
|
<h3>Skills</h3> |
|
<div>Speed: <span id="skill-speed">0</span><div class="skill-meter"><div id="skill-speed-meter" style="width: 0%"></div></div></div> |
|
<div>Shot Power: <span id="skill-shot-power">0</span><div class="skill-meter"><div id="skill-shot-power-meter" style="width: 0%"></div></div></div> |
|
<div>Shot Count: <span id="skill-shot-count">0</span><div class="skill-meter"><div id="skill-shot-count-meter" style="width: 0%"></div></div></div> |
|
<div>Turn Speed: <span id="skill-turn-speed">0</span><div class="skill-meter"><div id="skill-turn-speed-meter" style="width: 0%"></div></div></div> |
|
</div> |
|
<div class="leaderboard" id="leaderboard"> |
|
<h3>Leaderboard 📊</h3> |
|
<div id="logDisplay">Loading...</div> |
|
</div> |
|
</div> |
|
<div class="gallery" id="gallery"> |
|
<h2>Lake Minnetonka Gallery</h2> |
|
<p>Images of the Lake Minnetonka area</p> |
|
<img src="https://via.placeholder.com/400x200?text=Phelps+Island" alt="Phelps Island"> |
|
<img src="https://via.placeholder.com/400x200?text=Mound+Shoreline" alt="Mound Shoreline"> |
|
<img src="https://via.placeholder.com/400x200?text=Wayzata+Bay" alt="Wayzata Bay"> |
|
<button onclick="hideGallery()">Back to Menu</button> |
|
</div> |
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> |
|
<script> |
|
|
|
let gameState = 'racing'; |
|
let raceTime = 0; |
|
let score = 0; |
|
let currentTrack = 'Phelps Island Drift'; |
|
let lastGatePass = 0; |
|
let isJumping = false; |
|
let jumpCooldown = 0; |
|
let playerName = prompt('Enter your name:') || 'Player'; |
|
|
|
|
|
const scene = new THREE.Scene(); |
|
scene.background = new THREE.Color(0x87CEEB); |
|
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000); |
|
const renderer = new THREE.WebGLRenderer({ antialias: true }); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
renderer.shadowMap.enabled = true; |
|
document.body.appendChild(renderer.domElement); |
|
|
|
|
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5); |
|
scene.add(ambientLight); |
|
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8); |
|
directionalLight.position.set(50, 50, 50); |
|
directionalLight.castShadow = true; |
|
scene.add(directionalLight); |
|
|
|
|
|
const groundGeometry = new THREE.PlaneGeometry(1000, 1000); |
|
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.8, metalness: 0.2 }); |
|
const ground = new THREE.Mesh(groundGeometry, groundMaterial); |
|
ground.rotation.x = -Math.PI / 2; |
|
ground.position.y = -0.1; |
|
ground.receiveShadow = true; |
|
scene.add(ground); |
|
|
|
|
|
const shipTypes = [ |
|
{ name: 'Truck', scale: { x: 2, y: 1.5, z: 4 }, maxHP: 20, speed: 0.2, accel: 0.008 }, |
|
{ name: 'Subcompact', scale: { x: 1, y: 1, z: 2 }, maxHP: 10, speed: 0.3, accel: 0.01 }, |
|
{ name: 'Motorbike', scale: { x: 0.5, y: 0.5, z: 1.5 }, maxHP: 5, speed: 0.35, accel: 0.012 }, |
|
{ name: 'Rocketman', scale: { x: 0.5, y: 1, z: 0.5 }, maxHP: 3, speed: 0.4, accel: 0.015 } |
|
]; |
|
|
|
|
|
const skills = { |
|
speed: { level: 0, maxLevel: 10, effect: level => 1 + level * 0.05 }, |
|
shotPower: { level: 0, maxLevel: 10, effect: level => 5 + level * 2 }, |
|
shotCount: { level: 0, maxLevel: 10, effect: level => 1 + Math.floor(level / 2) }, |
|
turnSpeed: { level: 0, maxLevel: 10, effect: level => 0.05 + level * 0.005 } |
|
}; |
|
|
|
|
|
const pickupTypes = [ |
|
{ skill: 'speed', color: 0x00ff00 }, |
|
{ skill: 'shotPower', color: 0xff0000 }, |
|
{ skill: 'shotCount', color: 0xffff00 }, |
|
{ skill: 'turnSpeed', color: 0x0000ff } |
|
]; |
|
const pickups = []; |
|
|
|
function createPickup(position) { |
|
const geometry = new THREE.SphereGeometry(0.5, 16, 16); |
|
const type = pickupTypes[Math.floor(Math.random() * pickupTypes.length)]; |
|
const material = new THREE.MeshStandardMaterial({ color: type.color }); |
|
const pickup = new THREE.Mesh(geometry, material); |
|
pickup.position.copy(position); |
|
pickup.userData = { skill: type.skill }; |
|
scene.add(pickup); |
|
pickups.push(pickup); |
|
return pickup; |
|
} |
|
|
|
|
|
let playerShip = null; |
|
let playerData = null; |
|
function createShip(typeIndex, isPlayer, scaleMultiplier = 1, splitCount = 0) { |
|
const type = shipTypes[typeIndex]; |
|
const ship = new THREE.Group(); |
|
const scale = { |
|
x: type.scale.x * scaleMultiplier, |
|
y: type.scale.y * scaleMultiplier, |
|
z: type.scale.z * scaleMultiplier |
|
}; |
|
const bodyGeometry = new THREE.BoxGeometry(scale.x, scale.y, scale.z); |
|
const bodyMaterial = new THREE.MeshStandardMaterial({ |
|
color: isPlayer ? 0x00ff00 : 0xff0000, |
|
metalness: 0.8, |
|
roughness: 0.2 |
|
}); |
|
const body = new THREE.Mesh(bodyGeometry, bodyMaterial); |
|
ship.add(body); |
|
if (type.name !== 'Rocketman') { |
|
const thrusterGeometry = new THREE.CylinderGeometry(0.2 * scaleMultiplier, 0.2 * scaleMultiplier, 0.5 * scaleMultiplier, 8); |
|
const thrusterMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa }); |
|
for (let i = 0; i < 4; i++) { |
|
const thruster = new THREE.Mesh(thrusterGeometry, thrusterMaterial); |
|
thruster.position.set( |
|
(i % 2 === 0 ? 0.5 : -0.5) * scale.x * 0.8, |
|
0, |
|
(i < 2 ? 0.5 : -0.5) * scale.z * 0.8 |
|
); |
|
thruster.rotation.x = Math.PI / 2; |
|
ship.add(thruster); |
|
} |
|
} else { |
|
const jetpackGeometry = new THREE.CylinderGeometry(0.3 * scaleMultiplier, 0.3 * scaleMultiplier, 0.8 * scaleMultiplier, 8); |
|
const jetpack = new THREE.Mesh(jetpackGeometry, new THREE.MeshStandardMaterial({ color: 0xaaaaaa })); |
|
jetpack.position.set(0, -0.5 * scaleMultiplier, 0); |
|
ship.add(jetpack); |
|
} |
|
ship.position.y = 10; |
|
ship.castShadow = true; |
|
const maxHP = type.maxHP * scaleMultiplier; |
|
ship.userData = { |
|
type: type.name, |
|
maxSpeed: type.speed, |
|
acceleration: type.accel, |
|
thrusters: Array(4).fill(maxHP), |
|
body: { front: maxHP, back: maxHP, left: maxHP, right: maxHP }, |
|
active: true, |
|
speed: 0, |
|
currentWaypoint: 0, |
|
lastGatePass: 0, |
|
isPlayer: isPlayer, |
|
splitCount: splitCount, |
|
typeIndex: typeIndex |
|
}; |
|
scene.add(ship); |
|
if (!isPlayer) aiShips.push(ship); |
|
return ship; |
|
} |
|
|
|
|
|
function initPlayer() { |
|
const typeIndex = Math.floor(Math.random() * shipTypes.length); |
|
playerShip = createShip(typeIndex, true); |
|
playerData = playerShip.userData; |
|
Object.keys(skills).forEach(skill => skills[skill].level = 0); |
|
score = 0; |
|
document.getElementById('score').textContent = `Score: ${score}`; |
|
updateCharacterSheet(); |
|
updateSkillsSheet(); |
|
updateLeaderboard(); |
|
} |
|
|
|
|
|
const aiShips = []; |
|
for (let i = 0; i < 3; i++) { |
|
const typeIndex = Math.floor(Math.random() * shipTypes.length); |
|
aiShips.push(createShip(typeIndex, false)); |
|
} |
|
|
|
|
|
const gateGeometry = new THREE.BoxGeometry(60, 12, 1); |
|
const gateMaterial = new THREE.MeshBasicMaterial({ visible: false }); |
|
const startGate = new THREE.Mesh(gateGeometry, gateMaterial); |
|
scene.add(startGate); |
|
|
|
|
|
const missiles = []; |
|
function createMissile(position, target) { |
|
const geometry = new THREE.ConeGeometry(0.2, 0.5, 8); |
|
const material = new THREE.MeshStandardMaterial({ color: 0xff0000 }); |
|
const missile = new THREE.Mesh(geometry, material); |
|
missile.position.copy(position); |
|
missile.rotation.x = Math.PI / 2; |
|
missile.castShadow = true; |
|
|
|
|
|
const flameCount = 10; |
|
const flameGeometry = new THREE.BufferGeometry(); |
|
const flamePositions = new Float32Array(flameCount * 3); |
|
const flameColors = new Float32Array(flameCount * 3); |
|
for (let i = 0; i < flameCount; i++) { |
|
const i3 = i * 3; |
|
flamePositions[i3] = 0; |
|
flamePositions[i3 + 1] = 0; |
|
flamePositions[i3 + 2] = 0; |
|
flameColors[i3] = 1; |
|
flameColors[i3 + 1] = 0; |
|
flameColors[i3 + 2] = 0; |
|
} |
|
flameGeometry.setAttribute('position', new THREE.BufferAttribute(flamePositions, 3)); |
|
flameGeometry.setAttribute('color', new THREE.BufferAttribute(flameColors, 3)); |
|
const flameMaterial = new THREE.PointsMaterial({ |
|
size: 0.2, |
|
vertexColors: true, |
|
transparent: true, |
|
opacity: 0.5 |
|
}); |
|
const flame = new THREE.Points(flameGeometry, flameMaterial); |
|
flame.position.z = -0.3; |
|
missile.add(flame); |
|
|
|
missile.userData = { |
|
target: target, |
|
speed: 1, |
|
lifetime: 300, |
|
damage: skills.shotPower.effect(skills.shotPower.level) |
|
}; |
|
scene.add(missile); |
|
missiles.push(missile); |
|
return missile; |
|
} |
|
|
|
|
|
function createExplosion(position) { |
|
const particleCount = 50; |
|
const geometry = new THREE.BufferGeometry(); |
|
const positions = new Float32Array(particleCount * 3); |
|
const colors = new Float32Array(particleCount * 3); |
|
for (let i = 0; i < particleCount; i++) { |
|
const i3 = i * 3; |
|
positions[i3] = position.x; |
|
positions[i3 + 1] = position.y; |
|
positions[i3 + 2] = position.z; |
|
const color = Math.random() > 0.5 ? [1, 1, 0] : [1, 0, 0]; |
|
colors[i3] = color[0]; |
|
colors[i3 + 1] = color[1]; |
|
colors[i3 + 2] = color[2]; |
|
} |
|
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); |
|
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); |
|
const material = new THREE.PointsMaterial({ |
|
size: 0.5, |
|
vertexColors: true, |
|
transparent: true, |
|
opacity: 0.8 |
|
}); |
|
const particles = new THREE.Points(geometry, material); |
|
particles.userData = { lifetime: 60, velocities: [] }; |
|
for (let i = 0; i < particleCount; i++) { |
|
particles.userData.velocities.push( |
|
new THREE.Vector3( |
|
(Math.random() - 0.5) * 0.2, |
|
(Math.random() - 0.5) * 0.2, |
|
(Math.random() - 0.5) * 0.2 |
|
) |
|
); |
|
} |
|
scene.add(particles); |
|
return particles; |
|
} |
|
|
|
|
|
function createBuilding(x, z, width, depth, height, scaleMultiplier = 1, splitCount = 0) { |
|
const geometry = new THREE.BoxGeometry(width * scaleMultiplier, height * scaleMultiplier, depth * scaleMultiplier); |
|
const material = new THREE.MeshStandardMaterial({ |
|
color: Math.random() * 0xffffff, |
|
roughness: 0.7, |
|
metalness: 0.3 |
|
}); |
|
const building = new THREE.Mesh(geometry, material); |
|
building.position.set(x, (height * scaleMultiplier) / 2, z); |
|
building.castShadow = true; |
|
building.receiveShadow = true; |
|
building.userData = { |
|
splitCount: splitCount, |
|
maxHP: 10 * scaleMultiplier, |
|
currentHP: 10 * scaleMultiplier, |
|
width: width, |
|
depth: depth, |
|
height: height |
|
}; |
|
return building; |
|
} |
|
|
|
function createColumn(x, z, height) { |
|
const geometry = new THREE.CylinderGeometry(1, 1, height, 8); |
|
const material = new THREE.MeshStandardMaterial({ color: 0x666666, roughness: 0.8, metalness: 0.5 }); |
|
const column = new THREE.Mesh(geometry, material); |
|
column.position.set(x, height / 2, z); |
|
column.castShadow = true; |
|
column.receiveShadow = true; |
|
return column; |
|
} |
|
|
|
|
|
function createTrackSegment(type, inputPos, inputRot, params) { |
|
const points = []; |
|
const buildings = []; |
|
const columns = []; |
|
let length = params.length; |
|
let width = params.streetWidth; |
|
let height = params.buildingHeight; |
|
let outputPos, outputRot; |
|
|
|
if (type === 'straight') { |
|
points.push(inputPos); |
|
outputPos = inputPos.clone().add(new THREE.Vector3(0, 0, -length).applyEuler(inputRot)); |
|
points.push(outputPos); |
|
outputRot = inputRot.clone(); |
|
for (let i = 0; i <= length; i += 10) { |
|
const t = i / length; |
|
const pos = inputPos.clone().lerp(outputPos, t); |
|
buildings.push(createBuilding(pos.x + width / 2 + 5, pos.z, 10, length, height)); |
|
buildings.push(createBuilding(pos.x - width / 2 - 5, pos.z, 10, length, height)); |
|
if (i % 20 === 0) { |
|
columns.push(createColumn(pos.x + width / 2, pos.z, height)); |
|
columns.push(createColumn(pos.x - width / 2, pos.z, height)); |
|
} |
|
if (i % 30 === 0 && Math.random() < 0.3) { |
|
createPickup(new THREE.Vector3(pos.x + (Math.random() - 0.5) * width / 2, 10, pos.z)); |
|
} |
|
} |
|
} else if (type === 'left') { |
|
const center = inputPos.clone().add(new THREE.Vector3(length, 0, 0).applyEuler(inputRot)); |
|
for (let i = 0; i <= 16; i++) { |
|
const angle = (i / 16) * Math.PI / 2; |
|
const x = center.x - length * Math.cos(angle); |
|
const z = center.z - length * Math.sin(angle); |
|
points.push(new THREE.Vector3(x, inputPos.y, z)); |
|
if (i % 4 === 0) { |
|
buildings.push(createBuilding( |
|
x + Math.cos(angle) * (width / 2 + 5), z + Math.sin(angle) * (width / 2 + 5), |
|
10, 10, height |
|
)); |
|
buildings.push(createBuilding( |
|
x - Math.cos(angle) * (width / 2 + 5), z - Math.sin(angle) * (width / 2 + 5), |
|
10, 10, height |
|
)); |
|
columns.push(createColumn( |
|
x + Math.cos(angle) * width / 2, z + Math.sin(angle) * width / 2, height |
|
)); |
|
columns.push(createColumn( |
|
x - Math.cos(angle) * width / 2, z - Math.sin(angle) * width / 2, height |
|
)); |
|
if (Math.random() < 0.3) { |
|
createPickup(new THREE.Vector3(x + (Math.random() - 0.5) * width / 2, 10, z)); |
|
} |
|
} |
|
} |
|
outputPos = points[points.length - 1]; |
|
outputRot = inputRot.clone(); |
|
outputRot.y += Math.PI / 2; |
|
} else if (type === 'right') { |
|
const center = inputPos.clone().add(new THREE.Vector3(-length, 0, 0).applyEuler(inputRot)); |
|
for (let i = 0; i <= 16; i++) { |
|
const angle = (i / 16) * -Math.PI / 2; |
|
const x = center.x - length * Math.cos(angle); |
|
const z = center.z - length * Math.sin(angle); |
|
points.push(new THREE.Vector3(x, inputPos.y, z)); |
|
if (i % 4 === 0) { |
|
buildings.push(createBuilding( |
|
x - Math.cos(angle) * (width / 2 + 5), z - Math.sin(angle) * (width / 2 + 5), |
|
10, 10, height |
|
)); |
|
buildings.push(createBuilding( |
|
x + Math.cos(angle) * (width / 2 + 5), z + Math.sin(angle) * (width / 2 + 5), |
|
10, 10, height |
|
)); |
|
columns.push(createColumn( |
|
x - Math.cos(angle) * width / 2, z - Math.sin(angle) * width / 2, height |
|
)); |
|
columns.push(createColumn( |
|
x + Math.cos(angle) * width / 2, z + Math.sin(angle) * width / 2, height |
|
)); |
|
if (Math.random() < 0.3) { |
|
createPickup(new THREE.Vector3(x + (Math.random() - 0.5) * width / 2, 10, z)); |
|
} |
|
} |
|
} |
|
outputPos = points[points.length - 1]; |
|
outputRot = inputRot.clone(); |
|
outputRot.y -= Math.PI / 2; |
|
} else if (type === 'up') { |
|
points.push(inputPos); |
|
outputPos = inputPos.clone().add(new THREE.Vector3(0, length, -length).applyEuler(inputRot)); |
|
points.push(outputPos); |
|
outputRot = inputRot.clone(); |
|
outputRot.x += Math.PI / 4; |
|
for (let i = 0; i <= length; i += 10) { |
|
const t = i / length; |
|
const pos = inputPos.clone().lerp(outputPos, t); |
|
buildings.push(createBuilding(pos.x + width / 2 + 5, pos.z, 10, length, height)); |
|
buildings.push(createBuilding(pos.x - width / 2 - 5, pos.z, 10, length, height)); |
|
if (i % 20 === 0) { |
|
columns.push(createColumn(pos.x + width / 2, pos.z, height)); |
|
columns.push(createColumn(pos.x - width / 2, pos.z, height)); |
|
} |
|
if (i % 30 === 0 && Math.random() < 0.3) { |
|
createPickup(new THREE.Vector3(pos.x + (Math.random() - 0.5) * width / 2, pos.z, pos.y)); |
|
} |
|
} |
|
} else if (type === 'down') { |
|
points.push(inputPos); |
|
outputPos = inputPos.clone().add(new THREE.Vector3(0, -length, -length).applyEuler(inputRot)); |
|
points.push(outputPos); |
|
outputRot = inputRot.clone(); |
|
outputRot.x -= Math.PI / 4; |
|
for (let i = 0; i <= length; i += 10) { |
|
const t = i / length; |
|
const pos = inputPos.clone().lerp(outputPos, t); |
|
buildings.push(createBuilding(pos.x + width / 2 + 5, pos.z, 10, length, height)); |
|
buildings.push(createBuilding(pos.x - width / 2 - 5, pos.z, 10, length, height)); |
|
if (i % 20 === 0) { |
|
columns.push(createColumn(pos.x + width / 2, pos.z, height)); |
|
columns.push(createColumn(pos.x - width / 2, pos.z, height)); |
|
} |
|
if (i % 30 === 0 && Math.random() < 0.3) { |
|
createPickup(new THREE.Vector3(pos.x + (Math.random() - 0.5) * width / 2, pos.z, pos.y)); |
|
} |
|
} |
|
} else if (type === 'gap') { |
|
points.push(inputPos); |
|
outputPos = inputPos.clone().add(new THREE.Vector3(0, 0, -length * 2).applyEuler(inputRot)); |
|
points.push(outputPos); |
|
outputRot = inputRot.clone(); |
|
buildings.push(createBuilding(outputPos.x + width / 2 + 5, outputPos.z, 10, 10, height)); |
|
buildings.push(createBuilding(outputPos.x - width / 2 - 5, outputPos.z, 10, 10, height)); |
|
columns.push(createColumn(outputPos.x + width / 2, outputPos.z, height)); |
|
columns.push(createColumn(outputPos.x - width / 2, outputPos.z, height)); |
|
if (Math.random() < 0.3) { |
|
createPickup(new THREE.Vector3(outputPos.x + (Math.random() - 0.5) * width / 2, 10, outputPos.z)); |
|
} |
|
} |
|
|
|
return { points, outputPos, outputRot, buildings, columns }; |
|
} |
|
|
|
|
|
function generateTrack(params) { |
|
const trackGroup = new THREE.Group(); |
|
const waypoints = []; |
|
let currentPos = new THREE.Vector3(0, 10, 0); |
|
let currentRot = new THREE.Euler(0, 0, 0); |
|
const segmentTypes = params.segments; |
|
|
|
segmentTypes.forEach(type => { |
|
const segment = createTrackSegment(type, currentPos, currentRot, { |
|
length: params.length, |
|
streetWidth: params.streetWidth, |
|
buildingHeight: params.buildingHeight |
|
}); |
|
segment.buildings.forEach(building => trackGroup.add(building)); |
|
segment.columns.forEach(column => trackGroup.add(column)); |
|
segment.points.forEach(point => waypoints.push(point.clone())); |
|
currentPos = segment.outputPos; |
|
currentRot = segment.outputRot; |
|
}); |
|
|
|
const finalSegment = createTrackSegment('straight', currentPos, currentRot, { |
|
length: currentPos.distanceTo(new THREE.Vector3(0, 10, 0)), |
|
streetWidth: params.streetWidth, |
|
buildingHeight: params.buildingHeight |
|
}); |
|
finalSegment.buildings.forEach(building => trackGroup.add(building)); |
|
finalSegment.columns.forEach(column => trackGroup.add(column)); |
|
finalSegment.points.forEach(point => waypoints.push(point.clone())); |
|
|
|
return { trackGroup, waypoints, startPosition: new THREE.Vector3(0, 10, 0) }; |
|
} |
|
|
|
|
|
const trackConfigs = { |
|
'U of MN Twin Cities Track': { segments: ['straight', 'right', 'straight', 'left', 'up', 'straight', 'down', 'gap', 'straight'], length: 50, streetWidth: 30, buildingHeight: 40 }, |
|
'Phelps Island Drift': { segments: ['straight', 'left', 'left', 'right', 'gap', 'straight', 'right', 'left', 'gap'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Mound Overlook Circuit': { segments: ['straight', 'up', 'right', 'straight', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Lake Minnetonka Sprint': { segments: ['straight', 'left', 'right', 'gap', 'straight', 'up', 'down', 'left', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'St. Paul Riverbend Run': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Cathedral Hill Climb': { segments: ['up', 'straight', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Downtown Minneapolis Skyway Loop': { segments: ['straight', 'right', 'straight', 'left', 'gap', 'straight', 'right'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Nicollet Mall Straightaway': { segments: ['straight', 'straight', 'straight', 'gap', 'straight', 'straight'], length: 55, streetWidth: 35, buildingHeight: 50 }, |
|
'Lake of the Isles Island Circuit': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight', 'right'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Minnehaha Falls Meander': { segments: ['straight', 'left', 'up', 'right', 'down', 'gap', 'straight', 'left'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Milwaukee Loop': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Waukesha Speedway': { segments: ['straight', 'right', 'straight', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Manitowoc Marina Run': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Lake Winnebago Circuit': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Door County Coastal Cruise': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Green Bay Bayfront Blitz': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Sturgeon Bay Ship Canal Sprint': { segments: ['straight', 'straight', 'straight', 'gap', 'straight'], length: 55, streetWidth: 35, buildingHeight: 50 }, |
|
'Wisconsin Dells Rapids Run': { segments: ['straight', 'left', 'up', 'right', 'down', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Madison Capitol Circuit': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Superior North Shore Dash': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Houston Oil Refinery Rush': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Dallas Finance District Dash': { segments: ['straight', 'right', 'straight', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Austin Tech Corridor Cruise': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'San Antonio Riverwalk Run': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Corpus Christi Coastal Run': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Fort Worth Stockyards Loop': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Galveston Port Channel Circuit': { segments: ['straight', 'straight', 'straight', 'gap', 'straight'], length: 55, streetWidth: 35, buildingHeight: 50 }, |
|
'El Paso Border Trade Boulevard': { segments: ['straight', 'left', 'up', 'right', 'down', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Lubbock Innovation Loop': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Midland Permian Basin Sprint': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Clearwater Curve': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Miami Beach Boulevard': { segments: ['straight', 'right', 'straight', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Florida Keys Canal Cruise': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Orlando Theme Park Tour': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Tampa Bay Finance District Drift': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'standard'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Jacksonville River City Run': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Fort Lauderdale Cruise Port Circuit': { segments: ['straight', 'straight', 'straight', 'gap', 'straight'], length: 55, streetWidth: 35, buildingHeight: 50 }, |
|
'Key West Sunset Sprint': { segments: ['straight', 'left', 'up', 'right', 'down', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'St. Augustine Colonial Run': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Palm Beach Gold Coast Circuit': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Venice Beach Boardwalk Circuit': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Los Angeles Star-Studded Speedway': { segments: ['straight', 'right', 'straight', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'San Francisco Golden Gate Run': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'San Diego Coastal Drift': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Anaheim Theme Park Loop': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Santa Barbara Vineyard Tour': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 }, |
|
'Palm Springs Desert Dash': { segments: ['straight', 'straight', 'straight', 'gap', 'straight'], length: 55, streetWidth: 35, buildingHeight: 50 }, |
|
'Santa Cruz Boardwalk Sprint': { segments: ['straight', 'left', 'up', 'right', 'down', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Monterey Bay Coastal Circuit': { segments: ['straight', 'right', 'left', 'straight', 'gap', 'right', 'straight'], length: 50, streetWidth: 32, buildingHeight: 45 }, |
|
'Carmel-by-the-Sea Scenic Route': { segments: ['straight', 'up', 'right', 'down', 'left', 'gap', 'straight'], length: 48, streetWidth: 30, buildingHeight: 40 }, |
|
'Napa Valley Vineyard Ride': { segments: ['straight', 'left', 'right', 'gap', 'left', 'straight'], length: 45, streetWidth: 28, buildingHeight: 35 } |
|
}; |
|
|
|
let currentTrackGroup = null; |
|
let waypoints = []; |
|
|
|
|
|
const keys = { w: false, a: false, s: false, d: false, space: false }; |
|
document.addEventListener('keydown', (event) => { |
|
switch (event.key.toLowerCase()) { |
|
case 'w': keys.w = true; break; |
|
case 'a': keys.a = true; break; |
|
case 's': keys.s = true; break; |
|
case 'd': keys.d = true; break; |
|
case ' ': keys.space = true; break; |
|
} |
|
}); |
|
document.addEventListener('keyup', (event) => { |
|
switch (event.key.toLowerCase()) { |
|
case 'w': keys.w = false; break; |
|
case 'a': keys.a = false; break; |
|
case 's': keys.s = false; break; |
|
case 'd': keys.d = false; break; |
|
case ' ': keys.space = false; break; |
|
} |
|
}); |
|
|
|
|
|
let canShoot = true; |
|
let shotCooldown = 200; |
|
const raycaster = new THREE.Raycaster(); |
|
const mouse = new THREE.Vector2(); |
|
document.addEventListener('mousedown', (event) => { |
|
if (event.button === 0 && canShoot && gameState === 'racing' && playerData.active) { |
|
canShoot = false; |
|
setTimeout(() => canShoot = true, shotCooldown); |
|
mouse.x = (event.clientX / window.innerWidth) * 2 - 1; |
|
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; |
|
raycaster.setFromCamera(mouse, camera); |
|
const targets = [...aiShips, ...(currentTrackGroup ? currentTrackGroup.children.filter(c => c.isMesh && c.geometry.type === 'BoxGeometry') : [])].filter(t => t !== playerShip && (!t.userData.isPlayer || !t.userData.active)); |
|
const intersects = raycaster.intersectObjects(targets); |
|
if (intersects.length > 0) { |
|
const target = intersects[0].object; |
|
const position = playerShip.position.clone().add(new THREE.Vector3(0, 0, -2)); |
|
const count = skills.shotCount.effect(skills.shotCount.level); |
|
for (let i = 0; i < count; i++) { |
|
createMissile(position.clone().add(new THREE.Vector3((Math.random() - 0.5) * 0.5, (Math.random() - 0.5) * 0.5, 0)), target); |
|
} |
|
logAction(playerName, score, 'Shot Fired'); |
|
} |
|
} |
|
}); |
|
|
|
|
|
function logAction(name, points, action) { |
|
const logEntry = { |
|
player: name, |
|
score: points, |
|
action: action, |
|
timestamp: new Date().toISOString() |
|
}; |
|
let logs = JSON.parse(localStorage.getItem('gameLogs') || '[]'); |
|
logs.push(logEntry); |
|
localStorage.setItem('gameLogs', JSON.stringify(logs)); |
|
updateLeaderboard(); |
|
} |
|
|
|
function updateLeaderboard() { |
|
let logs = JSON.parse(localStorage.getItem('gameLogs') || '[]'); |
|
document.getElementById('logDisplay').innerHTML = logs.slice(-5).map(log => |
|
`${log.player} (${log.score} pts): ${log.action} at ${new Date(log.timestamp).toLocaleString()}` |
|
).join('<br>') || 'No history!'; |
|
} |
|
|
|
|
|
function updateCharacterSheet() { |
|
document.getElementById('ship-type').textContent = `Type: ${playerData.type}`; |
|
document.getElementById('thruster-1').textContent = `Thruster 1: ${playerData.thrusters[0]}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
document.getElementById('thruster-2').textContent = `Thruster 2: ${playerData.thrusters[1]}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
document.getElementById('thruster-3').textContent = `Thruster 3: ${playerData.thrusters[2]}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
document.getElementById('thruster-4').textContent = `Thruster 4: ${playerData.thrusters[3]}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
document.getElementById('body-front').textContent = `Body Front: ${playerData.body.front}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
document.getElementById('body-back').textContent = `Body Back: ${playerData.body.back}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
document.getElementById('body-left').textContent = `Body Left: ${playerData.body.left}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
document.getElementById('body-right').textContent = `Body Right: ${playerData.body.right}/${shipTypes.find(t => t.name === playerData.type).maxHP}`; |
|
} |
|
|
|
|
|
function updateSkillsSheet() { |
|
document.getElementById('skill-speed').textContent = skills.speed.level; |
|
document.getElementById('skill-speed-meter').style.width = `${(skills.speed.level / skills.speed.maxLevel) * 100}%`; |
|
document.getElementById('skill-shot-power').textContent = skills.shotPower.level; |
|
document.getElementById('skill-shot-power-meter').style.width = `${(skills.shotPower.level / skills.shotPower.maxLevel) * 100}%`; |
|
document.getElementById('skill-shot-count').textContent = skills.shotCount.level; |
|
document.getElementById('skill-shot-count-meter').style.width = `${(skills.shotCount.level / skills.shotCount.maxLevel) * 100}%`; |
|
document.getElementById('skill-turn-speed').textContent = skills.turnSpeed.level; |
|
document.getElementById('skill-turn-speed-meter').style.width = `${(skills.turnSpeed.level / skills.turnSpeed.maxLevel) * 100}%`; |
|
} |
|
|
|
|
|
function applyDamage(ship, damage, hitPosition) { |
|
if (!ship.userData.active) return; |
|
const direction = hitPosition.clone().sub(ship.position).normalize(); |
|
const absX = Math.abs(direction.x); |
|
const absZ = Math.abs(direction.z); |
|
let component; |
|
if (absZ > absX) { |
|
component = direction.z > 0 ? 'front' : 'back'; |
|
} else { |
|
component = direction.x > 0 ? 'right' : 'left'; |
|
} |
|
if (Math.random() < 0.4) { |
|
const thrusterIndex = Math.floor(Math.random() * 4); |
|
if (ship.userData.thrusters[thrusterIndex] > 0) { |
|
ship.userData.thrusters[thrusterIndex] -= damage; |
|
if (ship.userData.thrusters[thrusterIndex] <= 0) { |
|
ship.userData.thrusters[thrusterIndex] = 0; |
|
} |
|
} |
|
} else { |
|
if (ship.userData.body[component] > 0) { |
|
ship.userData.body[component] -= damage; |
|
if (ship.userData.body[component] <= 0) { |
|
ship.userData.body[component] = 0; |
|
} |
|
} |
|
} |
|
createExplosion(hitPosition); |
|
if (ship === playerShip) { |
|
updateCharacterSheet(); |
|
logAction(playerName, score, 'Ship Damaged'); |
|
} |
|
checkShipStatus(ship); |
|
} |
|
|
|
function checkShipStatus(ship, hitPosition) { |
|
const totalHP = ship.userData.thrusters.reduce((a, b) => a + b, 0) + |
|
Object.values(ship.userData.body).reduce((a, b) => a + b, 0); |
|
if (totalHP <= 0) { |
|
ship.userData.active = false; |
|
ship.visible = false; |
|
createExplosion(ship.position); |
|
if (ship.userData.splitCount < 3) { |
|
const newScale = 0.5; |
|
const offsets = [ |
|
new THREE.Vector3(1, 0, 1), |
|
new THREE.Vector3(-1, 0, -1) |
|
]; |
|
offsets.forEach(offset => { |
|
const newShip = createShip( |
|
ship.userData.typeIndex, |
|
false, |
|
newScale, |
|
ship.userData.splitCount + 1 |
|
); |
|
newShip.position.copy(ship.position).add(offset.multiplyScalar(2)); |
|
newShip.userData.currentWaypoint = ship.userData.currentWaypoint; |
|
}); |
|
} |
|
if (!ship.userData.isPlayer) { |
|
const index = aiShips.indexOf(ship); |
|
if (index > -1) aiShips.splice(index, 1); |
|
score += 100; |
|
document.getElementById('score').textContent = `Score: ${score}`; |
|
logAction(playerName, score, 'Enemy Destroyed'); |
|
} |
|
if (ship === playerShip) { |
|
document.getElementById('status').textContent = 'Status: Destroyed'; |
|
logAction(playerName, score, 'Player Destroyed'); |
|
endRace(); |
|
} |
|
} |
|
} |
|
|
|
|
|
function applyBuildingDamage(building, damage, hitPosition) { |
|
building.userData.currentHP -= damage; |
|
createExplosion(hitPosition); |
|
if (building.userData.currentHP <= 0) { |
|
currentTrackGroup.remove(building); |
|
if (building.userData.splitCount < 3) { |
|
const newScale = 0.5; |
|
const offsets = [ |
|
new THREE.Vector3(5, 0, 5), |
|
new THREE.Vector3(-5, 0, -5) |
|
]; |
|
offsets.forEach(offset => { |
|
const newBuilding = createBuilding( |
|
building.position.x + offset.x, |
|
building.position.z + offset.z, |
|
building.userData.width, |
|
building.userData.depth, |
|
building.userData.height, |
|
newScale, |
|
building.userData.splitCount + 1 |
|
); |
|
currentTrackGroup.add(newBuilding); |
|
}); |
|
} |
|
score += 100; |
|
document.getElementById('score').textContent = `Score: ${score}`; |
|
logAction(playerName, score, 'Building Destroyed'); |
|
} |
|
} |
|
|
|
|
|
function updatePickups() { |
|
for (let i = pickups.length - 1; i >= 0; i--) { |
|
const pickup = pickups[i]; |
|
if (playerShip.position.distanceTo(pickup.position) < 2 && playerData.active) { |
|
const skill = skills[pickup.userData.skill]; |
|
if (skill.level < skill.maxLevel) { |
|
skill.level++; |
|
updateSkillsSheet(); |
|
logAction(playerName, score, `Picked ${pickup.userData.skill}`); |
|
} |
|
scene.remove(pickup); |
|
pickups.splice(i, 1); |
|
} |
|
} |
|
} |
|
|
|
|
|
function updateMissiles() { |
|
for (let i = missiles.length - 1; i >= 0; i--) { |
|
const missile = missiles[i]; |
|
missile.userData.lifetime--; |
|
if (missile.userData.lifetime <= 0 || !missile.userData.target || (!missile.userData.target.userData?.active && !missile.userData.target.isMesh)) { |
|
scene.remove(missile); |
|
missiles.splice(i, 1); |
|
continue; |
|
} |
|
const targetPos = missile.userData.target.isMesh ? missile.userData.target.position : missile.userData.target.position; |
|
const direction = targetPos.clone().sub(missile.position).normalize(); |
|
missile.position.add(direction.multiplyScalar(missile.userData.speed)); |
|
missile.lookAt(targetPos); |
|
missile.rotation.x += Math.PI / 2; |
|
|
|
|
|
const flame = missile.children[0]; |
|
const positions = flame.geometry.attributes.position.array; |
|
for (let j = 0; j < positions.length / 3; j++) { |
|
const j3 = j * 3; |
|
positions[j3] = (Math.random() - 0.5) * 0.1; |
|
positions[j3 + 1] = (Math.random() - 0.5) * 0.1; |
|
positions[j3 + 2] = -0.3 - Math.random() * 0.2; |
|
} |
|
flame.geometry.attributes.position.needsUpdate = true; |
|
|
|
|
|
const missileBox = new THREE.Box3().setFromObject(missile); |
|
const targetBox = new THREE.Box3().setFromObject(missile.userData.target); |
|
if (missileBox.intersectsBox(targetBox)) { |
|
if (missile.userData.target.isMesh && missile.userData.target.geometry.type === 'BoxGeometry') { |
|
applyBuildingDamage(missile.userData.target, missile.userData.damage, missile.position); |
|
} else { |
|
applyDamage(missile.userData.target, missile.userData.damage, missile.position); |
|
} |
|
scene.remove(missile); |
|
missiles.splice(i, 1); |
|
} |
|
} |
|
} |
|
|
|
|
|
let rotation = 0; |
|
let bankAngle = 0; |
|
let jumpHeight = 0; |
|
function updatePlayer() { |
|
if (!playerData.active) return; |
|
const activeThrusters = playerData.thrusters.filter(hp => hp > 0).length; |
|
const speedMultiplier = skills.speed.effect(skills.speed.level); |
|
const maxSpeed = playerData.maxSpeed * (activeThrusters / 4) * speedMultiplier; |
|
const acceleration = playerData.acceleration * (activeThrusters / 4) * speedMultiplier; |
|
const turnSpeed = skills.turnSpeed.effect(skills.turnSpeed.level); |
|
if (keys.w) playerData.speed = Math.min(playerData.speed + acceleration, maxSpeed); |
|
else if (keys.s) playerData.speed = Math.max(playerData.speed - acceleration, -maxSpeed / 2); |
|
else playerData.speed *= 0.995; |
|
if (keys.a) rotation += turnSpeed * (activeThrusters / 4); |
|
if (keys.d) rotation -= turnSpeed * (activeThrusters / 4); |
|
bankAngle = (keys.a ? -0.3 : 0) + (keys.d ? 0.3 : 0); |
|
|
|
if (keys.space && !isJumping && jumpCooldown <= 0 && activeThrusters > 0) { |
|
isJumping = true; |
|
jumpHeight = 5; |
|
jumpCooldown = 60; |
|
logAction(playerName, score, 'Jumped'); |
|
} |
|
if (isJumping) { |
|
jumpHeight -= 0.2; |
|
if (jumpHeight <= 0) { |
|
jumpHeight = 0; |
|
isJumping = false; |
|
} |
|
} |
|
if (jumpCooldown > 0) jumpCooldown--; |
|
|
|
playerShip.rotation.z = rotation; |
|
playerShip.rotation.x = bankAngle; |
|
const direction = new THREE.Vector3(0, 0, -1).applyAxisAngle(new THREE.Vector3(0, 1, 0), rotation); |
|
playerShip.position.add(direction.multiplyScalar(playerData.speed)); |
|
playerShip.position.y = 10 + Math.sin(Date.now() * 0.005) * 0.2 + jumpHeight; |
|
checkCollisions(playerShip); |
|
checkColumnProximity(playerShip); |
|
updatePickups(); |
|
camera.position.copy(playerShip.position).add(new THREE.Vector3(0, 5 + jumpHeight, 15).applyAxisAngle(new THREE.Vector3(0, 1, 0), rotation)); |
|
camera.lookAt(playerShip.position); |
|
} |
|
|
|
|
|
function checkCollisions(ship) { |
|
if (!currentTrackGroup || !ship.userData.active) return; |
|
currentTrackGroup.traverse(child => { |
|
if (child.isMesh && child.geometry.type !== 'CylinderGeometry') { |
|
const shipBox = new THREE.Box3().setFromObject(ship); |
|
const wallBox = new THREE.Box3().setFromObject(child); |
|
if (shipBox.intersectsBox(wallBox) && !isJumping) { |
|
const direction = ship.position.clone().sub(child.position).normalize(); |
|
ship.position.add(direction.multiplyScalar(0.5)); |
|
ship.userData.speed *= 0.8; |
|
applyDamage(ship, 2, ship.position); |
|
} |
|
} |
|
}); |
|
} |
|
|
|
|
|
function checkColumnProximity(ship) { |
|
if (!currentTrackGroup || !ship.userData.active) return; |
|
currentTrackGroup.traverse(child => { |
|
if (child.isMesh && child.geometry.type === 'CylinderGeometry') { |
|
const shipPos = ship.position; |
|
const columnPos = child.position; |
|
const distance = shipPos.distanceTo(columnPos); |
|
if (distance < 5) { |
|
const direction = shipPos.clone().sub(columnPos).normalize(); |
|
ship.position.add(direction.multiplyScalar(0.2)); |
|
} |
|
} |
|
}); |
|
} |
|
|
|
|
|
const explosions = []; |
|
function updateExplosions() { |
|
for (let i = explosions.length - 1; i >= 0; i--) { |
|
const explosion = explosions[i]; |
|
explosion.userData.lifetime--; |
|
if (explosion.userData.lifetime <= 0) { |
|
scene.remove(explosion); |
|
explosions.splice(i, 1); |
|
continue; |
|
} |
|
const positions = explosion.geometry.attributes.position.array; |
|
for (let j = 0; j < positions.length / 3; j++) { |
|
const j3 = j * 3; |
|
positions[j3] += explosion.userData.velocities[j].x; |
|
positions[j3 + 1] += explosion.userData.velocities[j].y; |
|
positions[j3 + 2] += explosion.userData.velocities[j].z; |
|
} |
|
explosion.geometry.attributes.position.needsUpdate = true; |
|
explosion.material.opacity = explosion.userData.lifetime / 60; |
|
} |
|
} |
|
|
|
|
|
function updateAI() { |
|
aiShips.forEach((ship, index) => { |
|
if (!ship.userData.active) return; |
|
const activeThrusters = ship.userData.thrusters.filter(hp => hp > 0).length; |
|
const maxSpeed = ship.userData.maxSpeed * (activeThrusters / 4); |
|
const target = waypoints[ship.userData.currentWaypoint]; |
|
const direction = target.clone().sub(ship.position).normalize(); |
|
ship.position.add(direction.multiplyScalar(maxSpeed)); |
|
ship.rotation.z = Math.atan2(direction.x, -direction.z); |
|
ship.position.y = 10 + Math.sin(Date.now() * 0.005 + index) * 0.2; |
|
if (ship.position.distanceTo(target) < 2) { |
|
ship.userData.currentWaypoint = (ship.userData.currentWaypoint + 1) % waypoints.length; |
|
} |
|
checkCollisions(ship); |
|
checkColumnProximity(ship); |
|
}); |
|
} |
|
|
|
|
|
function startRace(trackName) { |
|
gameState = 'racing'; |
|
currentTrack = trackName; |
|
if (currentTrackGroup) scene.remove(currentTrackGroup); |
|
pickups.forEach(p => scene.remove(p)); |
|
missiles.forEach(m => scene.remove(m)); |
|
pickups.length = 0; |
|
missiles.length = 0; |
|
const { trackGroup, waypoints: newWaypoints, startPosition } = generateTrack(trackConfigs[trackName]); |
|
currentTrackGroup = trackGroup; |
|
waypoints = newWaypoints; |
|
scene.add(trackGroup); |
|
playerShip.position.set(startPosition.x, 10, startPosition.z); |
|
playerShip.rotation.z = Math.atan2(waypoints[1].x - waypoints[0].x, waypoints[1].z - waypoints[0].z); |
|
playerData.speed = 0; |
|
playerData.active = true; |
|
playerShip.visible = true; |
|
aiShips.forEach((ship, i) => { |
|
ship.position.set(startPosition.x + (i + 1) * 5, 10, startPosition.z); |
|
ship.userData.currentWaypoint = 0; |
|
ship.userData.lastGatePass = 0; |
|
ship.userData.active = true; |
|
ship.visible = true; |
|
const type = shipTypes.find(t => t.name === ship.userData.type); |
|
ship.userData.thrusters = Array(4).fill(type.maxHP); |
|
ship.userData.body = { front: type.maxHP, back: type.maxHP, left: type.maxHP, right: type.maxHP }; |
|
}); |
|
startGate.position.copy(startPosition); |
|
startGate.position.y = 12; |
|
startGate.rotation.y = Math.atan2(waypoints[1].x - waypoints[0].x, waypoints[1].z - waypoints[0].z); |
|
raceTime = 0; |
|
score = 0; |
|
document.getElementById('time').textContent = `Time: 0`; |
|
document.getElementById('score').textContent = `Score: ${score}`; |
|
document.getElementById('status').textContent = `Status: Active`; |
|
updateCharacterSheet(); |
|
updateSkillsSheet(); |
|
logAction(playerName, score, `Started ${trackName}`); |
|
} |
|
|
|
|
|
function restartRace() { |
|
scene.remove(playerShip); |
|
explosions.forEach(e => scene.remove(e)); |
|
pickups.forEach(p => scene.remove(p)); |
|
missiles.forEach(m => scene.remove(m)); |
|
explosions.length = 0; |
|
pickups.length = 0; |
|
missiles.length = 0; |
|
initPlayer(); |
|
startRace(currentTrack); |
|
} |
|
|
|
|
|
function endRace() { |
|
gameState = 'menu'; |
|
const activeShips = aiShips.filter(s => s.userData.active).length + (playerData.active ? 1 : 0); |
|
alert(`Race Over! Ships Remaining: ${activeShips}, Time: ${raceTime.toFixed(2)}s, Score: ${score}`); |
|
logAction(playerName, score, 'Race Ended'); |
|
} |
|
|
|
|
|
function showGallery() { |
|
document.getElementById('gallery').style.display = 'block'; |
|
} |
|
function hideGallery() { |
|
document.getElementById('gallery').style.display = 'none'; |
|
} |
|
|
|
|
|
function animate() { |
|
requestAnimationFrame(animate); |
|
if (gameState === 'racing') { |
|
updatePlayer(); |
|
updateAI(); |
|
updateMissiles(); |
|
updateExplosions(); |
|
raceTime += 1 / 60; |
|
document.getElementById('time').textContent = `Time: ${raceTime.toFixed(2)}`; |
|
if (aiShips.every(s => !s.userData.active) && playerData.active) { |
|
endRace(); |
|
} |
|
} |
|
renderer.render(scene, camera); |
|
} |
|
|
|
|
|
window.addEventListener('resize', () => { |
|
camera.aspect = window.innerWidth / window.innerHeight; |
|
camera.updateProjectionMatrix(); |
|
renderer.setSize(window.innerWidth, window.innerHeight); |
|
}); |
|
|
|
|
|
initPlayer(); |
|
startRace('Phelps Island Drift'); |
|
animate(); |
|
</script> |
|
</body> |
|
</html> |