Skip to content

Instantly share code, notes, and snippets.

@shricodev
Last active April 22, 2025 08:32
Show Gist options
  • Select an option

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

Select an option

Save shricodev/119b06ac133218601b0d367ad10f3d54 to your computer and use it in GitHub Desktop.
Gemini 2.5 - Blog SimCity Simulation - Part 1
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Simple City Builder</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div id="ui-container">
<h2>Building Legend</h2>
<div id="building-selector">
<!-- Buttons will be generated by JS -->
</div>
<div id="info-panel">
Population: <span id="population-count">0</span>
</div>
<p>Click on a green plot to build the selected building type.</p>
<p>Right-click/Alt-click + drag to pan. Scroll to zoom.</p>
</div>
<canvas id="city-canvas"></canvas>
<!-- Import Three.js -->
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.161.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.161.0/examples/jsm/"
}
}
</script>
<script type="module" src="main.js"></script>
</body>
</html>
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
// --- Configuration ---
const config = {
gridSize: 10, // Number of cells across (10x10 grid)
cellSize: 5, // Size of one grid cell in 3D units
roadWidthRatio: 0.2, // How much of the cell size is road (0.2 = 20%)
buildingHeightMin: 2,
buildingHeightMax: 8,
carSpeed: 0.15,
};
// --- Building Types (Based on reference image legend) ---
const buildingTypes = {
1: { name: "Residential", color: 0x4169e1, population: 10, height: 4 }, // Blue
2: { name: "Market", color: 0xffd700, population: 0, height: 3 }, // Yellow
3: { name: "Factory", color: 0x6a5acd, population: 0, height: 7 }, // Purple (SlateBlue)
4: { name: "TownHall", color: 0x808080, population: 0, height: 6 }, // Grey
5: { name: "PowerPlant", color: 0xff4500, population: 0, height: 5 }, // Red (OrangeRed)
6: { name: "WaterTower", color: 0xffffff, population: 0, height: 8 }, // White
7: { name: "Hospital", color: 0x00ced1, population: 0, height: 5.5 }, // Cyan (DarkTurquoise)
8: { name: "School", color: 0xff8c00, population: 0, height: 4.5 }, // Orange (DarkOrange)
9: { name: "PostOffice", color: 0x20b2aa, population: 0, height: 3.5 }, // Teal (LightSeaGreen)
};
let currentBuildingTypeId = 1; // Start with Residential selected
let population = 0;
// --- Scene Objects ---
let scene, camera, renderer, controls;
let gridHelperPlane; // Invisible plane for raycasting
let plotGroup, roadGroup, buildingGroup, carGroup;
const gridData = []; // 2D array to store placed building types
const buildingObjects = {}; // Store references to placed building meshes { 'x_y': mesh }
const cars = []; // Array to store car objects and their paths
// --- UI Elements ---
const buildingSelectorUI = document.getElementById("building-selector");
const populationCountUI = document.getElementById("population-count");
const canvas = document.getElementById("city-canvas");
// --- Initialization ---
function init() {
// Basic Three.js Setup
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb); // Sky blue background
scene.fog = new THREE.Fog(0x87ceeb, 100, 300);
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000,
);
camera.position.set(
config.gridSize * config.cellSize * 0.6,
config.gridSize * config.cellSize * 0.8,
config.gridSize * config.cellSize * 0.6,
);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ canvas: canvas, antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true; // Enable shadows
// Lighting
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.0);
directionalLight.position.set(50, 80, 30);
directionalLight.castShadow = true;
// Configure shadow properties for better quality/performance
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 500;
directionalLight.shadow.camera.left = -config.gridSize * config.cellSize;
directionalLight.shadow.camera.right = config.gridSize * config.cellSize;
directionalLight.shadow.camera.top = config.gridSize * config.cellSize;
directionalLight.shadow.camera.bottom = -config.gridSize * config.cellSize;
scene.add(directionalLight);
// scene.add( new THREE.CameraHelper( directionalLight.shadow.camera ) ); // Uncomment to debug shadow camera
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.screenSpacePanning = false; // Keep panning relative to ground
controls.maxPolarAngle = Math.PI / 2.1; // Prevent looking directly down or under ground
// Create Groups
plotGroup = new THREE.Group();
roadGroup = new THREE.Group();
buildingGroup = new THREE.Group();
carGroup = new THREE.Group();
scene.add(plotGroup, roadGroup, buildingGroup, carGroup);
// Create Environment and Grid
createEnvironment();
createGrid();
setupUI();
createInitialCars();
// Event Listeners
window.addEventListener("resize", onWindowResize);
canvas.addEventListener("click", onCanvasClick);
// Start Animation Loop
animate();
}
// --- Environment ---
function createEnvironment() {
// Ground Plane (Dirt/Surrounding Area)
const groundSize = config.gridSize * config.cellSize * 3; // Larger than the grid
const groundGeometry = new THREE.PlaneGeometry(groundSize, groundSize);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xaa8866 }); // Brownish color
const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
groundMesh.rotation.x = -Math.PI / 2; // Rotate flat
groundMesh.position.y = -0.1; // Slightly below grid
groundMesh.receiveShadow = true;
scene.add(groundMesh);
// Water Plane (Optional)
const waterSize = config.gridSize * config.cellSize * 1.5;
const waterGeometry = new THREE.PlaneGeometry(waterSize, waterSize);
const waterMaterial = new THREE.MeshStandardMaterial({
color: 0x336699,
transparent: true,
opacity: 0.8,
});
const waterMesh = new THREE.Mesh(waterGeometry, waterMaterial);
waterMesh.rotation.x = -Math.PI / 2;
waterMesh.position.y = -0.05; // Slightly above ground, below grid
waterMesh.position.z = config.gridSize * config.cellSize * 1.0; // Position it away from center
scene.add(waterMesh);
}
// --- Grid Creation ---
function createGrid() {
const totalGridSize = config.gridSize * config.cellSize;
const halfTotalGridSize = totalGridSize / 2;
const plotSize = config.cellSize * (1 - config.roadWidthRatio);
const roadThickness = config.cellSize * config.roadWidthRatio;
const offset = config.cellSize / 2; // Offset to center plots in their cells
// Materials
const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x444444 });
const plotMaterial = new THREE.MeshStandardMaterial({ color: 0x55aa55 }); // Green plots
// Base Road Surface (covers the whole grid area)
const baseRoadGeometry = new THREE.PlaneGeometry(
totalGridSize,
totalGridSize,
);
const baseRoadMesh = new THREE.Mesh(baseRoadGeometry, roadMaterial);
baseRoadMesh.rotation.x = -Math.PI / 2;
baseRoadMesh.receiveShadow = true; // Roads receive shadows
roadGroup.add(baseRoadMesh);
// Invisible Plane for Raycasting (slightly above plots)
const planeGeometry = new THREE.PlaneGeometry(totalGridSize, totalGridSize);
gridHelperPlane = new THREE.Mesh(
planeGeometry,
new THREE.MeshBasicMaterial({ visible: false }),
); // Invisible
gridHelperPlane.rotation.x = -Math.PI / 2;
gridHelperPlane.position.y = 0.01; // Just above the plots
scene.add(gridHelperPlane); // Add directly to scene for raycasting
// Create Plots on top of the road base
const plotGeometry = new THREE.PlaneGeometry(plotSize, plotSize);
for (let i = 0; i < config.gridSize; i++) {
gridData[i] = [];
for (let j = 0; j < config.gridSize; j++) {
gridData[i][j] = 0; // Initialize grid data (0 = empty)
const plotMesh = new THREE.Mesh(plotGeometry, plotMaterial);
plotMesh.rotation.x = -Math.PI / 2;
// Calculate position
const xPos = i * config.cellSize - halfTotalGridSize + offset;
const zPos = j * config.cellSize - halfTotalGridSize + offset;
plotMesh.position.set(xPos, 0.005, zPos); // Slightly above road base
plotMesh.receiveShadow = true; // Plots receive shadows
plotMesh.userData = { gridX: i, gridY: j, isPlot: true }; // Store grid coords
plotGroup.add(plotMesh);
}
}
}
// --- Building Placement ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onCanvasClick(event) {
// Calculate mouse position in normalized device coordinates (-1 to +1)
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
// Update the picking ray with the camera and mouse position
raycaster.setFromCamera(mouse, camera);
// Calculate objects intersecting the picking ray
// Important: Raycast against the *invisible* gridHelperPlane first
const intersects = raycaster.intersectObject(gridHelperPlane);
if (intersects.length > 0) {
const intersectPoint = intersects[0].point;
// Convert world coordinates to grid coordinates
const gridCoords = worldToGridCoords(intersectPoint);
if (gridCoords) {
const { gridX, gridY } = gridCoords;
// Check if cell is within bounds and empty
if (
gridX >= 0 &&
gridX < config.gridSize &&
gridY >= 0 &&
gridY < config.gridSize
) {
if (gridData[gridX][gridY] === 0) {
placeBuilding(gridX, gridY, currentBuildingTypeId);
} else {
console.log(`Cell (${gridX}, ${gridY}) is already occupied.`);
// Optional: Implement building removal here (e.g., on right-click)
}
}
}
}
}
function worldToGridCoords(worldPoint) {
const totalGridSize = config.gridSize * config.cellSize;
const halfTotalGridSize = totalGridSize / 2;
const gridX = Math.floor(
(worldPoint.x + halfTotalGridSize) / config.cellSize,
);
const gridY = Math.floor(
(worldPoint.z + halfTotalGridSize) / config.cellSize,
);
if (
gridX >= 0 &&
gridX < config.gridSize &&
gridY >= 0 &&
gridY < config.gridSize
) {
return { gridX, gridY };
}
return null; // Outside grid bounds
}
function gridToWorldCoords(gridX, gridY) {
const totalGridSize = config.gridSize * config.cellSize;
const halfTotalGridSize = totalGridSize / 2;
const offset = config.cellSize / 2;
const worldX = gridX * config.cellSize - halfTotalGridSize + offset;
const worldZ = gridY * config.cellSize - halfTotalGridSize + offset;
return { x: worldX, z: worldZ };
}
function placeBuilding(gridX, gridY, typeId) {
if (!buildingTypes[typeId]) {
console.error("Invalid building type ID:", typeId);
return;
}
if (gridData[gridX][gridY] !== 0) {
console.warn(
`Cannot place building: Cell (${gridX}, ${gridY}) is occupied.`,
);
return;
}
const buildingInfo = buildingTypes[typeId];
const buildingSizeRatio = 0.8; // Building occupies 80% of the plot width/depth
const width =
config.cellSize * (1 - config.roadWidthRatio) * buildingSizeRatio;
const depth = width;
// const height = config.buildingHeightMin + Math.random() * (config.buildingHeightMax - config.buildingHeightMin);
const height = buildingInfo.height || 5; // Use defined height or default
const geometry = new THREE.BoxGeometry(width, height, depth);
const material = new THREE.MeshStandardMaterial({
color: buildingInfo.color,
});
const buildingMesh = new THREE.Mesh(geometry, material);
// Calculate position
const worldCoords = gridToWorldCoords(gridX, gridY);
buildingMesh.position.set(worldCoords.x, height / 2, worldCoords.z); // Position base at y=0
buildingMesh.castShadow = true;
buildingMesh.receiveShadow = true;
buildingMesh.userData = { gridX, gridY, typeId };
buildingGroup.add(buildingMesh);
gridData[gridX][gridY] = typeId; // Mark cell as occupied
buildingObjects[`${gridX}_${gridY}`] = buildingMesh; // Store reference
// Update population if residential
if (buildingInfo.population > 0) {
population += buildingInfo.population;
updatePopulationDisplay();
}
console.log(`Placed ${buildingInfo.name} at (${gridX}, ${gridY})`);
}
// --- Car Animation ---
function createCar(color = 0xff0000) {
const carWidth = config.cellSize * 0.2;
const carHeight = config.cellSize * 0.15;
const carLength = config.cellSize * 0.3;
const carGeometry = new THREE.BoxGeometry(carLength, carHeight, carWidth);
const carMaterial = new THREE.MeshStandardMaterial({ color: color });
const carMesh = new THREE.Mesh(carGeometry, carMaterial);
carMesh.castShadow = true;
return carMesh;
}
function createInitialCars() {
const numCars = 5;
const totalGridSize = config.gridSize * config.cellSize;
const halfSize = totalGridSize / 2;
const roadOffset = config.cellSize * (config.roadWidthRatio / 3); // Offset cars slightly into lanes
for (let i = 0; i < numCars; i++) {
const car = createCar(Math.random() * 0xffffff);
let path;
const startPos = new THREE.Vector3();
const endPos = new THREE.Vector3();
// Simple predefined paths (horizontal or vertical)
if (Math.random() > 0.5) {
// Horizontal path
const z =
Math.floor(Math.random() * config.gridSize) * config.cellSize -
halfSize +
roadOffset;
startPos.set(
-halfSize - config.cellSize,
car.geometry.parameters.height / 2,
z,
);
endPos.set(
halfSize + config.cellSize,
car.geometry.parameters.height / 2,
z,
);
if (Math.random() > 0.5) {
// Reverse direction
[startPos.x, endPos.x] = [endPos.x, startPos.x];
car.rotation.y = Math.PI;
}
} else {
// Vertical path
const x =
Math.floor(Math.random() * config.gridSize) * config.cellSize -
halfSize +
roadOffset;
startPos.set(
x,
car.geometry.parameters.height / 2,
-halfSize - config.cellSize,
);
endPos.set(
x,
car.geometry.parameters.height / 2,
halfSize + config.cellSize,
);
if (Math.random() > 0.5) {
// Reverse direction
[startPos.z, endPos.z] = [endPos.z, startPos.z];
car.rotation.y = (Math.PI / 2) * (startPos.z > endPos.z ? -1 : 1);
} else {
car.rotation.y = (Math.PI / 2) * (startPos.z > endPos.z ? -1 : 1);
}
}
car.position.copy(startPos);
carGroup.add(car);
cars.push({
mesh: car,
start: startPos.clone(),
end: endPos.clone(),
progress: Math.random(),
}); // Start at random progress
}
}
function animateCars(deltaTime) {
const speed = config.carSpeed * deltaTime * 60; // Adjust speed based on deltaTime
cars.forEach((carData) => {
carData.progress += speed / carData.start.distanceTo(carData.end);
if (carData.progress >= 1) {
carData.progress = 0; // Reset path
}
carData.mesh.position.lerpVectors(
carData.start,
carData.end,
carData.progress,
);
});
}
// --- UI ---
function setupUI() {
// Clear existing buttons
buildingSelectorUI.innerHTML = "";
// Create buttons for each building type
Object.entries(buildingTypes).forEach(([id, type]) => {
const button = document.createElement("button");
button.innerHTML = `
<span class="color-swatch" style="background-color: #${type.color.toString(16).padStart(6, "0")};"></span>
${type.name} (${id})
`;
button.dataset.typeId = id;
button.addEventListener("click", () => selectBuildingType(id));
if (parseInt(id) === currentBuildingTypeId) {
button.classList.add("selected");
}
buildingSelectorUI.appendChild(button);
});
updatePopulationDisplay();
}
function selectBuildingType(typeId) {
currentBuildingTypeId = parseInt(typeId);
console.log(
"Selected building type:",
buildingTypes[currentBuildingTypeId].name,
);
// Update button styles
const buttons = buildingSelectorUI.querySelectorAll("button");
buttons.forEach((btn) => {
if (parseInt(btn.dataset.typeId) === currentBuildingTypeId) {
btn.classList.add("selected");
} else {
btn.classList.remove("selected");
}
});
}
function updatePopulationDisplay() {
populationCountUI.textContent = population;
}
// --- Window Resize ---
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// --- Animation Loop ---
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const deltaTime = clock.getDelta();
controls.update(); // Required if damping enabled
animateCars(deltaTime);
renderer.render(scene, camera);
}
// --- Start ---
init();
body {
margin: 0;
overflow: hidden; /* Prevent scrollbars */
font-family: sans-serif;
background-color: #555; /* Fallback background */
}
#city-canvas {
display: block; /* Remove default canvas spacing */
width: 100vw;
height: 100vh;
}
#ui-container {
position: absolute;
top: 10px;
right: 10px;
background-color: rgba(255, 255, 255, 0.8);
padding: 15px;
border-radius: 5px;
max-width: 200px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
}
#ui-container h2 {
margin-top: 0;
font-size: 1em;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
margin-bottom: 10px;
}
#building-selector button {
display: flex;
align-items: center;
width: 100%;
padding: 8px 5px;
margin-bottom: 5px;
border: 1px solid #ccc;
background-color: #f0f0f0;
cursor: pointer;
text-align: left;
font-size: 0.9em;
border-radius: 3px;
}
#building-selector button:hover {
background-color: #e0e0e0;
}
#building-selector button.selected {
border-color: #000;
background-color: #d0d0d0;
font-weight: bold;
}
.color-swatch {
display: inline-block;
width: 15px;
height: 15px;
margin-right: 8px;
border: 1px solid #555;
flex-shrink: 0; /* Prevent shrinking */
}
#info-panel {
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid #ccc;
font-size: 0.9em;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment