Skip to content

Instantly share code, notes, and snippets.

@shricodev
Last active April 22, 2025 08:38
Show Gist options
  • Save shricodev/90362cb8f2c1c55f46e3ea7c2779f0c7 to your computer and use it in GitHub Desktop.
Save shricodev/90362cb8f2c1c55f46e3ea7c2779f0c7 to your computer and use it in GitHub Desktop.
o3 - Blog SimCity Simulation - Part 2
<!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>
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);
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