276 lines
8.3 KiB
JavaScript
276 lines
8.3 KiB
JavaScript
import * as THREE from 'three';
|
|
import { MapControls } from 'three/addons/controls/MapControls.js';
|
|
import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js';
|
|
|
|
import { InputManager } from './InputManager.js';
|
|
import { RouteManager } from './RouteManager.js';
|
|
import { UIManager } from './UIManager.js';
|
|
import { GameManager } from './GameManager.js';
|
|
|
|
|
|
// ==========================================
|
|
// 1. Configuration
|
|
// ==========================================
|
|
const SETTINGS = {
|
|
colors: {
|
|
background: 0xE6E6E6,
|
|
ground: 0xDDDDDD,
|
|
zoningRes: new THREE.Color(0xA855F7),
|
|
zoningCom: new THREE.Color(0x3B82F6),
|
|
building: new THREE.Color(0xFFFFFF),
|
|
water: 0xAADAFF,
|
|
park: 0xC3E6CB,
|
|
road: 0x999999,
|
|
pathStart: 0x00FF00,
|
|
pathEnd: 0xFF0000,
|
|
route: 0x2563EB,
|
|
},
|
|
files: {
|
|
visual: './city_data.json',
|
|
routing: './routing_graph.json'
|
|
}
|
|
};
|
|
|
|
let scene, camera, renderer, controls;
|
|
let inputManager, routeManager, uiManager, gameManager;
|
|
|
|
function init() {
|
|
setupScene();
|
|
|
|
// 1. Core Systems
|
|
routeManager = new RouteManager(scene, SETTINGS);
|
|
uiManager = new UIManager(routeManager);
|
|
|
|
// 2. Game Logic
|
|
gameManager = new GameManager(routeManager, uiManager);
|
|
routeManager.setGameManager(gameManager); // Dependency Injection
|
|
|
|
gameManager.start(); // Start the loop
|
|
|
|
// 3. Input
|
|
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
|
|
inputManager.init();
|
|
|
|
// Wiring
|
|
inputManager.onClick = (point, object) => {
|
|
if (object.name === "GROUND") routeManager.addNodeByWorldPosition(point);
|
|
};
|
|
inputManager.onDrag = (markerObject, newPoint) => {
|
|
routeManager.dragNode(markerObject, newPoint);
|
|
};
|
|
|
|
uiManager.onToggleZoning = (isActive) => updateBuildingColors(isActive);
|
|
|
|
// When path updates, show new stats in UI
|
|
routeManager.onRouteChanged = (stats) => {
|
|
uiManager.updateDraftStats(stats);
|
|
};
|
|
|
|
// 4. Load Data
|
|
Promise.all([
|
|
fetch(SETTINGS.files.visual).then(r => r.json()),
|
|
fetch(SETTINGS.files.routing).then(r => r.json())
|
|
]).then(([visual, routing]) => {
|
|
renderCity(visual);
|
|
routeManager.initGraph(routing);
|
|
});
|
|
|
|
animate();
|
|
}
|
|
|
|
function updateBuildingColors(showZoning) {
|
|
scene.traverse((obj) => {
|
|
// We tagged buildings with userData in renderCity (see below)
|
|
if (obj.name === 'BUILDING_MESH') {
|
|
|
|
if (!showZoning) {
|
|
// Revert to white
|
|
obj.material.color.setHex(SETTINGS.colors.building.getHex());
|
|
return;
|
|
}
|
|
|
|
// Get Data
|
|
const data = obj.userData.cityData; // We need to ensure we save this during creation
|
|
if (!data) return;
|
|
|
|
if (data.type === 'residential') {
|
|
// Lerp from White to Purple based on density
|
|
const color = SETTINGS.colors.building.clone();
|
|
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
|
|
obj.material.color.copy(color);
|
|
}
|
|
else if (data.type === 'commercial') {
|
|
// Lerp from White to Blue
|
|
const color = SETTINGS.colors.building.clone();
|
|
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
|
|
obj.material.color.copy(color);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
|
|
// ==========================================
|
|
// 2. Scene Setup
|
|
// ==========================================
|
|
function setupScene() {
|
|
scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(SETTINGS.colors.background);
|
|
scene.fog = new THREE.FogExp2(SETTINGS.colors.background, 0.0002);
|
|
|
|
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, 20000);
|
|
camera.position.set(0, 800, 800);
|
|
|
|
renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.shadowMap.enabled = true;
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
const ambient = new THREE.HemisphereLight(0xffffff, 0x555555, 0.7);
|
|
scene.add(ambient);
|
|
|
|
const dirLight = new THREE.DirectionalLight(0xffffff, 1.5);
|
|
dirLight.position.set(500, 1000, 500);
|
|
dirLight.castShadow = true;
|
|
dirLight.shadow.mapSize.set(2048, 2048);
|
|
dirLight.shadow.camera.left = -1500;
|
|
dirLight.shadow.camera.right = 1500;
|
|
dirLight.shadow.camera.top = 1500;
|
|
dirLight.shadow.camera.bottom = -1500;
|
|
dirLight.shadow.camera.near = 0.5;
|
|
dirLight.shadow.camera.far = 3000; // Must be > 1225 to reach the ground
|
|
dirLight.shadow.bias = -0.01; // Clean up shadow artifacts
|
|
|
|
scene.add(dirLight);
|
|
|
|
const plane = new THREE.Mesh(
|
|
new THREE.PlaneGeometry(10000, 10000),
|
|
new THREE.MeshLambertMaterial({ color: SETTINGS.colors.ground })
|
|
);
|
|
plane.rotation.x = -Math.PI / 2;
|
|
plane.position.y = -0.5;
|
|
plane.name = "GROUND";
|
|
plane.receiveShadow = true;
|
|
scene.add(plane);
|
|
|
|
controls = new MapControls(camera, renderer.domElement);
|
|
controls.dampingFactor = 0.05;
|
|
controls.enableDamping = true;
|
|
controls.maxPolarAngle = Math.PI / 2 - 0.1;
|
|
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// 3. Visual Rendering
|
|
// ==========================================
|
|
function renderCity(data) {
|
|
const createLayer = (items, color, height, lift, isExtruded) => {
|
|
if (!items || !items.length) return;
|
|
const geometries = [];
|
|
|
|
items.forEach(item => {
|
|
const polyData = item.shape;
|
|
if (!polyData || !polyData.outer || polyData.outer.length < 3) return;
|
|
|
|
const shape = new THREE.Shape();
|
|
shape.moveTo(polyData.outer[0][0], polyData.outer[0][1]);
|
|
for (let i = 1; i < polyData.outer.length; i++) shape.lineTo(polyData.outer[i][0], polyData.outer[i][1]);
|
|
|
|
if (polyData.holes) {
|
|
polyData.holes.forEach(holePts => {
|
|
if (holePts.length < 3) return;
|
|
const holePath = new THREE.Path();
|
|
holePath.moveTo(holePts[0][0], holePts[0][1]);
|
|
for (let j = 1; j < holePts.length; j++) holePath.lineTo(holePts[j][0], holePts[j][1]);
|
|
shape.holes.push(holePath);
|
|
});
|
|
}
|
|
|
|
if (isExtruded) {
|
|
const geom = new THREE.ExtrudeGeometry(shape, { depth: item.height || height, bevelEnabled: false });
|
|
geom.rotateX(-Math.PI / 2);
|
|
geometries.push(geom);
|
|
} else {
|
|
const geom = new THREE.ShapeGeometry(shape);
|
|
geom.rotateX(-Math.PI / 2);
|
|
geom.translate(0, lift, 0);
|
|
geometries.push(geom);
|
|
}
|
|
});
|
|
|
|
if (!geometries.length) return;
|
|
const merged = BufferGeometryUtils.mergeGeometries(geometries);
|
|
const mat = new THREE.MeshStandardMaterial({ color: color, roughness: 0.8, side: THREE.DoubleSide });
|
|
const mesh = new THREE.Mesh(merged, mat);
|
|
mesh.receiveShadow = true;
|
|
if (isExtruded) mesh.castShadow = true;
|
|
scene.add(mesh);
|
|
};
|
|
|
|
createBuildingLayer(data.buildings);
|
|
|
|
createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
|
|
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
|
|
createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false);
|
|
|
|
}
|
|
|
|
function createBuildingLayer(buildings) {
|
|
if (!buildings || !buildings.length) return;
|
|
|
|
|
|
const mat = new THREE.MeshStandardMaterial({
|
|
color: SETTINGS.colors.building,
|
|
roughness: 0.6,
|
|
side: THREE.DoubleSide,
|
|
shadowSide: THREE.DoubleSide
|
|
|
|
});
|
|
|
|
buildings.forEach(b => {
|
|
const shape = new THREE.Shape();
|
|
if (b.shape.outer.length < 3) return;
|
|
shape.moveTo(b.shape.outer[0][0], b.shape.outer[0][1]);
|
|
for (let i = 1; i < b.shape.outer.length; i++) shape.lineTo(b.shape.outer[i][0], b.shape.outer[i][1]);
|
|
|
|
if (b.shape.holes) {
|
|
b.shape.holes.forEach(h => {
|
|
const path = new THREE.Path();
|
|
path.moveTo(h[0][0], h[0][1]);
|
|
for (let k = 1; k < h.length; k++) path.lineTo(h[k][0], h[k][1]);
|
|
shape.holes.push(path);
|
|
});
|
|
}
|
|
|
|
const geom = new THREE.ExtrudeGeometry(shape, {
|
|
depth: b.height,
|
|
bevelEnabled: false
|
|
});
|
|
geom.rotateX(-Math.PI / 2);
|
|
|
|
const mesh = new THREE.Mesh(geom, mat.clone());
|
|
mesh.castShadow = true;
|
|
mesh.receiveShadow = true;
|
|
|
|
// Store metadata for the Zoning Toggle
|
|
mesh.name = 'BUILDING_MESH';
|
|
mesh.userData.cityData = b.data;
|
|
|
|
scene.add(mesh);
|
|
});
|
|
}
|
|
|
|
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
}
|
|
|
|
init();
|