Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Isle of Mull Ferry Driving Simulator</title> | |
<style> | |
body { | |
margin: 0; | |
overflow: hidden; | |
font-family: Arial, sans-serif; | |
} | |
#info { | |
position: absolute; | |
top: 10px; | |
width: 100%; | |
text-align: center; | |
color: white; | |
background-color: rgba(0,0,0,0.5); | |
padding: 10px; | |
z-index: 100; | |
} | |
#timer { | |
position: absolute; | |
top: 10px; | |
right: 10px; | |
color: white; | |
background-color: rgba(0,0,0,0.5); | |
padding: 5px 10px; | |
border-radius: 5px; | |
z-index: 100; | |
} | |
#message { | |
position: absolute; | |
bottom: 20px; | |
width: 100%; | |
text-align: center; | |
color: white; | |
background-color: rgba(0,0,0,0.7); | |
padding: 10px; | |
font-size: 18px; | |
z-index: 100; | |
transition: opacity 0.5s; | |
opacity: 0; | |
} | |
#speedometer { | |
position: absolute; | |
bottom: 10px; | |
left: 10px; | |
color: white; | |
background-color: rgba(0,0,0,0.5); | |
padding: 5px 10px; | |
border-radius: 5px; | |
z-index: 100; | |
} | |
#score { | |
position: absolute; | |
top: 10px; | |
left: 10px; | |
color: white; | |
background-color: rgba(0,0,0,0.5); | |
padding: 5px 10px; | |
border-radius: 5px; | |
z-index: 100; | |
} | |
#gameOver { | |
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; | |
z-index: 200; | |
display: none; | |
} | |
button { | |
background-color: #4CAF50; | |
border: none; | |
color: white; | |
padding: 10px 20px; | |
text-align: center; | |
text-decoration: none; | |
display: inline-block; | |
font-size: 16px; | |
margin: 10px 2px; | |
cursor: pointer; | |
border-radius: 5px; | |
} | |
#instructions { | |
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; | |
z-index: 300; | |
max-width: 600px; | |
} | |
.highlight { | |
color: #ffcc00; | |
font-weight: bold; | |
} | |
/* Hide specific error messages only */ | |
.error-message { | |
display: none ; | |
} | |
/* Make sure Three.js canvas is visible */ | |
canvas { | |
display: block ; | |
} | |
</style> | |
</head> | |
<body> | |
<div id="info">Isle of Mull Ferry Driving Simulator</div> | |
<div id="timer">Time: 0:00</div> | |
<div id="speedometer">Speed: 0 mph</div> | |
<div id="score">Score: 0</div> | |
<div id="message"></div> | |
<div id="rearView" style="position: absolute; top: 10px; left: 50%; transform: translateX(-50%); width: 200px; height: 40px; background-color: rgba(0,0,0,0.5); border: 2px solid #333; border-radius: 5px; z-index: 100;"></div> | |
<div id="healthBar" style="position: absolute; bottom: 50px; left: 10px; width: 200px; height: 20px; background-color: rgba(0,0,0,0.5); border: 1px solid #fff; z-index: 100;"> | |
<div id="health" style="width: 100%; height: 100%; background-color: #00ff00;"></div> | |
</div> | |
<div id="airTime" style="position: absolute; bottom: 80px; left: 10px; color: white; background-color: rgba(0,0,0,0.5); padding: 5px 10px; border-radius: 5px; z-index: 100;">Air Time: 0.0s</div> | |
<div id="gameOver"> | |
<h2 id="gameOverTitle">Game Over</h2> | |
<p id="gameOverText"></p> | |
<button id="restartButton">Try Again</button> | |
</div> | |
<div id="instructions"> | |
<h2>Welcome to the Isle of Mull Ferry Driving Simulator!</h2> | |
<p>You're driving to catch the ferry to the Isle of Mull in Scotland. Practice safe driving on Scotland's unique single-track roads with passing places.</p> | |
<h3>How to Play:</h3> | |
<ul style="text-align: left;"> | |
<li>Drive on the <span class="highlight">left side</span> of the road</li> | |
<li>Use <span class="highlight">W/S</span> or <span class="highlight">↑/↓</span> to accelerate/brake</li> | |
<li>Use <span class="highlight">A/D</span> or <span class="highlight">←/→</span> to steer</li> | |
<li>Press <span class="highlight">SPACE</span> or <span class="highlight">J</span> to jump (when driving fast)</li> | |
<li>Watch for oncoming traffic and stop at <span class="highlight">passing places</span> to let them through</li> | |
<li>Be courteous to cars behind you by using passing places</li> | |
<li>Cross <span class="highlight">one-lane bridges</span> carefully - yield to oncoming traffic</li> | |
<li>Watch your <span class="highlight">rear view mirror</span> for cars flashing to overtake</li> | |
<li>Look for <span class="highlight">jump ramps</span> to perform jumps and earn bonus points!</li> | |
<li>Avoid potholes and collisions - watch your damage meter!</li> | |
<li>Earn <span class="highlight">points</span> for courteous driving and jump tricks</li> | |
<li>Reach the ferry in under 2 minutes!</li> | |
</ul> | |
<p><span class="highlight">Passing Place Etiquette:</span> If a car is coming toward you, stop at a passing place on YOUR side of the road. Don't cross to the other side unless necessary.</p> | |
<button id="startButton">Start Driving</button> | |
</div> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
<script> | |
// Game variables | |
let scene, camera, renderer, clock; | |
let playerCar, road, terrain; | |
let aiCars = []; | |
let passingPlaces = []; | |
let bridges = []; | |
let clouds = []; | |
let oceanPlane; | |
let jumpRamps = []; // Array to store jump ramps | |
let ferryObject; // For the ferry mesh | |
let gameStarted = false; | |
let gameOver = false; | |
let gameTime = 0; | |
let speed = 0; | |
const roadWidth = 5; | |
let maxSpeed = 45; | |
let acceleration = 0.2; | |
let deceleration = 0.3; | |
let braking = 0.5; | |
let steering = 0.02; | |
let roadLength = 2000; | |
let ferryPosition = roadLength - 100; | |
let potholes = []; | |
let score = 0; | |
let playerHealth = 100; | |
let goodStopsInARow = 0; | |
let carsBehind = []; | |
// Physics variables | |
let gravity = 0.25; | |
let velocity = new THREE.Vector3(0, 0, 0); | |
let isGrounded = true; | |
let airTime = 0; | |
let jumpForce = 0; | |
let lastRoadY = 0; | |
let suspensionCompression = 0; | |
let suspensionStrength = 0.3; | |
let suspensionDamping = 0.8; | |
const keys = { | |
ArrowUp: false, ArrowDown: false, ArrowLeft: false, ArrowRight: false, | |
w: false, a: false, s: false, d: false, | |
' ': false, j: false | |
}; | |
function init() { | |
const bodyChildNodes = document.body.childNodes; | |
for (let i = bodyChildNodes.length - 1; i >= 0; i--) { | |
const node = bodyChildNodes[i]; | |
if (node.nodeType === Node.TEXT_NODE && node.textContent.trim() !== '' && | |
(node.textContent.includes('function') || node.textContent.includes('var') || node.textContent.includes('let') || node.textContent.includes('const'))) { | |
node.textContent = ''; | |
} | |
} | |
scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0x87CEEB); | |
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 3000); // Increased far plane | |
camera.position.set(0, 5, -10); | |
camera.lookAt(0, 0, 10); | |
renderer = new THREE.WebGLRenderer({ antialias: true }); | |
renderer.setSize(window.innerWidth, window.innerHeight); | |
renderer.shadowMap.enabled = true; | |
renderer.domElement.id = 'game-canvas'; | |
document.body.appendChild(renderer.domElement); | |
const ambientLight = new THREE.AmbientLight(0xffffff, 0.7); | |
scene.add(ambientLight); | |
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.9); | |
directionalLight.position.set(100, 150, 75); | |
directionalLight.castShadow = true; | |
directionalLight.shadow.mapSize.width = 2048; | |
directionalLight.shadow.mapSize.height = 2048; | |
directionalLight.shadow.camera.near = 0.5; | |
directionalLight.shadow.camera.far = 500; | |
directionalLight.shadow.camera.left = -100; | |
directionalLight.shadow.camera.right = 100; | |
directionalLight.shadow.camera.top = 100; | |
directionalLight.shadow.camera.bottom = -100; | |
scene.add(directionalLight); | |
createTerrain(); | |
createRoad(); | |
createPlayerCar(); | |
createAICars(); | |
createPassingPlaces(); | |
createBridges(); | |
createPotholes(); | |
createOcean(); | |
createClouds(); | |
createJumpRamps(); // Add jump ramps | |
createFerry(); // Add the ferry | |
clock = new THREE.Clock(); | |
window.addEventListener('resize', onWindowResize); | |
window.addEventListener('keydown', onKeyDown); | |
window.addEventListener('keyup', onKeyUp); | |
document.getElementById('startButton').addEventListener('click', startGame); | |
document.getElementById('restartButton').addEventListener('click', restartGame); | |
} | |
function getRoadPropertiesAtZ(worldZPos) { | |
const localZ = worldZPos - (roadLength / 2); | |
const roadCurve = Math.sin(localZ * 0.005) * 15; | |
// Gradually reduce elevation variation as we approach the ferry (coastal approach) | |
const distanceToFerry = Math.max(0, ferryPosition - worldZPos); | |
const coastalFactor = Math.min(1, distanceToFerry / 400); // Start flattening 400 units before ferry | |
const elevationAmplitude = coastalFactor * 0.5; | |
const roadY = (Math.sin(localZ * 0.01) * 5 + Math.sin(localZ * 0.03) * 2) * elevationAmplitude; | |
return { roadY, roadCurve }; | |
} | |
function createTerrain() { | |
const terrainWidth = 1000; | |
const terrainSegmentsX = 150; | |
const terrainSegmentsZ = 300; | |
const groundGeometry = new THREE.PlaneGeometry(terrainWidth, roadLength, terrainSegmentsX, terrainSegmentsZ); | |
groundGeometry.rotateX(-Math.PI / 2); | |
const vertices = groundGeometry.attributes.position; | |
for (let i = 0; i < vertices.count; i++) { | |
const x_local = vertices.getX(i); | |
const z_local = vertices.getZ(i); | |
const worldZ = z_local + roadLength / 2; | |
const { roadY: actualRoadY, roadCurve: actualRoadCurve } = getRoadPropertiesAtZ(worldZ); | |
let height = actualRoadY; | |
const distFromRoadCenter = Math.abs(x_local - actualRoadCurve); | |
const roadEdgeBuffer = 5; | |
// Calculate coastal approach factor - mountains recede as we near the ferry | |
const distanceToFerry = Math.max(0, ferryPosition - worldZ); | |
const coastalTransition = Math.min(1, distanceToFerry / 600); // Start transition 600 units before ferry | |
const mountainHeightFactor = coastalTransition; | |
// Also create asymmetric coastal effect - mountains more on one side | |
const ferryApproachFactor = 1 - Math.max(0, Math.min(1, (ferryPosition - worldZ) / 800)); | |
const coastalAsymmetry = ferryApproachFactor * Math.max(0, 1 - Math.abs(x_local + actualRoadCurve) / 200); | |
if (distFromRoadCenter > (roadWidth / 2 + roadEdgeBuffer)) { | |
const mountainBaseHeight = 10 + Math.abs(Math.sin(z_local * 0.001 + x_local * 0.005)) * 20; | |
const mountainDetail = Math.sin(z_local * 0.02 + x_local * 0.03) * 15 + Math.cos(z_local * 0.015) * 10; | |
let mountainOffset = mountainBaseHeight + mountainDetail; | |
const riseFactor = Math.min((distFromRoadCenter - (roadWidth / 2 + roadEdgeBuffer)) * 0.2, 1.0) + 0.5; | |
mountainOffset *= riseFactor; | |
const glenFactor = 0.6 + (Math.sin(z_local * 0.004) * 0.5 + 0.5) * 0.4; | |
mountainOffset *= glenFactor; | |
// Apply coastal factors to reduce mountain height near ferry | |
mountainOffset *= mountainHeightFactor; | |
mountainOffset *= (1 - coastalAsymmetry * 0.7); // Reduce mountains more on ocean side | |
height += Math.max(0, mountainOffset); | |
} | |
else if (distFromRoadCenter > roadWidth / 2) { | |
height -= (distFromRoadCenter - roadWidth/2) * 0.5; | |
} | |
// Ensure terrain near ferry is at reasonable coastal elevation | |
if (worldZ > ferryPosition - 200) { | |
const coastalBlend = (worldZ - (ferryPosition - 200)) / 200; | |
const targetCoastalHeight = -1; // Slightly below sea level for realism | |
height = height * (1 - coastalBlend) + targetCoastalHeight * coastalBlend; | |
} | |
vertices.setY(i, height); | |
} | |
groundGeometry.attributes.position.needsUpdate = true; | |
groundGeometry.computeVertexNormals(); | |
const groundMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x365E36, flatShading: true, roughness: 0.9, metalness: 0.1 | |
}); | |
terrain = new THREE.Mesh(groundGeometry, groundMaterial); | |
terrain.position.z = roadLength / 2; | |
terrain.receiveShadow = true; | |
scene.add(terrain); | |
} | |
function createRoad() { | |
const roadGeometry = new THREE.PlaneGeometry(roadWidth, roadLength, 1, 200); | |
roadGeometry.rotateX(-Math.PI / 2); | |
const vertices = roadGeometry.attributes.position; | |
for (let i = 0; i < vertices.count; i++) { | |
const localZ = vertices.getZ(i); | |
const curve_val = Math.sin(localZ * 0.005) * 15; | |
vertices.setX(i, roadGeometry.attributes.position.getX(i) + curve_val); | |
const elevation_val = (Math.sin(localZ * 0.01) * 5 + Math.sin(localZ * 0.03) * 2) * 0.5; | |
vertices.setY(i, elevation_val); | |
} | |
roadGeometry.attributes.position.needsUpdate = true; | |
roadGeometry.computeVertexNormals(); | |
const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x333333, roughness: 0.8 }); | |
road = new THREE.Mesh(roadGeometry, roadMaterial); | |
road.position.z = roadLength / 2; | |
road.receiveShadow = true; | |
scene.add(road); | |
const centerLineGeometry = new THREE.PlaneGeometry(0.1, roadLength, 1, 200); | |
centerLineGeometry.rotateX(-Math.PI / 2); | |
const lineVertices = centerLineGeometry.attributes.position; | |
for (let i = 0; i < lineVertices.count; i++) { | |
const localZ = lineVertices.getZ(i); | |
const curve_val = Math.sin(localZ * 0.005) * 15; | |
lineVertices.setX(i, lineVertices.getX(i) + curve_val); | |
const elevation_val = (Math.sin(localZ * 0.01) * 5 + Math.sin(localZ * 0.03) * 2) * 0.5; | |
lineVertices.setY(i, elevation_val + 0.01); | |
} | |
centerLineGeometry.attributes.position.needsUpdate = true; | |
centerLineGeometry.computeVertexNormals(); | |
const centerLineMaterial = new THREE.MeshStandardMaterial({ color: 0xFFFFFF }); | |
const centerLine = new THREE.Mesh(centerLineGeometry, centerLineMaterial); | |
centerLine.position.z = roadLength / 2; | |
scene.add(centerLine); | |
} | |
function createOcean() { | |
const oceanSize = 4000; | |
const oceanGeometry = new THREE.PlaneGeometry(oceanSize, oceanSize); | |
const oceanMaterial = new THREE.MeshStandardMaterial({ | |
color: 0x0077be, transparent: true, opacity: 0.85, roughness: 0.3, metalness: 0.1, | |
}); | |
oceanPlane = new THREE.Mesh(oceanGeometry, oceanMaterial); | |
oceanPlane.rotation.x = -Math.PI / 2; | |
const ferryRoadProps = getRoadPropertiesAtZ(ferryPosition); | |
oceanPlane.position.set(ferryRoadProps.roadCurve, ferryRoadProps.roadY - 2, ferryPosition + 50); | |
oceanPlane.receiveShadow = true; | |
scene.add(oceanPlane); | |
} | |
function createClouds() { | |
const cloudMaterial = new THREE.MeshBasicMaterial({ | |
color: 0xffffff, transparent: true, opacity: 0.6, depthWrite: false | |
}); | |
const numClouds = 15; | |
const skyHeight = 150; | |
const skyDepthRange = 1000; | |
const skyWidthRange = 2000; | |
for (let i = 0; i < numClouds; i++) { | |
const cloudWidth = 100 + Math.random() * 200; | |
const cloudHeight = 50 + Math.random() * 100; | |
const cloudGeometry = new THREE.PlaneGeometry(cloudWidth, cloudHeight); | |
const cloud = new THREE.Mesh(cloudGeometry, cloudMaterial); | |
cloud.position.set( | |
(Math.random() - 0.5) * skyWidthRange, | |
skyHeight + (Math.random() - 0.5) * 50, | |
(Math.random() * skyDepthRange) - skyDepthRange / 4 | |
); | |
cloud.rotation.y = (Math.random() - 0.5) * 0.5; | |
// cloud.lookAt(camera.position); // Initial orientation | |
cloud.userData.speed = 0.5 + Math.random() * 1; | |
clouds.push(cloud); | |
scene.add(cloud); | |
} | |
} | |
function updateClouds(delta) { | |
const wrapAroundX = 2200; | |
clouds.forEach(cloud => { | |
cloud.position.x += cloud.userData.speed * delta * 5; | |
if (cloud.position.x > wrapAroundX / 2) { | |
cloud.position.x = -wrapAroundX / 2; | |
cloud.position.z = (Math.random() * 1000) - 500 + playerCar.position.z; // Re-position relative to player Z | |
} | |
// Make clouds face the general direction of the camera's Z but not directly lookAt | |
const targetZ = camera.position.z + 500; // A point far in front of camera | |
const direction = new THREE.Vector3(cloud.position.x, cloud.position.y, targetZ); | |
cloud.lookAt(direction); | |
}); | |
} | |
function createPlayerCar() { | |
const bodyGroup = new THREE.Group(); | |
scene.add(bodyGroup); | |
playerCar = bodyGroup; | |
const bodyGeometry = new THREE.BoxGeometry(2, 1, 4); | |
bodyGeometry.translate(0, 0.5, 0); | |
const carMaterial = new THREE.MeshStandardMaterial({ color: 0x3366FF, roughness: 0.5, metalness: 0.7 }); | |
const carBody = new THREE.Mesh(bodyGeometry, carMaterial); | |
carBody.castShadow = true; | |
bodyGroup.add(carBody); | |
const windshieldGeometry = new THREE.CylinderGeometry(1, 1, 1.8, 16, 1, false, 0, Math.PI); | |
windshieldGeometry.rotateZ(Math.PI / 2); windshieldGeometry.rotateY(Math.PI / 2); | |
windshieldGeometry.scale(1, 0.4, 0.8); windshieldGeometry.translate(0, 1.1, 0.5); | |
const windshieldMaterial = new THREE.MeshStandardMaterial({ color: 0xAACCFF, transparent: true, opacity: 0.7, roughness: 0.1, metalness: 0.2 }); | |
const windshield = new THREE.Mesh(windshieldGeometry, windshieldMaterial); | |
bodyGroup.add(windshield); | |
const frontBumperGeometry = new THREE.CylinderGeometry(0.5, 0.5, 2, 16, 1, false, -Math.PI/2, Math.PI); | |
frontBumperGeometry.rotateZ(Math.PI / 2); frontBumperGeometry.scale(1, 0.5, 0.5); frontBumperGeometry.translate(0, 0.5, 2); | |
const bumperMaterial = new THREE.MeshStandardMaterial({ color: 0x2255DD, roughness: 0.7, metalness: 0.3 }); | |
const frontBumper = new THREE.Mesh(frontBumperGeometry, bumperMaterial); | |
frontBumper.castShadow = true; bodyGroup.add(frontBumper); | |
const rearBumperGeometry = new THREE.CylinderGeometry(0.5, 0.5, 2, 16, 1, false, Math.PI/2, Math.PI); | |
rearBumperGeometry.rotateZ(Math.PI / 2); rearBumperGeometry.scale(1, 0.5, 0.5); rearBumperGeometry.translate(0, 0.5, -2); | |
const rearBumper = new THREE.Mesh(rearBumperGeometry, bumperMaterial); | |
rearBumper.castShadow = true; bodyGroup.add(rearBumper); | |
const wheelGeometry = new THREE.CylinderGeometry(0.5, 0.5, 0.3, 24); | |
wheelGeometry.rotateZ(Math.PI / 2); | |
const wheelMaterial = new THREE.MeshStandardMaterial({ color: 0x111111, roughness: 0.9, metalness: 0.2 }); | |
const hubCapGeometry = new THREE.CircleGeometry(0.3, 16); | |
const hubCapMaterial = new THREE.MeshStandardMaterial({ color: 0xCCCCCC, roughness: 0.5, metalness: 0.8 }); | |
const wheelsInfo = [ | |
{ x: -1, z: 1.5, hubCapX: 0.16, hubCapRotY: Math.PI / 2 }, { x: 1, z: 1.5, hubCapX: -0.16, hubCapRotY: -Math.PI / 2 }, | |
{ x: -1, z: -1.5, hubCapX: 0.16, hubCapRotY: Math.PI / 2 }, { x: 1, z: -1.5, hubCapX: -0.16, hubCapRotY: -Math.PI / 2 } | |
]; | |
wheelsInfo.forEach(info => { | |
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial); | |
wheel.position.set(info.x, 0.5, info.z); wheel.isWheel = true; | |
const hubCap = new THREE.Mesh(hubCapGeometry, hubCapMaterial); | |
hubCap.position.set(info.hubCapX, 0, 0); hubCap.rotation.y = info.hubCapRotY; | |
wheel.add(hubCap); bodyGroup.add(wheel); | |
}); | |
const springMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 }); | |
function createSpring(x, z_offset) { | |
const springGroup = new THREE.Group(); const coilCount = 5; const coilHeight = 0.08; | |
for (let i = 0; i < coilCount; i++) { | |
const coil = new THREE.Mesh(new THREE.TorusGeometry(0.15, 0.03, 8, 16), springMaterial); | |
coil.position.y = i * coilHeight; springGroup.add(coil); | |
} | |
springGroup.position.set(x, 0.2, z_offset); return springGroup; | |
} | |
bodyGroup.add(createSpring(-0.8, 1.5)); bodyGroup.add(createSpring(0.8, 1.5)); | |
bodyGroup.add(createSpring(-0.8, -1.5)); bodyGroup.add(createSpring(0.8, -1.5)); | |
const mirrorBase = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.1, 0.1), carMaterial); | |
mirrorBase.position.set(0, 1.3, -0.5); bodyGroup.add(mirrorBase); | |
const mirrorGeo = new THREE.CylinderGeometry(0.1, 0.1, 1.8, 16, 1, true, 0, Math.PI); | |
mirrorGeo.rotateX(Math.PI / 2); mirrorGeo.translate(0, 0.1, 0); | |
const mirror = new THREE.Mesh(mirrorGeo, new THREE.MeshStandardMaterial({ color: 0x444444, roughness: 0.3, metalness: 0.8 })); | |
mirror.position.set(0, 1.3, -0.5); bodyGroup.add(mirror); | |
const particlesCount = 50; const particlesGeometry = new THREE.BufferGeometry(); | |
const posArray = new Float32Array(particlesCount * 3); const sizeArray = new Float32Array(particlesCount); | |
for (let i = 0; i < particlesCount; i++) { | |
posArray[i*3]=0; posArray[i*3+1]=0; posArray[i*3+2]=0; sizeArray[i]=Math.random()*0.2; | |
} | |
particlesGeometry.setAttribute('position', new THREE.BufferAttribute(posArray,3)); | |
particlesGeometry.setAttribute('size', new THREE.BufferAttribute(sizeArray,1)); | |
const particlesMaterial = new THREE.PointsMaterial({color:0xCCCCCC,size:0.1,sizeAttenuation:true,transparent:true,opacity:0.5}); | |
const particles = new THREE.Points(particlesGeometry, particlesMaterial); | |
particles.visible = false; bodyGroup.add(particles); bodyGroup.particles = particles; | |
const initialRoadProps = getRoadPropertiesAtZ(0); | |
bodyGroup.position.set(initialRoadProps.roadCurve, 1 + initialRoadProps.roadY, 0); | |
} | |
function createAICars() { | |
for (let i = 0; i < 5; i++) { | |
createAICar(300 + i * 350, true); | |
} | |
for (let i = 0; i < 2; i++) { | |
createAICar(-100 - i * 150, false); | |
} | |
} | |
function createAICar(worldZPosition, isOncoming) { | |
const carColors = [0xCC0000,0x00CC00,0xCCCC00,0xCCCCCC,0x9900CC]; | |
const car = new THREE.Mesh(new THREE.BoxGeometry(1.8,1,3.8), new THREE.MeshStandardMaterial({color:carColors[Math.floor(Math.random()*carColors.length)]})); | |
car.castShadow = true; | |
const {roadY,roadCurve} = getRoadPropertiesAtZ(worldZPosition); | |
const laneXOffset = isOncoming ? (roadWidth/4 + 0.25) : (-roadWidth/4 - 0.25); | |
car.position.set(roadCurve+laneXOffset, 1+roadY, worldZPosition); | |
car.rotation.y = isOncoming ? Math.PI : 0; | |
scene.add(car); | |
const headlightMat = new THREE.MeshBasicMaterial({color:0xFFFFFF}); | |
const leftHeadlight = new THREE.Mesh(new THREE.SphereGeometry(0.2,8,8), headlightMat); | |
leftHeadlight.position.set(-0.7,0.3,1.9); car.add(leftHeadlight); | |
const rightHeadlight = new THREE.Mesh(new THREE.SphereGeometry(0.2,8,8), headlightMat); | |
rightHeadlight.position.set(0.7,0.3,1.9); car.add(rightHeadlight); | |
aiCars.push({mesh:car,speed:isOncoming?8:10,isOncoming:isOncoming,honking:false,waiting:false,initialPositionZ:worldZPosition,flashing:false,waitingAtPassingPlace:false,flashingLights:false,leftHeadlight:leftHeadlight,rightHeadlight:rightHeadlight,politeness:Math.random()*0.8+0.2}); | |
} | |
function createPassingPlaces() { | |
for (let i = 0; i < 15; i++) createPassingPlace(80 + i * 130); | |
} | |
function createPassingPlace(worldZPosition) { | |
const side = (passingPlaces.length%2===0)?-1:1; | |
const {roadY,roadCurve} = getRoadPropertiesAtZ(worldZPosition); | |
const passingLength=20, passingWidth=6, taperLength=12; | |
const mainPassingGroup = new THREE.Group(); | |
const groupXOffset = side * (roadWidth/2 + passingWidth/2); | |
mainPassingGroup.position.set(roadCurve+groupXOffset, roadY+0.01, worldZPosition); | |
scene.add(mainPassingGroup); | |
const passingMaterial = new THREE.MeshStandardMaterial({color:0x555555}); | |
const mainGeom = createRoundedRectGeometry(passingWidth,passingLength,1.5); | |
mainGeom.rotateX(-Math.PI/2); | |
const mainMesh = new THREE.Mesh(mainGeom, passingMaterial); | |
mainPassingGroup.add(mainMesh); | |
const entranceTaperGeom = createSmoothTaperGeometry(taperLength,passingWidth,true,10); | |
entranceTaperGeom.rotateX(-Math.PI/2); | |
const entranceTaper = new THREE.Mesh(entranceTaperGeom, passingMaterial); | |
entranceTaper.position.set(0,0,-passingLength/2-taperLength/2); | |
if(side<0) entranceTaper.rotation.z=Math.PI; | |
mainPassingGroup.add(entranceTaper); | |
const exitTaperGeom = createSmoothTaperGeometry(taperLength,passingWidth,false,10); | |
exitTaperGeom.rotateX(-Math.PI/2); | |
const exitTaper = new THREE.Mesh(exitTaperGeom, passingMaterial); | |
exitTaper.position.set(0,0,passingLength/2+taperLength/2); | |
if(side<0) exitTaper.rotation.z=Math.PI; | |
mainPassingGroup.add(exitTaper); | |
const sign = new THREE.Mesh(new THREE.BoxGeometry(0.5,2,0.1), new THREE.MeshStandardMaterial({color:0xFFFFFF})); | |
const signXOffset = side * (roadWidth/2 + passingWidth + 1); | |
sign.position.set(roadCurve+signXOffset, 1+roadY, worldZPosition); | |
scene.add(sign); | |
const signGraphic = new THREE.Mesh(new THREE.CircleGeometry(0.3,16), new THREE.MeshBasicMaterial({color:0x000000})); | |
signGraphic.position.set(0,0.5,0.06); | |
sign.add(signGraphic); | |
passingPlaces.push({position:worldZPosition,side:side,width:passingWidth,length:passingLength+taperLength*2,mesh:mainMesh,group:mainPassingGroup,entranceTaper:entranceTaper,exitTaper:exitTaper,worldXCenter:roadCurve+groupXOffset}); | |
} | |
function createRoundedRectGeometry(width,length,radius){ | |
const s=new THREE.Shape(); const x=-width/2,y=-length/2; | |
s.moveTo(x,y+radius); s.lineTo(x,y+length-radius); s.quadraticCurveTo(x,y+length,x+radius,y+length); | |
s.lineTo(x+width-radius,y+length); s.quadraticCurveTo(x+width,y+length,x+width,y+length-radius); | |
s.lineTo(x+width,y+radius); s.quadraticCurveTo(x+width,y,x+width-radius,y); | |
s.lineTo(x+radius,y); s.quadraticCurveTo(x,y,x,y+radius); | |
return new THREE.ShapeGeometry(s,16); | |
} | |
function createSmoothTaperGeometry(length,baseWidth,isEntrance,segments){ | |
const g=new THREE.PlaneGeometry(baseWidth,length,1,segments); const p=g.attributes.position; | |
for(let i=0;i<=segments;i++){ | |
const t=i/segments; let wf=isEntrance?easeInOut(t):1-easeInOut(t); | |
const cw=baseWidth*wf; const lidx=i*2,ridx=i*2+1; | |
p.setX(lidx,-cw/2); p.setX(ridx,cw/2); | |
} | |
p.needsUpdate=true; g.computeVertexNormals(); return g; | |
} | |
function createTaperedGeometry(length,startWidth,endWidth,segments){ | |
const piw=Math.max(startWidth,endWidth); const g=new THREE.PlaneGeometry(piw,length,1,segments); | |
const p=g.attributes.position; | |
for(let i=0;i<=segments;i++){ | |
const t=i/segments; const et=easeInOut(t); const cw=startWidth+(endWidth-startWidth)*et; | |
const lidx=i*2,ridx=i*2+1; | |
p.setX(lidx,-cw/2); p.setX(ridx,cw/2); | |
} | |
p.needsUpdate=true; g.computeVertexNormals(); return g; | |
} | |
function createBridges() { | |
for (let i = 0; i < 5; i++) createBridge(300 + i * 350); | |
} | |
function createBridge(worldZPosition) { | |
const {roadY,roadCurve}=getRoadPropertiesAtZ(worldZPosition); | |
const bridgeW=6,bridgeL=20,approachL=15,taperSegs=10,approachRoadW=12,deckH=1; | |
const bridgeGeom = new THREE.BoxGeometry(bridgeW,deckH,bridgeL); | |
const bridgeMat = new THREE.MeshStandardMaterial({color:0x888888}); | |
const bridge = new THREE.Mesh(bridgeGeom,bridgeMat); | |
bridge.position.set(roadCurve,roadY+deckH/2,worldZPosition); | |
bridge.castShadow=true; bridge.receiveShadow=true; scene.add(bridge); | |
const railW=0.5,railH=1; const railGeom=new THREE.BoxGeometry(railW,railH,bridgeL); | |
const railMat = new THREE.MeshStandardMaterial({color:0x444444}); | |
const lRail=new THREE.Mesh(railGeom,railMat); lRail.position.set(-bridgeW/2+railW/2,railH/2,0); bridge.add(lRail); | |
const rRail=new THREE.Mesh(railGeom,railMat); rRail.position.set(bridgeW/2-railW/2,railH/2,0); bridge.add(rRail); | |
const approachMat=new THREE.MeshStandardMaterial({color:0x555555}); | |
const nAppGeom=createTaperedGeometry(approachL,approachRoadW,bridgeW,taperSegs); | |
nAppGeom.rotateX(-Math.PI/2); | |
const nApp=new THREE.Mesh(nAppGeom,approachMat); | |
nApp.position.set(roadCurve,roadY+0.02,worldZPosition-bridgeL/2-approachL/2); | |
nApp.receiveShadow=true; scene.add(nApp); | |
const sAppGeom=createTaperedGeometry(approachL,bridgeW,approachRoadW,taperSegs); | |
sAppGeom.rotateX(-Math.PI/2); | |
const sApp=new THREE.Mesh(sAppGeom,approachMat); | |
sApp.position.set(roadCurve,roadY+0.02,worldZPosition+bridgeL/2+approachL/2); | |
sApp.receiveShadow=true; scene.add(sApp); | |
const sign=new THREE.Mesh(new THREE.BoxGeometry(0.5,2,0.1),new THREE.MeshStandardMaterial({color:0xFFFFFF})); | |
sign.position.set(roadCurve-(approachRoadW/2+2),1+roadY,worldZPosition-bridgeL/2-approachL-5); scene.add(sign); | |
const signGraphic=new THREE.Mesh(new THREE.PlaneGeometry(0.4,0.4),new THREE.MeshBasicMaterial({color:0x000000})); | |
signGraphic.position.set(0,0.5,0.06); sign.add(signGraphic); | |
bridges.push({position:worldZPosition,width:bridgeW,length:bridgeL,approachLength:approachL,mesh:bridge,northApproach:nApp,southApproach:sApp,carsWaiting:[]}); | |
} | |
function easeInOut(t){return t<0.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;} | |
function createPotholes() { | |
for (let i = 0; i < 10; i++) createPothole(200 + i * 180 + Math.random()*40); | |
} | |
function createPothole(worldZPosition) { | |
const {roadY,roadCurve}=getRoadPropertiesAtZ(worldZPosition); | |
const xOff=-roadWidth/4+(Math.random()-0.5)*(roadWidth/2-1); | |
const potholeGeom=new THREE.CircleGeometry(0.5+Math.random()*0.3,8); | |
potholeGeom.rotateX(-Math.PI/2); | |
const pothole=new THREE.Mesh(potholeGeom,new THREE.MeshStandardMaterial({color:0x111111,roughness:0.9})); | |
pothole.position.set(roadCurve+xOff,roadY+0.01,worldZPosition); scene.add(pothole); | |
potholes.push({mesh:pothole,positionZ:worldZPosition,positionX:roadCurve+xOff,hit:false}); | |
} | |
function createJumpRamps() { | |
const numRamps = 5; | |
const rampLength = 10; | |
const rampWidth = 4; | |
const rampHeight = 2; // Height at the peak of the ramp | |
for (let i = 0; i < numRamps; i++) { | |
const worldZPosition = 250 + i * (roadLength / (numRamps + 1)) + (Math.random() - 0.5) * 100; // Distribute ramps | |
const { roadY, roadCurve } = getRoadPropertiesAtZ(worldZPosition); | |
// Create ramp geometry (a wedge) | |
const shape = new THREE.Shape(); | |
shape.moveTo(-rampWidth / 2, 0); | |
shape.lineTo(rampWidth / 2, 0); | |
shape.lineTo(rampWidth / 2, rampHeight); // This point defines the peak | |
shape.lineTo(-rampWidth / 2, rampHeight * 0.3); // Lower front part of ramp for smoother entry | |
shape.closePath(); | |
const extrudeSettings = { depth: rampLength, bevelEnabled: false }; | |
const rampGeometry = new THREE.ExtrudeGeometry(shape, extrudeSettings); | |
// Rotate and position the ramp | |
rampGeometry.rotateY(Math.PI / 2); // Rotate so length is along Z | |
rampGeometry.translate(0, 0, -rampLength / 2); // Center it | |
const rampMaterial = new THREE.MeshStandardMaterial({ color: 0x777777, roughness: 0.6 }); | |
const ramp = new THREE.Mesh(rampGeometry, rampMaterial); | |
// Place ramp on the road, slightly to one side or centered | |
const xOffset = (Math.random() - 0.5) * (roadWidth - rampWidth) * 0.5; | |
ramp.position.set(roadCurve + xOffset, roadY + 0.05, worldZPosition); // +0.05 to be slightly above road | |
ramp.castShadow = true; | |
ramp.receiveShadow = true; | |
scene.add(ramp); | |
jumpRamps.push({ mesh: ramp, worldZ: worldZPosition, length: rampLength, width: rampWidth, height: rampHeight, used: false }); | |
} | |
} | |
function createFerry() { | |
ferryObject = new THREE.Group(); | |
const { roadY, roadCurve } = getRoadPropertiesAtZ(ferryPosition); | |
// Ferry Deck | |
const deckWidth = 20; | |
const deckLength = 40; | |
const deckHeight = 2; | |
const deckGeometry = new THREE.BoxGeometry(deckWidth, deckHeight, deckLength); | |
const deckMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa, roughness: 0.7 }); | |
const deck = new THREE.Mesh(deckGeometry, deckMaterial); | |
deck.position.y = deckHeight / 2; // Sit on the water level (which is roadY - 2) | |
deck.receiveShadow = true; | |
deck.castShadow = true; | |
ferryObject.add(deck); | |
// Superstructure (Cabin) | |
const cabinWidth = 10; | |
const cabinLength = 15; | |
const cabinHeight = 8; | |
const cabinGeometry = new THREE.BoxGeometry(cabinWidth, cabinHeight, cabinLength); | |
const cabinMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: 0.8 }); | |
const cabin = new THREE.Mesh(cabinGeometry, cabinMaterial); | |
cabin.position.set(0, deckHeight + cabinHeight / 2, -deckLength / 4); // Position on deck towards the rear | |
cabin.castShadow = true; | |
ferryObject.add(cabin); | |
// Funnel | |
const funnelRadius = 1.5; | |
const funnelHeight = 7; | |
const funnelGeometry = new THREE.CylinderGeometry(funnelRadius, funnelRadius * 0.8, funnelHeight, 16); | |
const funnelMaterial = new THREE.MeshStandardMaterial({ color: 0x555555 }); | |
const funnel = new THREE.Mesh(funnelGeometry, funnelMaterial); | |
funnel.position.set(0, deckHeight + cabinHeight + funnelHeight / 2 - 2, cabinLength / 3); | |
funnel.castShadow = true; | |
ferryObject.add(funnel); | |
ferryObject.position.set(roadCurve, roadY - 1, ferryPosition + deckLength/2 + 5); // Position ferry at destination | |
ferryObject.rotation.y = Math.PI / 2; // Sideways to the road | |
scene.add(ferryObject); | |
} | |
function startGame() { | |
document.getElementById('instructions').style.display = 'none'; | |
gameStarted = true; | |
clock.start(); | |
animate(); | |
clearErrors(); | |
} | |
function restartGame() { | |
gameOver = false; gameTime = 0; speed = 0; score = 0; playerHealth = 100; | |
goodStopsInARow = 0; isGrounded = true; airTime = 0; velocity.set(0,0,0); | |
suspensionCompression = 0; | |
document.getElementById('score').textContent = `Score: ${score}`; | |
document.getElementById('health').style.width = '100%'; | |
document.getElementById('health').style.backgroundColor = '#00ff00'; | |
const startPos = getRoadPropertiesAtZ(0); | |
playerCar.position.set(startPos.roadCurve, 1 + startPos.roadY, 0); | |
playerCar.rotation.set(0,0,0); | |
aiCars.forEach(ai => { | |
const {roadY:rY, roadCurve:rC} = getRoadPropertiesAtZ(ai.initialPositionZ); | |
const lo = ai.isOncoming ? (roadWidth/4+0.25) : (-roadWidth/4-0.25); | |
ai.mesh.position.set(rC+lo, 1+rY, ai.initialPositionZ); | |
ai.mesh.rotation.y = ai.isOncoming ? Math.PI : 0; | |
Object.assign(ai, {waiting:false,honking:false,waitingAtPassingPlace:false,flashingLights:false,isOvertaking:false}); | |
if(ai.leftHeadlight) ai.leftHeadlight.material.color.setHex(0xFFFFFF); | |
if(ai.rightHeadlight) ai.rightHeadlight.material.color.setHex(0xFFFFFF); | |
}); | |
potholes.forEach(p => p.hit = false); | |
jumpRamps.forEach(r => r.used = false); // Reset used state of ramps | |
document.getElementById('rearView').innerHTML = ''; | |
document.getElementById('gameOver').style.display = 'none'; | |
document.getElementById('instructions').style.display = 'none'; | |
clock.stop(); clock.start(); gameStarted = true; | |
} | |
function animate() { | |
if (gameOver && !gameStarted) { renderer.render(scene, camera); requestAnimationFrame(animate); return; } | |
if (!gameStarted && gameOver) { renderer.render(scene, camera); requestAnimationFrame(animate); return; } | |
if (!gameStarted) return; | |
requestAnimationFrame(animate); | |
const delta = clock.getDelta(); | |
if (!gameOver) update(delta); | |
renderer.render(scene, camera); | |
if (Math.random() < 0.05) clearErrors(); | |
} | |
function update(delta) { | |
gameTime += delta; updateTimer(); updateClouds(delta); | |
if (playerCar.position.z >= ferryPosition) { endGame(true); return; } | |
if (gameTime > 120) { showMessage("Time's up! You missed the ferry!", 5); endGame(false, "Time's up!"); return; } | |
updatePlayerCar(delta); updateAICars(delta); checkCollisions(); updateCamera(); | |
if (!isGrounded) { | |
document.getElementById('airTime').textContent = `Air Time: ${airTime.toFixed(1)}s`; | |
document.getElementById('airTime').style.display = 'block'; | |
} else { document.getElementById('airTime').style.display = 'none'; } | |
updateParticleEffects(delta); | |
} | |
function updateParticleEffects(delta) { | |
if (playerCar.particles) { | |
const particles = playerCar.particles; const particlePositions = particles.geometry.attributes.position; | |
const showParticles = (isGrounded && Math.abs(speed)>15 && (keys.ArrowLeft||keys.ArrowRight||keys.a||keys.d)) || | |
(!isGrounded && Math.abs(velocity.y)<0.1 && airTime>0.1 && playerCar.position.y < lastRoadY+1.5) || | |
(isGrounded && Math.abs(speed)>25 && (keys.ArrowUp||keys.w)); | |
if (showParticles) { | |
particles.visible = true; | |
for (let i=0; i<particlePositions.count; i++) { | |
const wheelIdx=(Math.floor(Math.random()*2)+2), wheelX=(wheelIdx===2)?-1:1, wheelZ=-1.5; | |
const xOff=(Math.random()-0.5)*0.5, yOff=Math.random()*0.1-0.2, zOff=(Math.random()-0.5)*0.5-0.3; | |
particlePositions.setXYZ(i,wheelX+xOff,yOff,wheelZ+zOff); | |
} | |
particlePositions.needsUpdate = true; | |
} else { particles.visible = false; } | |
} | |
} | |
function updatePlayerCar(delta) { | |
updateCarPhysics(delta); | |
if((keys.ArrowUp||keys.w)&&!gameOver) speed+=acceleration*(isGrounded?1.2:0.3); | |
else if((keys.ArrowDown||keys.s)&&!gameOver) speed-=braking*(isGrounded?1.2:0.3); | |
else { if(isGrounded)speed*=0.98; else speed*=0.995; if(Math.abs(speed)<0.05)speed=0; } | |
if((keys[' ']||keys.j)&&isGrounded&&Math.abs(speed)>10){ | |
jumpForce=1.2+Math.abs(speed)*0.06+suspensionCompression*6; // Increased base and scaling | |
velocity.y=jumpForce; isGrounded=false; showMessage("Jumping!",1); | |
} | |
speed=Math.max(-maxSpeed/2,Math.min(maxSpeed,speed)); | |
document.getElementById('speedometer').textContent=`Speed: ${Math.abs(Math.round(speed))} mph`; | |
const actualMoveSpeed=speed*delta*2.5; | |
const steeringInput=(keys.ArrowLeft||keys.a)?1:(keys.ArrowRight||keys.d)?-1:0; | |
if(steeringInput!==0&&Math.abs(speed)>0.1){ | |
const steerEff=isGrounded?1.0:0.3; const turnRate=steering*steerEff*Math.abs(speed/maxSpeed)*2.0; | |
playerCar.rotation.y+=steeringInput*turnRate*Math.sign(speed); | |
} | |
playerCar.position.x+=Math.sin(playerCar.rotation.y)*actualMoveSpeed; | |
playerCar.position.z+=Math.cos(playerCar.rotation.y)*actualMoveSpeed; | |
if(!isGrounded){ | |
const airTilt=Math.min(Math.max(velocity.y*0.1,-0.3),0.3); | |
playerCar.rotation.x=airTilt; | |
playerCar.rotation.z+=steeringInput*speed*0.0005; | |
playerCar.rotation.z=Math.max(-0.3,Math.min(0.3,playerCar.rotation.z)); | |
}else{playerCar.rotation.x*=0.8; playerCar.rotation.z*=0.8;} | |
const {roadY:currentRoadY,roadCurve:currentRoadCurve}=getRoadPropertiesAtZ(playerCar.position.z); | |
if(isGrounded) playerCar.position.y=1+currentRoadY+suspensionCompression; | |
if(isGrounded){ | |
const latOff=playerCar.position.x-currentRoadCurve; const maxOff=roadWidth/2+0.5; | |
if(Math.abs(latOff)>maxOff){ | |
playerCar.position.x-=Math.sign(latOff)*0.1*Math.abs(latOff-maxOff); | |
if(Math.abs(latOff)>maxOff+1.0)speed*=0.95; | |
} | |
} | |
} | |
function updateCarPhysics(delta) { | |
const {roadY: groundHeightAtCar}=getRoadPropertiesAtZ(playerCar.position.z); | |
const carEffectiveRadius=0.5; | |
// Check for jump ramp interaction | |
let onRamp = false; | |
jumpRamps.forEach(ramp => { | |
const distToRampZ = Math.abs(playerCar.position.z - ramp.mesh.position.z); | |
const distToRampX = Math.abs(playerCar.position.x - ramp.mesh.position.x); | |
if (distToRampZ < ramp.length / 2 && distToRampX < ramp.width / 2 && playerCar.position.y < ramp.mesh.position.y + ramp.height + carEffectiveRadius) { | |
onRamp = true; | |
if (!ramp.used && isGrounded) { // Only trigger jump once and if grounded | |
velocity.y = ramp.height * 1.5 + Math.abs(speed) * 0.1; // Ramp jump force | |
isGrounded = false; | |
ramp.used = true; // Mark ramp as used for this jump | |
showMessage("Ramp Jump!", 2); | |
score += 150; // Bonus points for ramp jump | |
document.getElementById('score').textContent = `Score: ${score}`; | |
setTimeout(() => { ramp.used = false; }, 3000); // Allow reuse after a delay | |
} | |
} | |
}); | |
if (isGrounded && !onRamp) { // Don't apply ground physics if on ramp and about to jump | |
const elevationChange = groundHeightAtCar - lastRoadY; | |
suspensionCompression = -elevationChange * suspensionStrength; | |
suspensionCompression = Math.max(-0.3, Math.min(0.3, suspensionCompression)); | |
playerCar.position.y = groundHeightAtCar + carEffectiveRadius + suspensionCompression; | |
velocity.y = 0; | |
} else { | |
velocity.y -= gravity * delta * 20; | |
playerCar.position.y += velocity.y * delta * 5; | |
airTime += delta; | |
if (playerCar.position.y <= groundHeightAtCar + carEffectiveRadius && velocity.y < 0 && !onRamp) { // Land only if not on ramp | |
playerCar.position.y = groundHeightAtCar + carEffectiveRadius; | |
isGrounded = true; | |
const impactForce = Math.abs(velocity.y); | |
velocity.y = 0; airTime = 0; | |
if (impactForce > 0.3) { | |
decreaseHealth(Math.floor(impactForce*15),`Hard landing!`); | |
suspensionCompression = Math.min(impactForce*0.2,0.4); | |
} else { suspensionCompression = Math.min(impactForce*0.1,0.1); } | |
} | |
} | |
lastRoadY = groundHeightAtCar; | |
playerCar.children.forEach(c=>{if(c.isWheel)c.position.y=0.5+suspensionCompression*0.5;}); | |
} | |
function updateAICars(delta) { | |
carsBehind = []; | |
aiCars.forEach(ai => { | |
const carM=ai.mesh; let curSpd=ai.waiting||ai.waitingAtPassingPlace?0:ai.speed; | |
const moveDist=curSpd*(ai.isOncoming?-1:1)*delta*2.5; carM.position.z+=moveDist; | |
const {roadY,roadCurve}=getRoadPropertiesAtZ(carM.position.z); | |
let tLaneXOff=ai.isOncoming?(roadWidth/4):(-roadWidth/4); | |
if(ai.isOvertaking)tLaneXOff=ai.isOncoming?(-roadWidth/4):(roadWidth/4); | |
const tX=roadCurve+tLaneXOff; carM.position.x+=(tX-carM.position.x)*0.1; carM.position.y=1+roadY; | |
if(ai.isOncoming){ | |
const dToP=playerCar.position.distanceTo(carM.position); | |
if(dToP<40&&!ai.waitingAtPassingPlace){ | |
let pYield=isPlayerInPassingPlaceForOncoming(ai); | |
if(!pYield&&ai.politeness>0.5){ | |
let canAIPull=false; | |
passingPlaces.forEach(pp=>{if(pp.side===1&&Math.abs(carM.position.z-pp.position)<pp.length/2+10){canAIPull=true;ai.waitingAtPassingPlace=true;ai.flashingLights=true;}}); | |
if(!canAIPull)ai.waiting=true; | |
} | |
}else if((ai.waiting||ai.waitingAtPassingPlace)&&dToP>50){ai.waiting=false;ai.waitingAtPassingPlace=false;ai.flashingLights=false;} | |
}else{ | |
const distBPlayer=playerCar.position.z-carM.position.z; | |
if(distBPlayer>5&&distBPlayer<30&&speed<ai.speed*0.8&&!ai.isOvertaking){ | |
let canOvertake=false; | |
passingPlaces.forEach(pp=>{if(Math.abs(playerCar.position.z-pp.position)<pp.length/2&&playerCar.position.x*pp.side<0&&Math.abs(speed)<5){if(pp.side===-1&&playerCar.position.x<getRoadPropertiesAtZ(playerCar.position.z).roadCurve)canOvertake=true; if(pp.side===1&&playerCar.position.x>getRoadPropertiesAtZ(playerCar.position.z).roadCurve)canOvertake=true;}}); | |
if(canOvertake){ai.isOvertaking=true;ai.flashingLights=false;setTimeout(()=>{ai.isOvertaking=false;},5000);} | |
else if(!ai.waitingAtPassingPlace)ai.flashingLights=true; | |
}else if(ai.flashingLights&&distBPlayer>50)ai.flashingLights=false; | |
} | |
if(ai.flashingLights){const t=Date.now()*0.005; const fs=Math.sin(t*5)>0; if(ai.leftHeadlight)ai.leftHeadlight.material.color.setHex(fs?0xFFFF00:0xFFFFFF); if(ai.rightHeadlight)ai.rightHeadlight.material.color.setHex(fs?0xFFFF00:0xFFFFFF);} | |
else{if(ai.leftHeadlight)ai.leftHeadlight.material.color.setHex(0xFFFFFF); if(ai.rightHeadlight)ai.rightHeadlight.material.color.setHex(0xFFFFFF);} | |
if(!ai.isOncoming&&carM.position.z<playerCar.position.z&&carM.position.z>playerCar.position.z-50)carsBehind.push(ai); | |
if(Math.abs(carM.position.z-(roadLength/2))>roadLength/2+100){ | |
const iZ=ai.initialPositionZ; const{roadY:iRY,roadCurve:iRC}=getRoadPropertiesAtZ(iZ); | |
const lo=ai.isOncoming?(roadWidth/4+0.25):(-roadWidth/4-0.25); | |
carM.position.set(iRC+lo,1+iRY,iZ); Object.assign(ai,{waiting:false,waitingAtPassingPlace:false,isOvertaking:false,flashingLights:false}); | |
} | |
}); | |
updateRearViewMirror(); | |
} | |
function isPlayerInPassingPlaceForOncoming(oncomingAICar) { | |
const pZ=playerCar.position.z,pX=playerCar.position.x; const{roadCurve:pRC}=getRoadPropertiesAtZ(pZ); | |
for(const pp of passingPlaces){ | |
if(Math.abs(pZ-pp.position)<pp.length/2){ | |
if(pp.side===-1){if(pX<pRC-roadWidth/4&&Math.abs(speed)<5)return true;} | |
} | |
}return false; | |
} | |
function awardPointsForGoodStop(){goodStopsInARow++;let pts=0;if(goodStopsInARow===1)pts=100;else if(goodStopsInARow===2)pts=500;else if(goodStopsInARow>=3)pts=1000;if(pts>0){score+=pts;document.getElementById('score').textContent=`Score: ${score}`;showMessage(`+${pts} points! ${goodStopsInARow} good stops!`,3);}} | |
function updateRearViewMirror(){ | |
const rvEl=document.getElementById('rearView'); rvEl.innerHTML=''; | |
carsBehind.sort((a,b)=>(playerCar.position.z-a.mesh.position.z)-(playerCar.position.z-b.mesh.position.z)); | |
for(let i=0;i<Math.min(carsBehind.length,3);i++){ | |
const cd=carsBehind[i]; const dist=playerCar.position.z-cd.mesh.position.z; | |
const ind=document.createElement('div'); ind.style.position='absolute'; | |
ind.style.width=`${Math.max(5,30-dist*0.5)}px`; ind.style.height=`${Math.max(3,20-dist*0.3)}px`; | |
ind.style.backgroundColor=cd.flashingLights?(Math.sin(Date.now()*0.01)>0?'#ffff00':'#cc0000'):'#cc0000'; | |
ind.style.borderRadius='3px'; | |
ind.style.left=`${(rvEl.offsetWidth/2)-(parseFloat(ind.style.width)/2)+(i-Math.floor(Math.min(carsBehind.length,3)/2))*35}px`; | |
ind.style.bottom=`${5+Math.max(0,20-dist*0.8)}px`; ind.style.zIndex=50-Math.floor(dist); | |
rvEl.appendChild(ind); | |
} | |
} | |
function checkCollisions() { | |
const playerBox = new THREE.Box3().setFromObject(playerCar); | |
potholes.forEach(pd=>{if(!pd.hit){const dist=playerCar.position.distanceTo(pd.mesh.position);if(dist<1.5){pd.hit=true;const oSpd=Math.abs(speed);speed*=0.6;decreaseHealth(15,"Hit a pothole!");if(oSpd>20){showMessage("Flat tire!",3);decreaseHealth(25,"Flat Tire!");}}}}); | |
aiCars.forEach(aiD=>{const aiB=new THREE.Box3().setFromObject(aiD.mesh);if(playerBox.intersectsBox(aiB)){decreaseHealth(35,"Collided!");playerCar.position.z-=Math.sign(speed)*2.5;speed*=0.1;aiD.waiting=true;setTimeout(()=>{if(aiD)aiD.waiting=false;},2500);}}); | |
} | |
function decreaseHealth(amount,message){playerHealth-=amount;playerHealth=Math.max(0,playerHealth);document.getElementById('health').style.width=`${playerHealth}%`;if(playerHealth<30)document.getElementById('health').style.backgroundColor='#ff0000';else if(playerHealth<60)document.getElementById('health').style.backgroundColor='#ffff00';else document.getElementById('health').style.backgroundColor='#00ff00';if(message)showMessage(message,3);if(playerHealth<=0&&!gameOver)endGame(false,"Car too damaged!");} | |
function updateCamera(){const cH=3.8,cD=9,laD=18;const tCP=new THREE.Vector3();tCP.set(0,cH,-cD);tCP.applyMatrix4(playerCar.matrixWorld);const tLA=new THREE.Vector3();tLA.set(0,1.2,laD);tLA.applyMatrix4(playerCar.matrixWorld);camera.position.lerp(tCP,0.08);camera.lookAt(tLA);} | |
function updateTimer(){const m=Math.floor(gameTime/60),s=Math.floor(gameTime%60);document.getElementById('timer').textContent=`Time: ${m}:${s<10?'0':''}${s}`;} | |
function showMessage(text,duration){const msgEl=document.getElementById('message');msgEl.textContent=text;msgEl.style.opacity=1;setTimeout(()=>{msgEl.style.opacity=0;},duration*1000);clearErrors();} | |
function clearErrors(){document.querySelectorAll('.error-message').forEach(el=>el.style.display='none');const bCN=document.body.childNodes;for(let i=bCN.length-1;i>=0;i--){const n=bCN[i];if(n.nodeType===Node.TEXT_NODE&&n.parentElement===document.body&&n.textContent.trim()!==''){if(n.textContent.includes('function')||n.textContent.includes('var')||n.textContent.includes('error'))n.textContent='';}}} | |
function endGame(success,customMessage){if(gameOver)return;gameOver=true;gameStarted=false;const goEl=document.getElementById('gameOver'),goT=document.getElementById('gameOverTitle'),goTxt=document.getElementById('gameOverText');if(success){goT.textContent="Success!";goTxt.innerHTML=`Made it in ${Math.floor(gameTime/60)}:${Math.floor(gameTime%60)<10?'0':''}${Math.floor(gameTime%60)}!<br><br>Score: ${score}<br>Condition: ${playerHealth}%`;}else{goT.textContent="Game Over";goTxt.innerHTML=(customMessage||"Didn't make it.")+`<br><br>Score: ${score}`;}goEl.style.display='block';} | |
function onWindowResize(){camera.aspect=window.innerWidth/window.innerHeight;camera.updateProjectionMatrix();renderer.setSize(window.innerWidth,window.innerHeight);} | |
function onKeyDown(e){if(keys.hasOwnProperty(e.key.toLowerCase()))keys[e.key.toLowerCase()]=true;else if(e.key.startsWith('Arrow'))keys[e.key]=true;else if(e.code==='Space')keys[' ']=true;} | |
function onKeyUp(e){if(keys.hasOwnProperty(e.key.toLowerCase()))keys[e.key.toLowerCase()]=false;else if(e.key.startsWith('Arrow'))keys[e.key]=false;else if(e.code==='Space')keys[' ']=false;} | |
init(); | |
</script> | |
</body> | |
</html> |