-
-
Save shricodev/1231936bd34723e8a201027b80d5f413 to your computer and use it in GitHub Desktop.
Gemini 2.5 - 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" /> | |
<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> |
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 "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(); |
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
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