fix approval stat; save game; customize color

This commit is contained in:
Evan Scamehorn
2025-12-04 12:51:32 -06:00
parent e501a2c480
commit 8fc551ad68
8 changed files with 451 additions and 199 deletions

View File

@@ -11,6 +11,9 @@
<body> <body>
<button id="ui-toggle" title="Toggle Menu"></button> <button id="ui-toggle" title="Toggle Menu"></button>
<!-- Hidden Input for Loading Games -->
<input type="file" id="file-load-game" style="display: none;" accept=".json" />
<!-- Floating Income Feedback --> <!-- Floating Income Feedback -->
<div id="income-float" <div id="income-float"
style="position:absolute; top:60px; left:220px; color:#10B981; font-weight:bold; font-size:20px; opacity:0; transition: all 1s ease-out; z-index:90; text-shadow:0 1px 2px white;"> style="position:absolute; top:60px; left:220px; color:#10B981; font-weight:bold; font-size:20px; opacity:0; transition: all 1s ease-out; z-index:90; text-shadow:0 1px 2px white;">
@@ -20,6 +23,12 @@
<div class="header"> <div class="header">
<h2>Route Planner</h2> <h2>Route Planner</h2>
<!-- Save/Load System -->
<div style="display:flex; gap:5px; margin-bottom:10px;">
<button id="btn-save-game" class="secondary" style="flex:1; font-size:12px;">💾 Save</button>
<button id="btn-load-game" class="secondary" style="flex:1; font-size:12px;">📂 Load</button>
</div>
<!-- Global Stats --> <!-- Global Stats -->
<div <div
style="background:#f3f4f6; padding:8px; border-radius:6px; margin-bottom:10px; display:grid; grid-template-columns: 1fr 1fr; gap:5px; font-size:13px;"> style="background:#f3f4f6; padding:8px; border-radius:6px; margin-bottom:10px; display:grid; grid-template-columns: 1fr 1fr; gap:5px; font-size:13px;">

24
presentation.md Normal file
View File

@@ -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

View File

@@ -22,14 +22,69 @@ export class GameManager {
start() { start() {
this.buildCensusArrays(); this.buildCensusArrays();
this.recalculateApproval(); // Initial calc this.recalculateApproval();
this.updateUI(); this.updateUI();
if (this.gameLoopInterval) clearInterval(this.gameLoopInterval);
this.gameLoopInterval = setInterval(() => { this.gameLoopInterval = setInterval(() => {
this.processDay(); this.processDay();
}, 5000); }, 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() { buildCensusArrays() {
if (!this.routeManager.graphData) return; if (!this.routeManager.graphData) return;
@@ -51,7 +106,6 @@ export class GameManager {
} }
} }
// UPDATED: Weighted Approval Calculation
recalculateApproval() { recalculateApproval() {
if (!this.censusNodes || this.censusNodes.length === 0) { if (!this.censusNodes || this.censusNodes.length === 0) {
this.approvalRating = 0; this.approvalRating = 0;
@@ -62,7 +116,7 @@ export class GameManager {
let totalMaxScore = 0; // The score if everyone had 0m walk let totalMaxScore = 0; // The score if everyone had 0m walk
// Constants for walking distance // 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%. const IDEAL_WALK_DIST = 50; // Meters. Below this, satisfaction is 100%.
for (const node of this.censusNodes) { for (const node of this.censusNodes) {
@@ -79,8 +133,6 @@ export class GameManager {
satisfaction = Math.max(0, satisfaction); satisfaction = Math.max(0, satisfaction);
// 4. Add weighted score (Satisfaction * People Count) // 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); totalWeightedScore += (satisfaction * node.count);
} }
} }

View File

@@ -9,22 +9,21 @@ export class RouteManager {
// -- State -- // -- State --
this.currentRouteNodes = []; this.currentRouteNodes = [];
this.savedRoutes = []; this.savedRoutes = []; // { nodes, stats, mesh, color }
// -- Visuals -- // -- Visuals --
this.markers = []; this.markers = [];
this.currentPathMesh = null; this.currentPathMesh = null;
this.servedNodes = new Set(); this.servedNodes = new Set();
this.servedCoordinates = []; this.servedCoordinates = [];
this.ROAD_OFFSET = 2.5; this.ROAD_OFFSET = 2.5;
this.onRouteChanged = null; this.onRouteChanged = null;
this.gameManager = null; this.gameManager = null;
this.vehicleSystem = null; this.vehicleSystem = null;
// Draft state
this.latestPathPoints = []; this.latestPathPoints = [];
} }
@@ -36,7 +35,6 @@ export class RouteManager {
this.gameManager = gm; this.gameManager = gm;
} }
initGraph(data) { initGraph(data) {
this.graphData = data; this.graphData = data;
this.graphData.adjacency = {}; this.graphData.adjacency = {};
@@ -54,58 +52,234 @@ export class RouteManager {
}); });
} }
// Helper to check if a node is covered // ============================
isNodeServed(nodeId) { // Save / Load / Serialization
return this.servedNodes.has(parseInt(nodeId)); // ============================
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 loadRoutes(routesData) {
refreshServedNodes() { // 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.servedNodes.clear();
this.servedCoordinates = [];
this.savedRoutes.forEach(route => { if (this.vehicleSystem) this.vehicleSystem.clearVehicles();
route.nodes.forEach(nodeId => {
if (!this.servedNodes.has(nodeId)) {
this.servedNodes.add(nodeId);
const node = this.graphData.nodes[nodeId]; // 2. Rebuild each route
if (node) { routesData.forEach((data, index) => {
// Cache World Coordinates (x, z) this.rebuildRouteFromData(data.nodes, data.color || this.getRandomColor(), index);
// Note: node.y in graphData is already flipped to match World Z
this.servedCoordinates.push({ x: node.x, z: node.y });
}
}
}); });
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. getRandomColor() {
getDistanceToNearestTransit(x, z) { const colors = ["#ef4444", "#f97316", "#f59e0b", "#84cc16", "#10b981", "#06b6d4", "#3b82f6", "#8b5cf6", "#d946ef"];
if (this.servedCoordinates.length === 0) return Infinity; return colors[Math.floor(Math.random() * colors.length)];
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); // ============================
// 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) { calculateRidership(nodeList) {
if (!this.graphData || nodeList.length < 2) return 0; if (!this.graphData || nodeList.length < 2) return 0;
let totalPop = 0; let totalPop = 0;
let totalJobs = 0; let totalJobs = 0;
// Sum census data for all nodes traversed by the route
nodeList.forEach(nodeId => { nodeList.forEach(nodeId => {
const node = this.graphData.nodes[nodeId]; const node = this.graphData.nodes[nodeId];
if (node) { if (node) {
@@ -113,20 +287,12 @@ export class RouteManager {
totalJobs += (node.jobs || 0); totalJobs += (node.jobs || 0);
} }
}); });
const synergy = Math.min(totalPop, totalJobs); 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; const GAME_BALANCE_MULTIPLIER = 5.0;
return Math.floor(synergy * GAME_BALANCE_MULTIPLIER); return Math.floor(synergy * GAME_BALANCE_MULTIPLIER);
} }
// ============================ // ... (Existing Pathfinding & Drafting Methods) ...
// API Methods
// ============================
addNodeByWorldPosition(vector3) { addNodeByWorldPosition(vector3) {
if (!this.graphData) return; 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() { clearCurrentRoute() {
if (this.currentPathMesh) { this.scene.remove(this.currentPathMesh); this.currentPathMesh.geometry.dispose(); this.currentPathMesh = null; } if (this.currentPathMesh) { this.scene.remove(this.currentPathMesh); this.currentPathMesh.geometry.dispose(); this.currentPathMesh = null; }
this.resetDraftingState(); this.resetDraftingState();
@@ -220,61 +330,23 @@ export class RouteManager {
if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 }); 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; } getSavedRoutes() { return this.savedRoutes; }
// ============================
// Visuals & Logic
// ============================
updatePathVisuals() { updatePathVisuals() {
if (this.currentRouteNodes.length < 2) { if (this.currentRouteNodes.length < 2) {
if (this.currentPathMesh) { if (this.currentPathMesh) {
this.scene.remove(this.currentPathMesh); this.scene.remove(this.currentPathMesh);
this.currentPathMesh = null; this.currentPathMesh = null;
} }
// Report 0 stats
if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 }); if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 });
return; return;
} }
let fullPathPoints = []; // Reuse logic
let totalDist = 0; const result = this.calculateGeometryFromNodes(this.currentRouteNodes);
if (!result) return;
for (let i = 0; i < this.currentRouteNodes.length - 1; i++) { this.latestPathPoints = result.points;
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;
// Rebuild Mesh // Rebuild Mesh
if (this.currentPathMesh) { if (this.currentPathMesh) {
@@ -282,25 +354,19 @@ export class RouteManager {
this.currentPathMesh.geometry.dispose(); 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 }); const tubeMat = new THREE.MeshBasicMaterial({ color: this.settings.colors.route });
this.currentPathMesh = new THREE.Mesh(result.geometry, tubeMat);
this.currentPathMesh = new THREE.Mesh(tubeGeom, tubeMat); this.currentPathMesh.userData.length = result.length;
this.currentPathMesh.userData.length = totalDist;
this.scene.add(this.currentPathMesh); this.scene.add(this.currentPathMesh);
this.updateMarkerColors(); this.updateMarkerColors();
// -- CALCULATE LIVE GAMEPLAY STATS --
const projectedRiders = this.calculateRidership(this.currentRouteNodes); 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) { if (this.onRouteChanged) {
this.onRouteChanged({ this.onRouteChanged({
length: totalDist, length: result.length,
cost: projectedCost, cost: projectedCost,
ridership: projectedRiders ridership: projectedRiders
}); });
@@ -330,9 +396,35 @@ export class RouteManager {
this.updateMarkerColors(); this.updateMarkerColors();
} }
// ============================ refreshServedNodes() {
// Algorithms 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) { findNearestNode(x, z) {
let closestId = null; let closestId = null;

View File

@@ -1,6 +1,7 @@
export class UIManager { export class UIManager {
constructor(routeManager) { constructor(routeManager) {
this.routeManager = routeManager; this.routeManager = routeManager;
this.gameManager = null; // Set via dependency injection in main.js if needed, or we just access logic differently
// UI Elements // UI Elements
this.elCurrentLength = document.getElementById('current-length'); this.elCurrentLength = document.getElementById('current-length');
@@ -10,7 +11,7 @@ export class UIManager {
this.elBudget = document.getElementById('val-budget'); this.elBudget = document.getElementById('val-budget');
this.elDay = document.getElementById('val-day'); this.elDay = document.getElementById('val-day');
this.elTotalRiders = document.getElementById('val-riders'); 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.elIncomeFloat = document.getElementById('income-float');
this.elRouteList = document.getElementById('route-list'); this.elRouteList = document.getElementById('route-list');
@@ -20,7 +21,12 @@ export class UIManager {
this.btnDiscard = document.getElementById('btn-discard'); this.btnDiscard = document.getElementById('btn-discard');
this.btnToggle = document.getElementById('ui-toggle'); 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.selectViewMode = document.getElementById('view-mode');
this.onViewModeChanged = null; this.onViewModeChanged = null;
@@ -41,12 +47,37 @@ export class UIManager {
this.elContainer.classList.toggle('hidden'); this.elContainer.classList.toggle('hidden');
}); });
// Handle Dropdown Change
this.selectViewMode.addEventListener('change', (e) => { this.selectViewMode.addEventListener('change', (e) => {
if (this.onViewModeChanged) { if (this.onViewModeChanged) {
this.onViewModeChanged(e.target.value); 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) { updateGameStats(stats) {
@@ -54,12 +85,10 @@ export class UIManager {
this.elDay.textContent = stats.day; this.elDay.textContent = stats.day;
this.elTotalRiders.textContent = stats.totalRiders.toLocaleString(); this.elTotalRiders.textContent = stats.totalRiders.toLocaleString();
// Update Approval
this.elApproval.textContent = stats.approval + "%"; this.elApproval.textContent = stats.approval + "%";
// Color code it if (stats.approval > 75) this.elApproval.style.color = "#10B981";
if (stats.approval > 75) this.elApproval.style.color = "#10B981"; // Green else if (stats.approval < 40) this.elApproval.style.color = "#EF4444";
else if (stats.approval < 40) this.elApproval.style.color = "#EF4444"; // Red else this.elApproval.style.color = "#D97706";
else this.elApproval.style.color = "#D97706"; // Orange
} }
showIncomeFeedback(amount) { showIncomeFeedback(amount) {
@@ -92,12 +121,34 @@ export class UIManager {
? (route.stats.length / 1000).toFixed(1) + "km" ? (route.stats.length / 1000).toFixed(1) + "km"
: Math.round(route.stats.length) + "m"; : 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'); const span = document.createElement('span');
span.innerHTML = ` span.innerHTML = `
<strong>Route ${index + 1}</strong> <br> <strong>Route ${index + 1}</strong> <br>
<small>${lenStr} | ${route.stats.ridership} riders</small> <small>${lenStr} | ${route.stats.ridership} riders</small>
`; `;
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'); const btnEdit = document.createElement('button');
btnEdit.textContent = "Edit"; btnEdit.textContent = "Edit";
@@ -106,7 +157,6 @@ export class UIManager {
this.routeManager.editSavedRoute(index); this.routeManager.editSavedRoute(index);
this.renderRouteList(); this.renderRouteList();
}; };
li.appendChild(btnEdit);
const btnDel = document.createElement('button'); const btnDel = document.createElement('button');
btnDel.textContent = "✕"; btnDel.textContent = "✕";
@@ -115,7 +165,12 @@ export class UIManager {
this.routeManager.deleteSavedRoute(index); this.routeManager.deleteSavedRoute(index);
this.renderRouteList(); this.renderRouteList();
}; };
li.appendChild(btnDel);
const btnDiv = document.createElement('div');
btnDiv.appendChild(btnEdit);
btnDiv.appendChild(btnDel);
li.appendChild(btnDiv);
this.elRouteList.appendChild(li); this.elRouteList.appendChild(li);
}); });

View File

@@ -3,31 +3,39 @@ import * as THREE from 'three';
export class VehicleSystem { export class VehicleSystem {
constructor(scene) { constructor(scene) {
this.scene = 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 = new THREE.BoxGeometry(3.5, 4.0, 10.0);
this.busGeom.translate(0, 3.5, 0); this.busGeom.translate(0, 3.5, 0);
this.busMat = new THREE.MeshStandardMaterial({ this.baseBusMat = new THREE.MeshStandardMaterial({
color: 0xF59E0B, // Amber body color: 0xF59E0B,
emissive: 0xB45309, // Slight orange glow so they don't get lost in shadow emissive: 0xB45309,
emissiveIntensity: 0.4, emissiveIntensity: 0.2,
roughness: 0.2 roughness: 0.2
}); });
} }
addBusToRoute(routePathPoints) { addBusToRoute(routePathPoints, colorStr, routeIndex) {
if (!routePathPoints || routePathPoints.length < 2) return; 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 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.position.copy(points[0]);
mesh.castShadow = true; mesh.castShadow = true;
this.scene.add(mesh); this.scene.add(mesh);
// Pre-calculate cumulative distances for smooth interpolation // Pre-calculate
let totalLen = 0; let totalLen = 0;
const dists = [0]; const dists = [0];
for (let i = 0; i < points.length - 1; i++) { for (let i = 0; i < points.length - 1; i++) {
@@ -42,17 +50,36 @@ export class VehicleSystem {
dists: dists, dists: dists,
totalLen: totalLen, totalLen: totalLen,
currentDist: 0, currentDist: 0,
speed: 40, // Speed in units/sec speed: 40,
direction: 1 // 1 = Forward, -1 = Backward 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) { update(deltaTime) {
this.buses.forEach(bus => { this.buses.forEach(bus => {
// 1. Move
bus.currentDist += bus.speed * deltaTime * bus.direction; bus.currentDist += bus.speed * deltaTime * bus.direction;
// 2. Check Bounds & Reversal
if (bus.currentDist >= bus.totalLen) { if (bus.currentDist >= bus.totalLen) {
bus.currentDist = bus.totalLen; bus.currentDist = bus.totalLen;
bus.direction = -1; bus.direction = -1;
@@ -61,27 +88,20 @@ export class VehicleSystem {
bus.direction = 1; bus.direction = 1;
} }
// 3. Find current segment index
let i = 0; let i = 0;
// Simple linear search (efficient enough for small N points)
while (i < bus.dists.length - 2 && bus.currentDist > bus.dists[i + 1]) { while (i < bus.dists.length - 2 && bus.currentDist > bus.dists[i + 1]) {
i++; i++;
} }
// 4. Interpolate Position
const startDist = bus.dists[i]; const startDist = bus.dists[i];
const endDist = bus.dists[i + 1]; const endDist = bus.dists[i + 1];
const segmentLen = endDist - startDist; const segmentLen = endDist - startDist;
// Avoid divide by zero if 2 points are identical
const alpha = segmentLen > 0.0001 ? (bus.currentDist - startDist) / segmentLen : 0; const alpha = segmentLen > 0.0001 ? (bus.currentDist - startDist) / segmentLen : 0;
const pStart = bus.points[i]; const pStart = bus.points[i];
const pEnd = bus.points[i + 1]; const pEnd = bus.points[i + 1];
bus.mesh.position.lerpVectors(pStart, pEnd, alpha); bus.mesh.position.lerpVectors(pStart, pEnd, alpha);
// 5. Rotation (Look at target)
const lookTarget = bus.direction === 1 ? pEnd : pStart; const lookTarget = bus.direction === 1 ? pEnd : pStart;
bus.mesh.lookAt(lookTarget); bus.mesh.lookAt(lookTarget);
}); });

View File

@@ -55,7 +55,6 @@ function init() {
vehicleSystem = new VehicleSystem(scene); vehicleSystem = new VehicleSystem(scene);
routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager
gameManager.start(); // Start the loop
// 3. Input // 3. Input
inputManager = new InputManager(camera, renderer.domElement, scene, controls); inputManager = new InputManager(camera, renderer.domElement, scene, controls);
@@ -88,6 +87,7 @@ function init() {
]).then(([visual, routing]) => { ]).then(([visual, routing]) => {
routeManager.initGraph(routing); routeManager.initGraph(routing);
renderCity(visual); renderCity(visual);
gameManager.start(); // Start the loop
}); });
animate(); animate();

0
src/tmp Normal file
View File