Compare commits

...

10 Commits

Author SHA1 Message Date
Evan Scamehorn
2edbb35236 attribution for data
Some checks failed
Deploy to GitHub Pages / deploy (push) Has been cancelled
2025-12-04 14:55:21 -06:00
Evan Scamehorn
b760760be5 update presentation 2025-12-04 14:51:00 -06:00
Evan Scamehorn
459fe8cd6c change button to save 2025-12-04 14:45:59 -06:00
Evan Scamehorn
e0be371bbc fix editing routes in UI 2025-12-04 14:38:44 -06:00
Evan Scamehorn
04167c656a UI: main/draft menu separation, ghost node indicator 2025-12-04 14:28:53 -06:00
Evan Scamehorn
f3cb583836 small updates 2025-12-04 13:08:07 -06:00
Evan Scamehorn
8fc551ad68 fix approval stat; save game; customize color 2025-12-04 12:51:32 -06:00
Evan Scamehorn
e501a2c480 approval rating 2025-12-02 16:36:55 -06:00
Evan Scamehorn
dc0659b1df draw busses 2025-12-02 15:02:30 -06:00
Evan Scamehorn
68ca45f58e gameplay mechanics 2025-12-02 14:40:18 -06:00
11 changed files with 1181 additions and 323 deletions

View File

@@ -3,10 +3,10 @@ import json
import os
import networkx as nx
from shapely.ops import unary_union
from shapely.geometry import Polygon, MultiPolygon
from scipy.spatial import cKDTree
import re
import numpy as np
import pandas as pd
# ==========================================
# 1. Configuration
@@ -41,7 +41,6 @@ JOB_DENSITY_FACTOR = 0.08 # Jobs per m3 (Commercial)
# 2. Helpers
# ==========================================
def get_height(row):
"""Estimates building height."""
h = 8.0
if "height" in row and str(row["height"]).lower() != "nan":
try:
@@ -63,7 +62,6 @@ def get_height(row):
def estimate_road_width(row):
"""Estimates width with US-unit safety checks."""
for key in ["width", "width:carriageway", "est_width"]:
if key in row and str(row[key]) != "nan":
val_str = str(row[key]).lower()
@@ -96,10 +94,6 @@ def estimate_road_width(row):
def classify_building(row, height, area):
"""
Classifies a building as Residential (Pop) or Commercial (Jobs)
and estimates the count based on volume.
"""
b_type = str(row.get("building", "yes")).lower()
amenity = str(row.get("amenity", "")).lower()
office = str(row.get("office", "")).lower()
@@ -107,7 +101,6 @@ def classify_building(row, height, area):
volume = area * height
# Lists of tags
residential_tags = [
"apartments",
"residential",
@@ -136,9 +129,7 @@ def classify_building(row, height, area):
or (shop != "nan" and shop != "")
)
# Default logic if generic "yes"
if not is_res and not is_com:
# Small buildings likely houses, big generic likely commercial in city center
if volume > 5000:
is_com = True
else:
@@ -152,11 +143,11 @@ def classify_building(row, height, area):
if is_res:
pop = round(volume * POP_DENSITY_FACTOR)
category = "residential"
density_score = min(1.0, pop / 500) # Normalize for color (0-1)
density_score = min(1.0, pop / 500)
elif is_com:
jobs = round(volume * JOB_DENSITY_FACTOR)
category = "commercial"
density_score = min(1.0, jobs / 1000) # Normalize for color (0-1)
density_score = min(1.0, jobs / 1000)
return category, density_score, pop, jobs
@@ -201,12 +192,32 @@ def parse_line_points(geom, center_x, center_y):
# ==========================================
print(f"1. Downloading Data for: {PLACE_NAME}...")
# Define valid tags lists for filtering later
PARK_TAGS_LEISURE = [
"park",
"garden",
"playground",
"golf_course",
"pitch",
"recreation_ground",
]
PARK_TAGS_LANDUSE = [
"grass",
"forest",
"park",
"meadow",
"village_green",
"recreation_ground",
"orchard",
]
NATURAL_TAGS = ["water", "bay", "coastline"]
tags_visual = {
"building": True,
"natural": ["water", "bay"],
"leisure": ["park", "garden"],
"landuse": ["grass", "forest", "park"],
"amenity": True, # Fetch amenities to help classify jobs
"natural": NATURAL_TAGS,
"leisure": PARK_TAGS_LEISURE,
"landuse": PARK_TAGS_LANDUSE,
"amenity": True, # Needed for zoning, but MUST be filtered out of geometry
"office": True,
"shop": True,
}
@@ -228,23 +239,26 @@ center_y = gdf_visual.geometry.centroid.y.mean()
output_visual = {"buildings": [], "water": [], "parks": [], "roads": []}
output_routing = {"nodes": {}, "edges": []}
# We will store building data to map it to graph nodes later
building_data_points = [] # (x, y, pop, jobs)
building_data_points = []
print("3. Processing Visual Layers & Census Simulation...")
for idx, row in gdf_visual.iterrows():
# Only process polygons
if row.geometry.geom_type not in ["Polygon", "MultiPolygon"]:
continue
polygons = parse_geometry(row.geometry, center_x, center_y)
for poly_data in polygons:
# 1. Buildings (With Zoning Logic)
# -----------------------------
# 1. BUILDINGS
# -----------------------------
if "building" in row and str(row["building"]) != "nan":
height = get_height(row)
area = row.geometry.area
# Zoning / Census Simulation
cat, score, pop, jobs = classify_building(row, height, area)
# Store centroid for graph mapping
cx = row.geometry.centroid.x - center_x
cy = row.geometry.centroid.y - center_y
building_data_points.append([cx, cy, pop, jobs])
@@ -257,16 +271,29 @@ for idx, row in gdf_visual.iterrows():
}
)
# 2. Water
elif ("natural" in row and str(row["natural"]) != "nan") or (
# -----------------------------
# 2. WATER
# -----------------------------
elif ("natural" in row and str(row["natural"]) in NATURAL_TAGS) or (
"water" in row and str(row["water"]) != "nan"
):
output_visual["water"].append({"shape": poly_data})
# 3. Parks
else:
# -----------------------------
# 3. PARKS (STRICT FILTER)
# -----------------------------
elif ("leisure" in row and str(row["leisure"]) in PARK_TAGS_LEISURE) or (
"landuse" in row and str(row["landuse"]) in PARK_TAGS_LANDUSE
):
output_visual["parks"].append({"shape": poly_data})
# -----------------------------
# 4. IGNORE EVERYTHING ELSE
# -----------------------------
# Amenities, parking lots, and landuse=commercial fall here and are NOT drawn.
else:
pass
print(" Buffering roads...")
road_polys = []
for idx, row in gdf_edges.iterrows():
@@ -281,21 +308,18 @@ if road_polys:
output_visual["roads"].append({"shape": shape})
print("4. Mapping Census Data to Graph Nodes...")
# Create a KDTree of building centroids
if building_data_points:
b_coords = np.array([[b[0], b[1]] for b in building_data_points])
b_data = np.array([[b[2], b[3]] for b in building_data_points]) # pop, jobs
b_data = np.array([[b[2], b[3]] for b in building_data_points])
tree = cKDTree(b_coords)
for node_id, row in gdf_nodes.iterrows():
nx = row.geometry.x - center_x
ny = row.geometry.y - center_y
# Find all buildings within 100m of this node
if building_data_points:
indices = tree.query_ball_point([nx, ny], r=100)
if indices:
# Sum pop/jobs of nearby buildings
nearby_stats = np.sum(b_data[indices], axis=0)
node_pop = int(nearby_stats[0])
node_jobs = int(nearby_stats[1])
@@ -307,7 +331,7 @@ for node_id, row in gdf_nodes.iterrows():
output_routing["nodes"][int(node_id)] = {
"x": round(nx, 2),
"y": round(ny, 2),
"pop": node_pop, # Store for gameplay later
"pop": node_pop,
"jobs": node_jobs,
}

View File

@@ -9,34 +9,84 @@
</head>
<body>
<!-- The UI Overlay -->
<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 -->
<div id="income-float"
style="position:absolute; top:60px; left:220px; color:#6AFF00; font-weight:bold; font-size:20px; opacity:0; transition: all 1s ease-out; z-index:90; text-shadow: 3px 3px 5px black;">
+$0</div>
<!-- Map Attribution -->
<div id="attribution"
style="position: absolute; bottom: 8px; right: 8px; z-index: 50; font-family: sans-serif; font-size: 11px; background: rgba(255,255,255,0.8); padding: 4px 8px; border-radius: 4px; pointer-events: auto;">
Map data &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank"
style="text-decoration: none; color: #333;">OpenStreetMap</a> contributors
</div>
<div id="ui-container">
<div class="header">
<h2>Route Planner</h2>
<p>Left Click: Add Point<br>Drag: Move Point</p>
<button id="btn-zoning" class="secondary" style="margin-top:10px; width:100%">Toggle Zoning View</button>
<!-- 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 -->
<div
style="background:#f3f4f6; padding:8px; border-radius:6px; margin-bottom:10px; display:grid; grid-template-columns: 1fr 1fr; gap:5px; font-size:13px;">
<div style="color:#2563EB">Budget: <strong id="val-budget">$1,000,000</strong></div>
<div>Day: <strong id="val-day">1</strong></div>
<div style="grid-column: span 2; border-top:1px solid #ddd; margin-top:4px; padding-top:4px;">
Total Daily Riders: <strong id="val-riders">0</strong>
</div>
<div style="color:#D97706">Approval: <strong id="val-approval">50%</strong></div>
</div>
<p style="font-size:12px; color:#666; margin-bottom:5px;">Left Click: Add Point | Drag: Move</p>
<select id="view-mode"
style="width:100%; padding:6px; border-radius:4px; border:1px solid #ccc; background:white; font-size:13px; cursor:pointer;">
<option value="none">View: Standard</option>
<option value="zoning">View: Zoning Density</option>
<option value="approval">View: Transit Coverage</option>
</select>
</div>
<div class="section">
<h3>Current Draft</h3>
<div class="stat-row">
<span>Length:</span>
<span id="current-length">0 m</span>
</div>
<div class="button-row">
<button id="btn-save" class="primary">Save Route</button>
<button id="btn-discard" class="danger">Discard</button>
<div id="ui-main-menu">
<div class="section">
<button id="btn-create-route" class="primary"
style="width:100%; padding: 12px; margin-bottom: 15px; font-weight:bold;">
+ Create New Route
</button>
<h3>Active Routes</h3>
<ul id="route-list"></ul>
</div>
</div>
<div class="section">
<h3>Saved Routes</h3>
<ul id="route-list">
<!-- List items will be injected here -->
</ul>
<!-- DRAFTING STATE (Hidden by default) -->
<div id="ui-draft-menu" style="display:none;">
<div class="section" style="border: 2px solid #2563EB; padding: 10px; border-radius: 6px; background: #eff6ff;">
<h3 style="margin-top:0; color:#2563EB;">Route Planner</h3>
<p style="font-size:12px; color:#666; margin-bottom:5px;">Left Click: Add Point | Drag: Move</p>
<div class="stat-row"><span>Length:</span> <span id="current-length">0 m</span></div>
<div class="stat-row"><span>Cost:</span> <span id="current-cost" style="color:#ef4444">$0</span></div>
<div class="stat-row"><span>Est. Riders:</span> <span id="current-riders" style="color:#10B981">0 / day</span>
</div>
<div class="button-row">
<button id="btn-save" class="primary">Save</button>
<button id="btn-discard" class="danger">Discard / Cancel</button>
</div>
</div>
</div>
</div>
<script type="module" src="/src/main.js"></script>

63
presentation.md Normal file
View File

@@ -0,0 +1,63 @@
# CS 559 GP Presentation
## Group ID 3826
### 3D City Transit Simulator
### 1. Route Planning & Construction
* **Drafting Mode:** The game separates "Planning" from "Building." You must click "Create New Route" to enter a drafting mode where you can experiment without spending money.
* **Point-and-Click Plotting:** You build routes by clicking on the ground. The system automatically finds the shortest path along the road network between the points you select.
* **Smart Snapping (Ghost Marker):** When hovering the mouse over the map in drafting mode, a transparent "ghost" sphere appears, indicating exactly which road intersection or node the route will snap to if you click.
* **Draft Interaction:**
* **Add Point:** Left-click to extend the route.
* **Move Point:** Click and drag existing yellow markers to adjust the path dynamically.
* **Draft Statistics:** While planning, you see real-time estimates for:
* **Length:** Total distance of the route.
* **Cost:** Construction cost (track/road upgrades) + Fleet cost (buses required).
* **Est. Riders:** Projected daily passengers based on the population density near your stops.
* **Commit or Discard:** You can finalize the route (spending the money) or discard the draft to cancel.
### 2. Route Management
* **Active Route List:** A sidebar lists all currently operating transit lines.
* **Color Customization:** You can click the colored box next to any route number to pick a custom color for that line and its vehicles.
* **Editing:** You can click the "Pencil" icon to edit a route. *Note: This deletes the existing route and puts its nodes back into "Draft Mode" for you to redraw.*
* **Deleting:** You can permanently remove a route to clear clutter (though construction costs are sunk).
### 3. Economy & Simulation
* **Budget System:** You start with a fixed amount of capital ($1,000,000). You must manage construction costs against your remaining funds.
* **Daily Income:** Every in-game "Day," you earn cash based on the total ridership across all your lines (Ticket Sales).
* **Floating Feedback:** When a day passes, floating green text appears over the UI showing exactly how much cash you just earned.
* **Ridership Logic:** Ridership is calculated based on "Synergy"—connecting Residential areas (Population) to Commercial/Industrial areas (Jobs).
* **Public Approval:** A percentage score (0-100%) that tracks how happy the city is. This is calculated based on how many buildings are within walking distance (approx. 600m) of your transit stops.
### 4. Visuals & Map Modes
* **3D City Rendering:** The map features extruded 3D buildings, water bodies, parks, and a road network.
* **Vehicle Simulation:** Small blocky buses travel along your constructed routes in real-time.
* **Data Views:** You can toggle the map visualization to help plan better routes:
* **Standard:** Default visual look.
* **Zoning Density:** Colors buildings by type (Purple for Residential, Blue for Commercial) and intensity (darker colors = higher density/more potential riders).
* **Transit Coverage (Approval):** A heat map showing service coverage. Buildings turn Green if they are close to a stop, Yellow if they are borderline, and Red if they have no transit access.
### 5. System Features
* **Save/Load System:** You can save your current city state (routes, budget, day, approval) to a local JSON file and load it back later to continue playing.
* **UI Toggling:** The entire interface can be hidden/shown via a hamburger menu button for cinematic screenshots.
## For Peer Evaluators
* Recall: each component can be only claimed by one group member
* I created this project alone
* I only claim to complete **user interaction**, including:
* Click to add route nodes
* Drag to edit route nodes
* A* pathfinding updates instantly on node movement
* UI provides immediate feedback on cost and ridership
* Multiple mapping layers to help plan out building routes
* Save/load system
* 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

196
src/GameManager.js Normal file
View File

@@ -0,0 +1,196 @@
export class GameManager {
constructor(routeManager, uiManager) {
this.routeManager = routeManager;
this.uiManager = uiManager;
// Game State
this.budget = 1000000;
this.day = 1;
this.ticketPrice = 2.50;
// Approval
this.approvalRating = 0;
// Config
this.COST_PER_METER = 200;
this.BUS_COST = 50000;
this.gameLoopInterval = null;
// Cache for nodes that have people or jobs
this.censusNodes = [];
}
start() {
this.buildCensusArrays();
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;
this.censusNodes = []; // Clear array
const nodes = this.routeManager.graphData.nodes;
for (const [id, node] of Object.entries(nodes)) {
// Combine Population and Jobs for total "Human Presence"
const totalPeople = (node.pop || 0) + (node.jobs || 0);
if (totalPeople > 0) {
this.censusNodes.push({
id: parseInt(id),
count: totalPeople, // Weighting factor
x: node.x,
z: node.y // Graph Y is World Z
});
}
}
}
recalculateApproval() {
if (!this.censusNodes || this.censusNodes.length === 0) {
this.approvalRating = 0;
return;
}
let totalWeightedScore = 0;
let totalMaxScore = 0; // The score if everyone had 0m walk
// Constants for walking distance
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) {
// 1. Add this building's population/jobs to the potential max score
totalMaxScore += node.count;
// 2. Get walking distance to nearest transit
const dist = this.routeManager.getDistanceToNearestTransit(node.x, node.z);
// 3. Calculate Satisfaction Factor (0.0 to 1.0)
if (dist < MAX_WALK_DIST) {
// Linear falloff from 1.0 (at 50m) to 0.0 (at 600m)
let satisfaction = 1.0 - (Math.max(0, dist - IDEAL_WALK_DIST) / (MAX_WALK_DIST - IDEAL_WALK_DIST));
satisfaction = Math.max(0, satisfaction);
// 4. Add weighted score (Satisfaction * People Count)
totalWeightedScore += (satisfaction * node.count);
}
}
// Approval % = (Actual Weighted Score / Max Possible Weighted Score)
if (totalMaxScore > 0) {
this.approvalRating = Math.floor((totalWeightedScore / totalMaxScore) * 100);
} else {
this.approvalRating = 0;
}
}
processDay() {
this.day++;
const savedRoutes = this.routeManager.getSavedRoutes();
let dailyIncome = 0;
savedRoutes.forEach(route => {
dailyIncome += route.stats.ridership * this.ticketPrice;
});
this.budget += dailyIncome;
if (dailyIncome > 0) {
this.uiManager.showIncomeFeedback(dailyIncome);
}
this.updateUI();
}
getLastKnownRiders() {
const savedRoutes = this.routeManager.getSavedRoutes();
let total = 0;
savedRoutes.forEach(r => total += r.stats.ridership);
return total;
}
getProjectedCost(lengthInMeters) {
const construction = lengthInMeters * this.COST_PER_METER;
const fleet = Math.ceil(lengthInMeters / 800) * this.BUS_COST;
return Math.floor(construction + fleet);
}
canAfford(cost) {
return this.budget >= cost;
}
deductFunds(amount) {
this.budget -= amount;
this.updateUI();
}
updateUI() {
this.uiManager.updateGameStats({
budget: this.budget,
day: this.day,
totalRiders: this.getLastKnownRiders(),
approval: this.approvalRating
});
}
}

View File

@@ -5,20 +5,21 @@ export class InputManager {
this.camera = camera;
this.domElement = domElement;
this.scene = scene;
this.controls = controls; // Need access to controls to disable them during drag
this.controls = controls;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
// Interaction State
this.downPosition = new THREE.Vector2();
this.dragObject = null; // The object currently being dragged (marker)
this.dragObject = null;
this.isPanning = false;
// Callbacks
this.onClick = null; // (point, object) -> void
this.onDrag = null; // (object, newPoint) -> void
this.onDragEnd = null; // () -> void
this.onHover = null; // (point) -> void <-- NEW
}
init() {
@@ -28,20 +29,17 @@ export class InputManager {
}
onPointerDown(event) {
if (event.button !== 0) return; // Left click only
if (event.button !== 0) return;
// Record start position for Pan detection
this.downPosition.set(event.clientX, event.clientY);
this.isPanning = false;
// Raycast to see what we hit (Marker vs Ground)
const hit = this.raycast(event);
if (hit) {
// Case A: We hit a Marker -> Start Dragging
if (hit.object.userData.isMarker) {
this.dragObject = hit.object;
this.controls.enabled = false; // Disable camera orbit
this.controls.enabled = false;
this.domElement.style.cursor = 'grabbing';
}
}
@@ -50,7 +48,6 @@ export class InputManager {
onPointerMove(event) {
// Case A: Dragging a Marker
if (this.dragObject) {
// Raycast against the GROUND to find where we are dragging to
const hit = this.raycastGround(event);
if (hit && this.onDrag) {
this.onDrag(this.dragObject, hit.point);
@@ -58,31 +55,30 @@ export class InputManager {
return;
}
// Case B: Detecting Pan
// If mouse is down and moving, check distance
// (We don't need continuous logic here, just the final check in pointerUp is usually enough,
// but for "floating pointer" later we'd use this.)
// Case B: Hovering (Ghost Marker Logic) <-- NEW
// We only care about hovering the ground for placing new nodes
const hit = this.raycastGround(event);
if (hit && this.onHover) {
this.onHover(hit.point);
}
}
onPointerUp(event) {
if (event.button !== 0) return;
// 1. If we were dragging a marker, stop now.
if (this.dragObject) {
this.dragObject = null;
this.controls.enabled = true; // Re-enable camera
this.controls.enabled = true;
this.domElement.style.cursor = 'auto';
if (this.onDragEnd) this.onDragEnd();
return; // Don't trigger a click
return;
}
// 2. Check if it was a Camera Pan (move > 3px)
const upPosition = new THREE.Vector2(event.clientX, event.clientY);
if (this.downPosition.distanceTo(upPosition) > 3) {
return; // It was a pan, ignore
return;
}
// 3. It was a clean Click (Place new node)
const hit = this.raycast(event);
if (hit && hit.object.name === "GROUND" && this.onClick) {
this.onClick(hit.point, hit.object);
@@ -100,10 +96,12 @@ export class InputManager {
raycast(event) {
this.raycaster.setFromCamera(this.getMouse(event), this.camera);
// Intersection order: Markers (sorted by dist) -> Ground
// Ignore Ghost Marker in standard raycast interaction
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
// Return first valid hit (Marker or Ground)
return intersects.find(obj => obj.object.name === "GROUND" || obj.object.userData.isMarker);
return intersects.find(obj =>
(obj.object.name === "GROUND" || obj.object.userData.isMarker) &&
obj.object.name !== "GHOST_MARKER"
);
}
raycastGround(event) {

View File

@@ -8,189 +8,275 @@ export class RouteManager {
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 = {};
// 1. Flip Coordinates
for (let key in this.graphData.nodes) {
this.graphData.nodes[key].y = -this.graphData.nodes[key].y;
}
// 2. Build Adjacency
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, // Fallback if length missing
edgeIndex: index
});
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
});
this.graphData.adjacency[edge.v].push({ to: edge.u, cost: edge.length || 1, edgeIndex: index, isReverse: true });
}
});
}
// ============================
// API Methods
// Draft Mode & Ghost Marker
// ============================
addNodeByWorldPosition(vector3) {
if (!this.graphData) return;
const nodeId = this.findNearestNode(vector3.x, vector3.z);
if (nodeId === null) return;
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);
}
if (this.currentRouteNodes.length > 0 &&
this.currentRouteNodes[this.currentRouteNodes.length - 1] === nodeId) {
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;
}
this.currentRouteNodes.push(nodeId);
this.addMarkerVisual(nodeId);
this.updatePathVisuals();
}
if (!worldPoint) {
this.ghostMarker.visible = false;
return;
}
dragNode(markerObject, worldPoint) {
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();
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.currentRouteNodes.length < 2 || !this.currentPathMesh) return;
if (!this.isDrafting) return false;
if (this.currentRouteNodes.length < 2 || !this.currentPathMesh) {
alert("Route must have at least 2 points.");
return false;
}
const totalLength = this.currentPathMesh.userData.length || 0;
const length = this.currentPathMesh.userData.length || 0;
const cost = this.gameManager.getProjectedCost(length);
// Freeze mesh color
this.currentPathMesh.material.color.setHex(0x10B981);
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],
length: totalLength,
mesh: this.currentPathMesh
stats: { length, cost, ridership },
mesh: this.currentPathMesh,
color: color
});
this.currentPathMesh = null;
this.resetDraftingState();
// 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;
}
editSavedRoute(index) {
updateRouteColor(index, hexColor) {
if (index < 0 || index >= this.savedRoutes.length) return;
// 1. If we are currently drafting, discard it (or save it automatically? let's discard for simplicity)
this.clearCurrentRoute();
const route = this.savedRoutes[index];
// 2. Load nodes
this.currentRouteNodes = [...route.nodes];
// 3. Remove the saved mesh from scene (we will redraw it as active)
if (route.mesh) {
this.scene.remove(route.mesh);
route.mesh.geometry.dispose();
}
// 4. Remove from saved list
this.savedRoutes.splice(index, 1);
// 5. Restore Visuals (Markers & Path)
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
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(0);
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);
}
getSavedRoutes() {
return this.savedRoutes;
}
// ============================
// Visuals & Logic
// ============================
updatePathVisuals() {
// Need 2+ nodes
if (this.currentRouteNodes.length < 2) {
if (this.currentPathMesh) {
this.scene.remove(this.currentPathMesh);
this.currentPathMesh = null;
}
if (this.onRouteChanged) this.onRouteChanged(0);
return;
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; // Reset Distance
for (let i = 0; i < this.currentRouteNodes.length - 1; i++) {
const start = this.currentRouteNodes[i];
const end = this.currentRouteNodes[i + 1];
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 => {
// --- FIX: Accumulate Distance ---
// If Python didn't send 'length', calculate Euclidean
let dist = step.edgeData.length;
if (!dist) {
const p1 = step.edgeData.points[0];
@@ -198,7 +284,6 @@ export class RouteManager {
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]));
@@ -209,24 +294,108 @@ export class RouteManager {
});
}
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();
}
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();
if (this.onRouteChanged) this.onRouteChanged(totalDist);
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() {
@@ -252,9 +421,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;
@@ -335,3 +530,4 @@ export class RouteManager {
return newPath;
}
}

View File

@@ -1,96 +1,255 @@
export class UIManager {
constructor(routeManager) {
this.routeManager = routeManager;
this.gameManager = null;
// DOM Elements
// Panels
this.panelMain = document.getElementById('ui-main-menu');
this.panelDraft = document.getElementById('ui-draft-menu');
// UI Elements
this.elCurrentLength = document.getElementById('current-length');
this.elCurrentCost = document.getElementById('current-cost');
this.elCurrentRiders = document.getElementById('current-riders');
this.elBudget = document.getElementById('val-budget');
this.elDay = document.getElementById('val-day');
this.elTotalRiders = document.getElementById('val-riders');
this.elApproval = document.getElementById('val-approval');
this.elIncomeFloat = document.getElementById('income-float');
this.elRouteList = document.getElementById('route-list');
this.elContainer = document.getElementById('ui-container');
// Buttons
this.btnCreate = document.getElementById('btn-create-route');
this.btnSave = document.getElementById('btn-save');
this.btnDiscard = document.getElementById('btn-discard');
this.btnToggle = document.getElementById('ui-toggle');
this.btnZoning = document.getElementById('btn-zoning');
// We need a callback to main.js to actually change colors
this.onToggleZoning = null;
// 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;
this.initListeners();
}
initListeners() {
// --- MODE SWITCHING ---
this.btnCreate.addEventListener('click', () => {
this.enterDraftMode();
});
this.btnSave.addEventListener('click', () => {
this.routeManager.saveCurrentRoute();
this.renderRouteList();
this.updateStats(0);
const success = this.routeManager.saveCurrentRoute();
if (success) {
this.renderRouteList();
this.exitDraftMode();
}
});
this.btnDiscard.addEventListener('click', () => {
this.routeManager.clearCurrentRoute();
this.updateStats(0);
this.exitDraftMode();
});
// ----------------------
// Toggle Logic
this.btnToggle.addEventListener('click', () => {
this.elContainer.classList.toggle('hidden');
});
this.btnZoning.addEventListener('click', () => {
const isActive = this.btnZoning.classList.toggle('active');
this.btnZoning.style.background = isActive ? '#4B5563' : ''; // Darken when active
this.btnZoning.style.color = isActive ? 'white' : '';
if (this.onToggleZoning) {
this.onToggleZoning(isActive);
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 = '';
});
}
updateStats(lengthInMeters) {
let text = "";
if (lengthInMeters > 1000) {
text = (lengthInMeters / 1000).toFixed(2) + " km";
} else {
text = Math.round(lengthInMeters) + " m";
}
this.elCurrentLength.textContent = text;
enterDraftMode() {
this.panelMain.style.display = 'none';
this.panelDraft.style.display = 'block';
this.routeManager.startDrafting();
}
exitDraftMode() {
this.panelMain.style.display = 'block';
this.panelDraft.style.display = 'none';
this.routeManager.stopDrafting();
}
updateGameStats(stats) {
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
this.elDay.textContent = stats.day;
this.elTotalRiders.textContent = stats.totalRiders.toLocaleString();
this.elApproval.textContent = stats.approval + "%";
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) {
this.elIncomeFloat.textContent = "+ $" + amount.toLocaleString();
this.elIncomeFloat.style.opacity = 1;
this.elIncomeFloat.style.top = "40px";
setTimeout(() => {
this.elIncomeFloat.style.opacity = 0;
this.elIncomeFloat.style.top = "60px";
}, 2000);
}
updateDraftStats(stats) {
let lenText = stats.length > 1000
? (stats.length / 1000).toFixed(2) + " km"
: Math.round(stats.length) + " m";
this.elCurrentLength.textContent = lenText;
this.elCurrentCost.textContent = "$" + stats.cost.toLocaleString();
this.elCurrentRiders.textContent = stats.ridership.toLocaleString() + " / day";
}
renderRouteList() {
this.elRouteList.innerHTML = '';
const routes = this.routeManager.getSavedRoutes();
if (routes.length === 0) {
this.elRouteList.innerHTML = '<li style="color:#999; text-align:center; font-style:italic; padding:10px;">No active routes.<br>Click create to build one.</li>';
return;
}
routes.forEach((route, index) => {
const li = document.createElement('li');
li.style.display = 'flex';
li.style.alignItems = 'center';
li.style.justifyContent = 'space-between';
li.style.padding = '8px 0';
li.style.borderBottom = '1px solid #eee';
let lenStr = route.length > 1000
? (route.length / 1000).toFixed(2) + " km"
: Math.round(route.length) + " m";
// --- BADGE CONTAINER ---
const badgeContainer = document.createElement('div');
badgeContainer.style.position = 'relative';
badgeContainer.style.width = '28px';
badgeContainer.style.height = '28px';
badgeContainer.style.marginRight = '10px';
// Create Label
const span = document.createElement('span');
span.innerHTML = `<strong>Route ${index + 1}</strong> (${lenStr})`;
li.appendChild(span);
const badge = document.createElement('div');
badge.textContent = (index + 1);
badge.style.width = '100%';
badge.style.height = '100%';
badge.style.backgroundColor = route.color;
badge.style.color = '#fff';
badge.style.fontWeight = 'bold';
badge.style.display = 'flex';
badge.style.alignItems = 'center';
badge.style.justifyContent = 'center';
badge.style.borderRadius = '4px';
badge.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
badge.style.textShadow = '0 1px 2px rgba(0,0,0,0.5)';
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = route.color || "#000000";
colorInput.style.position = 'absolute';
colorInput.style.top = '0';
colorInput.style.left = '0';
colorInput.style.width = '100%';
colorInput.style.height = '100%';
colorInput.style.opacity = '0';
colorInput.style.cursor = 'pointer';
colorInput.style.border = 'none';
colorInput.style.padding = '0';
colorInput.addEventListener('input', (e) => {
const newColor = e.target.value;
badge.style.backgroundColor = newColor;
this.routeManager.updateRouteColor(index, newColor);
});
badgeContainer.appendChild(badge);
badgeContainer.appendChild(colorInput);
li.appendChild(badgeContainer);
// --- ROUTE INFO ---
let lenStr = route.stats.length > 1000
? (route.stats.length / 1000).toFixed(1) + "km"
: Math.round(route.stats.length) + "m";
const infoDiv = document.createElement('div');
infoDiv.style.flex = '1';
infoDiv.style.display = 'flex';
infoDiv.style.flexDirection = 'column';
infoDiv.innerHTML = `
<span style="font-size:12px; font-weight:600; color:#333;">Line ${index + 1}</span>
<span style="font-size:11px; color:#666;">${lenStr} | ${route.stats.ridership} riders</span>
`;
// --- BUTTONS ---
const btnDiv = document.createElement('div');
btnDiv.style.display = 'flex';
btnDiv.style.gap = '4px';
// Edit Button
const btnEdit = document.createElement('button');
btnEdit.textContent = "Edit";
btnEdit.className = "btn-icon btn-edit";
btnEdit.textContent = "";
btnEdit.className = "btn-icon";
btnEdit.title = "Redraw Route";
btnEdit.style.padding = "4px 8px";
btnEdit.onclick = () => {
// --- FIX IS HERE ---
// 1. Enter draft mode first (this resets the drafting state)
this.enterDraftMode();
// 2. Load the route data (this populates the drafting state)
this.routeManager.editSavedRoute(index);
this.renderRouteList(); // Re-render to remove it from list
};
li.appendChild(btnEdit);
// Delete Button
const btnDel = document.createElement('button');
btnDel.textContent = "✕";
btnDel.className = "btn-icon btn-del";
btnDel.className = "btn-icon";
btnDel.title = "Delete Route";
btnDel.style.color = "#ef4444";
btnDel.style.padding = "4px 8px";
btnDel.onclick = () => {
this.routeManager.deleteSavedRoute(index);
this.renderRouteList();
if (confirm("Delete this route?")) {
this.routeManager.deleteSavedRoute(index);
this.renderRouteList();
}
};
li.appendChild(btnDel);
btnDiv.appendChild(btnEdit);
btnDiv.appendChild(btnDel);
li.appendChild(infoDiv);
li.appendChild(btnDiv);
this.elRouteList.appendChild(li);
});

109
src/VehicleSystem.js Normal file
View File

@@ -0,0 +1,109 @@
import * as THREE from 'three';
export class VehicleSystem {
constructor(scene) {
this.scene = scene;
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.baseBusMat = new THREE.MeshStandardMaterial({
color: 0xF59E0B,
emissive: 0xB45309,
emissiveIntensity: 0.2,
roughness: 0.2
});
}
addBusToRoute(routePathPoints, colorStr, routeIndex) {
if (!routePathPoints || routePathPoints.length < 2) return;
const points = routePathPoints.map(p => p.clone());
// 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
let totalLen = 0;
const dists = [0];
for (let i = 0; i < points.length - 1; i++) {
const d = points[i].distanceTo(points[i + 1]);
totalLen += d;
dists.push(totalLen);
}
this.buses.push({
mesh: mesh,
points: points,
dists: dists,
totalLen: totalLen,
currentDist: 0,
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 => {
bus.currentDist += bus.speed * deltaTime * bus.direction;
if (bus.currentDist >= bus.totalLen) {
bus.currentDist = bus.totalLen;
bus.direction = -1;
} else if (bus.currentDist <= 0) {
bus.currentDist = 0;
bus.direction = 1;
}
let i = 0;
while (i < bus.dists.length - 2 && bus.currentDist > bus.dists[i + 1]) {
i++;
}
const startDist = bus.dists[i];
const endDist = bus.dists[i + 1];
const segmentLen = endDist - startDist;
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);
const lookTarget = bus.direction === 1 ? pEnd : pStart;
bus.mesh.lookAt(lookTarget);
});
}
}

View File

@@ -5,7 +5,8 @@ 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
@@ -16,6 +17,8 @@ const SETTINGS = {
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,
@@ -31,77 +34,131 @@ const SETTINGS = {
};
let scene, camera, renderer, controls;
let inputManager, routeManager, uiManager;
let inputManager, routeManager, uiManager, gameManager, vehicleSystem;
const clock = new THREE.Clock();
let currentViewMode = 'none'; // 'none', 'zoning', 'approval'
function init() {
setupScene();
// 1. Managers
// 1. Core Systems
routeManager = new RouteManager(scene, SETTINGS);
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
uiManager = new UIManager(routeManager); // Wire UI to Route Logic
uiManager = new UIManager(routeManager);
// 2. Events
// 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) => {
if (object.name === "GROUND") {
routeManager.addNodeByWorldPosition(point);
}
// Only allow adding nodes if we are actually in drafting mode
// (RouteManager handles the check internally, but we pass the intent)
if (object.name === "GROUND") routeManager.addNodeByWorldPosition(point);
};
// Wiring Drag
inputManager.onDrag = (markerObject, newPoint) => {
routeManager.dragNode(markerObject, newPoint);
};
// Wire RouteManager back to UI (to update stats when dragging)
routeManager.onRouteChanged = (dist) => {
uiManager.updateStats(dist);
// Wiring Hover (NEW)
inputManager.onHover = (point) => {
routeManager.updateGhostMarker(point);
};
uiManager.onToggleZoning = (isActive) => {
updateBuildingColors(isActive);
// Wire UI View Mode
uiManager.onViewModeChanged = (mode) => {
currentViewMode = mode;
updateBuildingColors();
};
// 3. Data Load
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]) => {
console.log("Data loaded.");
renderCity(visual);
routeManager.initGraph(routing);
renderCity(visual);
gameManager.start();
});
animate();
}
function updateBuildingColors(showZoning) {
function updateBuildingColors() {
scene.traverse((obj) => {
// We tagged buildings with userData in renderCity (see below)
if (obj.name === 'BUILDING_MESH') {
if (!showZoning) {
// Revert to white
obj.material.color.setHex(SETTINGS.colors.building.getHex());
return;
}
// Get Data
const data = obj.userData.cityData; // We need to ensure we save this during creation
const data = obj.userData.cityData;
if (!data) return;
if (data.type === 'residential') {
// Lerp from White to Purple based on density
const color = SETTINGS.colors.building.clone();
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
obj.material.color.copy(color);
// 1. STANDARD VIEW
if (currentViewMode === 'none') {
obj.material.color.copy(SETTINGS.colors.building);
}
else if (data.type === 'commercial') {
// Lerp from White to Blue
const color = SETTINGS.colors.building.clone();
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
obj.material.color.copy(color);
// 2. ZONING VIEW
else if (currentViewMode === 'zoning') {
if (data.type === 'residential') {
const color = SETTINGS.colors.building.clone();
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
obj.material.color.copy(color);
} else if (data.type === 'commercial') {
const color = SETTINGS.colors.building.clone();
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
obj.material.color.copy(color);
} else {
obj.material.color.copy(SETTINGS.colors.building);
}
}
// 3. APPROVAL / COVERAGE VIEW (GRADIENT)
else if (currentViewMode === 'approval') {
// Get graph node position
const nearestId = obj.userData.nearestNodeId;
// RouteManager has logic for this
const node = routeManager.graphData.nodes[nearestId];
if (node) {
// Calculate distance to nearest transit
// node.y is Z in world space
const dist = routeManager.getDistanceToNearestTransit(node.x, node.y);
// Color Logic:
// < 100m = Green (Great)
// < 300m = Yellow (Okay)
// > 600m = Red (Bad)
if (dist === Infinity) {
obj.material.color.copy(SETTINGS.colors.coverageBad); // Deep Red
} else {
const MAX_DIST = 600;
const factor = Math.min(1.0, dist / MAX_DIST); // 0.0 (Close) to 1.0 (Far)
// Lerp from Green to Red
// (Green at 0, Red at 1)
const color = SETTINGS.colors.coverageGood.clone();
// We can lerp to Red.
// Or use a Yellow midpoint?
// Simple lerp: Green -> Red
color.lerp(SETTINGS.colors.coverageBad, factor);
obj.material.color.copy(color);
}
}
}
}
});
@@ -210,63 +267,69 @@ function renderCity(data) {
scene.add(mesh);
};
createBuildingLayer(data.buildings);
// Dedicated Building Creator to cache Nearest Node ID
const createBuildingLayer = (buildings) => {
if (!buildings || !buildings.length) return;
const mat = new THREE.MeshStandardMaterial({
color: SETTINGS.colors.building,
roughness: 0.6,
side: THREE.DoubleSide,
shadowSide: THREE.DoubleSide
});
buildings.forEach(b => {
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);
});
}
const geom = new THREE.ExtrudeGeometry(shape, { depth: b.height, bevelEnabled: false });
geom.rotateX(-Math.PI / 2);
const mesh = new THREE.Mesh(geom, mat.clone());
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = 'BUILDING_MESH';
mesh.userData.cityData = b.data;
// CALCULATE NEAREST NODE FOR APPROVAL MECHANIC
// We use the first point of the outer ring as a proxy for position
const bx = b.shape.outer[0][0];
const by = b.shape.outer[0][1];
const nearestId = routeManager.findNearestNode(bx, -by);
mesh.userData.nearestNodeId = nearestId;
scene.add(mesh);
});
};
createBuildingLayer(data.buildings);
createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false);
}
function createBuildingLayer(buildings) {
if (!buildings || !buildings.length) return;
const mat = new THREE.MeshStandardMaterial({
color: SETTINGS.colors.building,
roughness: 0.6,
side: THREE.DoubleSide,
shadowSide: THREE.DoubleSide
});
buildings.forEach(b => {
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);
});
}
const geom = new THREE.ExtrudeGeometry(shape, {
depth: b.height,
bevelEnabled: false
});
geom.rotateX(-Math.PI / 2);
const mesh = new THREE.Mesh(geom, mat.clone());
mesh.castShadow = true;
mesh.receiveShadow = true;
// Store metadata for the Zoning Toggle
mesh.name = 'BUILDING_MESH';
mesh.userData.cityData = b.data;
scene.add(mesh);
});
}
function animate() {
requestAnimationFrame(animate);
const delta = clock.getDelta(); // Get time since last frame
controls.update();
if (vehicleSystem) {
vehicleSystem.update(delta);
}
renderer.render(scene, camera);
}