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> |