Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created June 14, 2025 07:38
Show Gist options
  • Select an option

  • Save shricodev/f951ccb6cc02cc58a270c5378b113a45 to your computer and use it in GitHub Desktop.

Select an option

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