<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Advanced 3D Car Simulator - Single File Demo</title>
<style>
body { margin: 0; overflow: hidden; background-color: #87CEEB; font-family: Arial, sans-serif; }
canvas { display: block; }
#info {
position: absolute;
top: 10px;
width: 100%;
text-align: center;
z-index: 100;
display: block;
color: white;
background-color: rgba(0,0,0,0.3);
padding: 5px 0;
}
/* Basic Touch Controls UI */
#touch-controls {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 150px; /* Adjust height as needed */
z-index: 10;
display: flex; /* Or use absolute positioning for zones */
opacity: 0.3; /* Make semi-transparent */
}
#touch-left {
flex: 1;
background-color: rgba(255, 255, 0, 0.3); /* Yellowish for steering */
border-right: 1px solid rgba(255,255,255,0.5);
display: flex;
justify-content: center;
align-items: center;
color: white;
font-size: 1.5em;
}
#touch-right {
flex: 1;
background-color: rgba(0, 255, 0, 0.3); /* Greenish for accel/brake */
display: flex;
justify-content: center;
align-items: center;
color: white;
font-size: 1.5em;
}
/* Hide touch controls on non-touch devices potentially */
@media (hover: hover) and (pointer: fine) {
/* #touch-controls { display: none; } */ /* Optional: hide on desktop */
}
</style>
</head>
<body>
<div id="info">Use WASD or Arrow keys to drive. Touch: Left side=Steer, Right side=Accelerate/Brake (Tap=Accel, Hold=Brake - Simplified)</div>
<div id="container"></div>
<!-- Basic Touch Controls Layer -->
<div id="touch-controls">
<div id="touch-left">STEER</div>
<div id="touch-right">ACCEL/BRAKE</div>
</div>
<!-- Import map for Three.js -->
<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 car, ground, train;
let mountains = [], trees = [], clouds = [];
let trainPath, trainProgress = 0;
const worldSize = 400; // Size of the ground plane
// Car physics properties (simplified)
const carProps = {
speed: 0,
maxSpeed: 1.5,
acceleration: 0.02,
braking: 0.05,
friction: 0.01,
steering: 0.03,
maxSteer: 0.5
};
// Input state
const keys = {
forward: false,
backward: false,
left: false,
right: false
};
// Touch state
let touchLeftActive = false;
let touchRightActive = false;
let touchLeftX = 0; // Relative X position on the left side (-1 to 1)
let touchRightAction = 'none'; // 'accel', 'brake'
init();
animate();
function init() {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB); // Sky blue
scene.fog = new THREE.Fog(0x87CEEB, worldSize * 0.3, worldSize * 0.9);
// Clock
clock = new THREE.Clock();
// Camera
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, worldSize * 2);
camera.position.set(0, 5, -10); // Initial position slightly behind where car will be
camera.lookAt(0, 0, 0);
// Renderer
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows
document.getElementById('container').appendChild(renderer.domElement);
// Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(50, 100, 50);
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 = -worldSize/2;
directionalLight.shadow.camera.right = worldSize/2;
directionalLight.shadow.camera.top = worldSize/2;
directionalLight.shadow.camera.bottom = -worldSize/2;
scene.add(directionalLight);
// const shadowHelper = new THREE.CameraHelper( directionalLight.shadow.camera ); // Debug shadows
// scene.add( shadowHelper );
// Ground
createGround();
// Road (Texture on Ground) - Now using the texture
// createRoad(); // No longer needed as separate geometry
// Mountains
createMountains(20);
// Trees
createTrees(100);
// Clouds
createClouds(30);
// Car
createCar();
// Train
createTrain();
// Event Listeners
window.addEventListener('resize', onWindowResize, false);
document.addEventListener('keydown', onKeyDown, false);
document.addEventListener('keyup', onKeyUp, false);
// Touch Event Listeners
const touchLeftEl = document.getElementById('touch-left');
const touchRightEl = document.getElementById('touch-right');
touchLeftEl.addEventListener('touchstart', handleTouchStartLeft, { passive: false });
touchLeftEl.addEventListener('touchmove', handleTouchMoveLeft, { passive: false });
touchLeftEl.addEventListener('touchend', handleTouchEndLeft, { passive: false });
touchLeftEl.addEventListener('touchcancel', handleTouchEndLeft, { passive: false });
touchRightEl.addEventListener('touchstart', handleTouchStartRight, { passive: false });
// touchRightEl.addEventListener('touchmove', handleTouchMoveRight, { passive: false }); // Can add if needed
touchRightEl.addEventListener('touchend', handleTouchEndRight, { passive: false });
touchRightEl.addEventListener('touchcancel', handleTouchEndRight, { passive: false });
}
function createGround() {
// Simple road texture using Data URL (white dashed line on grey)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 64;
canvas.height = 64;
ctx.fillStyle = '#404040'; // Dark grey background
ctx.fillRect(0, 0, 64, 64);
ctx.strokeStyle = '#FFFFFF'; // White line
ctx.lineWidth = 3;
ctx.setLineDash([10, 10]); // Dashed line pattern
ctx.beginPath();
ctx.moveTo(32, 0);
ctx.lineTo(32, 64);
ctx.stroke();
const roadTextureUrl = canvas.toDataURL();
const roadTexture = new THREE.TextureLoader().load(roadTextureUrl);
roadTexture.wrapS = THREE.RepeatWrapping;
roadTexture.wrapT = THREE.RepeatWrapping;
roadTexture.repeat.set(1, worldSize / 8); // Repeat vertically along the road length
const groundTextureUrl = createGroundTexture();
const groundTexture = new THREE.TextureLoader().load(groundTextureUrl);
groundTexture.wrapS = THREE.RepeatWrapping;
groundTexture.wrapT = THREE.RepeatWrapping;
groundTexture.repeat.set(worldSize / 10, worldSize / 10);
const groundMaterial = new THREE.MeshStandardMaterial({ map: groundTexture, roughness: 0.9, metalness: 0.1 });
const roadMaterial = new THREE.MeshStandardMaterial({ map: roadTexture, roughness: 0.8, metalness: 0.1 });
const groundGeometry = new THREE.PlaneGeometry(worldSize, worldSize);
ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; // Rotate to be flat
ground.receiveShadow = true;
scene.add(ground);
// Add the road plane slightly above the ground
const roadWidth = 10;
const roadGeometry = new THREE.PlaneGeometry(roadWidth, worldSize);
const road = new THREE.Mesh(roadGeometry, roadMaterial);
road.rotation.x = -Math.PI / 2;
road.position.y = 0.01; // Slightly above ground
road.receiveShadow = true;
scene.add(road);
}
function createGroundTexture() {
// Procedural simple green grass texture
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 32;
canvas.height = 32;
ctx.fillStyle = '#2E7D32'; // Base green
ctx.fillRect(0, 0, 32, 32);
// Add some noise/variation
for (let i = 0; i < 100; i++) {
const x = Math.random() * 32;
const y = Math.random() * 32;
const lightness = Math.random() * 0.2 + 0.9; // 0.9 to 1.1
const baseGreen = [46, 125, 50];
ctx.fillStyle = `rgb(${Math.floor(baseGreen[0] * lightness)}, ${Math.floor(baseGreen[1] * lightness)}, ${Math.floor(baseGreen[2] * lightness)})`;
ctx.fillRect(x, y, 1, 1);
}
return canvas.toDataURL();
}
function createMountains(count) {
const geometry = new THREE.ConeGeometry(40, 80 + Math.random() * 120, 8); // Base radius, height, segments
const material = new THREE.MeshStandardMaterial({ color: 0x606060, roughness: 0.8, flatShading: true });
for (let i = 0; i < count; i++) {
const mountain = new THREE.Mesh(geometry, material);
const angle = Math.random() * Math.PI * 2;
const distance = worldSize * 0.4 + Math.random() * worldSize * 0.2; // Place near edges
mountain.position.set(
Math.cos(angle) * distance,
geometry.parameters.height / 2 - 1, // Base slightly below ground
Math.sin(angle) * distance
);
mountain.scale.set(1 + Math.random(), 1 + Math.random() * 1.5, 1 + Math.random());
mountain.rotation.y = Math.random() * Math.PI;
mountain.castShadow = true;
mountain.receiveShadow = true;
scene.add(mountain);
mountains.push(mountain);
}
}
function createTrees(count) {
const trunkHeight = 4 + Math.random() * 4;
const foliageRadius = 2 + Math.random() * 2;
const trunkGeo = new THREE.CylinderGeometry(0.5, 0.7, trunkHeight, 8);
const trunkMat = new THREE.MeshStandardMaterial({ color: 0x8B4513 }); // Brown
const foliageGeo = new THREE.ConeGeometry(foliageRadius, foliageRadius * 2.5, 8);
const foliageMat = new THREE.MeshStandardMaterial({ color: 0x228B22 }); // Forest green
for (let i = 0; i < count; i++) {
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
const foliage = new THREE.Mesh(foliageGeo, foliageMat);
const tree = new THREE.Group();
trunk.position.y = trunkHeight / 2;
trunk.castShadow = true;
foliage.position.y = trunkHeight + foliageRadius * 1;
foliage.castShadow = true;
tree.add(trunk);
tree.add(foliage);
// Place trees randomly, avoiding the road and center
let placed = false;
while (!placed) {
const x = (Math.random() - 0.5) * worldSize;
const z = (Math.random() - 0.5) * worldSize;
if (Math.abs(x) > 8) { // Avoid road area (approx roadWidth/2 + buffer)
tree.position.set(x, 0, z);
placed = true;
}
}
tree.castShadow = true; // Group doesn't cast, children do
scene.add(tree);
trees.push(tree);
}
}
function createClouds(count) {
const geometry = new THREE.SphereGeometry(10 + Math.random() * 15, 8, 6);
const material = new THREE.MeshStandardMaterial({ color: 0xffffff, transparent: true, opacity: 0.8, flatShading: true });
for (let i = 0; i < count; i++) {
const cloud = new THREE.Mesh(geometry, material);
cloud.position.set(
(Math.random() - 0.5) * worldSize * 1.2, // Spread wider than ground
100 + Math.random() * 50, // High up
(Math.random() - 0.5) * worldSize * 1.2
);
cloud.scale.set(1 + Math.random(), 0.5 + Math.random() * 0.5, 1 + Math.random()); // Flattened spheres
// No shadows for clouds to save performance
// cloud.castShadow = true;
scene.add(cloud);
clouds.push(cloud);
}
}
function createCar() {
const carBodyGeo = new THREE.BoxGeometry(2, 1, 4);
const carBodyMat = new THREE.MeshStandardMaterial({ color: 0xff0000, roughness: 0.5, metalness: 0.3 });
car = new THREE.Mesh(carBodyGeo, carBodyMat);
car.position.set(0, 0.5, 0); // Start at center, half height up
car.castShadow = true;
// Simple wheels
const wheelGeo = new THREE.CylinderGeometry(0.4, 0.4, 0.3, 16);
const wheelMat = new THREE.MeshStandardMaterial({ color: 0x111111 });
const wheelPositions = [
{ x: -1, y: 0.4, z: 1.5 }, { x: 1, y: 0.4, z: 1.5 }, // Front
{ x: -1, y: 0.4, z: -1.5 }, { x: 1, y: 0.4, z: -1.5 } // Rear
];
wheelPositions.forEach(pos => {
const wheel = new THREE.Mesh(wheelGeo, wheelMat);
wheel.rotation.z = Math.PI / 2; // Align horizontally
wheel.position.set(pos.x, pos.y, pos.z);
wheel.castShadow = true;
car.add(wheel); // Add wheels as children of the car body
});
scene.add(car);
}
function createTrain() {
const trainGroup = new THREE.Group();
const trainRadius = worldSize * 0.3; // Circular path radius
const numCars = 5;
const carLength = 10;
const carSpacing = 2;
// Define the circular path
trainPath = new THREE.CatmullRomCurve3(
Array.from({ length: 50 }, (_, i) => {
const angle = (i / 50) * Math.PI * 2;
return new THREE.Vector3(Math.cos(angle) * trainRadius, 0.6, Math.sin(angle) * trainRadius);
}),
true // Closed loop
);
// Visualize path (optional debug)
// const points = trainPath.getPoints( 50 );
// const geometry = new THREE.BufferGeometry().setFromPoints( points );
// const material = new THREE.LineBasicMaterial( { color : 0xff0000 } );
// const curveObject = new THREE.Line( geometry, material );
// scene.add( curveObject );
// Create train cars
const carGeo = new THREE.BoxGeometry(carLength, 3, 4);
const carMat = new THREE.MeshStandardMaterial({ color: 0x0000ff, roughness: 0.6 });
for (let i = 0; i < numCars; i++) {
const trainCar = new THREE.Mesh(carGeo, carMat);
trainCar.castShadow = true;
trainCar.userData.offset = i * (carLength + carSpacing); // Store offset for positioning
trainGroup.add(trainCar);
}
train = trainGroup;
scene.add(train);
}
// --- Update Functions ---
function updateCar(delta) {
let steerValue = 0;
// --- Touch Controls ---
if (touchLeftActive) {
// Simple linear steering based on touch position relative to center of left zone
steerValue = -touchLeftX * carProps.maxSteer; // Invert X
}
if (touchRightActive) {
// Simple logic: Tap/brief touch = accelerate, Longer hold = brake
// This needs refinement. Maybe use two distinct areas on the right?
// Or differentiate based on touch duration? Let's try duration.
// If touch duration > 200ms, assume brake. (Needs state tracking)
// --- Simplified: Always accelerate for now ---
if (touchRightAction === 'accel') {
keys.forward = true;
keys.backward = false;
} else if (touchRightAction === 'brake') {
keys.forward = false;
keys.backward = true;
}
} else {
// Reset touch-driven keys if touch ends
// keys.forward = false; // Keep keyboard separate for now
// keys.backward = false;
}
// --- Keyboard Controls ---
if (keys.left) steerValue = carProps.maxSteer;
if (keys.right) steerValue = -carProps.maxSteer;
// Apply steering rotation smoothly (optional - lerp or direct)
// Direct application for simplicity:
const steerRotation = steerValue * carProps.steering * (carProps.speed / carProps.maxSpeed); // Less steering at low speed
car.rotation.y += steerRotation;
// Apply acceleration/braking
if (keys.forward) {
carProps.speed += carProps.acceleration;
} else if (keys.backward) {
carProps.speed -= carProps.braking;
} else {
// Apply friction
if (carProps.speed > 0) {
carProps.speed -= carProps.friction;
} else if (carProps.speed < 0) {
carProps.speed += carProps.friction;
}
// Stop completely if speed is very low
if (Math.abs(carProps.speed) < carProps.friction) {
carProps.speed = 0;
}
}
// Clamp speed
carProps.speed = Math.max(-carProps.maxSpeed / 2, Math.min(carProps.maxSpeed, carProps.speed)); // Allow reversing slower
// Move the car
const moveDistance = carProps.speed * delta * 60; // Adjust multiplier for desired speed feel
const moveX = Math.sin(car.rotation.y) * moveDistance;
const moveZ = Math.cos(car.rotation.y) * moveDistance;
car.position.x += moveX;
car.position.z += moveZ;
// Basic world bounds check
const limit = worldSize / 2 - 5; // Keep car within bounds
car.position.x = Math.max(-limit, Math.min(limit, car.position.x));
car.position.z = Math.max(-limit, Math.min(limit, car.position.z));
// Stop if hitting boundary (very basic collision)
if (Math.abs(car.position.x) >= limit || Math.abs(car.position.z) >= limit) {
carProps.speed *= 0.5; // Slow down drastically
}
}
function updateTrain(delta) {
const trainSpeed = 0.01; // Speed along the curve (0 to 1 per second)
trainProgress = (trainProgress + trainSpeed * delta) % 1; // Loop progress
const pathLength = trainPath.getLength();
const currentPos = trainPath.getPointAt(trainProgress);
const tangent = trainPath.getTangentAt(trainProgress);
// Position and orient each car
train.children.forEach(trainCar => {
// Calculate the progress for this specific car based on its offset
const carOffsetDistance = trainCar.userData.offset;
const carProgressOffset = carOffsetDistance / pathLength;
// Calculate car's position slightly 'behind' the main progress point
let carSpecificProgress = (trainProgress - carProgressOffset + 1) % 1; // +1 handles negative wrap
const carPos = trainPath.getPointAt(carSpecificProgress);
const carTangent = trainPath.getTangentAt(carSpecificProgress);
trainCar.position.copy(carPos);
// Orient the car to face along the tangent
trainCar.lookAt(carPos.clone().add(carTangent));
trainCar.position.y = 0.6; // Ensure it's slightly above ground
});
}
function updateCamera() {
// Simple third-person camera follow
const relativeCameraOffset = new THREE.Vector3(0, 4, -8); // Behind and above car
const cameraOffset = relativeCameraOffset.applyMatrix4(car.matrixWorld);
// Smooth camera movement (lerp)
camera.position.lerp(cameraOffset, 0.1); // Adjust 0.1 for faster/slower smoothing
camera.lookAt(car.position.clone().add(new THREE.Vector3(0, 1, 0))); // Look slightly above car center
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta();
updateCar(delta);
updateTrain(delta);
updateCamera(); // Camera update should be after car update
renderer.render(scene, camera);
}
// --- Event Handlers ---
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
function onKeyDown(event) {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
keys.forward = true;
break;
case 'ArrowDown':
case 'KeyS':
keys.backward = true;
break;
case 'ArrowLeft':
case 'KeyA':
keys.left = true;
break;
case 'ArrowRight':
case 'KeyD':
keys.right = true;
break;
}
}
function onKeyUp(event) {
switch (event.code) {
case 'ArrowUp':
case 'KeyW':
keys.forward = false;
break;
case 'ArrowDown':
case 'KeyS':
keys.backward = false;
break;
case 'ArrowLeft':
case 'KeyA':
keys.left = false;
break;
case 'ArrowRight':
case 'KeyD':
keys.right = false;
break;
}
}
// --- Touch Handlers ---
function handleTouchStartLeft(event) {
event.preventDefault(); // Prevent scrolling/zooming
touchLeftActive = true;
const touch = event.touches[0];
const rect = event.target.getBoundingClientRect();
// Calculate relative X position within the left zone (-1 to 1)
touchLeftX = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
}
function handleTouchMoveLeft(event) {
event.preventDefault();
if (!touchLeftActive) return;
const touch = event.touches[0];
const rect = event.target.getBoundingClientRect();
touchLeftX = ((touch.clientX - rect.left) / rect.width) * 2 - 1;
}
function handleTouchEndLeft(event) {
// Don't prevent default on touchend if you want click events later
touchLeftActive = false;
touchLeftX = 0; // Reset steering
keys.left = false; // Ensure keyboard state reset if touch ends
keys.right = false;
}
// --- Touch Right Side (Accel/Brake) ---
let touchRightStartTime = 0;
function handleTouchStartRight(event) {
event.preventDefault();
touchRightActive = true;
touchRightStartTime = Date.now();
// Immediately assume acceleration on touch start
touchRightAction = 'accel';
keys.forward = true; // Activate car movement immediately
keys.backward = false;
}
// Optional: Handle Move on Right Side (Could change action based on Y position?)
// function handleTouchMoveRight(event) { ... }
function handleTouchEndRight(event) {
touchRightActive = false;
touchRightAction = 'none';
keys.forward = false; // Deactivate car movement
keys.backward = false;
touchRightStartTime = 0;
}
</script>
</body>
</html>
This Pen doesn't use any external CSS resources.
This Pen doesn't use any external JavaScript resources.