Files
transit-sim/src/main.js
Evan Scamehorn 66fa2cdc65
All checks were successful
Deploy to GitHub Pages / deploy (push) Has been skipped
loading screen
2025-12-16 21:45:53 -06:00

403 lines
12 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';
import { VehicleSystem } from './VehicleSystem.js';
// ==========================================
// 1. Configuration
// ==========================================
const SETTINGS = {
colors: {
background: 0xE6E6E6,
ground: 0xDDDDDD,
zoningRes: new THREE.Color(0xA855F7),
zoningCom: new THREE.Color(0x3B82F6),
coverageGood: new THREE.Color(0x10B981),
coverageBad: new THREE.Color(0xEF4444),
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'
},
graphics: {
shadows: true,
antialias: true,
maxPixelRatio: 1.0,
farClip: 6000,
}
};
let scene, camera, renderer, controls;
let inputManager, routeManager, uiManager, gameManager, vehicleSystem;
let cityMesh;
let buildingRegistry = [];
const clock = new THREE.Clock();
let currentViewMode = 'none';
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);
// Vehicle System
vehicleSystem = new VehicleSystem(scene);
routeManager.setVehicleSystem(vehicleSystem);
// 3. Input
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
inputManager.init();
// Wiring Click
inputManager.onClick = (point, object) => {
// Only allow adding nodes if we are actually in drafting mode
if (object.name === "GROUND") routeManager.addNodeByWorldPosition(point);
};
// Wiring Drag
inputManager.onDrag = (markerObject, newPoint) => {
routeManager.dragNode(markerObject, newPoint);
};
// Wiring Hover
inputManager.onHover = (point) => {
routeManager.updateGhostMarker(point);
};
// Wire UI View Mode
uiManager.onViewModeChanged = (mode) => {
currentViewMode = mode;
updateBuildingColors();
};
routeManager.onRouteChanged = (stats) => {
uiManager.updateDraftStats(stats);
if (currentViewMode === 'approval') updateBuildingColors();
};
// 4. Load Data
Promise.all([
fetch(SETTINGS.files.visual).then(r => r.json()),
fetch(SETTINGS.files.routing).then(r => r.json())
]).then(([visual, routing]) => {
routeManager.initGraph(routing);
renderCity(visual);
gameManager.start();
// --- UPDATE: Notify UI that loading is done ---
uiManager.setLoadingComplete();
});
animate();
}
function updateBuildingColors() {
if (!cityMesh || !buildingRegistry.length) return;
const colorAttribute = cityMesh.geometry.attributes.color;
const colorArray = colorAttribute.array;
const _color = new THREE.Color();
for (let i = 0; i < buildingRegistry.length; i++) {
const entry = buildingRegistry[i];
const data = entry.data;
// STANDARD VIEW
if (currentViewMode === 'none') {
_color.copy(SETTINGS.colors.building);
}
// ZONING VIEW
else if (currentViewMode === 'zoning') {
if (data.type === 'residential') {
_color.copy(SETTINGS.colors.building).lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
} else if (data.type === 'commercial') {
_color.copy(SETTINGS.colors.building).lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
} else {
_color.copy(SETTINGS.colors.building);
}
}
// APPROVAL VIEW
else if (currentViewMode === 'approval') {
const node = routeManager.graphData.nodes[entry.nearestNodeId];
if (node) {
const dist = routeManager.getDistanceToNearestTransit(node.x, node.y);
if (dist === Infinity) {
_color.copy(SETTINGS.colors.coverageBad);
} else {
const MAX_DIST = 600;
const factor = Math.min(1.0, dist / MAX_DIST);
_color.copy(SETTINGS.colors.coverageGood).lerp(SETTINGS.colors.coverageBad, factor);
}
} else {
_color.copy(SETTINGS.colors.coverageBad);
}
}
const start = entry.startIndex;
const end = start + entry.count;
for (let v = start; v < end; v++) {
const idx = v * 3;
colorArray[idx] = _color.r;
colorArray[idx + 1] = _color.g;
colorArray[idx + 2] = _color.b;
}
}
colorAttribute.needsUpdate = true;
}
// ==========================================
// 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.0003);
camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 1, SETTINGS.graphics.farClip);
camera.position.set(0, 800, 800);
renderer = new THREE.WebGLRenderer({ antialias: SETTINGS.graphics.antialias, logarithmicDepthBuffer: true });
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
renderer.domElement.style.display = 'block';
document.documentElement.style.margin = '0';
document.documentElement.style.height = '100%';
document.body.style.margin = '0';
document.body.style.height = '100%';
document.body.style.overflow = 'hidden';
renderer.shadowMap.enabled = SETTINGS.graphics.shadows;
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 = SETTINGS.graphics.shadows;
if (SETTINGS.graphics.shadows) {
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;
dirLight.shadow.bias = -0.0001;
}
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 = SETTINGS.graphics.shadows;
scene.add(plane);
controls = new MapControls(camera, renderer.domElement);
controls.dampingFactor = 0.07;
controls.enableDamping = true;
controls.maxPolarAngle = Math.PI / 2 - 0.1;
}
function resizeRendererToDisplaySize() {
const canvas = renderer.domElement;
const rect = canvas.getBoundingClientRect();
const pixelRatio = Math.min(window.devicePixelRatio, SETTINGS.graphics.maxPixelRatio);
const width = Math.round(rect.width * pixelRatio);
const height = Math.round(rect.height * pixelRatio);
const needResize = canvas.width !== width || canvas.height !== height;
if (needResize) {
renderer.setSize(width, height, false);
}
return needResize;
}
// ==========================================
// 3. Visual Rendering
// ==========================================
function renderCity(data) {
const createMergedLayer = (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);
});
}
let geom;
if (isExtruded) {
geom = new THREE.ExtrudeGeometry(shape, { depth: item.height || height, bevelEnabled: false });
} else {
geom = new THREE.ShapeGeometry(shape);
}
geom.rotateX(-Math.PI / 2);
if (!isExtruded) geom.translate(0, lift, 0);
geometries.push(geom);
});
if (geometries.length === 0) return;
const mergedGeom = BufferGeometryUtils.mergeGeometries(geometries);
const mat = new THREE.MeshLambertMaterial({ color: color });
const mesh = new THREE.Mesh(mergedGeom, mat);
mesh.receiveShadow = SETTINGS.graphics.shadows;
if (isExtruded) mesh.castShadow = SETTINGS.graphics.shadows;
scene.add(mesh);
};
// --- OPTIMIZED BUILDING GENERATION ---
const createBuildingLayer = (buildings) => {
if (!buildings || !buildings.length) return;
const geometries = [];
buildingRegistry = [];
let currentVertexOffset = 0;
buildings.forEach(b => {
// 1. Create Shape
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);
});
}
// 2. Create Geometry
const geom = new THREE.ExtrudeGeometry(shape, { depth: b.height, bevelEnabled: false });
geom.rotateX(-Math.PI / 2);
// 3. Pre-calculate Logic Data
const bx = b.shape.outer[0][0];
const by = b.shape.outer[0][1];
const nearestId = routeManager.findNearestNode(bx, -by);
// 4. Register Metadata
const vertexCount = geom.attributes.position.count;
buildingRegistry.push({
data: b.data,
nearestNodeId: nearestId,
startIndex: currentVertexOffset,
count: vertexCount
});
currentVertexOffset += vertexCount;
geometries.push(geom);
});
if (geometries.length === 0) return;
// 5. Merge
const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries);
// 6. Initialize Vertex Colors Attribute
const count = mergedGeometry.attributes.position.count;
const colors = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) {
colors[i] = 1;
}
mergedGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
// 7. Material Setup
const mat = new THREE.MeshLambertMaterial({
vertexColors: true,
shadowSide: THREE.BackSide
});
// 8. Create and Add Single Mesh
cityMesh = new THREE.Mesh(mergedGeometry, mat);
cityMesh.castShadow = SETTINGS.graphics.shadows;
cityMesh.receiveShadow = SETTINGS.graphics.shadows;
cityMesh.name = 'CITY_MESH';
scene.add(cityMesh);
};
createBuildingLayer(data.buildings);
createMergedLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
createMergedLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
createMergedLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false);
}
function animate() {
requestAnimationFrame(animate);
if (resizeRendererToDisplaySize()) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
const delta = clock.getDelta();
controls.update();
if (vehicleSystem) {
vehicleSystem.update(delta);
}
renderer.render(scene, camera);
}
init();