Skip to content

Instantly share code, notes, and snippets.

@shricodev
Created April 22, 2025 08:34
Show Gist options
  • Save shricodev/1231936bd34723e8a201027b80d5f413 to your computer and use it in GitHub Desktop.
Save shricodev/1231936bd34723e8a201027b80d5f413 to your computer and use it in GitHub Desktop.
Gemini 2.5 - Blog SimCity Simulation - Part 2
<!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 Enhanced</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">
<div>Population: <span id="population-count">0</span></div>
<!-- Added Money Display -->
<div>Money: $<span id="money-count">0</span></div>
</div>
<!-- Added Reset Button -->
<button id="reset-button">Reset City</button>
<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/[email protected]/build/three.module.js",
"three/addons/": "https://unpkg.com/[email protected]/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,
cellSize: 5,
roadWidthRatio: 0.2,
buildingHeightMin: 2,
buildingHeightMax: 8,
carSpeed: 0.15,
personSpeed: 0.04, // Slower speed for people
startingMoney: 5000, // Added starting money
populationGrowthRate: 0.01, // Controls how fast population animates (percentage per frame-ish)
};
// --- Building Types (Added cost) ---
const buildingTypes = {
// ID: { name, color, population_increase, height, cost }
1: {
name: "Residential",
color: 0x4169e1,
population: 10,
height: 4,
cost: 100,
},
2: { name: "Market", color: 0xffd700, population: 0, height: 3, cost: 150 },
3: { name: "Factory", color: 0x6a5acd, population: 0, height: 7, cost: 500 },
4: {
name: "TownHall",
color: 0x808080,
population: 0,
height: 6,
cost: 1000,
},
5: {
name: "PowerPlant",
color: 0xff4500,
population: 0,
height: 5,
cost: 800,
},
6: {
name: "WaterTower",
color: 0xffffff,
population: 0,
height: 8,
cost: 300,
},
7: {
name: "Hospital",
color: 0x00ced1,
population: 0,
height: 5.5,
cost: 700,
},
8: { name: "School", color: 0xff8c00, population: 0, height: 4.5, cost: 400 },
9: {
name: "PostOffice",
color: 0x20b2aa,
population: 0,
height: 3.5,
cost: 120,
},
};
// --- Game State ---
let currentBuildingTypeId = 1;
let targetPopulation = 0; // The max population based on buildings
let currentDisplayedPopulation = 0; // The population shown in UI, animates towards target
let currentMoney = config.startingMoney; // Start with initial amount
// --- Scene Objects ---
let scene, camera, renderer, controls;
let gridHelperPlane;
let plotGroup, roadGroup, buildingGroup, carGroup, personGroup; // Added personGroup
const gridData = [];
const buildingObjects = {};
const cars = [];
const people = []; // Array for person objects { mesh, pathPoints, currentSegment, progress }
// --- UI Elements ---
const buildingSelectorUI = document.getElementById("building-selector");
const populationCountUI = document.getElementById("population-count");
const moneyCountUI = document.getElementById("money-count"); // Added money UI element
const resetButtonUI = document.getElementById("reset-button"); // Added reset button element
const canvas = document.getElementById("city-canvas");
// --- Initialization ---
function init() {
// Basic Three.js Setup
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87ceeb);
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;
// 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;
directionalLight.shadow.mapSize.width = 2048;
directionalLight.shadow.mapSize.height = 2048;
directionalLight.shadow.camera.near = 0.5;
directionalLight.shadow.camera.far = 500;
const shadowCamSize = config.gridSize * config.cellSize * 0.7; // Adjust shadow camera size
directionalLight.shadow.camera.left = -shadowCamSize;
directionalLight.shadow.camera.right = shadowCamSize;
directionalLight.shadow.camera.top = shadowCamSize;
directionalLight.shadow.camera.bottom = -shadowCamSize;
scene.add(directionalLight);
// scene.add( new THREE.CameraHelper( directionalLight.shadow.camera ) );
// Controls
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.screenSpacePanning = false;
controls.maxPolarAngle = Math.PI / 2.1;
// Create Groups
plotGroup = new THREE.Group();
roadGroup = new THREE.Group();
buildingGroup = new THREE.Group();
carGroup = new THREE.Group();
personGroup = new THREE.Group(); // Initialize person group
scene.add(plotGroup, roadGroup, buildingGroup, carGroup, personGroup); // Add personGroup to scene
// Reset game state variables before creating elements
resetState();
// Create Environment and Grid
createEnvironment(); // Keep environment static for now
createGrid();
setupUI(); // Setup UI after resetting state
// Initial population (will start animating from 0)
createInitialCars();
createInitialPeople(); // Create people
// Event Listeners
window.addEventListener("resize", onWindowResize);
canvas.addEventListener("click", onCanvasClick);
resetButtonUI.addEventListener("click", resetCity); // Add listener for reset button
// Start Animation Loop
animate();
}
// --- Reset Logic ---
function resetState() {
currentBuildingTypeId = 1; // Default back to residential
targetPopulation = 0;
currentDisplayedPopulation = 0;
currentMoney = config.startingMoney;
// Clear grid data array
for (let i = 0; i < config.gridSize; i++) {
gridData[i] = [];
for (let j = 0; j < config.gridSize; j++) {
gridData[i][j] = 0;
}
}
}
function clearGroup(group, objectArray) {
// Remove meshes from the group and dispose of resources
group.children.slice().forEach((child) => {
// Iterate over a copy
group.remove(child);
if (child.geometry) child.geometry.dispose();
if (child.material) {
// Handle arrays of materials, typical for multi-material objects
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose());
} else {
child.material.dispose();
}
}
});
// Clear the tracking array
objectArray.length = 0;
}
function resetCity() {
console.log("Resetting City...");
// Clear Buildings
clearGroup(buildingGroup, Object.values(buildingObjects)); // Pass the values array
Object.keys(buildingObjects).forEach((key) => delete buildingObjects[key]); // Clear the map
// Clear Cars
clearGroup(carGroup, cars);
// Clear People
clearGroup(personGroup, people);
// Reset game state variables
resetState();
// Update UI immediately
updatePopulationDisplay();
updateMoneyDisplay();
updateBuildingButtons(); // Refresh button states (affordability)
// Respawn cars and people
createInitialCars();
createInitialPeople();
console.log("City Reset Complete.");
}
// --- Environment & Grid (Mostly unchanged) ---
function createEnvironment() {
const groundSize = config.gridSize * config.cellSize * 3;
const groundGeometry = new THREE.PlaneGeometry(groundSize, groundSize);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0xaa8866 });
const groundMesh = new THREE.Mesh(groundGeometry, groundMaterial);
groundMesh.rotation.x = -Math.PI / 2;
groundMesh.position.y = -0.1;
groundMesh.receiveShadow = true;
scene.add(groundMesh);
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;
waterMesh.position.z = config.gridSize * config.cellSize * 1.0;
scene.add(waterMesh);
}
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;
const roadMaterial = new THREE.MeshStandardMaterial({ color: 0x444444 });
const plotMaterial = new THREE.MeshStandardMaterial({ color: 0x55aa55 });
const baseRoadGeometry = new THREE.PlaneGeometry(
totalGridSize,
totalGridSize,
);
const baseRoadMesh = new THREE.Mesh(baseRoadGeometry, roadMaterial);
baseRoadMesh.rotation.x = -Math.PI / 2;
baseRoadMesh.receiveShadow = true;
roadGroup.add(baseRoadMesh);
const planeGeometry = new THREE.PlaneGeometry(totalGridSize, totalGridSize);
gridHelperPlane = new THREE.Mesh(
planeGeometry,
new THREE.MeshBasicMaterial({ visible: false }),
);
gridHelperPlane.rotation.x = -Math.PI / 2;
gridHelperPlane.position.y = 0.01;
scene.add(gridHelperPlane);
const plotGeometry = new THREE.PlaneGeometry(plotSize, plotSize);
for (let i = 0; i < config.gridSize; i++) {
// gridData initialization moved to resetState
for (let j = 0; j < config.gridSize; j++) {
// gridData[i][j] = 0; // No longer needed here
const plotMesh = new THREE.Mesh(
plotGeometry.clone(),
plotMaterial.clone(),
); // Clone to avoid sharing
plotMesh.rotation.x = -Math.PI / 2;
const xPos = i * config.cellSize - halfTotalGridSize + offset;
const zPos = j * config.cellSize - halfTotalGridSize + offset;
plotMesh.position.set(xPos, 0.005, zPos);
plotMesh.receiveShadow = true;
plotMesh.userData = { gridX: i, gridY: j, isPlot: true };
plotGroup.add(plotMesh);
}
}
}
// --- Building Placement (Added cost check) ---
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onCanvasClick(event) {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(gridHelperPlane);
if (intersects.length > 0) {
const intersectPoint = intersects[0].point;
const gridCoords = worldToGridCoords(intersectPoint);
if (gridCoords) {
const { gridX, gridY } = gridCoords;
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.`);
// Future: Implement removal or info display on 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;
}
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) {
const buildingInfo = buildingTypes[typeId];
if (!buildingInfo) {
console.error("Invalid building type ID:", typeId);
return;
}
if (gridData[gridX][gridY] !== 0) {
console.warn(
`Cannot place building: Cell (${gridX}, ${gridY}) is occupied.`,
);
return;
}
// *** Economy Check ***
if (currentMoney < buildingInfo.cost) {
console.warn(
`Not enough money to build ${buildingInfo.name}. Need $${buildingInfo.cost}, have $${currentMoney}.`,
);
// Optional: Add visual feedback to the user (e.g., flash the money red)
return; // Stop placement
}
// *** Deduct Cost & Update UI ***
currentMoney -= buildingInfo.cost;
updateMoneyDisplay();
updateBuildingButtons(); // Update button states after spending money
// --- Create Building Mesh ---
const buildingSizeRatio = 0.8;
const width =
config.cellSize * (1 - config.roadWidthRatio) * buildingSizeRatio;
const depth = width;
const height = buildingInfo.height || 5;
const geometry = new THREE.BoxGeometry(width, height, depth);
const material = new THREE.MeshStandardMaterial({
color: buildingInfo.color,
});
const buildingMesh = new THREE.Mesh(geometry, material);
const worldCoords = gridToWorldCoords(gridX, gridY);
buildingMesh.position.set(worldCoords.x, height / 2, worldCoords.z);
buildingMesh.castShadow = true;
buildingMesh.receiveShadow = true;
buildingMesh.userData = { gridX, gridY, typeId };
buildingGroup.add(buildingMesh);
gridData[gridX][gridY] = typeId;
buildingObjects[`${gridX}_${gridY}`] = buildingMesh;
// Update *target* population if residential
if (buildingInfo.population > 0) {
targetPopulation += buildingInfo.population;
// Don't update display immediately, animation handles it
}
console.log(
`Placed ${buildingInfo.name} at (${gridX}, ${gridY}) for $${buildingInfo.cost}. Money left: $${currentMoney}`,
);
}
// --- Car Animation (Unchanged) ---
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);
for (let i = 0; i < numCars; i++) {
const car = createCar(Math.random() * 0xffffff);
const startPos = new THREE.Vector3();
const endPos = new THREE.Vector3();
let initialRotationY = 0;
if (Math.random() > 0.5) {
// Horizontal path
const z =
Math.floor(Math.random() * config.gridSize) * config.cellSize -
halfSize +
roadOffset * (Math.random() > 0.5 ? 1 : -1); // Random lane
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) {
[startPos.x, endPos.x] = [endPos.x, startPos.x];
initialRotationY = Math.PI;
}
} else {
// Vertical path
const x =
Math.floor(Math.random() * config.gridSize) * config.cellSize -
halfSize +
roadOffset * (Math.random() > 0.5 ? 1 : -1); // Random lane
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) {
[startPos.z, endPos.z] = [endPos.z, startPos.z];
initialRotationY = Math.PI / 2;
} else {
initialRotationY = -Math.PI / 2;
}
}
car.rotation.y = initialRotationY;
car.position.copy(startPos);
carGroup.add(car);
cars.push({
mesh: car,
start: startPos.clone(),
end: endPos.clone(),
progress: Math.random(),
});
}
}
function animateCars(deltaTime) {
const speed = config.carSpeed; // Use fixed speed for now, deltaTime handled by lerp? No, needs delta
const frameAdjustedSpeed = speed * deltaTime * 60; // Normalize speed based on 60fps target
cars.forEach((carData) => {
const distance = carData.start.distanceTo(carData.end);
if (distance === 0) return; // Avoid division by zero
carData.progress += frameAdjustedSpeed / distance; // Progress is fraction of distance covered
if (carData.progress >= 1) {
carData.progress = 0; // Reset path
// Optional: Teleport slightly off-screen before restarting for smoother loop
}
carData.mesh.position.lerpVectors(
carData.start,
carData.end,
carData.progress,
);
});
}
// --- Person Animation (New) ---
// Creates a simple cylinder mesh to represent a person
function createPerson(color = 0x00ff00) {
const personRadius = 0.2;
const personHeight = 0.8;
const personGeometry = new THREE.CylinderGeometry(
personRadius,
personRadius,
personHeight,
8,
);
const personMaterial = new THREE.MeshStandardMaterial({ color: color });
const personMesh = new THREE.Mesh(personGeometry, personMaterial);
personMesh.castShadow = true;
personMesh.position.y = personHeight / 2; // Position base at ground level
return personMesh;
}
// Generates paths for people, typically along grid edges (sidewalks)
function generateSidewalkPath(startGridX, startGridY, length, direction) {
const pathPoints = [];
const totalGridSize = config.gridSize * config.cellSize;
const halfTotalGridSize = totalGridSize / 2;
const plotSize = config.cellSize * (1 - config.roadWidthRatio);
const sidewalkOffset =
plotSize / 2 + (config.cellSize * config.roadWidthRatio) / 4; // Offset from center towards edge
let currentX = startGridX;
let currentY = startGridY;
for (let i = 0; i <= length; i++) {
const worldCoords = gridToWorldCoords(currentX, currentY);
let pathX = worldCoords.x;
let pathZ = worldCoords.z;
// Adjust position to be on the "sidewalk" area
if (direction === "horizontal") {
pathZ += sidewalkOffset * (Math.random() > 0.5 ? 1 : -1); // Random side of road center
pathX -= config.cellSize / 2; // Start at beginning of cell edge
pathX += (i * config.cellSize) / 2; // Move half cell length per step for finer path
} else {
// vertical
pathX += sidewalkOffset * (Math.random() > 0.5 ? 1 : -1);
pathZ -= config.cellSize / 2;
pathZ += (i * config.cellSize) / 2;
}
pathPoints.push(new THREE.Vector3(pathX, 0, pathZ)); // Y is 0 for ground level path points
// This simple path generation needs refinement for turns etc.
// For now, just straight lines defined by start/length/direction
// More complex paths would need a waypoint system.
}
// Simplified: for now, just make them walk along one edge
const worldStart = gridToWorldCoords(startGridX, startGridY);
const worldEnd = gridToWorldCoords(
startGridX + (direction === "horizontal" ? length : 0),
startGridY + (direction === "vertical" ? length : 0),
);
const offsetVec = new THREE.Vector3(
(Math.random() - 0.5) * config.roadWidthRatio * config.cellSize,
0,
(Math.random() - 0.5) * config.roadWidthRatio * config.cellSize,
);
return [worldStart.add(offsetVec), worldEnd.add(offsetVec)];
}
function createInitialPeople() {
const numPeople = 15;
const totalGridSize = config.gridSize * config.cellSize;
const halfSize = totalGridSize / 2;
// Define some simple paths (start and end points for now)
const pathDefinitions = [
// Path 1: Walk along bottom edge
{
start: new THREE.Vector3(-halfSize, 0, halfSize - config.cellSize * 0.8),
end: new THREE.Vector3(halfSize, 0, halfSize - config.cellSize * 0.8),
},
// Path 2: Walk along right edge
{
start: new THREE.Vector3(halfSize - config.cellSize * 0.8, 0, halfSize),
end: new THREE.Vector3(halfSize - config.cellSize * 0.8, 0, -halfSize),
},
// Path 3: Walk along top edge
{
start: new THREE.Vector3(halfSize, 0, -halfSize + config.cellSize * 0.8),
end: new THREE.Vector3(-halfSize, 0, -halfSize + config.cellSize * 0.8),
},
// Path 4: Walk along left edge
{
start: new THREE.Vector3(-halfSize + config.cellSize * 0.8, 0, -halfSize),
end: new THREE.Vector3(-halfSize + config.cellSize * 0.8, 0, halfSize),
},
];
for (let i = 0; i < numPeople; i++) {
const person = createPerson(Math.random() * 0xaaaaaa + 0x555555); // Grayscale people
const pathDef =
pathDefinitions[Math.floor(Math.random() * pathDefinitions.length)];
// Randomize direction
let startPoint = pathDef.start.clone();
let endPoint = pathDef.end.clone();
if (Math.random() > 0.5) {
[startPoint, endPoint] = [endPoint, startPoint];
}
// Make pathPoints simple start/end for lerp
const pathPoints = [startPoint, endPoint];
person.position.copy(startPoint);
person.lookAt(endPoint); // Initial orientation
personGroup.add(person);
people.push({
mesh: person,
pathPoints: pathPoints, // Simple two-point path for now
currentSegment: 0, // Always 0 for two-point path
progress: Math.random(), // Start at random progress
});
}
}
function animatePeople(deltaTime) {
const speed = config.personSpeed;
const frameAdjustedSpeed = speed * deltaTime * 60; // Normalize speed
people.forEach((personData) => {
const startPoint = personData.pathPoints[0];
const endPoint = personData.pathPoints[1];
const distance = startPoint.distanceTo(endPoint);
if (distance === 0) return;
// Calculate how much progress to add based on speed and segment length
personData.progress += frameAdjustedSpeed / distance;
if (personData.progress >= 1) {
// Reached end, swap start/end and reset progress
[personData.pathPoints[0], personData.pathPoints[1]] = [
endPoint,
startPoint,
]; // Swap points
personData.progress = 0; // Start from beginning of reversed path
personData.mesh.lookAt(personData.pathPoints[1]); // Look at new end point
}
// Interpolate position along the current path segment
personData.mesh.position.lerpVectors(
personData.pathPoints[0],
personData.pathPoints[1],
personData.progress,
);
});
}
// --- UI ---
function setupUI() {
buildingSelectorUI.innerHTML = ""; // Clear existing
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} ($${type.cost})
`;
button.dataset.typeId = id;
button.addEventListener("click", () => selectBuildingType(id));
buildingSelectorUI.appendChild(button);
});
// Initial UI state update
selectBuildingType(currentBuildingTypeId); // Select default and update styles
updateMoneyDisplay();
updatePopulationDisplay();
updateBuildingButtons(); // Set initial button disabled states
}
function selectBuildingType(typeId) {
currentBuildingTypeId = parseInt(typeId);
console.log(
"Selected building type:",
buildingTypes[currentBuildingTypeId].name,
);
updateBuildingButtons(); // Update styles based on selection and cost
}
// Updates button styles (selected, disabled based on cost)
function updateBuildingButtons() {
const buttons = buildingSelectorUI.querySelectorAll("button");
buttons.forEach((btn) => {
const typeId = parseInt(btn.dataset.typeId);
const typeInfo = buildingTypes[typeId];
// Selected state
if (typeId === currentBuildingTypeId) {
btn.classList.add("selected");
} else {
btn.classList.remove("selected");
}
// Disabled state (cannot afford)
if (currentMoney < typeInfo.cost) {
btn.classList.add("disabled");
} else {
btn.classList.remove("disabled");
}
});
}
// Updates the population display in the UI
function updatePopulationDisplay() {
// Use the animated population value
populationCountUI.textContent = Math.floor(currentDisplayedPopulation);
}
// Updates the money display in the UI
function updateMoneyDisplay() {
moneyCountUI.textContent = currentMoney;
}
// --- Window Resize ---
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
// --- Animation Loop (Added Population Animation) ---
const clock = new THREE.Clock();
let lastDisplayedPop = -1; // Track last displayed value to reduce DOM updates
function animate() {
requestAnimationFrame(animate);
const deltaTime = clock.getDelta();
// --- Population Animation ---
if (currentDisplayedPopulation < targetPopulation) {
const difference = targetPopulation - currentDisplayedPopulation;
// Grow faster when difference is large, ensure at least a small increase
const growthAmount =
Math.max(0.1, difference * config.populationGrowthRate) * deltaTime * 60; // Frame-rate adjusted growth
currentDisplayedPopulation = Math.min(
targetPopulation,
currentDisplayedPopulation + growthAmount,
);
// Only update DOM if the integer value changes
const displayPop = Math.floor(currentDisplayedPopulation);
if (displayPop !== lastDisplayedPop) {
updatePopulationDisplay();
lastDisplayedPop = displayPop;
}
} else if (currentDisplayedPopulation > targetPopulation) {
// Handle potential population decrease (if buildings are removed in future)
// For now, just ensure it doesn't exceed target if target drops suddenly (e.g. reset)
currentDisplayedPopulation = targetPopulation;
const displayPop = Math.floor(currentDisplayedPopulation);
if (displayPop !== lastDisplayedPop) {
updatePopulationDisplay();
lastDisplayedPop = displayPop;
}
}
// --- Other Animations ---
controls.update();
animateCars(deltaTime);
animatePeople(deltaTime); // Animate people
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.85); /* Slightly more opaque */
padding: 15px;
border-radius: 5px;
max-width: 220px; /* Increased width */
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;
box-sizing: border-box; /* Include padding/border in width */
}
#building-selector button:hover {
background-color: #e0e0e0;
}
#building-selector button.selected {
border-color: #000;
background-color: #d0d0d0;
font-weight: bold;
}
/* Added style for disabled buttons */
#building-selector button.disabled {
background-color: #f5c6cb; /* Light red */
cursor: not-allowed;
opacity: 0.7;
}
.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: 10px; /* Reduced margin */
padding-top: 10px;
border-top: 1px solid #ccc;
font-size: 0.9em;
line-height: 1.5; /* Spacing between lines */
}
/* Style for Reset Button */
#reset-button {
display: block; /* Make it full width */
width: 100%;
padding: 10px;
margin-top: 15px;
background-color: #dc3545; /* Red color */
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
text-align: center;
}
#reset-button:hover {
background-color: #c82333; /* Darker red on hover */
}
#ui-container p {
font-size: 0.8em;
color: #555;
margin-top: 10px;
line-height: 1.3;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment