534 lines
16 KiB
JavaScript
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;
|
|
}
|
|
}
|
|
|