<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Three.js Multi-Vehicle Simulator</title>
<style>
body { margin: 0; overflow: hidden; background-color: #87CEEB; font-family: Arial, sans-serif; }
canvas { display: block; }
#info {
position: absolute;
top: 10px;
left: 10px;
color: white;
background-color: rgba(0,0,0,0.5);
padding: 5px 10px;
border-radius: 5px;
font-size: 12px;
}
#controls {
position: absolute;
bottom: 10px;
left: 10px;
display: grid;
grid-template-areas:
". up ."
"left fwd right"
". down back";
gap: 5px;
opacity: 0.7;
}
#controls button {
padding: 15px;
font-size: 18px;
background-color: rgba(80, 80, 80, 0.8);
color: white;
border: 1px solid #555;
border-radius: 8px;
user-select: none; /* Prevent text selection on hold */
user-select: none; /* Safari */
touch-action: manipulation; /* Prevent zoom on double tap */
}
#btn-fwd { grid-area: fwd; }
#btn-back { grid-area: back; }
#btn-left { grid-area: left; }
#btn-right { grid-area: right; }
#btn-up { grid-area: up; }
#btn-down { grid-area: down; }
#switch-vehicle {
position: absolute;
bottom: 10px;
right: 10px;
padding: 15px;
font-size: 14px;
background-color: rgba(0, 100, 200, 0.8);
color: white;
border: 1px solid #005;
border-radius: 8px;
opacity: 0.8;
}
/* Hide desktop-only instructions on small screens */
@media (max-width: 768px) {
#desktop-instructions { display: none; }
}
/* Hide mobile buttons on large screens */
@media (min-width: 769px) {
#controls { display: none; }
}
</style>
</head>
<body>
<div id="info">
Loading...
<div id="desktop-instructions"><br>Controls: [W/S] or [Up/Down] = Forward/Back | [A/D] or [Left/Right] = Turn | [Q/E] or [Shift/Ctrl] = Altitude/Strafe(Car)</div>
</div>
<div id="controls">
<button id="btn-fwd">▲</button>
<button id="btn-back">▼</button>
<button id="btn-left">◄</button>
<button id="btn-right">►</button>
<button id="btn-up">U</button> <!-- Up/Altitude -->
<button id="btn-down">D</button> <!-- Down/Altitude -->
</div>
<button id="switch-vehicle">Switch Vehicle</button>
<!-- Import map for Three.js modules -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js"
}
}
</script>
<script type="module">
import * as THREE from 'three';
let scene, camera, renderer, clock;
let playerVehicles = {};
let currentVehicleType = 'car'; // 'car', 'plane', 'rocket'
let activeVehicle;
let aiPlanes = [];
let train;
let trainPath;
const trainSpeed = 20; // units per second
let trainT = 0; // parameter along the path
const keyboardState = {};
const touchState = { forward: false, backward: false, left: false, right: false, up: false, down: false };
const vehicleSettings = {
car: { speed: 50, turnSpeed: Math.PI / 2 },
plane: { speed: 100, turnSpeed: Math.PI / 3, altitudeSpeed: 20, minAltitude: 10, maxAltitude: 500 },
rocket: { speed: 150, turnSpeed: Math.PI / 4, altitudeSpeed: 50, minAltitude: 1 }
};
const cameraOffset = new THREE.Vector3(0, 5, -15); // Behind and slightly above
init();
animate();
function init() {
// Basic Setup
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB); // Sky blue
scene.fog = new THREE.Fog(0x87CEEB, 100, 800);
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 20, 30); // Initial camera position
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
clock = new THREE.Clock();
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(50, 100, 75);
// // Basic shadow setup (uncomment if needed, impacts performance)
// directionalLight.castShadow = true;
// directionalLight.shadow.mapSize.width = 1024;
// directionalLight.shadow.mapSize.height = 1024;
// directionalLight.shadow.camera.near = 0.5;
// directionalLight.shadow.camera.far = 500;
// renderer.shadowMap.enabled = true;
scene.add(directionalLight);
// --- Create Environment ---
createGround();
createMountains(15, 100, 400); // Number, max size, spread radius
createTrees(100, 400); // Number, spread radius
createRoad(200, 10); // Length, width
createBuildings(30, 150, 400); // Number, max size, spread radius
createHouse(new THREE.Vector3(30, 0, 30));
createClouds(50, 200, 500); // Number, average altitude, spread radius
createTrainAndTrack(150, 5); // Track radius, number of carriages
// --- Create Player Vehicles ---
playerVehicles.car = createCar();
playerVehicles.plane = createPlane();
playerVehicles.rocket = createRocket();
// Position vehicles initially
playerVehicles.car.position.set(0, 0.5, 10);
playerVehicles.plane.position.set(50, vehicleSettings.plane.minAltitude, -50);
playerVehicles.rocket.position.set(-50, vehicleSettings.rocket.minAltitude, -50);
scene.add(playerVehicles.car);
scene.add(playerVehicles.plane);
scene.add(playerVehicles.rocket);
// --- Create AI Planes ---
aiPlanes.push(createAIPlane(new THREE.Vector3(100, 150, 0), 80)); // Position, speed
aiPlanes.push(createAIPlane(new THREE.Vector3(-150, 120, 100), 90));
aiPlanes.forEach(p => scene.add(p.mesh));
// --- Initial Setup ---
switchVehicle(currentVehicleType); // Set initial active vehicle
// --- Event Listeners ---
window.addEventListener('resize', onWindowResize);
window.addEventListener('keydown', (event) => { keyboardState[event.code] = true; });
window.addEventListener('keyup', (event) => { keyboardState[event.code] = false; });
setupMobileControls();
document.getElementById('switch-vehicle').addEventListener('click', () => {
if (currentVehicleType === 'car') switchVehicle('plane');
else if (currentVehicleType === 'plane') switchVehicle('rocket');
else switchVehicle('car');
});
document.getElementById('info').innerHTML = `Controlling: ${currentVehicleType.toUpperCase()}<br><span id="desktop-instructions">Controls: [W/S] or [Up/Down] = Fwd/Back | [A/D] or [Left/Right] = Turn | [Q/E] or [Shift/Ctrl] = Altitude/Strafe</span>`;
}
// --- Creation Functions ---
function createGround() {
const groundGeometry = new THREE.PlaneGeometry(1000, 1000);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x55aa55, side: THREE.DoubleSide }); // Greenish
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; // Rotate flat
// ground.receiveShadow = true; // Uncomment for shadows
scene.add(ground);
}
function createMountains(count, maxSize, radius) {
const mountainMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513 }); // Brown
const snowMaterial = new THREE.MeshStandardMaterial({ color: 0xffffff }); // White
for (let i = 0; i < count; i++) {
const height = Math.random() * maxSize + maxSize * 0.5;
const width = height * (0.8 + Math.random() * 0.4);
const mountainGeometry = new THREE.ConeGeometry(width / 2, height, 4 + Math.floor(Math.random() * 4)); // Fewer sides for jagged look
const mountain = new THREE.Mesh(mountainGeometry, mountainMaterial);
const angle = Math.random() * Math.PI * 2;
const dist = radius * 0.5 + Math.random() * radius * 0.5;
mountain.position.set(
Math.cos(angle) * dist,
height / 2 - 1, // Base slightly below ground
Math.sin(angle) * dist
);
mountain.rotation.y = Math.random() * Math.PI * 2;
// mountain.castShadow = true; // Uncomment for shadows
scene.add(mountain);
// Add snow cap (optional)
if (height > maxSize * 0.8) {
const snowHeight = height * 0.2;
const snowGeometry = new THREE.ConeGeometry(width/3, snowHeight, mountainGeometry.parameters.radialSegments);
const snowCap = new THREE.Mesh(snowGeometry, snowMaterial);
snowCap.position.y = height/2 - snowHeight/2 + 0.1; // Position on top
mountain.add(snowCap); // Add to mountain group
}
}
}
function createTrees(count, radius) {
const trunkMaterial = new THREE.MeshStandardMaterial({ color: 0x8B4513 }); // Brown
const leavesMaterial = new THREE.MeshStandardMaterial({ color: 0x228B22 }); // Forest Green
for (let i = 0; i < count; i++) {
const treeGroup = new THREE.Group();
const height = Math.random() * 8 + 4; // 4 to 12 units high
const trunkHeight = height * 0.4;
const leavesHeight = height * 0.6;
const trunkRadius = height * 0.05;
const leavesRadius = height * 0.2;
const trunkGeometry = new THREE.CylinderGeometry(trunkRadius, trunkRadius, trunkHeight, 8);
const trunk = new THREE.Mesh(trunkGeometry, trunkMaterial);
trunk.position.y = trunkHeight / 2;
// trunk.castShadow = true; // Uncomment for shadows
const leavesGeometry = new THREE.ConeGeometry(leavesRadius, leavesHeight, 8);
const leaves = new THREE.Mesh(leavesGeometry, leavesMaterial);
leaves.position.y = trunkHeight + leavesHeight / 2;
// leaves.castShadow = true; // Uncomment for shadows
treeGroup.add(trunk);
treeGroup.add(leaves);
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * radius;
// Avoid placing trees directly on the road/track area
if (Math.abs(Math.cos(angle) * dist) < 10 && Math.abs(Math.sin(angle) * dist) < 220) continue; // Skip if near road center
if (Math.pow(Math.cos(angle) * dist, 2) + Math.pow(Math.sin(angle) * dist, 2) < Math.pow(160,2)) continue; // Skip if inside train track radius
treeGroup.position.set(
Math.cos(angle) * dist,
0,
Math.sin(angle) * dist
);
treeGroup.rotation.y = Math.random() * Math.PI;
scene.add(treeGroup);
}
}
function createRoad(length, width) {
const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x444444 });
const roadGeometry = new THREE.PlaneGeometry(width, length);
const road = new THREE.Mesh(roadGeometry, roadMaterial);
road.rotation.x = -Math.PI / 2;
road.position.set(0, 0.01, 0); // Slightly above ground
// road.receiveShadow = true; // Uncomment for shadows
scene.add(road);
// Center line (optional)
const lineMaterial = new THREE.MeshBasicMaterial({ color: 0xFFFF00 });
const lineGeometry = new THREE.PlaneGeometry(0.3, length);
const centerLine = new THREE.Mesh(lineGeometry, lineMaterial);
centerLine.rotation.x = -Math.PI / 2;
centerLine.position.set(0, 0.02, 0); // Slightly above road
scene.add(centerLine);
}
function createBuildings(count, maxSize, radius) {
const buildingMaterial = new THREE.MeshStandardMaterial({ color: 0xAAAAAA }); // Grey
for (let i = 0; i < count; i++) {
const height = Math.random() * maxSize + 20;
const width = Math.random() * (maxSize / 4) + 10;
const depth = Math.random() * (maxSize / 4) + 10;
const buildingGeometry = new THREE.BoxGeometry(width, height, depth);
const building = new THREE.Mesh(buildingGeometry, buildingMaterial);
// building.castShadow = true; // Uncomment for shadows
// building.receiveShadow = true; // Uncomment for shadows
const angle = Math.random() * Math.PI * 2;
// Place buildings further out
const dist = radius * 0.6 + Math.random() * radius * 0.4;
// Avoid placing buildings on road or near house/track
const x = Math.cos(angle) * dist;
const z = Math.sin(angle) * dist;
if (Math.abs(x) < width/2 + 10 && Math.abs(z) < 110) continue; // Avoid road
if (Math.abs(x - 30) < width/2 + 15 && Math.abs(z - 30) < depth/2 + 15) continue; // Avoid house
if (Math.pow(x, 2) + Math.pow(z, 2) < Math.pow(170, 2) && Math.pow(x, 2) + Math.pow(z, 2) > Math.pow(130, 2) ) continue; // Avoid track
building.position.set(x, height / 2, z);
building.rotation.y = (Math.random() > 0.5 ? 0 : Math.PI / 2); // Align some buildings
scene.add(building);
}
}
function createHouse(position) {
const houseGroup = new THREE.Group();
const baseMaterial = new THREE.MeshStandardMaterial({ color: 0xF5DEB3 }); // Wheat
const roofMaterial = new THREE.MeshStandardMaterial({ color: 0xA0522D }); // Sienna
const baseWidth = 10, baseHeight = 6, baseDepth = 12;
const baseGeometry = new THREE.BoxGeometry(baseWidth, baseHeight, baseDepth);
const base = new THREE.Mesh(baseGeometry, baseMaterial);
base.position.y = baseHeight / 2;
// base.castShadow = true; // Uncomment for shadows
houseGroup.add(base);
const roofGeometry = new THREE.ConeGeometry(baseWidth * 0.7, baseHeight * 0.7, 4); // Pyramid roof
const roof = new THREE.Mesh(roofGeometry, roofMaterial);
roof.position.y = baseHeight + (baseHeight * 0.7) / 2;
roof.rotation.y = Math.PI / 4; // Align roof edges
// roof.castShadow = true; // Uncomment for shadows
houseGroup.add(roof);
houseGroup.position.copy(position);
scene.add(houseGroup);
}
function createClouds(count, altitude, radius) {
const cloudMaterial = new THREE.MeshBasicMaterial({
color: 0xffffff,
transparent: true,
opacity: 0.7
});
for (let i = 0; i < count; i++) {
const cloudGroup = new THREE.Group();
const numPuffs = 5 + Math.floor(Math.random() * 5);
for (let j = 0; j < numPuffs; j++) {
const puffSize = Math.random() * 15 + 10;
const puffGeometry = new THREE.SphereGeometry(puffSize, 8, 6);
const puff = new THREE.Mesh(puffGeometry, cloudMaterial);
puff.position.set(
(Math.random() - 0.5) * puffSize * 2,
(Math.random() - 0.5) * puffSize * 0.5,
(Math.random() - 0.5) * puffSize * 2
);
cloudGroup.add(puff);
}
const angle = Math.random() * Math.PI * 2;
const dist = Math.random() * radius;
const cloudAltitude = altitude + (Math.random() - 0.5) * 50;
cloudGroup.position.set(
Math.cos(angle) * dist,
cloudAltitude,
Math.sin(angle) * dist
);
scene.add(cloudGroup);
}
}
function createTrainAndTrack(radius, numCarriages) {
// Track (visual only)
const trackMaterial = new THREE.MeshBasicMaterial({ color: 0x666666 });
const trackGeometry = new THREE.TorusGeometry(radius, 0.5, 8, 100);
const trackMesh = new THREE.Mesh(trackGeometry, trackMaterial);
trackMesh.rotation.x = Math.PI / 2;
trackMesh.position.y = 0.1;
scene.add(trackMesh);
// Define path for train movement
trainPath = new THREE.CatmullRomCurve3(
Array.from({ length: 101 }, (_, i) => {
const angle = (i / 100) * Math.PI * 2;
return new THREE.Vector3(Math.cos(angle) * radius, 1, Math.sin(angle) * radius);
})
);
trainPath.curveType = 'catmullrom';
trainPath.closed = true;
// Create Train
train = new THREE.Group();
const carriageLength = 8;
const carriageWidth = 4;
const carriageHeight = 4;
const gap = 1;
const engineMaterial = new THREE.MeshStandardMaterial({ color: 0xff0000 }); // Red
const carriageMaterial = new THREE.MeshStandardMaterial({ color: 0x0000ff }); // Blue
for (let i = 0; i < numCarriages; i++) {
const geometry = new THREE.BoxGeometry(carriageLength, carriageHeight, carriageWidth);
const material = i === 0 ? engineMaterial : carriageMaterial;
const carriage = new THREE.Mesh(geometry, material);
// carriage.castShadow = true; // Uncomment for shadows
// Position along the group's local Z axis initially (will be oriented by path later)
carriage.position.z = -(i * (carriageLength + gap));
train.add(carriage);
}
scene.add(train);
}
function createCar() {
const carGroup = new THREE.Group();
const bodyMaterial = new THREE.MeshStandardMaterial({ color: 0xfff00 }); // Yellow
const wheelMaterial = new THREE.MeshStandardMaterial({ color: 0x222222 }); // Dark Grey
// Body
const bodyGeometry = new THREE.BoxGeometry(6, 2, 3);
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
body.position.y = 1;
// body.castShadow = true; // Uncomment for shadows
carGroup.add(body);
// Cabin
const cabinGeometry = new THREE.BoxGeometry(3, 1.5, 2.5);
const cabin = new THREE.Mesh(bodyGeometry, bodyMaterial); // Use same material or different
cabin.position.set(0, 2.25, 0); // Place on top of body
carGroup.add(cabin);
// Wheels
const wheelRadius = 0.7;
const wheelWidth = 0.5;
const wheelGeometry = new THREE.CylinderGeometry(wheelRadius, wheelRadius, wheelWidth, 16);
wheelGeometry.rotateX(Math.PI / 2); // Orient wheels correctly
const wheelPositions = [
{ x: 2.5, y: 0.7, z: 1.5 },
{ x: -2.5, y: 0.7, z: 1.5 },
{ x: 2.5, y: 0.7, z: -1.5 },
{ x: -2.5, y: 0.7, z: -1.5 },
];
wheelPositions.forEach(pos => {
const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
wheel.position.set(pos.x, pos.y, pos.z);
// wheel.castShadow = true; // Uncomment for shadows
carGroup.add(wheel);
});
return carGroup;
}
function createPlane() {
const planeGroup = new THREE.Group();
const bodyMaterial = new THREE.MeshStandardMaterial({ color: 0xcccccc }); // Light Grey
const wingMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa }); // Darker Grey
// Fuselage
const fuselageGeometry = new THREE.CylinderGeometry(1, 1, 10, 12);
fuselageGeometry.rotateX(Math.PI / 2); // Point forward
const fuselage = new THREE.Mesh(fuselageGeometry, bodyMaterial);
// fuselage.castShadow = true; // Uncomment for shadows
planeGroup.add(fuselage);
// Wings
const wingGeometry = new THREE.BoxGeometry(12, 0.5, 3);
const wings = new THREE.Mesh(wingGeometry, wingMaterial);
wings.position.y = 0;
// wings.castShadow = true; // Uncomment for shadows
planeGroup.add(wings);
// Tail Wing (Horizontal Stabilizer)
const tailWingGeometry = new THREE.BoxGeometry(5, 0.3, 1.5);
const tailWing = new THREE.Mesh(tailWingGeometry, wingMaterial);
tailWing.position.set(0, 0.5, 4.5); // Back and slightly up
// tailWing.castShadow = true; // Uncomment for shadows
planeGroup.add(tailWing);
// Tail Fin (Vertical Stabilizer)
const tailFinGeometry = new THREE.BoxGeometry(0.3, 2.5, 1.5);
const tailFin = new THREE.Mesh(tailFinGeometry, bodyMaterial);
tailFin.position.set(0, 1.5, 4.5); // Back and up
// tailFin.castShadow = true; // Uncomment for shadows
planeGroup.add(tailFin);
// Propeller (simple)
const propGeometry = new THREE.BoxGeometry(0.2, 3, 0.2);
const propeller = new THREE.Mesh(propGeometry, wingMaterial);
propeller.position.z = -5.2; // Front of fuselage
planeGroup.add(propeller);
planeGroup.rotation.order = 'YXZ'; // Rotation order for intuitive flight controls
return planeGroup;
}
function createRocket() {
const rocketGroup = new THREE.Group();
const bodyMaterial = new THREE.MeshStandardMaterial({ color: 0xdddddd }); // Light Grey
const tipMaterial = new THREE.MeshStandardMaterial({ color: 0xff4444 }); // Reddish
const finMaterial = new THREE.MeshStandardMaterial({ color: 0xaaaaaa }); // Darker Grey
const bodyHeight = 10;
const bodyRadius = 1;
const tipHeight = 3;
// Main Body
const bodyGeometry = new THREE.CylinderGeometry(bodyRadius, bodyRadius, bodyHeight, 16);
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
body.position.y = bodyHeight / 2;
// body.castShadow = true; // Uncomment for shadows
rocketGroup.add(body);
// Nose Cone
const tipGeometry = new THREE.ConeGeometry(bodyRadius, tipHeight, 16);
const tip = new THREE.Mesh(tipGeometry, tipMaterial);
tip.position.y = bodyHeight + tipHeight / 2;
// tip.castShadow = true; // Uncomment for shadows
rocketGroup.add(tip);
// Fins (simple triangles)
const finShape = new THREE.Shape();
finShape.moveTo(0, 0);
finShape.lineTo(0, 3); // Height of fin
finShape.lineTo(-1.5, 0); // Width away from body
finShape.lineTo(0, 0);
const extrudeSettings = { depth: 0.2, bevelEnabled: false };
const finGeometry = new THREE.ExtrudeGeometry(finShape, extrudeSettings);
const finMesh = new THREE.Mesh(finGeometry, finMaterial);
// finMesh.castShadow = true; // Uncomment for shadows
// Add 3 fins
for (let i = 0; i < 3; i++) {
const fin = finMesh.clone();
fin.rotation.y = (i * Math.PI * 2) / 3;
fin.position.y = 0.5; // Position near bottom of body
// Rotate fin to be perpendicular to radius
fin.rotateOnWorldAxis(new THREE.Vector3(0,1,0), (i * Math.PI * 2) / 3);
fin.rotateOnAxis(new THREE.Vector3(1,0,0), -Math.PI / 2); // Make upright
rocketGroup.add(fin);
}
// Initially point straight up
rocketGroup.rotation.x = -Math.PI / 2;
rocketGroup.rotation.order = 'YXZ';
return rocketGroup;
}
function createAIPlane(startPos, speed) {
const planeMesh = createPlane(); // Reuse plane creation function
planeMesh.scale.set(0.5, 0.5, 0.5); // Make AI planes smaller
planeMesh.position.copy(startPos);
const radius = 80 + Math.random() * 40; // Circular path radius
const angleOffset = Math.random() * Math.PI * 2; // Start at random point on circle
const altitude = startPos.y;
return {
mesh: planeMesh,
speed: speed,
radius: radius,
altitude: altitude,
angle: angleOffset, // Current angle on the circle
center: new THREE.Vector3(startPos.x - Math.cos(angleOffset) * radius, altitude, startPos.z - Math.sin(angleOffset) * radius) // Center of the circular path
};
}
// --- Control & Update Functions ---
function switchVehicle(type) {
// Hide all vehicles
Object.values(playerVehicles).forEach(v => v.visible = false);
// Show the selected one
activeVehicle = playerVehicles[type];
if (activeVehicle) {
activeVehicle.visible = true;
currentVehicleType = type;
document.getElementById('info').innerHTML = `Controlling: ${currentVehicleType.toUpperCase()}<br><span id="desktop-instructions">Controls: [W/S] or [Up/Down] = Fwd/Back | [A/D] or [Left/Right] = Turn | [Q/E] or [Shift/Ctrl] = Altitude/Strafe</span>`;
} else {
console.error("Tried to switch to unknown vehicle type:", type);
currentVehicleType = 'car'; // Default back to car
activeVehicle = playerVehicles.car;
activeVehicle.visible = true;
document.getElementById('info').innerHTML = `Controlling: ${currentVehicleType.toUpperCase()}<br><span id="desktop-instructions">Controls: [W/S] or [Up/Down] = Fwd/Back | [A/D] or [Left/Right] = Turn | [Q/E] or [Shift/Ctrl] = Altitude/Strafe</span>`;
}
// Reset controls to avoid sticky keys/buttons
Object.keys(keyboardState).forEach(k => keyboardState[k] = false);
Object.keys(touchState).forEach(k => touchState[k] = false);
}
function updatePlayerVehicle(delta) {
if (!activeVehicle) return;
const settings = vehicleSettings[currentVehicleType];
let moveForward = 0;
let turn = 0;
let altitudeChange = 0;
// Keyboard Input
if (keyboardState['KeyW'] || keyboardState['ArrowUp']) moveForward += 1;
if (keyboardState['KeyS'] || keyboardState['ArrowDown']) moveForward -= 1;
if (keyboardState['KeyA'] || keyboardState['ArrowLeft']) turn += 1;
if (keyboardState['KeyD'] || keyboardState['ArrowRight']) turn -= 1;
if (keyboardState['KeyE'] || keyboardState['ShiftLeft'] || keyboardState['ShiftRight']) altitudeChange += 1; // Q/Shift up
if (keyboardState['KeyQ'] || keyboardState['ControlLeft'] || keyboardState['ControlRight']) altitudeChange -= 1; // E/Ctrl down
// Touch Input
if (touchState.forward) moveForward += 1;
if (touchState.backward) moveForward -= 1;
if (touchState.left) turn += 1;
if (touchState.right) turn -= 1;
if (touchState.up) altitudeChange += 1;
if (touchState.down) altitudeChange -= 1;
// Normalize diagonal movement (optional but good practice)
if(moveForward !== 0 && turn !== 0) {
// moveForward *= 0.707; // Or just cap speed
}
// --- Apply Movement based on Vehicle Type ---
const moveSpeed = settings.speed * delta;
const turnSpeed = settings.turnSpeed * delta;
const altitudeSpeed = (settings.altitudeSpeed || 0) * delta;
// Rotation (Yaw) - applies to all
activeVehicle.rotation.y += turn * turnSpeed;
// Movement
const forwardVector = new THREE.Vector3();
activeVehicle.getWorldDirection(forwardVector);
if (currentVehicleType === 'car') {
activeVehicle.position.addScaledVector(forwardVector, moveForward * moveSpeed);
// Simple strafe for car using altitude controls
const rightVector = new THREE.Vector3();
rightVector.crossVectors(activeVehicle.up, forwardVector).normalize(); // Get right vector relative to car
activeVehicle.position.addScaledVector(rightVector, -altitudeChange * moveSpeed * 0.5); // Strafe with Q/E or Up/Down on mobile
// Keep car on the ground (simple)
activeVehicle.position.y = 0.5;
activeVehicle.rotation.x = 0; // Keep it flat
activeVehicle.rotation.z = 0;
} else if (currentVehicleType === 'plane' || currentVehicleType === 'rocket') {
// Forward/Backward Movement
activeVehicle.position.addScaledVector(forwardVector, moveForward * moveSpeed);
// Altitude Change
activeVehicle.position.y += altitudeChange * altitudeSpeed;
// Banking Turn for Plane/Rocket (Visual Only - Roll)
const targetRoll = -turn * Math.PI / 6; // Max roll angle
activeVehicle.rotation.z += (targetRoll - activeVehicle.rotation.z) * delta * 5; // Smoothly lerp to target roll
// Pitch Control (linked to altitude change for simplicity)
const targetPitch = altitudeChange * Math.PI / 8; // Pitch up/down slightly when changing altitude
activeVehicle.rotation.x += (targetPitch - activeVehicle.rotation.x) * delta * 3;
// Altitude Constraints
if (settings.minAltitude && activeVehicle.position.y < settings.minAltitude) {
activeVehicle.position.y = settings.minAltitude;
}
if (settings.maxAltitude && activeVehicle.position.y > settings.maxAltitude) {
activeVehicle.position.y = settings.maxAltitude;
}
}
// Simple world bounds
const bound = 490;
activeVehicle.position.x = Math.max(-bound, Math.min(bound, activeVehicle.position.x));
activeVehicle.position.z = Math.max(-bound, Math.min(bound, activeVehicle.position.z));
}
function updateAIPlanes(delta) {
aiPlanes.forEach(plane => {
const angularSpeed = plane.speed / plane.radius; // Radians per second
plane.angle += angularSpeed * delta;
plane.angle %= (Math.PI * 2); // Keep angle within 0 to 2*PI
const x = plane.center.x + Math.cos(plane.angle) * plane.radius;
const z = plane.center.z + Math.sin(plane.angle) * plane.radius;
plane.mesh.position.set(x, plane.altitude, z);
// Make the plane look in the direction of travel (tangent to the circle)
const lookAtX = plane.center.x + Math.cos(plane.angle + angularSpeed * delta * 10) * plane.radius; // Look slightly ahead
const lookAtZ = plane.center.z + Math.sin(plane.angle + angularSpeed * delta * 10) * plane.radius;
plane.mesh.lookAt(lookAtX, plane.altitude, lookAtZ);
plane.mesh.rotation.z = -Math.PI / 8; // Constant bank angle
// Simple propeller spin (visual only)
const propeller = plane.mesh.children.find(child => child.geometry.type === 'BoxGeometry' && child.position.z < -5); // Find propeller mesh
if (propeller) {
propeller.rotation.z += delta * 50; // Spin fast
}
});
}
function updateTrain(delta) {
if (!train || !trainPath) return;
const pathLength = trainPath.getLength();
trainT += (trainSpeed / pathLength) * delta; // Increment t based on speed and path length
trainT %= 1.0; // Loop back to start
const currentPos = trainPath.getPointAt(trainT);
const nextPos = trainPath.getPointAt((trainT + 0.001) % 1.0); // Point slightly ahead for orientation
train.position.copy(currentPos);
train.lookAt(nextPos);
// Adjust individual carriages slightly based on path curvature (optional - basic approximation)
const tangent = trainPath.getTangentAt(trainT);
const normal = new THREE.Vector3(0,1,0); // Assume flat ground
const binormal = new THREE.Vector3().crossVectors(tangent, normal).normalize();
train.children.forEach((carriage, index) => {
// Apply a slight offset based on binormal for curve effect
const offsetFactor = (index - (train.children.length - 1) / 2) * 0.1; // Small offset factor
// carriage.position.x = offsetFactor * carriage.geometry.parameters.width * 0.1; // Adjust side position slightly (can cause issues if not careful)
});
}
function updateCamera(delta) {
if (!activeVehicle) return;
const targetCameraPosition = new THREE.Vector3();
activeVehicle.localToWorld(targetCameraPosition.copy(cameraOffset)); // Calculate target position relative to vehicle
// Smoothly interpolate camera position (Lerp)
camera.position.lerp(targetCameraPosition, delta * 4.0);
// Smoothly interpolate camera target (LookAt)
const targetLookAt = new THREE.Vector3();
// Look slightly *ahead* of the vehicle for planes/rockets, directly at car
const lookAheadDistance = (currentVehicleType === 'car' ? 0 : 5);
const lookAtOffset = new THREE.Vector3(0, 1.5, lookAheadDistance); // Offset slightly up and forward from vehicle center
activeVehicle.localToWorld(targetLookAt.copy(lookAtOffset));
// Store current lookAt target
const currentLookAt = new THREE.Vector3(); // Need a vector to store the current target
// Get the current direction the camera is pointing
camera.getWorldDirection(currentLookAt);
// Add the current camera position to get the point in space it's looking at
currentLookAt.add(camera.position);
// Lerp the lookAt point
currentLookAt.lerp(targetLookAt, delta * 3.0);
camera.lookAt(currentLookAt); // Use the interpolated lookAt point
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function setupMobileControls() {
const buttons = [
{ id: 'btn-fwd', stateKey: 'forward' },
{ id: 'btn-back', stateKey: 'backward' },
{ id: 'btn-left', stateKey: 'left' },
{ id: 'btn-right', stateKey: 'right' },
{ id: 'btn-up', stateKey: 'up' },
{ id: 'btn-down', stateKey: 'down' },
];
buttons.forEach(({ id, stateKey }) => {
const button = document.getElementById(id);
if (button) {
button.addEventListener('touchstart', (e) => { e.preventDefault(); touchState[stateKey] = true; }, { passive: false });
button.addEventListener('touchend', (e) => { e.preventDefault(); touchState[stateKey] = false; }, { passive: false });
button.addEventListener('touchcancel', (e) => { e.preventDefault(); touchState[stateKey] = false; }, { passive: false });
// Mouse events for desktop testing
button.addEventListener('mousedown', () => { touchState[stateKey] = true; });
button.addEventListener('mouseup', () => { touchState[stateKey] = false; });
button.addEventListener('mouseleave', () => { touchState[stateKey] = false; });
} else {
console.warn(`Button with ID ${id} not found.`);
}
});
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
updatePlayerVehicle(delta);
updateAIPlanes(delta);
updateTrain(delta);
updateCamera(delta);
renderer.render(scene, camera);
}
</script>
</body>
</html>
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.