-
-
Save shricodev/ea15914739acffb063c0ace229c0c057 to your computer and use it in GitHub Desktop.
3D Town and Fire truck Simulation (Developed by Gemini 2.5 Pro Model) - Blog Demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>3D Town Builder & Driver</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: Arial, sans-serif; | |
| background-color: #333; | |
| } | |
| #game-canvas { | |
| display: block; | |
| } | |
| #ui-container { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| color: white; | |
| background-color: rgba(0, 0, 0, 0.5); | |
| padding: 15px; | |
| border-radius: 10px; | |
| max-width: 300px; | |
| } | |
| h3 { | |
| margin-top: 0; | |
| border-bottom: 1px solid #fff; | |
| padding-bottom: 5px; | |
| } | |
| button { | |
| display: block; | |
| width: 100%; | |
| padding: 8px; | |
| margin-top: 8px; | |
| cursor: pointer; | |
| background-color: #555; | |
| color: white; | |
| border: 1px solid #777; | |
| border-radius: 5px; | |
| } | |
| button:hover { | |
| background-color: #777; | |
| } | |
| button.selected { | |
| background-color: #007bff; | |
| border-color: #0056b3; | |
| } | |
| #instructions { | |
| position: absolute; | |
| bottom: 10px; | |
| right: 10px; | |
| color: white; | |
| background-color: rgba(0, 0, 0, 0.5); | |
| padding: 10px; | |
| border-radius: 5px; | |
| text-align: right; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="ui-container"> | |
| <h3 id="mode-title">Placement Mode</h3> | |
| <div id="placement-controls"> | |
| <p>1. Select a building type:</p> | |
| <button id="btn-house">House</button> | |
| <button id="btn-skyscraper">Skyscraper</button> | |
| <button id="btn-office">Office Block</button> | |
| <p style="margin-top: 15px">2. Click on the green areas to place it.</p> | |
| <button | |
| id="btn-drive" | |
| style="background-color: #28a745; margin-top: 20px" | |
| > | |
| Switch to Drive Mode | |
| </button> | |
| </div> | |
| <div id="drive-controls" style="display: none"> | |
| <p>Use <b>W, A, S, D</b> to drive.</p> | |
| <button id="btn-build">Switch to Placement Mode</button> | |
| </div> | |
| </div> | |
| <div id="instructions"> | |
| <div id="placement-instructions"> | |
| <b>Controls:</b><br /> | |
| Left Click: Place Building<br /> | |
| Mouse Wheel: Zoom<br /> | |
| Right Click + Drag: Pan | |
| </div> | |
| <div id="drive-instructions" style="display: none"> | |
| <b>Controls:</b><br /> | |
| W: Accelerate<br /> | |
| S: Brake/Reverse<br /> | |
| A/D: Steer | |
| </div> | |
| </div> | |
| <canvas id="game-canvas"></canvas> | |
| <!-- Import Three.js and OrbitControls --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from "three"; | |
| import { OrbitControls } from "three/addons/controls/OrbitControls.js"; | |
| // --- SCENE SETUP --- | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); // Sky blue | |
| scene.fog = new THREE.Fog(0x87ceeb, 100, 300); | |
| const camera = new THREE.PerspectiveCamera( | |
| 75, | |
| window.innerWidth / window.innerHeight, | |
| 0.1, | |
| 1000, | |
| ); | |
| const renderer = new THREE.WebGLRenderer({ | |
| canvas: document.getElementById("game-canvas"), | |
| antialias: true, | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| // --- LIGHTING --- | |
| const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); | |
| scene.add(ambientLight); | |
| const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); | |
| directionalLight.position.set(50, 100, 25); | |
| directionalLight.castShadow = true; | |
| directionalLight.shadow.mapSize.width = 2048; | |
| directionalLight.shadow.mapSize.height = 2048; | |
| directionalLight.shadow.camera.left = -150; | |
| directionalLight.shadow.camera.right = 150; | |
| directionalLight.shadow.camera.top = 150; | |
| directionalLight.shadow.camera.bottom = -150; | |
| scene.add(directionalLight); | |
| // --- CONTROLS & STATE --- | |
| let controls = new OrbitControls(camera, renderer.domElement); | |
| let currentMode = "placement"; // 'placement' or 'driving' | |
| let selectedBuilding = null; | |
| const buildings = []; // To store placed buildings for collision | |
| const buildingBoundingBoxes = []; | |
| // --- GROUND & ROADS --- | |
| const groundSize = 200; | |
| const gridSize = 20; | |
| const roadWidth = 4; | |
| const groundGeometry = new THREE.PlaneGeometry(groundSize, groundSize); | |
| const groundMaterial = new THREE.MeshStandardMaterial({ | |
| color: 0x228b22, | |
| }); // ForestGreen | |
| const ground = new THREE.Mesh(groundGeometry, groundMaterial); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.receiveShadow = true; | |
| scene.add(ground); | |
| // Create roads | |
| const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x444444 }); | |
| for (let i = -groundSize / 2; i <= groundSize / 2; i += gridSize) { | |
| // Roads along Z-axis | |
| const roadZ = new THREE.Mesh( | |
| new THREE.BoxGeometry(roadWidth, 0.1, groundSize), | |
| roadMaterial, | |
| ); | |
| roadZ.position.set(i, 0.05, 0); | |
| roadZ.receiveShadow = true; | |
| scene.add(roadZ); | |
| // Roads along X-axis | |
| const roadX = new THREE.Mesh( | |
| new THREE.BoxGeometry(groundSize, 0.1, roadWidth), | |
| roadMaterial, | |
| ); | |
| roadX.position.set(0, 0.05, i); | |
| roadX.receiveShadow = true; | |
| scene.add(roadX); | |
| } | |
| // --- BUILDING PLACEMENT --- | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| let previewBuilding = null; | |
| const buildingTypes = { | |
| house: { size: new THREE.Vector3(5, 6, 7), color: 0xdeb887 }, | |
| skyscraper: { size: new THREE.Vector3(8, 30, 8), color: 0xc0c0c0 }, | |
| office: { size: new THREE.Vector3(15, 12, 10), color: 0x778899 }, | |
| }; | |
| function createBuildingMesh(type, isPreview = false) { | |
| const definition = buildingTypes[type]; | |
| const geometry = new THREE.BoxGeometry( | |
| definition.size.x, | |
| definition.size.y, | |
| definition.size.z, | |
| ); | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: definition.color, | |
| transparent: isPreview, | |
| opacity: isPreview ? 0.5 : 1.0, | |
| }); | |
| const building = new THREE.Mesh(geometry, material); | |
| building.castShadow = !isPreview; | |
| building.receiveShadow = !isPreview; | |
| building.position.y = definition.size.y / 2; | |
| return building; | |
| } | |
| function selectBuilding(type) { | |
| document | |
| .querySelectorAll("#placement-controls button") | |
| .forEach((b) => b.classList.remove("selected")); | |
| if (selectedBuilding === type) { | |
| selectedBuilding = null; | |
| if (previewBuilding) scene.remove(previewBuilding); | |
| previewBuilding = null; | |
| } else { | |
| selectedBuilding = type; | |
| document.getElementById(`btn-${type}`).classList.add("selected"); | |
| if (previewBuilding) scene.remove(previewBuilding); | |
| previewBuilding = createBuildingMesh(type, true); | |
| scene.add(previewBuilding); | |
| } | |
| } | |
| // --- PLAYER CAR --- | |
| let playerCar = null; | |
| const carState = { | |
| velocity: new THREE.Vector3(), | |
| speed: 0, | |
| acceleration: 0.02, | |
| braking: 0.04, | |
| drag: 0.98, | |
| maxSpeed: 1.5, | |
| steerAngle: 0.04, | |
| }; | |
| const keyState = {}; | |
| function createPlayerCar() { | |
| const carGroup = new THREE.Group(); | |
| const bodyGeo = new THREE.BoxGeometry(2, 1, 4); | |
| const bodyMat = new THREE.MeshStandardMaterial({ color: 0xff0000 }); // Red | |
| const body = new THREE.Mesh(bodyGeo, bodyMat); | |
| body.castShadow = true; | |
| body.position.y = 0.5; | |
| carGroup.add(body); | |
| carGroup.position.set(0, 0, 5); | |
| return carGroup; | |
| } | |
| function updatePlayerCar() { | |
| if (!playerCar) return; | |
| // Handle input | |
| if (keyState["w"]) carState.speed += carState.acceleration; | |
| if (keyState["s"]) carState.speed -= carState.braking; | |
| // Apply drag and clamp speed | |
| carState.speed *= carState.drag; | |
| carState.speed = Math.max( | |
| -carState.maxSpeed / 2, | |
| Math.min(carState.maxSpeed, carState.speed), | |
| ); | |
| if (Math.abs(carState.speed) < 0.001) carState.speed = 0; | |
| // Steering | |
| if (carState.speed !== 0) { | |
| const steerDirection = keyState["a"] ? 1 : keyState["d"] ? -1 : 0; | |
| playerCar.rotation.y += | |
| steerDirection * | |
| carState.steerAngle * | |
| (carState.speed > 0 ? 1 : -1); | |
| } | |
| // Calculate velocity and new position | |
| carState.velocity | |
| .set(0, 0, -carState.speed) | |
| .applyQuaternion(playerCar.quaternion); | |
| const nextPosition = playerCar.position.clone().add(carState.velocity); | |
| // Collision Detection | |
| const carBox = new THREE.Box3().setFromObject(playerCar); | |
| carBox.min.add(carState.velocity); | |
| carBox.max.add(carState.velocity); | |
| let collision = false; | |
| for (const buildingBox of buildingBoundingBoxes) { | |
| if (carBox.intersectsBox(buildingBox)) { | |
| collision = true; | |
| break; | |
| } | |
| } | |
| if (!collision) { | |
| playerCar.position.copy(nextPosition); | |
| } else { | |
| carState.speed = 0; // Stop on collision | |
| } | |
| // Update camera to follow car | |
| const cameraOffset = new THREE.Vector3(0, 5, 10); | |
| cameraOffset.applyQuaternion(playerCar.quaternion); | |
| camera.position.copy(playerCar.position).add(cameraOffset); | |
| camera.lookAt(playerCar.position); | |
| } | |
| // --- AI TRAFFIC --- | |
| const trafficCars = []; | |
| const trafficPath = new THREE.CatmullRomCurve3([ | |
| new THREE.Vector3(-80, 0, 80), | |
| new THREE.Vector3(80, 0, 80), | |
| new THREE.Vector3(80, 0, -80), | |
| new THREE.Vector3(-80, 0, -80), | |
| ]); | |
| trafficPath.closed = true; | |
| function createTrafficCar(color, offset) { | |
| const carGroup = new THREE.Group(); | |
| const bodyGeo = new THREE.BoxGeometry(2, 1, 4); | |
| const bodyMat = new THREE.MeshStandardMaterial({ color }); | |
| const body = new THREE.Mesh(bodyGeo, bodyMat); | |
| body.castShadow = true; | |
| body.position.y = 0.5; | |
| carGroup.add(body); | |
| carGroup.userData.pathOffset = offset; | |
| scene.add(carGroup); | |
| trafficCars.push(carGroup); | |
| } | |
| function updateTraffic(delta) { | |
| trafficCars.forEach((car) => { | |
| car.userData.pathOffset = | |
| (car.userData.pathOffset + delta * 0.01) % 1; | |
| const pos = trafficPath.getPointAt(car.userData.pathOffset); | |
| const tangent = trafficPath.getTangentAt(car.userData.pathOffset); | |
| pos.y = car.position.y; | |
| car.position.copy(pos); | |
| car.lookAt(pos.add(tangent)); | |
| }); | |
| } | |
| createTrafficCar(0x0000ff, 0.0); // Blue car | |
| createTrafficCar(0xffff00, 0.5); // Yellow car | |
| // --- MODE SWITCHING --- | |
| function setMode(mode) { | |
| currentMode = mode; | |
| if (mode === "placement") { | |
| // UI | |
| document.getElementById("mode-title").innerText = "Placement Mode"; | |
| document.getElementById("placement-controls").style.display = "block"; | |
| document.getElementById("drive-controls").style.display = "none"; | |
| document.getElementById("placement-instructions").style.display = | |
| "block"; | |
| document.getElementById("drive-instructions").style.display = "none"; | |
| // Scene | |
| if (playerCar) scene.remove(playerCar); | |
| playerCar = null; | |
| // Camera and Controls | |
| controls.enabled = true; | |
| controls.target.set(0, 0, 0); | |
| camera.position.set(0, 80, 80); | |
| camera.lookAt(0, 0, 0); | |
| if (selectedBuilding) { | |
| if (previewBuilding) scene.remove(previewBuilding); | |
| previewBuilding = createBuildingMesh(selectedBuilding, true); | |
| scene.add(previewBuilding); | |
| } | |
| } else if (mode === "driving") { | |
| // UI | |
| document.getElementById("mode-title").innerText = "Driving Mode"; | |
| document.getElementById("placement-controls").style.display = "none"; | |
| document.getElementById("drive-controls").style.display = "block"; | |
| document.getElementById("placement-instructions").style.display = | |
| "none"; | |
| document.getElementById("drive-instructions").style.display = "block"; | |
| // Scene | |
| playerCar = createPlayerCar(); | |
| scene.add(playerCar); | |
| // Camera and Controls | |
| controls.enabled = false; | |
| if (previewBuilding) scene.remove(previewBuilding); | |
| previewBuilding = null; | |
| } | |
| } | |
| // --- EVENT LISTENERS --- | |
| window.addEventListener("resize", () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| window.addEventListener("mousemove", (event) => { | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
| if (currentMode === "placement" && previewBuilding) { | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObject(ground); | |
| if (intersects.length > 0) { | |
| const pos = intersects[0].point; | |
| const halfGrid = gridSize / 2; | |
| // Snap to grid | |
| pos.x = Math.round(pos.x / gridSize) * gridSize + halfGrid; | |
| pos.z = Math.round(pos.z / gridSize) * gridSize + halfGrid; | |
| previewBuilding.position.x = pos.x; | |
| previewBuilding.position.z = pos.z; | |
| } | |
| } | |
| }); | |
| window.addEventListener("click", (event) => { | |
| if ( | |
| currentMode === "placement" && | |
| selectedBuilding && | |
| previewBuilding | |
| ) { | |
| // Check if clicking on UI | |
| if (document.getElementById("ui-container").contains(event.target)) | |
| return; | |
| const newBuilding = createBuildingMesh(selectedBuilding, false); | |
| newBuilding.position.copy(previewBuilding.position); | |
| scene.add(newBuilding); | |
| buildings.push(newBuilding); | |
| // Add bounding box for collision detection | |
| const box = new THREE.Box3().setFromObject(newBuilding); | |
| buildingBoundingBoxes.push(box); | |
| } | |
| }); | |
| window.addEventListener("keydown", (e) => { | |
| keyState[e.key.toLowerCase()] = true; | |
| }); | |
| window.addEventListener("keyup", (e) => { | |
| keyState[e.key.toLowerCase()] = false; | |
| }); | |
| // UI Buttons | |
| document | |
| .getElementById("btn-house") | |
| .addEventListener("click", () => selectBuilding("house")); | |
| document | |
| .getElementById("btn-skyscraper") | |
| .addEventListener("click", () => selectBuilding("skyscraper")); | |
| document | |
| .getElementById("btn-office") | |
| .addEventListener("click", () => selectBuilding("office")); | |
| document | |
| .getElementById("btn-drive") | |
| .addEventListener("click", () => setMode("driving")); | |
| document | |
| .getElementById("btn-build") | |
| .addEventListener("click", () => setMode("placement")); | |
| // --- ANIMATION LOOP --- | |
| const clock = new THREE.Clock(); | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = clock.getDelta(); | |
| if (currentMode === "placement") { | |
| controls.update(); | |
| } else if (currentMode === "driving") { | |
| updatePlayerCar(); | |
| } | |
| updateTraffic(delta); | |
| renderer.render(scene, camera); | |
| } | |
| // Start | |
| setMode("placement"); | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>3D Firefight & Town Builder</title> | |
| <style> | |
| body { | |
| margin: 0; | |
| overflow: hidden; | |
| font-family: Arial, sans-serif; | |
| background-color: #333; | |
| } | |
| #game-canvas { | |
| display: block; | |
| } | |
| .ui-panel { | |
| position: absolute; | |
| color: white; | |
| background-color: rgba(0, 0, 0, 0.6); | |
| padding: 15px; | |
| border-radius: 10px; | |
| } | |
| #ui-container { | |
| top: 10px; | |
| left: 10px; | |
| max-width: 300px; | |
| } | |
| h3 { | |
| margin-top: 0; | |
| border-bottom: 1px solid #fff; | |
| padding-bottom: 5px; | |
| } | |
| button { | |
| display: block; | |
| width: 100%; | |
| padding: 8px; | |
| margin-top: 8px; | |
| cursor: pointer; | |
| background-color: #555; | |
| color: white; | |
| border: 1px solid #777; | |
| border-radius: 5px; | |
| } | |
| button:hover { | |
| background-color: #777; | |
| } | |
| button.selected { | |
| background-color: #007bff; | |
| border-color: #0056b3; | |
| } | |
| #instructions { | |
| bottom: 10px; | |
| right: 10px; | |
| text-align: right; | |
| } | |
| #fire-alert { | |
| top: 10px; | |
| right: 10px; | |
| background-color: rgba(255, 69, 0, 0.8); | |
| border: 2px solid #ffa500; | |
| display: none; /* Hidden by default */ | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="ui-container" class="ui-panel"> | |
| <h3 id="mode-title">Placement Mode</h3> | |
| <div id="placement-controls"> | |
| <p>1. Select a building type:</p> | |
| <button id="btn-house">Apartment</button> | |
| <button id="btn-skyscraper">Skyscraper</button> | |
| <button id="btn-office">Office Block</button> | |
| <p style="margin-top: 15px">2. Click on the green areas to place it.</p> | |
| <button | |
| id="btn-drive" | |
| style="background-color: #28a745; margin-top: 20px" | |
| > | |
| Start Firefight! (Drive Mode) | |
| </button> | |
| </div> | |
| <div id="drive-controls" style="display: none"> | |
| <p> | |
| Use <b>W, A, S, D</b> to drive.<br />Hold <b>SPACE</b> to spray water. | |
| </p> | |
| <button id="btn-build">Switch to Placement Mode</button> | |
| </div> | |
| </div> | |
| <div id="fire-alert" class="ui-panel"> | |
| <h3>FIRE ALERT!</h3> | |
| <p id="fire-location-text">A building is on fire!</p> | |
| </div> | |
| <div id="instructions" class="ui-panel"> | |
| <div id="placement-instructions"> | |
| <b>Controls:</b><br />Left Click: Place Building<br />Mouse Wheel: | |
| Zoom<br />Right Click + Drag: Pan | |
| </div> | |
| <div id="drive-instructions" style="display: none"> | |
| <b>Controls:</b><br />W: Accelerate | S: Brake/Reverse<br />A/D: Steer | | |
| SPACE: Spray Water | |
| </div> | |
| </div> | |
| <canvas id="game-canvas"></canvas> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.160.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from "three"; | |
| import { OrbitControls } from "three/addons/controls/OrbitControls.js"; | |
| // --- CORE SETUP --- | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87ceeb); | |
| scene.fog = new THREE.Fog(0x87ceeb, 150, 400); | |
| const camera = new THREE.PerspectiveCamera( | |
| 75, | |
| window.innerWidth / window.innerHeight, | |
| 0.1, | |
| 1000, | |
| ); | |
| const renderer = new THREE.WebGLRenderer({ | |
| canvas: document.getElementById("game-canvas"), | |
| antialias: true, | |
| }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| const clock = new THREE.Clock(); | |
| // --- LIGHTING --- | |
| scene.add(new THREE.AmbientLight(0xffffff, 0.7)); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 1.0); | |
| dirLight.position.set(100, 100, 50); | |
| dirLight.castShadow = true; | |
| dirLight.shadow.mapSize.width = 2048; | |
| dirLight.shadow.mapSize.height = 2048; | |
| dirLight.shadow.camera.left = -150; | |
| dirLight.shadow.camera.right = 150; | |
| dirLight.shadow.camera.top = 150; | |
| dirLight.shadow.camera.bottom = -150; | |
| scene.add(dirLight); | |
| // --- GAME STATE --- | |
| let controls = new OrbitControls(camera, renderer.domElement); | |
| let currentMode = "placement"; | |
| let selectedBuilding = null; | |
| const buildings = []; | |
| const buildingBoundingBoxes = []; | |
| const keyState = {}; | |
| const activeFires = []; | |
| let playerCar, rivalHeli; | |
| let waterParticles; | |
| // --- WORLD GENERATION --- | |
| const groundSize = 250, | |
| gridSize = 25, | |
| roadWidth = 5; | |
| const ground = new THREE.Mesh( | |
| new THREE.PlaneGeometry(groundSize, groundSize), | |
| new THREE.MeshStandardMaterial({ color: 0x228b22 }), | |
| ); | |
| ground.rotation.x = -Math.PI / 2; | |
| ground.receiveShadow = true; | |
| scene.add(ground); | |
| const roadMat = new THREE.MeshStandardMaterial({ color: 0x444444 }); | |
| for (let i = -groundSize / 2; i <= groundSize / 2; i += gridSize) { | |
| const roadZ = new THREE.Mesh( | |
| new THREE.BoxGeometry(roadWidth, 0.1, groundSize), | |
| roadMat, | |
| ); | |
| roadZ.position.set(i, 0.05, 0); | |
| roadZ.receiveShadow = true; | |
| scene.add(roadZ); | |
| const roadX = new THREE.Mesh( | |
| new THREE.BoxGeometry(groundSize, 0.1, roadWidth), | |
| roadMat, | |
| ); | |
| roadX.position.set(0, 0.05, i); | |
| roadX.receiveShadow = true; | |
| scene.add(roadX); | |
| } | |
| // --- BUILDING IMPROVEMENTS --- | |
| function createWindowTexture() { | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = 128; | |
| canvas.height = 256; | |
| const context = canvas.getContext("2d"); | |
| context.fillStyle = "#666"; | |
| context.fillRect(0, 0, 128, 256); | |
| context.fillStyle = "#add8e6"; // Light blue for windows | |
| for (let y = 8; y < 256; y += 32) { | |
| for (let x = 8; x < 128; x += 32) { | |
| context.fillRect(x, y, 24, 24); | |
| } | |
| } | |
| return new THREE.CanvasTexture(canvas); | |
| } | |
| const windowTexture = createWindowTexture(); | |
| windowTexture.wrapS = THREE.RepeatWrapping; | |
| windowTexture.wrapT = THREE.RepeatWrapping; | |
| const buildingTypes = { | |
| house: { size: new THREE.Vector3(8, 10, 10), color: 0xdeb887 }, | |
| skyscraper: { size: new THREE.Vector3(10, 40, 10), color: 0xc0c0c0 }, | |
| office: { size: new THREE.Vector3(18, 15, 12), color: 0x778899 }, | |
| }; | |
| function createBuildingMesh(type, isPreview = false) { | |
| const def = buildingTypes[type]; | |
| const group = new THREE.Group(); | |
| const winMat = new THREE.MeshStandardMaterial({ map: windowTexture }); | |
| winMat.map.repeat.set( | |
| Math.round(def.size.x / 4), | |
| Math.round(def.size.y / 8), | |
| ); | |
| const wallMat = new THREE.MeshStandardMaterial({ color: def.color }); | |
| const materials = [winMat, winMat, wallMat, wallMat, winMat, winMat]; | |
| const body = new THREE.Mesh( | |
| new THREE.BoxGeometry(def.size.x, def.size.y, def.size.z), | |
| materials, | |
| ); | |
| body.castShadow = !isPreview; | |
| body.receiveShadow = !isPreview; | |
| group.add(body); | |
| const roof = new THREE.Mesh( | |
| new THREE.BoxGeometry(def.size.x + 0.5, 0.5, def.size.z + 0.5), | |
| wallMat, | |
| ); | |
| roof.position.y = def.size.y / 2; | |
| roof.castShadow = !isPreview; | |
| group.add(roof); | |
| group.position.y = def.size.y / 2; | |
| if (isPreview) { | |
| group.children.forEach((child) => { | |
| if (Array.isArray(child.material)) { | |
| child.material.forEach((m) => { | |
| m.transparent = true; | |
| m.opacity = 0.5; | |
| }); | |
| } else { | |
| child.material.transparent = true; | |
| child.material.opacity = 0.5; | |
| } | |
| }); | |
| } | |
| group.userData.type = type; | |
| group.userData.size = def.size; | |
| return group; | |
| } | |
| // --- PARTICLE SYSTEM --- | |
| class ParticleSystem { | |
| constructor(options) { | |
| this.options = options; | |
| const geometry = new THREE.BufferGeometry(); | |
| const vertices = []; | |
| for (let i = 0; i < options.count; i++) { | |
| vertices.push(0, 0, 0); | |
| } | |
| geometry.setAttribute( | |
| "position", | |
| new THREE.Float32BufferAttribute(vertices, 3), | |
| ); | |
| this.material = new THREE.PointsMaterial({ | |
| color: options.color, | |
| size: options.size, | |
| blending: THREE.AdditiveBlending, | |
| transparent: true, | |
| depthWrite: false, | |
| map: this.createParticleTexture(options.particleType), | |
| }); | |
| this.points = new THREE.Points(geometry, this.material); | |
| this.particles = []; | |
| for (let i = 0; i < options.count; i++) { | |
| this.particles.push({ | |
| position: new THREE.Vector3(), | |
| velocity: new THREE.Vector3(), | |
| lifetime: 0, | |
| }); | |
| } | |
| scene.add(this.points); | |
| } | |
| createParticleTexture(type) { | |
| const canvas = document.createElement("canvas"); | |
| canvas.width = 64; | |
| canvas.height = 64; | |
| const context = canvas.getContext("2d"); | |
| const gradient = context.createRadialGradient(32, 32, 0, 32, 32, 32); | |
| if (type === "fire") { | |
| gradient.addColorStop(0, "rgba(255,255,150,1)"); | |
| gradient.addColorStop(0.2, "rgba(255,165,0,0.8)"); | |
| gradient.addColorStop(0.5, "rgba(255,0,0,0.5)"); | |
| gradient.addColorStop(1, "rgba(255,0,0,0)"); | |
| } else { | |
| // water | |
| gradient.addColorStop(0, "rgba(200,220,255,0.8)"); | |
| gradient.addColorStop(0.5, "rgba(150,180,255,0.5)"); | |
| gradient.addColorStop(1, "rgba(100,150,255,0)"); | |
| } | |
| context.fillStyle = gradient; | |
| context.fillRect(0, 0, 64, 64); | |
| return new THREE.CanvasTexture(canvas); | |
| } | |
| spawn(emitter, velocity) { | |
| const particle = this.particles.find((p) => p.lifetime <= 0); | |
| if (particle) { | |
| particle.position.copy(emitter); | |
| particle.velocity.copy(velocity); | |
| particle.lifetime = this.options.maxLifetime; | |
| } | |
| } | |
| update(delta) { | |
| const positions = this.points.geometry.attributes.position.array; | |
| let index = 0; | |
| for (const p of this.particles) { | |
| if (p.lifetime > 0) { | |
| p.lifetime -= delta; | |
| p.velocity.add( | |
| this.options.gravity.clone().multiplyScalar(delta), | |
| ); | |
| p.position.add(p.velocity.clone().multiplyScalar(delta)); | |
| positions[index++] = p.position.x; | |
| positions[index++] = p.position.y; | |
| positions[index++] = p.position.z; | |
| } else { | |
| positions[index++] = 0; | |
| positions[index++] = 0; | |
| positions[index++] = 0; | |
| } | |
| } | |
| this.points.geometry.attributes.position.needsUpdate = true; | |
| } | |
| destroy() { | |
| scene.remove(this.points); | |
| this.points.geometry.dispose(); | |
| this.material.map.dispose(); | |
| this.material.dispose(); | |
| } | |
| } | |
| // --- FIRE SYSTEM --- | |
| function startFire(building) { | |
| if (building.userData.isOnFire) return; | |
| building.userData.isOnFire = true; | |
| building.userData.fireHealth = 100; | |
| const fireParticles = new ParticleSystem({ | |
| count: 200, | |
| size: 2, | |
| color: 0xffa500, | |
| maxLifetime: 1.5, | |
| gravity: new THREE.Vector3(0, 2, 0), | |
| particleType: "fire", | |
| }); | |
| const fire = { | |
| building: building, | |
| particles: fireParticles, | |
| extinguished: false, | |
| }; | |
| activeFires.push(fire); | |
| document.getElementById("fire-alert").style.display = "block"; | |
| } | |
| function updateFires(delta) { | |
| for (let i = activeFires.length - 1; i >= 0; i--) { | |
| const fire = activeFires[i]; | |
| if (fire.extinguished) { | |
| fire.particles.destroy(); | |
| activeFires.splice(i, 1); | |
| continue; | |
| } | |
| const buildingSize = fire.building.userData.size; | |
| const emitterPos = fire.building.position.clone(); | |
| emitterPos.y += buildingSize.y / 2; | |
| for (let j = 0; j < 3; j++) { | |
| // Spawn 3 particles per frame | |
| const velocity = new THREE.Vector3( | |
| (Math.random() - 0.5) * 4, | |
| Math.random() * 5, | |
| (Math.random() - 0.5) * 4, | |
| ); | |
| fire.particles.spawn(emitterPos, velocity); | |
| } | |
| fire.particles.update(delta); | |
| } | |
| } | |
| setInterval(() => { | |
| if (currentMode === "driving" && buildings.length > 0) { | |
| const nonBurningBuildings = buildings.filter( | |
| (b) => !b.userData.isOnFire, | |
| ); | |
| if (nonBurningBuildings.length > 0) { | |
| const buildingToBurn = | |
| nonBurningBuildings[ | |
| Math.floor(Math.random() * nonBurningBuildings.length) | |
| ]; | |
| startFire(buildingToBurn); | |
| } | |
| } | |
| }, 20000); // New fire every 20 seconds in drive mode | |
| function extinguishFire(fire) { | |
| if (!fire || fire.extinguished) return; | |
| fire.extinguished = true; | |
| fire.building.userData.isOnFire = false; | |
| document.getElementById("fire-alert").style.display = "none"; | |
| } | |
| // --- PLAYER FIRETRUCK --- | |
| const carState = { | |
| speed: 0, | |
| acceleration: 0.02, | |
| braking: 0.04, | |
| drag: 0.98, | |
| maxSpeed: 1.2, | |
| steerAngle: 0.04, | |
| }; | |
| function createFiretruck() { | |
| const group = new THREE.Group(); | |
| const redMat = new THREE.MeshStandardMaterial({ | |
| color: 0xc40808, | |
| roughness: 0.4, | |
| }); | |
| const greyMat = new THREE.MeshStandardMaterial({ color: 0x808080 }); | |
| const body = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1.2, 5), redMat); | |
| body.position.y = 0.8; | |
| group.add(body); | |
| const cab = new THREE.Mesh(new THREE.BoxGeometry(2.2, 1, 1.5), redMat); | |
| cab.position.set(0, 1.8, -1.25); | |
| group.add(cab); | |
| const ladder = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.5, 0.5, 6), | |
| greyMat, | |
| ); | |
| ladder.position.set(0, 2.0, 0.5); | |
| ladder.rotation.x = -0.1; | |
| group.add(ladder); | |
| for (let i = -1; i <= 1; i += 2) { | |
| for (let j = -1; j <= 1; j += 2) { | |
| const wheel = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.5, 0.5, 0.5, 16), | |
| new THREE.MeshStandardMaterial({ color: 0x111111 }), | |
| ); | |
| wheel.rotation.z = Math.PI / 2; | |
| wheel.position.set(i * 1.2, 0.5, j * 1.8); | |
| group.add(wheel); | |
| } | |
| } | |
| group.position.set(0, 0, 10); | |
| return group; | |
| } | |
| function updatePlayerCar(delta) { | |
| if (!playerCar) return; | |
| // Movement | |
| if (keyState["w"]) carState.speed += carState.acceleration; | |
| if (keyState["s"]) carState.speed -= carState.braking; | |
| carState.speed *= carState.drag; | |
| carState.speed = Math.max( | |
| -carState.maxSpeed / 2, | |
| Math.min(carState.maxSpeed, carState.speed), | |
| ); | |
| if (Math.abs(carState.speed) < 0.001) carState.speed = 0; | |
| if (carState.speed !== 0) { | |
| const steer = (keyState["a"] ? 1 : 0) - (keyState["d"] ? 1 : 0); | |
| playerCar.rotation.y += | |
| steer * carState.steerAngle * Math.sign(carState.speed); | |
| } | |
| const velocity = new THREE.Vector3( | |
| 0, | |
| 0, | |
| carState.speed, | |
| ).applyQuaternion(playerCar.quaternion); | |
| const nextPos = playerCar.position.clone().add(velocity); | |
| // Collision | |
| const carBox = new THREE.Box3() | |
| .setFromObject(playerCar) | |
| .translate(velocity); | |
| let collision = buildingBoundingBoxes.some((box) => | |
| carBox.intersectsBox(box), | |
| ); | |
| if (!collision) { | |
| playerCar.position.copy(nextPos); | |
| } else { | |
| carState.speed = 0; | |
| } | |
| // Water Cannon | |
| if (keyState[" "] && waterParticles) { | |
| const nozzlePos = new THREE.Vector3(0, 1.5, -2.5).applyMatrix4( | |
| playerCar.matrixWorld, | |
| ); | |
| const waterVel = new THREE.Vector3(0, 1, -20).applyQuaternion( | |
| playerCar.quaternion, | |
| ); | |
| for (let i = 0; i < 5; i++) | |
| waterParticles.spawn( | |
| nozzlePos, | |
| waterVel | |
| .clone() | |
| .add( | |
| new THREE.Vector3( | |
| Math.random() - 0.5, | |
| Math.random() - 0.5, | |
| Math.random() - 0.5, | |
| ), | |
| ), | |
| ); | |
| } | |
| // Camera | |
| const camOffset = new THREE.Vector3(0, 6, 12).applyQuaternion( | |
| playerCar.quaternion, | |
| ); | |
| camera.position.lerp(playerCar.position.clone().add(camOffset), 0.1); | |
| camera.lookAt(playerCar.position); | |
| // Update Fire Alert | |
| if (activeFires.length > 0) { | |
| const firePos = activeFires[0].building.position; | |
| const dir = firePos.clone().sub(playerCar.position); | |
| const angle = Math.atan2(dir.x, dir.z); | |
| const directions = [ | |
| "North", | |
| "North-East", | |
| "East", | |
| "South-East", | |
| "South", | |
| "South-West", | |
| "West", | |
| "North-West", | |
| ]; | |
| const heading = | |
| directions[Math.round(((angle * 180) / Math.PI + 180) / 45) % 8]; | |
| document.getElementById("fire-location-text").innerText = | |
| `Fire detected to the ${heading}!`; | |
| } | |
| } | |
| // --- RIVAL HELICOPTER --- | |
| function createHelicopter() { | |
| const group = new THREE.Group(); | |
| const body = new THREE.Mesh( | |
| new THREE.SphereGeometry(1.5, 8, 6), | |
| new THREE.MeshStandardMaterial({ color: 0xeeee00 }), | |
| ); | |
| body.scale.z = 2; | |
| body.position.y = 1; | |
| group.add(body); | |
| const tail = new THREE.Mesh( | |
| new THREE.BoxGeometry(0.5, 0.5, 4), | |
| new THREE.MeshStandardMaterial({ color: 0xdddd00 }), | |
| ); | |
| tail.position.set(0, 1, 3); | |
| group.add(tail); | |
| group.userData.rotor = new THREE.Mesh( | |
| new THREE.BoxGeometry(8, 0.1, 0.5), | |
| new THREE.MeshStandardMaterial({ color: 0x333333 }), | |
| ); | |
| group.userData.rotor.position.y = 2.5; | |
| group.add(group.userData.rotor); | |
| group.position.set(0, 50, 0); | |
| group.userData.state = "PATROLLING"; | |
| group.userData.target = null; | |
| return group; | |
| } | |
| function updateHelicopter(delta) { | |
| if (!rivalHeli) return; | |
| rivalHeli.userData.rotor.rotation.y += delta * 25; | |
| switch (rivalHeli.userData.state) { | |
| case "PATROLLING": | |
| rivalHeli.position.x = Math.sin(clock.getElapsedTime() * 0.1) * 100; | |
| rivalHeli.position.z = Math.cos(clock.getElapsedTime() * 0.1) * 100; | |
| rivalHeli.lookAt(0, rivalHeli.position.y, 0); | |
| if (activeFires.length > 0 && !activeFires[0].extinguished) { | |
| rivalHeli.userData.target = activeFires[0]; | |
| rivalHeli.userData.state = "EN_ROUTE"; | |
| } | |
| break; | |
| case "EN_ROUTE": | |
| if ( | |
| !rivalHeli.userData.target || | |
| rivalHeli.userData.target.extinguished | |
| ) { | |
| rivalHeli.userData.state = "PATROLLING"; | |
| break; | |
| } | |
| const targetPos = rivalHeli.userData.target.building.position | |
| .clone() | |
| .add(new THREE.Vector3(0, 40, 0)); | |
| rivalHeli.position.lerp(targetPos, delta * 0.5); | |
| rivalHeli.lookAt(rivalHeli.userData.target.building.position); | |
| if (rivalHeli.position.distanceTo(targetPos) < 2) { | |
| rivalHeli.userData.state = "EXTINGUISHING"; | |
| rivalHeli.userData.extinguishTimer = 0; | |
| } | |
| break; | |
| case "EXTINGUISHING": | |
| if ( | |
| !rivalHeli.userData.target || | |
| rivalHeli.userData.target.extinguished | |
| ) { | |
| rivalHeli.userData.state = "PATROLLING"; | |
| break; | |
| } | |
| rivalHeli.userData.extinguishTimer += delta; | |
| // It takes the helicopter 8 seconds to put out the fire | |
| if (rivalHeli.userData.extinguishTimer > 8) { | |
| extinguishFire(rivalHeli.userData.target); | |
| rivalHeli.userData.target = null; | |
| rivalHeli.userData.state = "PATROLLING"; | |
| } | |
| break; | |
| } | |
| } | |
| // --- GAME LOGIC & MODE SWITCHING --- | |
| let previewBuilding = null; | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| function setMode(mode) { | |
| currentMode = mode; | |
| if (mode === "placement") { | |
| document.getElementById("mode-title").innerText = "Placement Mode"; | |
| document.getElementById("placement-controls").style.display = "block"; | |
| document.getElementById("drive-controls").style.display = "none"; | |
| document.getElementById("placement-instructions").style.display = | |
| "block"; | |
| document.getElementById("drive-instructions").style.display = "none"; | |
| if (playerCar) scene.remove(playerCar); | |
| playerCar = null; | |
| if (rivalHeli) scene.remove(rivalHeli); | |
| rivalHeli = null; | |
| if (waterParticles) { | |
| waterParticles.destroy(); | |
| waterParticles = null; | |
| } | |
| activeFires.forEach((f) => f.particles.destroy()); | |
| activeFires.length = 0; | |
| document.getElementById("fire-alert").style.display = "none"; | |
| buildings.forEach((b) => (b.userData.isOnFire = false)); | |
| controls.enabled = true; | |
| controls.target.set(0, 0, 0); | |
| camera.position.set(0, 80, 80); | |
| camera.lookAt(0, 0, 0); | |
| } else if (mode === "driving") { | |
| document.getElementById("mode-title").innerText = "Driving Mode"; | |
| document.getElementById("placement-controls").style.display = "none"; | |
| document.getElementById("drive-controls").style.display = "block"; | |
| document.getElementById("placement-instructions").style.display = | |
| "none"; | |
| document.getElementById("drive-instructions").style.display = "block"; | |
| if (previewBuilding) { | |
| scene.remove(previewBuilding); | |
| previewBuilding = null; | |
| } | |
| playerCar = createFiretruck(); | |
| scene.add(playerCar); | |
| rivalHeli = createHelicopter(); | |
| scene.add(rivalHeli); | |
| waterParticles = new ParticleSystem({ | |
| count: 500, | |
| size: 0.5, | |
| color: 0xabcdef, | |
| maxLifetime: 1, | |
| gravity: new THREE.Vector3(0, -9.8, 0), | |
| particleType: "water", | |
| }); | |
| controls.enabled = false; | |
| } | |
| } | |
| // --- EVENT LISTENERS & UI --- | |
| document | |
| .getElementById("btn-house") | |
| .addEventListener("click", () => selectBuilding("house")); | |
| document | |
| .getElementById("btn-skyscraper") | |
| .addEventListener("click", () => selectBuilding("skyscraper")); | |
| document | |
| .getElementById("btn-office") | |
| .addEventListener("click", () => selectBuilding("office")); | |
| document | |
| .getElementById("btn-drive") | |
| .addEventListener("click", () => setMode("driving")); | |
| document | |
| .getElementById("btn-build") | |
| .addEventListener("click", () => setMode("placement")); | |
| function selectBuilding(type) { | |
| document | |
| .querySelectorAll("#placement-controls button") | |
| .forEach((b) => b.classList.remove("selected")); | |
| if (selectedBuilding === type) { | |
| selectedBuilding = null; | |
| if (previewBuilding) { | |
| scene.remove(previewBuilding); | |
| previewBuilding = null; | |
| } | |
| } else { | |
| selectedBuilding = type; | |
| document.getElementById(`btn-${type}`).classList.add("selected"); | |
| if (previewBuilding) scene.remove(previewBuilding); | |
| previewBuilding = createBuildingMesh(type, true); | |
| scene.add(previewBuilding); | |
| } | |
| } | |
| window.addEventListener("resize", () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| window.addEventListener("mousemove", (e) => { | |
| mouse.x = (e.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; | |
| if (currentMode === "placement" && previewBuilding) { | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObject(ground); | |
| if (intersects.length > 0) { | |
| const pos = intersects[0].point; | |
| const halfGrid = gridSize / 2; | |
| pos.x = | |
| Math.round((pos.x - halfGrid) / gridSize) * gridSize + halfGrid; | |
| pos.z = | |
| Math.round((pos.z - halfGrid) / gridSize) * gridSize + halfGrid; | |
| previewBuilding.position.x = pos.x; | |
| previewBuilding.position.z = pos.z; | |
| } | |
| } | |
| }); | |
| window.addEventListener("click", (e) => { | |
| if ( | |
| currentMode === "placement" && | |
| selectedBuilding && | |
| previewBuilding && | |
| !document.getElementById("ui-container").contains(e.target) | |
| ) { | |
| const newBuilding = createBuildingMesh(selectedBuilding, false); | |
| newBuilding.position.copy(previewBuilding.position); | |
| scene.add(newBuilding); | |
| buildings.push(newBuilding); | |
| buildingBoundingBoxes.push( | |
| new THREE.Box3().setFromObject(newBuilding), | |
| ); | |
| } | |
| }); | |
| window.addEventListener("keydown", (e) => { | |
| keyState[e.key.toLowerCase()] = true; | |
| }); | |
| window.addEventListener("keyup", (e) => { | |
| keyState[e.key.toLowerCase()] = false; | |
| }); | |
| // --- ANIMATION LOOP --- | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const delta = clock.getDelta(); | |
| if (currentMode === "placement") { | |
| controls.update(); | |
| } else if (currentMode === "driving") { | |
| updatePlayerCar(delta); | |
| updateHelicopter(delta); | |
| updateFires(delta); | |
| if (waterParticles) waterParticles.update(delta); | |
| // Water collision with fire | |
| if (waterParticles) { | |
| const waterPosArray = | |
| waterParticles.points.geometry.attributes.position.array; | |
| for (let i = 0; i < waterParticles.particles.length; i++) { | |
| const p = waterParticles.particles[i]; | |
| if (p.lifetime > 0) { | |
| for (const fire of activeFires) { | |
| if ( | |
| fire.building.position.distanceTo(p.position) < | |
| fire.building.userData.size.y / 2 | |
| ) { | |
| fire.building.userData.fireHealth -= 0.2; // Each particle does a little damage | |
| if (fire.building.userData.fireHealth <= 0) { | |
| extinguishFire(fire); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| setMode("placement"); | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment