Files
transit-sim/src/RouteManager.js
2025-12-04 14:28:53 -06:00

534 lines
16 KiB
JavaScript

import * as THREE from 'three';
export class RouteManager {
constructor(scene, settings) {
this.scene = scene;
this.settings = settings;
this.graphData = null;
// -- State --
this.isDrafting = false; // New flag
this.currentRouteNodes = [];
this.savedRoutes = [];
// -- Visuals --
this.markers = [];
this.currentPathMesh = null;
this.ghostMarker = null; // Transparent sphere
this.servedNodes = new Set();
this.servedCoordinates = [];
this.ROAD_OFFSET = 2.5;
this.onRouteChanged = null;
this.gameManager = null;
this.vehicleSystem = null;
// Draft state
this.latestPathPoints = [];
this.initGhostMarker();
}
setVehicleSystem(vs) {
this.vehicleSystem = vs;
}
setGameManager(gm) {
this.gameManager = gm;
}
initGraph(data) {
this.graphData = data;
this.graphData.adjacency = {};
for (let key in this.graphData.nodes) {
this.graphData.nodes[key].y = -this.graphData.nodes[key].y;
}
this.graphData.edges.forEach((edge, index) => {
if (edge.points) edge.points.forEach(p => { p[1] = -p[1]; });
if (!this.graphData.adjacency[edge.u]) this.graphData.adjacency[edge.u] = [];
this.graphData.adjacency[edge.u].push({ to: edge.v, cost: edge.length || 1, edgeIndex: index });
if (!edge.oneway) {
if (!this.graphData.adjacency[edge.v]) this.graphData.adjacency[edge.v] = [];
this.graphData.adjacency[edge.v].push({ to: edge.u, cost: edge.length || 1, edgeIndex: index, isReverse: true });
}
});
}
// ============================
// Draft Mode & Ghost Marker
// ============================
initGhostMarker() {
const geom = new THREE.SphereGeometry(4);
const mat = new THREE.MeshBasicMaterial({
color: this.settings.colors.pathStart,
transparent: true,
opacity: 0.5
});
this.ghostMarker = new THREE.Mesh(geom, mat);
this.ghostMarker.visible = false;
this.ghostMarker.name = "GHOST_MARKER"; // Ignore in raycasting
this.scene.add(this.ghostMarker);
}
startDrafting() {
this.isDrafting = true;
this.resetDraftingState();
}
stopDrafting() {
this.isDrafting = false;
this.ghostMarker.visible = false;
this.clearCurrentRoute(); // Clean up visuals
}
// Called by Main.js input listener on mouse move
updateGhostMarker(worldPoint) {
if (!this.isDrafting || !this.graphData) {
this.ghostMarker.visible = false;
return;
}
if (!worldPoint) {
this.ghostMarker.visible = false;
return;
}
const nodeId = this.findNearestNode(worldPoint.x, worldPoint.z);
if (nodeId !== null) {
const node = this.graphData.nodes[nodeId];
this.ghostMarker.position.set(node.x, 2, node.y);
this.ghostMarker.visible = true;
} else {
this.ghostMarker.visible = false;
}
}
// ============================
// Save / Load / Serialization
// ============================
getSerializableRoutes() {
return this.savedRoutes.map(r => ({
nodes: r.nodes,
color: r.color
}));
}
loadRoutes(routesData) {
this.savedRoutes.forEach(r => {
if (r.mesh) {
this.scene.remove(r.mesh);
r.mesh.geometry.dispose();
}
});
this.savedRoutes = [];
this.servedNodes.clear();
if (this.vehicleSystem) this.vehicleSystem.clearVehicles();
routesData.forEach((data, index) => {
this.rebuildRouteFromData(data.nodes, data.color || this.getRandomColor(), index);
});
this.refreshServedNodes();
}
rebuildRouteFromData(nodes, color, routeIndex) {
const pathResult = this.calculateGeometryFromNodes(nodes);
if (!pathResult) return;
const tubeMat = new THREE.MeshBasicMaterial({ color: color });
const mesh = new THREE.Mesh(pathResult.geometry, tubeMat);
this.scene.add(mesh);
if (this.vehicleSystem && pathResult.points.length > 0) {
this.vehicleSystem.addBusToRoute(pathResult.points, color, routeIndex);
}
const ridership = this.calculateRidership(nodes);
this.savedRoutes.push({
nodes: [...nodes],
stats: { length: pathResult.length, cost: 0, ridership },
mesh: mesh,
color: color
});
}
getRandomColor() {
const colors = ["#ef4444", "#f97316", "#f59e0b", "#84cc16", "#10b981", "#06b6d4", "#3b82f6", "#8b5cf6", "#d946ef"];
return colors[Math.floor(Math.random() * colors.length)];
}
// ============================
// Gameplay Actions
// ============================
saveCurrentRoute() {
if (!this.isDrafting) return false;
if (this.currentRouteNodes.length < 2 || !this.currentPathMesh) {
alert("Route must have at least 2 points.");
return false;
}
const length = this.currentPathMesh.userData.length || 0;
const cost = this.gameManager.getProjectedCost(length);
if (!this.gameManager.canAfford(cost)) {
alert("Insufficient Funds!");
return false;
}
this.gameManager.deductFunds(cost);
const color = this.getRandomColor();
this.currentPathMesh.material.color.set(color);
const routeIndex = this.savedRoutes.length;
if (this.vehicleSystem && this.latestPathPoints.length > 0) {
this.vehicleSystem.addBusToRoute(this.latestPathPoints, color, routeIndex);
}
const ridership = this.calculateRidership(this.currentRouteNodes);
this.savedRoutes.push({
nodes: [...this.currentRouteNodes],
stats: { length, cost, ridership },
mesh: this.currentPathMesh,
color: color
});
// We do NOT call stopDrafting here, UIManager handles the logic to call stopDrafting
// We just return success
this.currentPathMesh = null; // Detach mesh from manager so it stays in scene
this.refreshServedNodes();
this.gameManager.recalculateApproval();
this.gameManager.updateUI();
return true;
}
updateRouteColor(index, hexColor) {
if (index < 0 || index >= this.savedRoutes.length) return;
const route = this.savedRoutes[index];
route.color = hexColor;
if (route.mesh) route.mesh.material.color.set(hexColor);
if (this.vehicleSystem) this.vehicleSystem.updateRouteColor(index, hexColor);
}
deleteSavedRoute(index) {
if (index < 0 || index >= this.savedRoutes.length) return;
const route = this.savedRoutes[index];
if (route.mesh) {
this.scene.remove(route.mesh);
route.mesh.geometry.dispose();
}
this.savedRoutes.splice(index, 1);
if (this.vehicleSystem) {
this.vehicleSystem.clearVehicles();
this.savedRoutes.forEach((r, idx) => {
const pathRes = this.calculateGeometryFromNodes(r.nodes);
if (pathRes && pathRes.points.length > 0) {
this.vehicleSystem.addBusToRoute(pathRes.points, r.color, idx);
}
});
}
this.refreshServedNodes();
this.gameManager.recalculateApproval();
this.gameManager.updateUI();
}
editSavedRoute(index) {
// Delete and pull back to draft
if (index < 0 || index >= this.savedRoutes.length) return;
const route = this.savedRoutes[index];
this.currentRouteNodes = [...route.nodes];
this.deleteSavedRoute(index);
// Visualize draft immediately
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
this.updatePathVisuals();
}
// ============================
// Helpers
// ============================
calculateGeometryFromNodes(nodeList) {
if (nodeList.length < 2) return null;
let fullPathPoints = [];
let totalDist = 0;
for (let i = 0; i < nodeList.length - 1; i++) {
const start = nodeList[i];
const end = nodeList[i + 1];
const segmentEdges = this.computePathAStar(start, end);
if (!segmentEdges) continue;
segmentEdges.forEach(step => {
let dist = step.edgeData.length;
if (!dist) {
const p1 = step.edgeData.points[0];
const p2 = step.edgeData.points[step.edgeData.points.length - 1];
dist = Math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2);
}
totalDist += dist;
const rawPoints = step.edgeData.points;
let segmentPoints = rawPoints.map(p => new THREE.Vector2(p[0], p[1]));
if (step.isReverse) segmentPoints.reverse();
const offsetSegment = this.getOffsetPath(segmentPoints, this.ROAD_OFFSET);
offsetSegment.forEach(p => fullPathPoints.push(new THREE.Vector3(p.x, 0.5, p.y)));
});
}
if (fullPathPoints.length < 2) return null;
const curve = new THREE.CatmullRomCurve3(fullPathPoints);
const geometry = new THREE.TubeGeometry(curve, fullPathPoints.length, 1.5, 6, false);
return { geometry, length: totalDist, points: fullPathPoints };
}
calculateRidership(nodeList) {
if (!this.graphData || nodeList.length < 2) return 0;
let totalPop = 0;
let totalJobs = 0;
nodeList.forEach(nodeId => {
const node = this.graphData.nodes[nodeId];
if (node) {
totalPop += (node.pop || 0);
totalJobs += (node.jobs || 0);
}
});
const synergy = Math.min(totalPop, totalJobs);
const GAME_BALANCE_MULTIPLIER = 1.0;
return Math.floor(synergy * GAME_BALANCE_MULTIPLIER);
}
addNodeByWorldPosition(vector3) {
if (!this.isDrafting) return; // BLOCK INPUT IF NOT DRAFTING
if (!this.graphData) return;
const nodeId = this.findNearestNode(vector3.x, vector3.z);
if (nodeId === null) return;
if (this.currentRouteNodes.length > 0 && this.currentRouteNodes[this.currentRouteNodes.length - 1] === nodeId) return;
this.currentRouteNodes.push(nodeId);
this.addMarkerVisual(nodeId);
this.updatePathVisuals();
}
dragNode(markerObject, worldPoint) {
if (!this.isDrafting) return; // BLOCK DRAG IF NOT DRAFTING
if (!this.graphData) return;
const index = this.markers.indexOf(markerObject);
if (index === -1) return;
const newNodeId = this.findNearestNode(worldPoint.x, worldPoint.z);
if (this.currentRouteNodes[index] !== newNodeId) {
this.currentRouteNodes[index] = newNodeId;
const nodeData = this.graphData.nodes[newNodeId];
markerObject.position.set(nodeData.x, 2, nodeData.y);
markerObject.userData.nodeId = newNodeId;
this.updatePathVisuals();
}
}
clearCurrentRoute() {
if (this.currentPathMesh) { this.scene.remove(this.currentPathMesh); this.currentPathMesh.geometry.dispose(); this.currentPathMesh = null; }
this.resetDraftingState();
}
resetDraftingState() {
this.currentRouteNodes = [];
this.markers.forEach(m => this.scene.remove(m));
this.markers = [];
if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 });
}
getSavedRoutes() { return this.savedRoutes; }
updatePathVisuals() {
if (this.currentRouteNodes.length < 2) {
if (this.currentPathMesh) {
this.scene.remove(this.currentPathMesh);
this.currentPathMesh = null;
}
if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 });
return;
}
const result = this.calculateGeometryFromNodes(this.currentRouteNodes);
if (!result) return;
this.latestPathPoints = result.points;
if (this.currentPathMesh) {
this.scene.remove(this.currentPathMesh);
this.currentPathMesh.geometry.dispose();
}
const tubeMat = new THREE.MeshBasicMaterial({ color: this.settings.colors.route });
this.currentPathMesh = new THREE.Mesh(result.geometry, tubeMat);
this.currentPathMesh.userData.length = result.length;
this.scene.add(this.currentPathMesh);
this.updateMarkerColors();
const projectedRiders = this.calculateRidership(this.currentRouteNodes);
const projectedCost = this.gameManager ? this.gameManager.getProjectedCost(result.length) : 0;
if (this.onRouteChanged) {
this.onRouteChanged({
length: result.length,
cost: projectedCost,
ridership: projectedRiders
});
}
}
updateMarkerColors() {
this.markers.forEach((marker, i) => {
let color = 0xFFFF00; // Yellow
if (i === 0) color = this.settings.colors.pathStart;
else if (i === this.markers.length - 1) color = this.settings.colors.pathEnd;
marker.material.color.setHex(color);
});
}
addMarkerVisual(nodeId) {
const node = this.graphData.nodes[nodeId];
const geom = new THREE.SphereGeometry(4);
const mat = new THREE.MeshBasicMaterial({ color: this.settings.colors.pathEnd });
const mesh = new THREE.Mesh(geom, mat);
mesh.position.set(node.x, 2, node.y);
mesh.userData = { isMarker: true, nodeId: nodeId };
this.scene.add(mesh);
this.markers.push(mesh);
this.updateMarkerColors();
}
refreshServedNodes() {
this.servedNodes.clear();
this.servedCoordinates = [];
this.savedRoutes.forEach(route => {
route.nodes.forEach(nodeId => {
if (!this.servedNodes.has(nodeId)) {
this.servedNodes.add(nodeId);
const node = this.graphData.nodes[nodeId];
if (node) {
this.servedCoordinates.push({ x: node.x, z: node.y });
}
}
});
});
}
getDistanceToNearestTransit(x, z) {
if (this.servedCoordinates.length === 0) return Infinity;
let minSq = Infinity;
for (let i = 0; i < this.servedCoordinates.length; i++) {
const sc = this.servedCoordinates[i];
const dx = sc.x - x;
const dz = sc.z - z;
const d2 = dx * dx + dz * dz;
if (d2 < minSq) minSq = d2;
}
return Math.sqrt(minSq);
}
findNearestNode(x, z) {
let closestId = null;
let minDist = Infinity;
for (const [id, node] of Object.entries(this.graphData.nodes)) {
const dx = node.x - x;
const dz = node.y - z;
const d2 = dx * dx + dz * dz;
if (d2 < minDist) {
minDist = d2;
closestId = parseInt(id);
}
}
return closestId;
}
computePathAStar(start, end) {
if (start === end) return [];
const openSet = new Set([start]);
const cameFrom = {};
const gScore = {};
const fScore = {};
gScore[start] = 0;
fScore[start] = this.heuristic(start, end);
while (openSet.size > 0) {
let current = null;
let minF = Infinity;
for (const node of openSet) {
const score = fScore[node] !== undefined ? fScore[node] : Infinity;
if (score < minF) { minF = score; current = node; }
}
if (current === end) return this.reconstructPath(cameFrom, current);
openSet.delete(current);
const neighbors = this.graphData.adjacency[current] || [];
for (const neighbor of neighbors) {
const tentativeG = gScore[current] + neighbor.cost;
if (tentativeG < (gScore[neighbor.to] !== undefined ? gScore[neighbor.to] : Infinity)) {
cameFrom[neighbor.to] = { prev: current, edgeIdx: neighbor.edgeIndex, isReverse: neighbor.isReverse };
gScore[neighbor.to] = tentativeG;
fScore[neighbor.to] = tentativeG + this.heuristic(neighbor.to, end);
openSet.add(neighbor.to);
}
}
}
return null;
}
heuristic(a, b) {
const nA = this.graphData.nodes[a];
const nB = this.graphData.nodes[b];
return Math.sqrt((nA.x - nB.x) ** 2 + (nA.y - nB.y) ** 2);
}
reconstructPath(cameFrom, current) {
const edges = [];
while (current in cameFrom) {
const data = cameFrom[current];
edges.push({ edgeData: this.graphData.edges[data.edgeIdx], isReverse: data.isReverse });
current = data.prev;
}
return edges.reverse();
}
getOffsetPath(points, offset) {
if (points.length < 2) return points;
const newPath = [];
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const dir = new THREE.Vector2().subVectors(p2, p1).normalize();
const normal = new THREE.Vector2(-dir.y, dir.x);
const off = normal.multiplyScalar(offset);
newPath.push(new THREE.Vector2().addVectors(p1, off));
if (i === points.length - 2) newPath.push(new THREE.Vector2().addVectors(p2, off));
}
return newPath;
}
}