Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

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