-
-
Save shricodev/90362cb8f2c1c55f46e3ea7c2779f0c7 to your computer and use it in GitHub Desktop.
o3 - Blog SimCity Simulation - Part 2
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‑SimCity</title> | |
<link rel="stylesheet" href="style.css" /> | |
</head> | |
<body> | |
<!-- HUD --> | |
<div id="hud"> | |
<span id="modeTxt">Mode: Road</span> | | |
<span id="popTxt">Population: 0</span> | | |
<!-- ─── NEW ─── --> | |
<span id="cashTxt">Cash: $10 000</span> | |
<div id="buttons"> | |
<button data-mode="road">Road ($10)</button> | |
<button data-mode="res">Residential ($100)</button> | |
<button data-mode="com">Commercial ($150)</button> | |
<button data-mode="ind">Industrial ($120)</button> | |
<button data-mode="lake">Lake ($50)</button> | |
<!-- ─── NEW ─── --> | |
<button | |
id="resetBtn" | |
style="margin-left: 8px; background: #f44336; color: #fff" | |
> | |
Reset | |
</button> | |
</div> | |
</div> | |
<!-- Render surface --> | |
<canvas id="scene"></canvas> | |
<script type="module" src="main.js"></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
import * as THREE from "https://unpkg.com/[email protected]/build/three.module.js"; | |
import { OrbitControls } from "https://unpkg.com/[email protected]/examples/jsm/controls/OrbitControls.js?module"; | |
/* Mini‑SimCity – v2 ------------------------------------------------------- | |
Additions: | |
• Simple money economy | |
• Balance shown in HUD | |
• Slow residential population growth | |
• Reset‑city button | |
• More walkers (“citizens”) roaming the city | |
----------------------------------------------------------------------------- */ | |
// ───────────────────────────────────────────────────────────────────────────── | |
// GLOBAL CONSTANTS | |
// ───────────────────────────────────────────────────────────────────────────── | |
const CELL = 5; // one grid cell = 5×5 Three‑JS units | |
const GRID = 20; // 20×20 cells => 100×100 world units | |
const HALF = GRID * CELL * 0.5; | |
const POP_PER_RES = 10; // max pop in one residential cube | |
const START_CASH = 10_000; // initial money | |
// Placing costs NEW | |
const COST = { | |
road: 10, | |
res: 100, | |
com: 150, | |
ind: 120, | |
lake: 50, | |
}; | |
// ───────────────────────────────────────────────────────────────────────────── | |
// RUNTIME STATE | |
// ───────────────────────────────────────────────────────────────────────────── | |
let mode = "road"; | |
let population = 0; | |
let cash = START_CASH; | |
const cells = [...Array(GRID)].map(() => Array(GRID).fill(null)); // will hold objects | |
const residentialLots = []; // references to all residential cells – for slow pop growth | |
const structures = []; // all placed meshes – makes reset very easy | |
const roads = []; // road meshes – for cars to spawn / drive | |
const cars = []; | |
const walkers = []; | |
// ───────────────────────────────────────────────────────────────────────────── | |
// THREE.JS SET‑UP | |
// ───────────────────────────────────────────────────────────────────────────── | |
const canvas = document.getElementById("scene"); | |
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); | |
renderer.setPixelRatio(devicePixelRatio); | |
renderer.setSize(innerWidth, innerHeight); | |
const scene = new THREE.Scene(); | |
scene.background = new THREE.Color(0xaee0ff); | |
const camera = new THREE.PerspectiveCamera( | |
60, | |
innerWidth / innerHeight, | |
1, | |
1000, | |
); | |
camera.position.set(60, 60, 60); | |
const controls = new OrbitControls(camera, renderer.domElement); | |
controls.enableDamping = true; | |
// Lights | |
scene.add(new THREE.AmbientLight(0xffffff, 0.6)); | |
const sun = new THREE.DirectionalLight(0xffffff, 0.6); | |
sun.position.set(50, 100, 20); | |
scene.add(sun); | |
// Ground grid helper | |
const gHelper = new THREE.GridHelper(GRID * CELL, GRID, 0x444444, 0xcccccc); | |
gHelper.position.y = 0.01; // avoid z‑fighting | |
scene.add(gHelper); | |
// ───────────────────────────────────────────────────────────────────────────── | |
// MATERIALS | |
// ───────────────────────────────────────────────────────────────────────────── | |
const Mats = { | |
road: new THREE.MeshStandardMaterial({ color: 0x333333 }), | |
res: new THREE.MeshStandardMaterial({ color: 0x4caf50 }), | |
com: new THREE.MeshStandardMaterial({ color: 0x03a9f4 }), | |
ind: new THREE.MeshStandardMaterial({ color: 0xffc107 }), | |
lake: new THREE.MeshStandardMaterial({ | |
color: 0x2196f3, | |
transparent: true, | |
opacity: 0.7, | |
}), | |
}; | |
// ───────────────────────────────────────────────────────────────────────────── | |
// DOM / HUD | |
// ───────────────────────────────────────────────────────────────────────────── | |
function refreshHUD() { | |
document.getElementById("modeTxt").textContent = | |
`Mode: ${mode[0].toUpperCase()}${mode.slice(1)}`; | |
document.getElementById("popTxt").textContent = | |
`Population: ${population.toLocaleString()}`; | |
document.getElementById("cashTxt").textContent = | |
`Cash: $${cash.toLocaleString()}`; | |
} | |
refreshHUD(); | |
// Mode buttons | |
document.querySelectorAll("#buttons button[data-mode]").forEach((btn) => { | |
btn.onclick = () => { | |
mode = btn.dataset.mode; | |
refreshHUD(); | |
}; | |
}); | |
// Reset button NEW | |
document.getElementById("resetBtn").onclick = resetCity; | |
// ───────────────────────────────────────────────────────────────────────────── | |
// EVENTS | |
// ───────────────────────────────────────────────────────────────────────────── | |
window.addEventListener("resize", onResize); | |
canvas.addEventListener("pointerdown", placeStructure); | |
function onResize() { | |
renderer.setSize(innerWidth, innerHeight); | |
camera.aspect = innerWidth / innerHeight; | |
camera.updateProjectionMatrix(); | |
} | |
// ───────────────────────────────────────────────────────────────────────────── | |
// STRUCTURE PLACEMENT | |
// ───────────────────────────────────────────────────────────────────────────── | |
const raycaster = new THREE.Raycaster(); | |
const mouse = new THREE.Vector2(); | |
function placeStructure(ev) { | |
// 1. --- Ray‑cast onto ground plane ---------------------------------------- | |
const rect = canvas.getBoundingClientRect(); | |
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1; | |
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1; | |
raycaster.setFromCamera(mouse, camera); | |
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); | |
const worldPos = new THREE.Vector3(); | |
raycaster.ray.intersectPlane(groundPlane, worldPos); | |
if (!worldPos) return; | |
// 2. --- Convert to grid indices ------------------------------------------ | |
const gx = Math.floor((worldPos.x + HALF) / CELL); | |
const gz = Math.floor((worldPos.z + HALF) / CELL); | |
if (gx < 0 || gx >= GRID || gz < 0 || gz >= GRID) return; | |
// 3. --- Occupied? --------------------------------------------------------- | |
if (cells[gx][gz]) return; | |
// 4. --- Enough money? ----------------------------------------------------- NEW | |
const price = COST[mode]; | |
if (cash < price) { | |
// could flash red, play sound, etc. | |
return; | |
} | |
cash -= price; | |
// 5. --- Create logical cell entry ---------------------------------------- | |
const cellData = { type: mode }; | |
cells[gx][gz] = cellData; | |
// Residential lots are tracked for slow growth NEW | |
if (mode === "res") { | |
cellData.pop = 0; // current inhabitants | |
residentialLots.push(cellData); | |
} | |
// 6. --- Visual mesh ------------------------------------------------------- | |
spawnMesh(gx, gz, mode); | |
refreshHUD(); | |
} | |
// Create & add mesh, store references for easy reset | |
function spawnMesh(gx, gz, type) { | |
const height = type === "road" || type === "lake" ? 0.2 : CELL; | |
const yPos = height * 0.5; | |
const geo = new THREE.BoxGeometry(CELL, height, CELL); | |
const mesh = new THREE.Mesh(geo, Mats[type]); | |
mesh.position.set( | |
gx * CELL - HALF + CELL * 0.5, | |
yPos, | |
gz * CELL - HALF + CELL * 0.5, | |
); | |
scene.add(mesh); | |
structures.push(mesh); // keep reference for reset | |
if (type === "road") roads.push(mesh); | |
} | |
// ───────────────────────────────────────────────────────────────────────────── | |
// TRAFFIC / CITIZENS (same as before, but spawns a bit more frequently) | |
// ───────────────────────────────────────────────────────────────────────────── | |
const CarGeo = new THREE.BoxGeometry(2, 1, 4); | |
const CarMat = new THREE.MeshStandardMaterial({ color: 0xff5252 }); | |
const WalkGeo = new THREE.BoxGeometry(1, 2, 1); | |
const WalkMat = new THREE.MeshStandardMaterial({ color: 0xffffff }); | |
// Cars – every 3 seconds if we have roads | |
setInterval(() => { | |
if (roads.length) spawnCar(); | |
}, 3000); | |
// Walkers – every 2.5 seconds CHANGED | |
setInterval(() => { | |
if (roads.length) spawnWalker(); | |
}, 2500); | |
function spawnCar() { | |
const seg = roads[(Math.random() * roads.length) | 0]; | |
const car = new THREE.Mesh(CarGeo, CarMat.clone()); | |
car.position.copy(seg.position); | |
car.position.y = 0.6; | |
car.userData.dir = Math.random() < 0.5 ? "x" : "z"; | |
car.userData.speed = 0.5 + Math.random() * 0.5; | |
cars.push(car); | |
scene.add(car); | |
} | |
function spawnWalker() { | |
// Walkers spawn next to a random road tile (side‑walk feeling) | |
const seg = roads[(Math.random() * roads.length) | 0]; | |
const walk = new THREE.Mesh(WalkGeo, WalkMat.clone()); | |
walk.position.set( | |
seg.position.x + (Math.random() < 0.5 ? -CELL / 2 : CELL / 2), // left / right side | |
1, | |
seg.position.z + (Math.random() < 0.5 ? -CELL / 2 : CELL / 2), // front / back side | |
); | |
walk.userData.speed = 0.2 + Math.random() * 0.2; | |
walk.userData.target = randomSidewalkTarget(); | |
walkers.push(walk); | |
scene.add(walk); | |
} | |
function randomSidewalkTarget() { | |
const seg = roads[(Math.random() * roads.length) | 0]; | |
return new THREE.Vector3( | |
seg.position.x + (Math.random() < 0.5 ? -CELL / 2 : CELL / 2), | |
1, | |
seg.position.z + (Math.random() < 0.5 ? -CELL / 2 : CELL / 2), | |
); | |
} | |
// ───────────────────────────────────────────────────────────────────────────── | |
// RESET FUNCTION NEW | |
// ───────────────────────────────────────────────────────────────────────────── | |
function resetCity() { | |
// 1. Remove placed structures | |
structures.forEach((m) => scene.remove(m)); | |
structures.length = 0; | |
// 2. Remove dynamic agents | |
[...cars, ...walkers].forEach((m) => scene.remove(m)); | |
cars.length = walkers.length = 0; | |
roads.length = 0; | |
// 3. Logic state | |
for (let x = 0; x < GRID; x++) cells[x].fill(null); | |
residentialLots.length = 0; | |
population = 0; | |
cash = START_CASH; | |
refreshHUD(); | |
} | |
// ───────────────────────────────────────────────────────────────────────────── | |
// GAME LOOP (also handles slow population growth) NEW | |
// ───────────────────────────────────────────────────────────────────────────── | |
let popTimer = 0; // accumulates time – we add one person every second | |
function animate(timeMS) { | |
requestAnimationFrame(animate); | |
const dt = Math.min( | |
0.033, | |
renderer.info.render.frame ? timeMS * 0.001 - popTimer : 0.016, | |
); | |
// ----------------------------------------------------------------- Cars | |
cars.forEach((c) => { | |
if (c.userData.dir === "x") c.position.x += c.userData.speed; | |
else c.position.z += c.userData.speed; | |
if ( | |
Math.abs(c.position.x) > HALF + 10 || | |
Math.abs(c.position.z) > HALF + 10 | |
) { | |
scene.remove(c); | |
cars.splice(cars.indexOf(c), 1); | |
} | |
}); | |
// ---------------------------------------------------------------- Walkers | |
walkers.forEach((w) => { | |
const tgt = w.userData.target; | |
const dir = tgt.clone().sub(w.position).setY(0); | |
if (dir.length() < 0.5) { | |
w.userData.target = randomSidewalkTarget(); | |
} else { | |
dir.normalize().multiplyScalar(w.userData.speed); | |
w.position.add(dir); | |
} | |
}); | |
// -------------------------------------------------------- Residential pop | |
popTimer += dt; | |
if (popTimer >= 1) { | |
// once per second | |
popTimer = 0; | |
let grew = false; | |
residentialLots.forEach((lot) => { | |
if (lot.pop < POP_PER_RES) { | |
lot.pop += 1; | |
population += 1; | |
grew = true; | |
} | |
}); | |
if (grew) refreshHUD(); | |
} | |
controls.update(); | |
renderer.render(scene, camera); | |
} | |
animate(0); |
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
html, | |
body { | |
margin: 0; | |
height: 100%; | |
overflow: hidden; | |
font-family: sans-serif; | |
} | |
#scene { | |
display: block; | |
width: 100%; | |
height: 100%; | |
} | |
#hud { | |
position: fixed; | |
top: 8px; | |
left: 8px; | |
background: #0009; | |
color: #fff; | |
padding: 6px 10px; | |
border-radius: 4px; | |
z-index: 10; | |
} | |
#buttons button { | |
margin: 2px; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment