-
-
Save shricodev/119b06ac133218601b0d367ad10f3d54 to your computer and use it in GitHub Desktop.
Gemini 2.5 - Blog SimCity Simulation - Part 1
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</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> |
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, // 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(); |
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.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