-
-
Save shricodev/f951ccb6cc02cc58a270c5378b113a45 to your computer and use it in GitHub Desktop.
3D Town and Fire truck Simulation (Developed by OpenAI o3 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" /> | |
| <title>Mini City Builder & Driver • Three.js</title> | |
| <style> | |
| html, | |
| body { | |
| margin: 0; | |
| height: 100%; | |
| overflow: hidden; | |
| font-family: sans-serif; | |
| color: #fff; | |
| } | |
| #info { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| line-height: 1.35; | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 8px 10px; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| pointer-events: none; /* Make it non-interactive */ | |
| } | |
| #ui { | |
| position: absolute; | |
| right: 10px; | |
| top: 10px; | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 6px 10px; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| } | |
| button { | |
| margin: 2px 0; | |
| width: 90px; | |
| cursor: pointer; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info"> | |
| BUILD mode (default) – click ground to place<br /> | |
| 1/2/3 choose building design<br /> | |
| DRiVE mode – press ENTER<br /> | |
| W A S D drive<br /> | |
| B back to build<br /> | |
| </div> | |
| <div id="ui"> | |
| <div>Building:</div> | |
| <button id="b1">Small</button> | |
| <button id="b2">Tall</button> | |
| <button id="b3">Wide</button> | |
| </div> | |
| <!-- | |
| CHANGE #1: Add an importmap. This tells the browser where to find | |
| the 'three' and 'three/addons/' modules. | |
| --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.156.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.156.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <!-- | |
| CHANGE #2: Remove the old <script> tags for three.min.js and OrbitControls.js. | |
| We will now handle this with 'import' statements below. | |
| --> | |
| <!-- <script src="..."></script> REMOVED --> | |
| <!-- <script src="..."></script> REMOVED --> | |
| <!-- CHANGE #3: Change the main script tag to have type="module" --> | |
| <script type="module"> | |
| /* CHANGE #4: Import THREE and OrbitControls at the top of the script */ | |
| import * as THREE from "three"; | |
| import { OrbitControls } from "three/addons/controls/OrbitControls.js"; | |
| /* ---------- BASIC CONSTANTS ---------- */ | |
| const BLOCK = 100; // distance between road centres | |
| const ROAD = 24; // road width | |
| const CITY = 10; // number of blocks half-width (city spans 2*CITY blocks) | |
| const TRAFFIC_COUNT = 18; | |
| /* ---------- THREE.JS SCENE ---------- */ | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87bffb); // sky blue | |
| const camera = new THREE.PerspectiveCamera( | |
| 60, | |
| innerWidth / innerHeight, | |
| 0.1, | |
| 4000, | |
| ); | |
| camera.position.set(300, 300, 300); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(innerWidth, innerHeight); | |
| document.body.appendChild(renderer.domElement); | |
| /* LIGHTS */ | |
| scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.7)); | |
| const sun = new THREE.DirectionalLight(0xffffff, 0.8); | |
| sun.position.set(500, 1000, 500); | |
| scene.add(sun); | |
| /* ---------- GROUND + ROAD GRID ---------- */ | |
| const groundMat = new THREE.MeshLambertMaterial({ color: 0x6ab04c }); | |
| const roadMat = new THREE.MeshLambertMaterial({ color: 0x2f3640 }); | |
| const ground = new THREE.Mesh( | |
| new THREE.PlaneGeometry((CITY * 2 + 1) * BLOCK, (CITY * 2 + 1) * BLOCK), | |
| groundMat, | |
| ); | |
| ground.rotation.x = -Math.PI / 2; | |
| scene.add(ground); | |
| /* roads along X (east/west) */ | |
| for (let z = -CITY * BLOCK; z <= CITY * BLOCK; z += BLOCK) { | |
| const road = new THREE.Mesh( | |
| new THREE.PlaneGeometry((CITY * 2 + 1) * BLOCK, ROAD), | |
| roadMat, | |
| ); | |
| road.rotation.x = -Math.PI / 2; | |
| road.position.z = z; | |
| scene.add(road); | |
| } | |
| /* roads along Z (north/south) */ | |
| for (let x = -CITY * BLOCK; x <= CITY * BLOCK; x += BLOCK) { | |
| const road = new THREE.Mesh( | |
| new THREE.PlaneGeometry(ROAD, (CITY * 2 + 1) * BLOCK), | |
| roadMat, | |
| ); | |
| road.rotation.x = -Math.PI / 2; | |
| road.position.x = x; | |
| scene.add(road); | |
| } | |
| /* ---------- CONTROLS FOR BUILD MODE ---------- */ | |
| /* | |
| CHANGE #5: Call the constructor directly as OrbitControls, not THREE.OrbitControls | |
| */ | |
| const orbit = new OrbitControls(camera, renderer.domElement); | |
| orbit.target.set(0, 0, 0); | |
| orbit.update(); | |
| let mode = "build"; // 'build' or 'drive' | |
| let buildingType = 1; // current preset (1-3) | |
| /* ---------- RAYCASTER FOR CLICK PLACEMENT ---------- */ | |
| const ray = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| /* ---------- BUILDING PRESETS ---------- */ | |
| function makeBuilding(type) { | |
| let geo, clr; | |
| if (type === 1) { | |
| geo = new THREE.BoxGeometry(30, 60, 30); | |
| clr = 0xbdc3c7; | |
| } | |
| if (type === 2) { | |
| geo = new THREE.BoxGeometry(40, 120, 40); | |
| clr = 0x95a5a6; | |
| } | |
| if (type === 3) { | |
| geo = new THREE.BoxGeometry(70, 35, 70); | |
| clr = 0x7f8fa6; | |
| } | |
| const mat = new THREE.MeshLambertMaterial({ color: clr }); | |
| geo.translate(0, geo.parameters.height / 2, 0); // lift so it stands on ground | |
| return new THREE.Mesh(geo, mat); | |
| } | |
| /* ---------- PLAYER CAR ---------- */ | |
| const carBody = new THREE.Mesh( | |
| new THREE.BoxGeometry(18, 10, 32), | |
| new THREE.MeshLambertMaterial({ color: 0xe84118 }), | |
| ); | |
| carBody.geometry.translate(0, 5, 0); | |
| scene.add(carBody); | |
| let speed = 0, | |
| steering = 0; | |
| const MAX_SPEED = 1.6; | |
| const ACC = 0.035; | |
| const FRICTION = 0.02; | |
| camera.position.set(0, 25, -55); | |
| camera.lookAt(carBody.position); | |
| function resetCar() { | |
| carBody.position.set(0, 5, 0); | |
| carBody.rotation.y = 0; | |
| speed = 0; | |
| } | |
| /* ---------- TRAFFIC ---------- */ | |
| function spawnTrafficCar(x, z, dir) { | |
| const c = new THREE.Mesh( | |
| new THREE.BoxGeometry(16, 8, 26), | |
| new THREE.MeshLambertMaterial({ color: Math.random() * 0xffffff }), | |
| ); | |
| c.geometry.translate(0, 4, 0); | |
| c.position.set(x, 4, z); | |
| c.userData.dir = dir.clone().normalize(); // THREE.Vector3 | |
| c.userData.speed = 1 + Math.random() * 0.5; | |
| scene.add(c); | |
| traffic.push(c); | |
| } | |
| const traffic = []; | |
| for (let i = 0; i < TRAFFIC_COUNT; i++) { | |
| const axis = Math.random() < 0.5 ? "x" : "z"; | |
| const blockLine = Math.round(Math.random() * CITY * 2 - CITY) * BLOCK; | |
| const pos = Math.round(Math.random() * CITY * 2 - CITY) * BLOCK; | |
| if (axis === "x") | |
| spawnTrafficCar(-CITY * BLOCK, pos, new THREE.Vector3(1, 0, 0)); | |
| else spawnTrafficCar(pos, -CITY * BLOCK, new THREE.Vector3(0, 0, 1)); | |
| } | |
| /* ---------- INPUT ---------- */ | |
| const keys = {}; | |
| addEventListener("keydown", (e) => { | |
| keys[e.code] = true; | |
| if (e.code === "Digit1") { | |
| buildingType = 1; | |
| } | |
| if (e.code === "Digit2") { | |
| buildingType = 2; | |
| } | |
| if (e.code === "Digit3") { | |
| buildingType = 3; | |
| } | |
| if (e.code === "Enter" && mode === "build") { | |
| // switch to drive | |
| mode = "drive"; | |
| orbit.enabled = false; | |
| document.body.style.cursor = "none"; | |
| } | |
| if (e.code === "KeyB" && mode === "drive") { | |
| // back to build | |
| mode = "build"; | |
| orbit.enabled = true; | |
| document.body.style.cursor = "default"; | |
| // Reset camera for build mode | |
| camera.position.set(300, 300, 300); | |
| camera.lookAt(0, 0, 0); | |
| orbit.target.set(0, 0, 0); | |
| } | |
| }); | |
| addEventListener("keyup", (e) => (keys[e.code] = false)); | |
| /* Click to place building */ | |
| addEventListener("pointerdown", (e) => { | |
| if (mode !== "build" || e.target.tagName === "BUTTON") return; | |
| mouse.x = (e.clientX / innerWidth) * 2 - 1; | |
| mouse.y = -(e.clientY / innerHeight) * 2 + 1; | |
| ray.setFromCamera(mouse, camera); | |
| const hit = ray.intersectObject(ground); | |
| if (!hit.length) return; | |
| let { x, z } = hit[0].point; | |
| /* snap to centre of block */ | |
| x = Math.round(x / BLOCK) * BLOCK; | |
| z = Math.round(z / BLOCK) * BLOCK; | |
| /* Abort if on road intersection */ | |
| if (x === 0 && z === 0) return; // simple check for central intersection, can be improved | |
| const b = makeBuilding(buildingType); | |
| b.position.set(x, 0, z); | |
| scene.add(b); | |
| }); | |
| /* Buttons on right panel */ | |
| ["b1", "b2", "b3"].forEach((id, i) => { | |
| document.getElementById(id).onclick = () => (buildingType = i + 1); | |
| }); | |
| /* ---------- RESIZE ---------- */ | |
| addEventListener("resize", () => { | |
| camera.aspect = innerWidth / innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth, innerHeight); | |
| }); | |
| /* ---------- MAIN LOOP ---------- */ | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| /* ---------- DRIVE MODE BEHAVIOUR ---------- */ | |
| if (mode === "drive") { | |
| /* acceleration / braking */ | |
| if (keys["KeyW"]) speed = Math.min(speed + ACC, MAX_SPEED); | |
| else if (keys["KeyS"]) speed = Math.max(speed - ACC, -MAX_SPEED / 2); | |
| else { | |
| // natural slow down | |
| if (speed > 0) speed = Math.max(0, speed - FRICTION); | |
| else speed = Math.min(0, speed + FRICTION); | |
| } | |
| /* steering */ | |
| if (keys["KeyA"]) steering = 0.03; | |
| else if (keys["KeyD"]) steering = -0.03; | |
| else steering = 0; | |
| carBody.rotation.y += steering * speed * 0.8; | |
| carBody.position.x -= Math.sin(carBody.rotation.y) * speed; | |
| carBody.position.z -= Math.cos(carBody.rotation.y) * speed; | |
| /* simple bounds reset */ | |
| const limit = CITY * BLOCK; | |
| if ( | |
| Math.abs(carBody.position.x) > limit || | |
| Math.abs(carBody.position.z) > limit | |
| ) { | |
| resetCar(); | |
| } | |
| /* follow camera */ | |
| const camOffset = new THREE.Vector3(0, 25, -55).applyAxisAngle( | |
| new THREE.Vector3(0, 1, 0), | |
| carBody.rotation.y, | |
| ); | |
| camera.position.copy(carBody.position).add(camOffset); | |
| camera.lookAt(carBody.position); | |
| } | |
| /* ---------- A.I. TRAFFIC ---------- */ | |
| traffic.forEach((c) => { | |
| const dir = c.userData.dir; | |
| c.position.addScaledVector(dir, c.userData.speed); | |
| c.rotation.y = Math.atan2(dir.x, dir.z); | |
| /* when reaching next intersection? */ | |
| if ( | |
| Math.abs(c.position.x % BLOCK) < c.userData.speed && | |
| Math.abs(c.position.z % BLOCK) < c.userData.speed | |
| ) { | |
| if (Math.random() < 0.3) { | |
| // 30% chance of turn | |
| const right = new THREE.Vector3(-dir.z, 0, dir.x); // 90° right | |
| const left = new THREE.Vector3(dir.z, 0, -dir.x); // 90° left | |
| c.userData.dir = (Math.random() < 0.5 ? right : left).clone(); | |
| } | |
| } | |
| /* boundary check */ | |
| const lim = CITY * BLOCK + 20; | |
| if (Math.abs(c.position.x) > lim || Math.abs(c.position.z) > lim) { | |
| c.userData.dir.negate(); // turn around | |
| } | |
| }); | |
| renderer.render(scene, camera); | |
| } | |
| 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" /> | |
| <title>Mini City Builder & Driver • Three.js</title> | |
| <style> | |
| html, | |
| body { | |
| margin: 0; | |
| height: 100%; | |
| overflow: hidden; | |
| font-family: sans-serif; | |
| color: #fff; | |
| } | |
| #info { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| line-height: 1.35; | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 8px 10px; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| pointer-events: none; | |
| } | |
| #ui { | |
| position: absolute; | |
| right: 10px; | |
| top: 10px; | |
| background: rgba(0, 0, 0, 0.6); | |
| padding: 6px 10px; | |
| border-radius: 6px; | |
| font-size: 13px; | |
| text-align: center; | |
| } | |
| button { | |
| margin: 2px 0; | |
| width: 90px; | |
| cursor: pointer; | |
| } | |
| #fireAlert { | |
| /* 🔥 NEW */ | |
| position: absolute; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| top: 20px; | |
| font-size: 20px; | |
| font-weight: 700; | |
| padding: 6px 14px; | |
| background: #ffc400; | |
| color: #000; | |
| border-radius: 6px; | |
| display: none; | |
| animation: pulse 1s infinite alternate; | |
| } | |
| @keyframes pulse { | |
| from { | |
| transform: translateX(-50%) scale(1); | |
| } | |
| to { | |
| transform: translateX(-50%) scale(1.08); | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="info"> | |
| BUILD mode – click ground to place<br /> | |
| 1/2/3 choose building design<br /> | |
| ENTER drive • B build<br /> | |
| W A S D drive · SPACE extinguish | |
| </div> | |
| <div id="ui"> | |
| <div>Building:</div> | |
| <button id="b1">Small</button> | |
| <button id="b2">Tall</button> | |
| <button id="b3">Wide</button> | |
| </div> | |
| <div id="fireAlert">⚠️ Building on FIRE ⚠️</div> | |
| <!-- 🔥 NEW --> | |
| <script type="importmap"> | |
| { | |
| "imports": { | |
| "three": "https://unpkg.com/three@0.156.0/build/three.module.js", | |
| "three/addons/": "https://unpkg.com/three@0.156.0/examples/jsm/" | |
| } | |
| } | |
| </script> | |
| <script type="module"> | |
| import * as THREE from "three"; | |
| import { OrbitControls } from "three/addons/controls/OrbitControls.js"; | |
| import { TextureLoader } from "three"; | |
| /* ---------- CONSTANTS ---------- */ | |
| const BLOCK = 100, | |
| ROAD = 24, | |
| CITY = 10; | |
| const TRAFFIC_COUNT = 14; | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x87bffb); | |
| const camera = new THREE.PerspectiveCamera( | |
| 60, | |
| innerWidth / innerHeight, | |
| 0.1, | |
| 4000, | |
| ); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(innerWidth, innerHeight); | |
| document.body.appendChild(renderer.domElement); | |
| /* LIGHTS */ | |
| scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.7)); | |
| const sun = new THREE.DirectionalLight(0xffffff, 0.9); | |
| sun.position.set(500, 1000, 500); | |
| scene.add(sun); | |
| /* ---------- GROUND + ROADS ---------- */ | |
| const groundMat = new THREE.MeshLambertMaterial({ color: 0x6ab04c }); | |
| const roadMat = new THREE.MeshLambertMaterial({ color: 0x2f3640 }); | |
| const ground = new THREE.Mesh( | |
| new THREE.PlaneGeometry((CITY * 2 + 1) * BLOCK, (CITY * 2 + 1) * BLOCK), | |
| groundMat, | |
| ); | |
| ground.rotation.x = -Math.PI / 2; | |
| scene.add(ground); | |
| for (let z = -CITY * BLOCK; z <= CITY * BLOCK; z += BLOCK) { | |
| const r = new THREE.Mesh( | |
| new THREE.PlaneGeometry((CITY * 2 + 1) * BLOCK, ROAD), | |
| roadMat, | |
| ); | |
| r.rotation.x = -Math.PI / 2; | |
| r.position.z = z; | |
| scene.add(r); | |
| } | |
| for (let x = -CITY * BLOCK; x <= CITY * BLOCK; x += BLOCK) { | |
| const r = new THREE.Mesh( | |
| new THREE.PlaneGeometry(ROAD, (CITY * 2 + 1) * BLOCK), | |
| roadMat, | |
| ); | |
| r.rotation.x = -Math.PI / 2; | |
| r.position.x = x; | |
| scene.add(r); | |
| } | |
| /* ---------- ORBIT ---------- */ | |
| const orbit = new OrbitControls(camera, renderer.domElement); | |
| orbit.target.set(0, 0, 0); | |
| camera.position.set(300, 300, 300); | |
| orbit.update(); | |
| let mode = "build", | |
| buildingType = 1; | |
| /* ---------- BUILDINGS ---------- */ | |
| const buildingTex = new THREE.TextureLoader().load( | |
| "https://i.imgur.com/oJ7VvdJ.png", | |
| ); // simple windows 🔥 NEW | |
| buildingTex.wrapS = buildingTex.wrapT = THREE.RepeatWrapping; | |
| const buildings = []; // 🔥 NEW keep references | |
| function makeBuilding(type) { | |
| let geo, | |
| clr, | |
| repX = 1, | |
| repY = 1; | |
| if (type === 1) { | |
| geo = new THREE.BoxGeometry(30, 60, 30); | |
| clr = 0xe0e0e0; | |
| repX = 1; | |
| repY = 2; | |
| } | |
| if (type === 2) { | |
| geo = new THREE.BoxGeometry(40, 120, 40); | |
| clr = 0xcfcfcf; | |
| repX = 1; | |
| repY = 4; | |
| } | |
| if (type === 3) { | |
| geo = new THREE.BoxGeometry(70, 35, 70); | |
| clr = 0xd8d8d8; | |
| repX = 2; | |
| repY = 1; | |
| } | |
| geo.translate(0, geo.parameters.height / 2, 0); | |
| const mat = [ | |
| new THREE.MeshLambertMaterial({ color: clr }), // right | |
| new THREE.MeshLambertMaterial({ color: clr }), // left | |
| new THREE.MeshLambertMaterial({ color: clr }), // top | |
| new THREE.MeshLambertMaterial({ color: clr }), // bottom | |
| new THREE.MeshLambertMaterial({ map: buildingTex, emissive: 0x0 }), // front | |
| new THREE.MeshLambertMaterial({ map: buildingTex, emissive: 0x0 }), // back | |
| ]; | |
| mat[4].map.repeat.set(repX, repY); | |
| mat[5].map.repeat.set(repX, repY); | |
| const m = new THREE.Mesh(geo, mat); | |
| m.userData.onFire = false; // 🔥 NEW | |
| m.userData.fireLife = 0; | |
| return m; | |
| } | |
| /* ---------- FIRE EFFECT HELPERS ---------- */ | |
| const flameTexture = new TextureLoader().load( | |
| "https://i.imgur.com/dGdJw5L.png", | |
| ); // transparent flame sprite | |
| function addFlame(parent) { | |
| const sprite = new THREE.Sprite( | |
| new THREE.SpriteMaterial({ | |
| map: flameTexture, | |
| transparent: true, | |
| depthWrite: false, | |
| }), | |
| ); | |
| sprite.scale.set(40, 60, 1); | |
| sprite.position.y = parent.geometry.parameters.height | |
| ? parent.geometry.parameters.height * 0.6 | |
| : 40; | |
| parent.add(sprite); | |
| parent.userData.flame = sprite; | |
| parent.material.forEach((m) => { | |
| if (m.emissive) m.emissive.setHex(0xff5500); | |
| }); | |
| } | |
| function removeFlame(parent) { | |
| if (parent.userData.flame) { | |
| parent.remove(parent.userData.flame); | |
| parent.userData.flame.material.dispose(); | |
| parent.material.forEach((m) => { | |
| if (m.emissive) m.emissive.setHex(0x000000); | |
| }); | |
| delete parent.userData.flame; | |
| } | |
| } | |
| /* ---------- PLAYER : FIRE-TRUCK ---------- */ | |
| const truckGroup = new THREE.Group(); | |
| const cabin = new THREE.Mesh( | |
| new THREE.BoxGeometry(20, 10, 18), | |
| new THREE.MeshLambertMaterial({ color: 0xe84118 }), | |
| ); | |
| cabin.geometry.translate(0, 5, -10); | |
| const tank = new THREE.Mesh( | |
| new THREE.BoxGeometry(22, 12, 26), | |
| new THREE.MeshLambertMaterial({ color: 0xd63031 }), | |
| ); | |
| tank.geometry.translate(0, 6, 4); | |
| truckGroup.add(cabin, tank); | |
| scene.add(truckGroup); | |
| let speed = 0, | |
| steering = 0; | |
| const MAX_SPEED = 1.4, | |
| ACC = 0.035, | |
| FRICTION = 0.02; | |
| function resetTruck() { | |
| truckGroup.position.set(0, 6, 0); | |
| truckGroup.rotation.y = 0; | |
| speed = 0; | |
| } | |
| resetTruck(); | |
| /* ---------- WATER SPRAY ---------- */ | |
| const waterGeo = new THREE.ConeGeometry(4, 18, 8, 1, true); | |
| const waterMat = new THREE.MeshBasicMaterial({ | |
| color: 0x4dabf7, | |
| transparent: true, | |
| opacity: 0.7, | |
| }); | |
| const spray = new THREE.Mesh(waterGeo, waterMat); | |
| spray.rotation.x = -Math.PI / 2; | |
| spray.visible = false; | |
| truckGroup.add(spray); | |
| /* ---------- TRAFFIC ---------- */ | |
| const traffic = []; | |
| function spawnTrafficCar(x, z, dir) { | |
| const c = new THREE.Mesh( | |
| new THREE.BoxGeometry(16, 8, 26), | |
| new THREE.MeshLambertMaterial({ color: Math.random() * 0xffffff }), | |
| ); | |
| c.geometry.translate(0, 4, 0); | |
| c.position.set(x, 4, z); | |
| c.userData.dir = dir.clone().normalize(); | |
| c.userData.speed = 1 + Math.random() * 0.5; | |
| traffic.push(c); | |
| scene.add(c); | |
| } | |
| for (let i = 0; i < TRAFFIC_COUNT; i++) { | |
| const axis = Math.random() < 0.5 ? "x" : "z"; | |
| const line = Math.round(Math.random() * CITY * 2 - CITY) * BLOCK; | |
| if (axis === "x") | |
| spawnTrafficCar(-CITY * BLOCK, line, new THREE.Vector3(1, 0, 0)); | |
| else spawnTrafficCar(line, -CITY * BLOCK, new THREE.Vector3(0, 0, 1)); | |
| } | |
| /* ---------- RIVAL HELICOPTER ---------- */ | |
| const heli = new THREE.Group(); | |
| const body = new THREE.Mesh( | |
| new THREE.BoxGeometry(16, 8, 20), | |
| new THREE.MeshLambertMaterial({ color: 0x00b894 }), | |
| ); | |
| body.geometry.translate(0, 4, 0); | |
| heli.add(body); | |
| const rotor = new THREE.Mesh( | |
| new THREE.CylinderGeometry(0.5, 0.5, 0.5, 6, 1), | |
| new THREE.MeshBasicMaterial({ color: 0x222 }), | |
| ); | |
| rotor.position.y = 8; | |
| heli.add(rotor); | |
| heli.position.set(-300, 80, -300); | |
| scene.add(heli); | |
| let heliTarget = null; | |
| /* ---------- INPUT ---------- */ | |
| const keys = {}; | |
| addEventListener("keydown", (e) => { | |
| keys[e.code] = true; | |
| if (e.code === "Digit1") buildingType = 1; | |
| if (e.code === "Digit2") buildingType = 2; | |
| if (e.code === "Digit3") buildingType = 3; | |
| if (e.code === "Enter" && mode === "build") { | |
| mode = "drive"; | |
| orbit.enabled = false; | |
| document.body.style.cursor = "none"; | |
| } | |
| if (e.code === "KeyB" && mode === "drive") { | |
| mode = "build"; | |
| orbit.enabled = true; | |
| document.body.style.cursor = "default"; | |
| camera.position.set(300, 300, 300); | |
| camera.lookAt(0, 0, 0); | |
| orbit.target.set(0, 0, 0); | |
| } | |
| }); | |
| addEventListener("keyup", (e) => (keys[e.code] = false)); | |
| /* Click placement */ | |
| const ray = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| addEventListener("pointerdown", (e) => { | |
| if (mode !== "build" || e.target.tagName === "BUTTON") return; | |
| mouse.x = (e.clientX / innerWidth) * 2 - 1; | |
| mouse.y = -(e.clientY / innerHeight) * 2 + 1; | |
| ray.setFromCamera(mouse, camera); | |
| const hit = ray.intersectObject(ground); | |
| if (!hit.length) return; | |
| let { x, z } = hit[0].point; | |
| x = Math.round(x / BLOCK) * BLOCK; | |
| z = Math.round(z / BLOCK) * BLOCK; | |
| const b = makeBuilding(buildingType); | |
| b.position.set(x, 0, z); | |
| scene.add(b); | |
| buildings.push(b); // 🔥 NEW | |
| }); | |
| ["b1", "b2", "b3"].forEach( | |
| (id, i) => | |
| (document.getElementById(id).onclick = () => (buildingType = i + 1)), | |
| ); | |
| /* ---------- FIRE SYSTEM ---------- */ | |
| const fireAlert = document.getElementById("fireAlert"); | |
| function randomIgnite() { | |
| if (buildings.length === 0) return; | |
| const safe = buildings.filter((b) => !b.userData.onFire); | |
| if (!safe.length) return; | |
| const b = safe[Math.floor(Math.random() * safe.length)]; | |
| b.userData.onFire = true; | |
| b.userData.fireLife = 100; | |
| addFlame(b); | |
| fireAlert.style.display = "block"; | |
| heliTarget = b; // helicopter heads over | |
| } | |
| setInterval(randomIgnite, 20000); | |
| function extinguishBuilding(b, byPlayer) { | |
| removeFlame(b); | |
| b.userData.onFire = false; | |
| b.userData.fireLife = 0; | |
| if (byPlayer) console.log("Fire put out by player"); | |
| else console.log("Fire put out by helicopter"); | |
| const still = buildings.some((x) => x.userData.onFire); | |
| if (!still) fireAlert.style.display = "none"; | |
| if (heliTarget === b) heliTarget = null; | |
| } | |
| /* ---------- RESIZE ---------- */ | |
| addEventListener("resize", () => { | |
| camera.aspect = innerWidth / innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth, innerHeight); | |
| }); | |
| /* ---------- MAIN LOOP ---------- */ | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| /* DRIVE controls */ | |
| if (mode === "drive") { | |
| if (keys["KeyW"]) speed = Math.min(speed + ACC, MAX_SPEED); | |
| else if (keys["KeyS"]) speed = Math.max(speed - ACC, -MAX_SPEED / 2); | |
| else { | |
| if (speed > 0) speed = Math.max(0, speed - FRICTION); | |
| else speed = Math.min(0, speed + FRICTION); | |
| } | |
| if (keys["KeyA"]) steering = 0.03; | |
| else if (keys["KeyD"]) steering = -0.03; | |
| else steering = 0; | |
| truckGroup.rotation.y += steering * speed * 0.8; | |
| truckGroup.position.x -= Math.sin(truckGroup.rotation.y) * speed; | |
| truckGroup.position.z -= Math.cos(truckGroup.rotation.y) * speed; | |
| const limit = CITY * BLOCK; | |
| if ( | |
| Math.abs(truckGroup.position.x) > limit || | |
| Math.abs(truckGroup.position.z) > limit | |
| ) | |
| resetTruck(); | |
| const camOff = new THREE.Vector3(0, 25, -55).applyAxisAngle( | |
| new THREE.Vector3(0, 1, 0), | |
| truckGroup.rotation.y, | |
| ); | |
| camera.position.copy(truckGroup.position).add(camOff); | |
| camera.lookAt(truckGroup.position); | |
| /* water */ | |
| const extinguishPressed = keys["Space"]; | |
| spray.visible = extinguishPressed; | |
| if (extinguishPressed) | |
| spray.material.opacity = Math.random() * 0.2 + 0.5; | |
| } | |
| /* Update traffic AI */ | |
| traffic.forEach((c) => { | |
| c.rotation.y = Math.atan2(c.userData.dir.x, c.userData.dir.z); | |
| c.position.addScaledVector(c.userData.dir, c.userData.speed); | |
| if ( | |
| Math.abs(c.position.x % BLOCK) < c.userData.speed && | |
| Math.abs(c.position.z % BLOCK) < c.userData.speed | |
| ) { | |
| if (Math.random() < 0.3) { | |
| const r = new THREE.Vector3( | |
| -c.userData.dir.z, | |
| 0, | |
| c.userData.dir.x, | |
| ); | |
| const l = new THREE.Vector3( | |
| c.userData.dir.z, | |
| 0, | |
| -c.userData.dir.x, | |
| ); | |
| c.userData.dir = (Math.random() < 0.5 ? r : l).clone(); | |
| } | |
| } | |
| const lim = CITY * BLOCK + 20; | |
| if (Math.abs(c.position.x) > lim || Math.abs(c.position.z) > lim) | |
| c.userData.dir.negate(); | |
| }); | |
| /* FIRE tick – lower life & check for extinguish */ | |
| buildings.forEach((b) => { | |
| if (!b.userData.onFire) return; | |
| if (b.userData.fireLife > 0) b.userData.fireLife -= 0.04; | |
| // if water spray intersects | |
| if (spray.visible) { | |
| const dist = truckGroup.position.distanceTo(b.position); | |
| if (dist < 45) b.userData.fireLife -= 1; // player extinguish | |
| } | |
| // helicopter extinguish | |
| if (heliTarget === b) { | |
| const dist = heli.position.distanceTo(b.position); | |
| if (dist < 20) b.userData.fireLife -= 1.5; | |
| } | |
| if (b.userData.fireLife <= 0) | |
| extinguishBuilding( | |
| b, | |
| spray.visible && truckGroup.position.distanceTo(b.position) < 45, | |
| ); | |
| if (b.userData.flame) b.userData.flame.material.rotation += 0.05; // flicker | |
| }); | |
| /* Helicopter behaviour */ | |
| rotor.rotation.y += 0.6; | |
| if (heliTarget) { | |
| const dir = new THREE.Vector3() | |
| .subVectors(heliTarget.position, heli.position) | |
| .setY(0) | |
| .normalize(); | |
| heli.position.addScaledVector(dir, 1.2); | |
| heli.position.y = 80 + Math.sin(Date.now() * 0.003) * 5; | |
| heli.lookAt(heliTarget.position.clone().setY(heli.position.y)); | |
| } else { | |
| heli.position.x += 0.8; | |
| heli.position.y = 80; | |
| if (heli.position.x > CITY * BLOCK) | |
| heli.position.set(-CITY * BLOCK, 80, -CITY * BLOCK); | |
| } | |
| renderer.render(scene, camera); | |
| } | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment