<!DOCTYPE html>
<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>

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

This Pen doesn't use any external JavaScript resources.