diff --git a/index.html b/index.html index 8e90f96..0ffd31a 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,9 @@ + + +
@@ -20,6 +23,12 @@

Route Planner

+ +
+ + +
+
diff --git a/presentation.md b/presentation.md new file mode 100644 index 0000000..73ee5bc --- /dev/null +++ b/presentation.md @@ -0,0 +1,24 @@ +# CS 559 GP Presentation + +## Group ID 3826 + +## Intro + +This project: + +- Simulates creating transit routes in Madison, and the ridership +- Encourages user to build a network that has good coverage, to optimize + approval rating +- Maps in 3d Madison's buildings, parks, roads, and bodies of water +- Limits user to a budget for expenses +- Simulates revenue from fares +- (allows for upgrading of bus rolling stock) + +## For Peer Evaluators + +- Recall: each component can be only claimed by one group member +- I created this project alone +- I only claim to complete **game design** +- You only will grade me on that category +- All other categories you should score with a 1, as I do not claim to complete + them diff --git a/src/GameManager.js b/src/GameManager.js index ae30917..7c9d88a 100644 --- a/src/GameManager.js +++ b/src/GameManager.js @@ -22,14 +22,69 @@ export class GameManager { start() { this.buildCensusArrays(); - this.recalculateApproval(); // Initial calc + this.recalculateApproval(); this.updateUI(); + if (this.gameLoopInterval) clearInterval(this.gameLoopInterval); this.gameLoopInterval = setInterval(() => { this.processDay(); }, 5000); } + // ========================== + // Save / Load System + // ========================== + + saveGame() { + const data = { + version: 1, + timestamp: Date.now(), + gameState: { + budget: this.budget, + day: this.day, + approval: this.approvalRating + }, + routes: this.routeManager.getSerializableRoutes() + }; + + const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `city_transit_save_day${this.day}.json`; + a.click(); + URL.revokeObjectURL(url); + } + + loadGame(jsonString) { + try { + const data = JSON.parse(jsonString); + + // 1. Restore State + this.budget = data.gameState.budget; + this.day = data.gameState.day; + + // 2. Restore Routes + // This will clear existing routes and rebuild meshes/vehicles + this.routeManager.loadRoutes(data.routes); + + // 3. Recalculate Logic + this.buildCensusArrays(); + this.recalculateApproval(); + this.updateUI(); + + console.log("Game loaded successfully."); + } catch (e) { + console.error("Failed to load save file", e); + alert("Error loading save file. See console."); + } + } + + // ========================== + // Core Logic + // ========================== + buildCensusArrays() { if (!this.routeManager.graphData) return; @@ -51,7 +106,6 @@ export class GameManager { } } - // UPDATED: Weighted Approval Calculation recalculateApproval() { if (!this.censusNodes || this.censusNodes.length === 0) { this.approvalRating = 0; @@ -62,7 +116,7 @@ export class GameManager { let totalMaxScore = 0; // The score if everyone had 0m walk // Constants for walking distance - const MAX_WALK_DIST = 600; // Meters. Beyond this, satisfaction is 0. + const MAX_WALK_DIST = 1700; // Meters. Beyond this, satisfaction is 0. const IDEAL_WALK_DIST = 50; // Meters. Below this, satisfaction is 100%. for (const node of this.censusNodes) { @@ -79,8 +133,6 @@ export class GameManager { satisfaction = Math.max(0, satisfaction); // 4. Add weighted score (Satisfaction * People Count) - // A high-rise (count=100) at 50% satisfaction adds 50 points. - // A house (count=3) at 50% satisfaction adds 1.5 points. totalWeightedScore += (satisfaction * node.count); } } diff --git a/src/RouteManager.js b/src/RouteManager.js index 221d8d3..bae95e5 100644 --- a/src/RouteManager.js +++ b/src/RouteManager.js @@ -9,22 +9,21 @@ export class RouteManager { // -- State -- this.currentRouteNodes = []; - this.savedRoutes = []; + this.savedRoutes = []; // { nodes, stats, mesh, color } // -- Visuals -- this.markers = []; this.currentPathMesh = null; this.servedNodes = new Set(); - this.servedCoordinates = []; - this.ROAD_OFFSET = 2.5; this.onRouteChanged = null; this.gameManager = null; - this.vehicleSystem = null; + + // Draft state this.latestPathPoints = []; } @@ -36,7 +35,6 @@ export class RouteManager { this.gameManager = gm; } - initGraph(data) { this.graphData = data; this.graphData.adjacency = {}; @@ -54,58 +52,234 @@ export class RouteManager { }); } - // Helper to check if a node is covered - isNodeServed(nodeId) { - return this.servedNodes.has(parseInt(nodeId)); + // ============================ + // Save / Load / Serialization + // ============================ + + getSerializableRoutes() { + // We only save the node IDs and the color. + // Mesh and stats can be rebuilt. + return this.savedRoutes.map(r => ({ + nodes: r.nodes, + color: r.color + })); } - // Rebuild the Set of served nodes - refreshServedNodes() { + loadRoutes(routesData) { + // 1. Cleanup existing + this.savedRoutes.forEach(r => { + if (r.mesh) { + this.scene.remove(r.mesh); + r.mesh.geometry.dispose(); + } + }); + this.savedRoutes = []; this.servedNodes.clear(); - this.servedCoordinates = []; - this.savedRoutes.forEach(route => { - route.nodes.forEach(nodeId => { - if (!this.servedNodes.has(nodeId)) { - this.servedNodes.add(nodeId); + if (this.vehicleSystem) this.vehicleSystem.clearVehicles(); - const node = this.graphData.nodes[nodeId]; - if (node) { - // Cache World Coordinates (x, z) - // Note: node.y in graphData is already flipped to match World Z - this.servedCoordinates.push({ x: node.x, z: node.y }); - } - } - }); + // 2. Rebuild each route + routesData.forEach((data, index) => { + this.rebuildRouteFromData(data.nodes, data.color || this.getRandomColor(), index); + }); + + this.refreshServedNodes(); + } + + rebuildRouteFromData(nodes, color, routeIndex) { + // 1. Calculate Path Geometry + const pathResult = this.calculateGeometryFromNodes(nodes); + if (!pathResult) return; + + // 2. Create Mesh + const tubeMat = new THREE.MeshBasicMaterial({ color: color }); + const mesh = new THREE.Mesh(pathResult.geometry, tubeMat); + this.scene.add(mesh); + + // 3. Spawn Bus + if (this.vehicleSystem && pathResult.points.length > 0) { + this.vehicleSystem.addBusToRoute(pathResult.points, color, routeIndex); + } + + // 4. Calculate Stats + const ridership = this.calculateRidership(nodes); + + // 5. Store + this.savedRoutes.push({ + nodes: [...nodes], + stats: { length: pathResult.length, cost: 0, ridership }, // Cost is sunk history, doesn't matter for load + mesh: mesh, + color: color }); } - // Returns distance in meters. Returns Infinity if no routes exist. - getDistanceToNearestTransit(x, z) { - if (this.servedCoordinates.length === 0) return Infinity; - - let minSq = Infinity; - - // Optimization: Standard loop is faster than forEach for high freq calls - 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); + 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.currentRouteNodes.length < 2 || !this.currentPathMesh) return; + + const length = this.currentPathMesh.userData.length || 0; + const cost = this.gameManager.getProjectedCost(length); + + if (!this.gameManager.canAfford(cost)) { + alert("Insufficient Funds!"); + return; + } + + this.gameManager.deductFunds(cost); + + // 1. Define Color (Random default) + const color = this.getRandomColor(); + + // 2. Finalize Visuals + this.currentPathMesh.material.color.set(color); + + // 3. Register Route + 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 + }); + + // Cleanup draft state + this.currentPathMesh = null; + this.resetDraftingState(); + this.refreshServedNodes(); + this.gameManager.recalculateApproval(); + this.gameManager.updateUI(); + } + + updateRouteColor(index, hexColor) { + if (index < 0 || index >= this.savedRoutes.length) return; + + const route = this.savedRoutes[index]; + route.color = hexColor; + + // Update Track Mesh + if (route.mesh) { + route.mesh.material.color.set(hexColor); + } + + // Update Vehicles + 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(); + } + + // We can't easily remove buses for just one route without refactoring array indices + // Simplification: Reload all buses or mark them as dead. + // To keep it simple: We will remove the route data, and rebuild the vehicle system's arrays relative to new indices. + + // 1. Remove from array + this.savedRoutes.splice(index, 1); + + // 2. Refresh simulation (easiest way to handle index shifts) + // Save current state -> Clear Vehicles -> Rebuild Vehicles + if (this.vehicleSystem) { + this.vehicleSystem.clearVehicles(); + this.savedRoutes.forEach((r, idx) => { + // Need to regenerate points for the bus system + 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; + + // We actually just delete it and put nodes in draft + // The player loses the "sunk cost" of construction in this simple version + const route = this.savedRoutes[index]; + this.currentRouteNodes = [...route.nodes]; + + // Delete the existing + this.deleteSavedRoute(index); + + // Visualize draft + 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; - - // Sum census data for all nodes traversed by the route nodeList.forEach(nodeId => { const node = this.graphData.nodes[nodeId]; if (node) { @@ -113,20 +287,12 @@ export class RouteManager { totalJobs += (node.jobs || 0); } }); - const synergy = Math.min(totalPop, totalJobs); - - // Efficiency Factor: How balanced is the route? - // + Base multiplier (arbitrary game balance constant) const GAME_BALANCE_MULTIPLIER = 5.0; - - return Math.floor(synergy * GAME_BALANCE_MULTIPLIER); } - // ============================ - // API Methods - // ============================ + // ... (Existing Pathfinding & Drafting Methods) ... addNodeByWorldPosition(vector3) { if (!this.graphData) return; @@ -152,62 +318,6 @@ export class RouteManager { } } - - saveCurrentRoute() { - if (this.currentRouteNodes.length < 2 || !this.currentPathMesh) return; - - const length = this.currentPathMesh.userData.length || 0; - const cost = this.gameManager.getProjectedCost(length); - - // 1. Check Funds - if (!this.gameManager.canAfford(cost)) { - alert("Insufficient Funds!"); - return; - } - - // 2. Pay - this.gameManager.deductFunds(cost); - - // Spawn bus - if (this.vehicleSystem && this.latestPathPoints.length > 0) { - this.vehicleSystem.addBusToRoute(this.latestPathPoints); - } - - // 3. Freeze & Save - this.currentPathMesh.material.color.setHex(0x10B981); - - const ridership = this.calculateRidership(this.currentRouteNodes); - - this.savedRoutes.push({ - nodes: [...this.currentRouteNodes], - stats: { length, cost, ridership }, - mesh: this.currentPathMesh - }); - - this.currentPathMesh = null; - this.resetDraftingState(); - - this.refreshServedNodes(); - - // Force UI update to show new total riders - this.gameManager.updateUI(); - } - - editSavedRoute(index) { - if (index < 0 || index >= this.savedRoutes.length) return; - this.clearCurrentRoute(); - const route = this.savedRoutes[index]; - this.currentRouteNodes = [...route.nodes]; - if (route.mesh) { this.scene.remove(route.mesh); route.mesh.geometry.dispose(); } - this.savedRoutes.splice(index, 1); - - this.refreshServedNodes(); - - this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId)); - this.updatePathVisuals(); - this.gameManager.updateUI(); // Update UI since we removed a route (income drops) - } - clearCurrentRoute() { if (this.currentPathMesh) { this.scene.remove(this.currentPathMesh); this.currentPathMesh.geometry.dispose(); this.currentPathMesh = null; } this.resetDraftingState(); @@ -220,61 +330,23 @@ export class RouteManager { if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 }); } - 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); - this.refreshServedNodes(); - this.gameManager.updateUI(); - } - getSavedRoutes() { return this.savedRoutes; } - // ============================ - // Visuals & Logic - // ============================ - updatePathVisuals() { if (this.currentRouteNodes.length < 2) { if (this.currentPathMesh) { this.scene.remove(this.currentPathMesh); this.currentPathMesh = null; } - // Report 0 stats if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 }); return; } - let fullPathPoints = []; - let totalDist = 0; + // Reuse logic + const result = this.calculateGeometryFromNodes(this.currentRouteNodes); + if (!result) return; - for (let i = 0; i < this.currentRouteNodes.length - 1; i++) { - const start = this.currentRouteNodes[i]; - const end = this.currentRouteNodes[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))); - }); - } - - this.latestPathPoints = fullPathPoints; + this.latestPathPoints = result.points; // Rebuild Mesh if (this.currentPathMesh) { @@ -282,25 +354,19 @@ export class RouteManager { this.currentPathMesh.geometry.dispose(); } - if (fullPathPoints.length < 2) return; - - const curve = new THREE.CatmullRomCurve3(fullPathPoints); - const tubeGeom = new THREE.TubeGeometry(curve, fullPathPoints.length, 1.5, 6, false); const tubeMat = new THREE.MeshBasicMaterial({ color: this.settings.colors.route }); - - this.currentPathMesh = new THREE.Mesh(tubeGeom, tubeMat); - this.currentPathMesh.userData.length = totalDist; + this.currentPathMesh = new THREE.Mesh(result.geometry, tubeMat); + this.currentPathMesh.userData.length = result.length; this.scene.add(this.currentPathMesh); this.updateMarkerColors(); - // -- CALCULATE LIVE GAMEPLAY STATS -- const projectedRiders = this.calculateRidership(this.currentRouteNodes); - const projectedCost = this.gameManager ? this.gameManager.getProjectedCost(totalDist) : 0; + const projectedCost = this.gameManager ? this.gameManager.getProjectedCost(result.length) : 0; if (this.onRouteChanged) { this.onRouteChanged({ - length: totalDist, + length: result.length, cost: projectedCost, ridership: projectedRiders }); @@ -330,9 +396,35 @@ export class RouteManager { this.updateMarkerColors(); } - // ============================ - // Algorithms - // ============================ + 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; diff --git a/src/UIManager.js b/src/UIManager.js index a15c30f..0b346fd 100644 --- a/src/UIManager.js +++ b/src/UIManager.js @@ -1,6 +1,7 @@ export class UIManager { constructor(routeManager) { this.routeManager = routeManager; + this.gameManager = null; // Set via dependency injection in main.js if needed, or we just access logic differently // UI Elements this.elCurrentLength = document.getElementById('current-length'); @@ -10,7 +11,7 @@ export class UIManager { this.elBudget = document.getElementById('val-budget'); this.elDay = document.getElementById('val-day'); this.elTotalRiders = document.getElementById('val-riders'); - this.elApproval = document.getElementById('val-approval'); // NEW + this.elApproval = document.getElementById('val-approval'); this.elIncomeFloat = document.getElementById('income-float'); this.elRouteList = document.getElementById('route-list'); @@ -20,7 +21,12 @@ export class UIManager { this.btnDiscard = document.getElementById('btn-discard'); this.btnToggle = document.getElementById('ui-toggle'); - // NEW: View Mode + // Save/Load + this.btnSaveGame = document.getElementById('btn-save-game'); + this.btnLoadGame = document.getElementById('btn-load-game'); + this.inputLoadGame = document.getElementById('file-load-game'); + + // View Mode this.selectViewMode = document.getElementById('view-mode'); this.onViewModeChanged = null; @@ -41,12 +47,37 @@ export class UIManager { this.elContainer.classList.toggle('hidden'); }); - // Handle Dropdown Change this.selectViewMode.addEventListener('change', (e) => { if (this.onViewModeChanged) { this.onViewModeChanged(e.target.value); } }); + + // Save / Load System + this.btnSaveGame.addEventListener('click', () => { + if (this.routeManager.gameManager) { + this.routeManager.gameManager.saveGame(); + } + }); + + this.btnLoadGame.addEventListener('click', () => { + this.inputLoadGame.click(); + }); + + this.inputLoadGame.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (evt) => { + if (this.routeManager.gameManager) { + this.routeManager.gameManager.loadGame(evt.target.result); + this.renderRouteList(); + } + }; + reader.readAsText(file); + e.target.value = ''; // Reset so we can load same file again if needed + }); } updateGameStats(stats) { @@ -54,12 +85,10 @@ export class UIManager { this.elDay.textContent = stats.day; this.elTotalRiders.textContent = stats.totalRiders.toLocaleString(); - // Update Approval this.elApproval.textContent = stats.approval + "%"; - // Color code it - if (stats.approval > 75) this.elApproval.style.color = "#10B981"; // Green - else if (stats.approval < 40) this.elApproval.style.color = "#EF4444"; // Red - else this.elApproval.style.color = "#D97706"; // Orange + if (stats.approval > 75) this.elApproval.style.color = "#10B981"; + else if (stats.approval < 40) this.elApproval.style.color = "#EF4444"; + else this.elApproval.style.color = "#D97706"; } showIncomeFeedback(amount) { @@ -92,12 +121,34 @@ export class UIManager { ? (route.stats.length / 1000).toFixed(1) + "km" : Math.round(route.stats.length) + "m"; + // Color Picker + const colorInput = document.createElement('input'); + colorInput.type = 'color'; + colorInput.value = route.color || "#000000"; + colorInput.style.border = "none"; + colorInput.style.width = "24px"; + colorInput.style.height = "24px"; + colorInput.style.cursor = "pointer"; + colorInput.title = "Change Route Color"; + + colorInput.addEventListener('input', (e) => { + this.routeManager.updateRouteColor(index, e.target.value); + }); + const span = document.createElement('span'); span.innerHTML = ` Route ${index + 1}
${lenStr} | ${route.stats.ridership} riders `; - li.appendChild(span); + + const detailsDiv = document.createElement('div'); + detailsDiv.style.display = "flex"; + detailsDiv.style.alignItems = "center"; + detailsDiv.style.gap = "8px"; + detailsDiv.appendChild(colorInput); + detailsDiv.appendChild(span); + + li.appendChild(detailsDiv); const btnEdit = document.createElement('button'); btnEdit.textContent = "Edit"; @@ -106,7 +157,6 @@ export class UIManager { this.routeManager.editSavedRoute(index); this.renderRouteList(); }; - li.appendChild(btnEdit); const btnDel = document.createElement('button'); btnDel.textContent = "✕"; @@ -115,7 +165,12 @@ export class UIManager { this.routeManager.deleteSavedRoute(index); this.renderRouteList(); }; - li.appendChild(btnDel); + + const btnDiv = document.createElement('div'); + btnDiv.appendChild(btnEdit); + btnDiv.appendChild(btnDel); + + li.appendChild(btnDiv); this.elRouteList.appendChild(li); }); diff --git a/src/VehicleSystem.js b/src/VehicleSystem.js index ddebee3..608c21a 100644 --- a/src/VehicleSystem.js +++ b/src/VehicleSystem.js @@ -3,31 +3,39 @@ import * as THREE from 'three'; export class VehicleSystem { constructor(scene) { this.scene = scene; - this.buses = []; // { mesh, points, dists, totalLen, currentDist, speed, direction } + this.buses = []; // { mesh, points, dists, totalLen, currentDist, speed, direction, routeIndex } this.busGeom = new THREE.BoxGeometry(3.5, 4.0, 10.0); this.busGeom.translate(0, 3.5, 0); - this.busMat = new THREE.MeshStandardMaterial({ - color: 0xF59E0B, // Amber body - emissive: 0xB45309, // Slight orange glow so they don't get lost in shadow - emissiveIntensity: 0.4, + this.baseBusMat = new THREE.MeshStandardMaterial({ + color: 0xF59E0B, + emissive: 0xB45309, + emissiveIntensity: 0.2, roughness: 0.2 }); } - addBusToRoute(routePathPoints) { + addBusToRoute(routePathPoints, colorStr, routeIndex) { if (!routePathPoints || routePathPoints.length < 2) return; - // Clone points to ensure they aren't affected by outside changes const points = routePathPoints.map(p => p.clone()); - const mesh = new THREE.Mesh(this.busGeom, this.busMat); + // Create material specific to this bus/route + const mat = this.baseBusMat.clone(); + if (colorStr) { + mat.color.set(colorStr); + // Slight emissive tint of same color + const c = new THREE.Color(colorStr); + mat.emissive.set(c.multiplyScalar(0.5)); + } + + const mesh = new THREE.Mesh(this.busGeom, mat); mesh.position.copy(points[0]); mesh.castShadow = true; this.scene.add(mesh); - // Pre-calculate cumulative distances for smooth interpolation + // Pre-calculate let totalLen = 0; const dists = [0]; for (let i = 0; i < points.length - 1; i++) { @@ -42,17 +50,36 @@ export class VehicleSystem { dists: dists, totalLen: totalLen, currentDist: 0, - speed: 40, // Speed in units/sec - direction: 1 // 1 = Forward, -1 = Backward + speed: 40, + direction: 1, + routeIndex: routeIndex }); } + updateRouteColor(routeIndex, hexColor) { + // Update all buses belonging to this route + this.buses.forEach(bus => { + if (bus.routeIndex === routeIndex) { + bus.mesh.material.color.set(hexColor); + const c = new THREE.Color(hexColor); + bus.mesh.material.emissive.set(c.multiplyScalar(0.5)); + } + }); + } + + clearVehicles() { + this.buses.forEach(bus => { + this.scene.remove(bus.mesh); + bus.mesh.geometry.dispose(); + bus.mesh.material.dispose(); + }); + this.buses = []; + } + update(deltaTime) { this.buses.forEach(bus => { - // 1. Move bus.currentDist += bus.speed * deltaTime * bus.direction; - // 2. Check Bounds & Reversal if (bus.currentDist >= bus.totalLen) { bus.currentDist = bus.totalLen; bus.direction = -1; @@ -61,27 +88,20 @@ export class VehicleSystem { bus.direction = 1; } - // 3. Find current segment index let i = 0; - // Simple linear search (efficient enough for small N points) while (i < bus.dists.length - 2 && bus.currentDist > bus.dists[i + 1]) { i++; } - // 4. Interpolate Position const startDist = bus.dists[i]; const endDist = bus.dists[i + 1]; const segmentLen = endDist - startDist; - // Avoid divide by zero if 2 points are identical const alpha = segmentLen > 0.0001 ? (bus.currentDist - startDist) / segmentLen : 0; - const pStart = bus.points[i]; const pEnd = bus.points[i + 1]; bus.mesh.position.lerpVectors(pStart, pEnd, alpha); - - // 5. Rotation (Look at target) const lookTarget = bus.direction === 1 ? pEnd : pStart; bus.mesh.lookAt(lookTarget); }); diff --git a/src/main.js b/src/main.js index b1826f6..c5f1440 100644 --- a/src/main.js +++ b/src/main.js @@ -55,7 +55,6 @@ function init() { vehicleSystem = new VehicleSystem(scene); routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager - gameManager.start(); // Start the loop // 3. Input inputManager = new InputManager(camera, renderer.domElement, scene, controls); @@ -88,6 +87,7 @@ function init() { ]).then(([visual, routing]) => { routeManager.initGraph(routing); renderCity(visual); + gameManager.start(); // Start the loop }); animate(); diff --git a/src/tmp b/src/tmp new file mode 100644 index 0000000..e69de29