Compare commits
11 Commits
a3e057ef37
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbb0919b42 | ||
|
|
2edbb35236 | ||
|
|
b760760be5 | ||
|
|
459fe8cd6c | ||
|
|
e0be371bbc | ||
|
|
04167c656a | ||
|
|
f3cb583836 | ||
|
|
8fc551ad68 | ||
|
|
e501a2c480 | ||
|
|
dc0659b1df | ||
|
|
68ca45f58e |
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -15,6 +15,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
|
if: ${{ github.server_url == 'https://github.com' }}
|
||||||
environment:
|
environment:
|
||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import json
|
|||||||
import os
|
import os
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
from shapely.ops import unary_union
|
from shapely.ops import unary_union
|
||||||
from shapely.geometry import Polygon, MultiPolygon
|
|
||||||
from scipy.spatial import cKDTree
|
from scipy.spatial import cKDTree
|
||||||
import re
|
import re
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 1. Configuration
|
# 1. Configuration
|
||||||
@@ -41,7 +41,6 @@ JOB_DENSITY_FACTOR = 0.08 # Jobs per m3 (Commercial)
|
|||||||
# 2. Helpers
|
# 2. Helpers
|
||||||
# ==========================================
|
# ==========================================
|
||||||
def get_height(row):
|
def get_height(row):
|
||||||
"""Estimates building height."""
|
|
||||||
h = 8.0
|
h = 8.0
|
||||||
if "height" in row and str(row["height"]).lower() != "nan":
|
if "height" in row and str(row["height"]).lower() != "nan":
|
||||||
try:
|
try:
|
||||||
@@ -63,7 +62,6 @@ def get_height(row):
|
|||||||
|
|
||||||
|
|
||||||
def estimate_road_width(row):
|
def estimate_road_width(row):
|
||||||
"""Estimates width with US-unit safety checks."""
|
|
||||||
for key in ["width", "width:carriageway", "est_width"]:
|
for key in ["width", "width:carriageway", "est_width"]:
|
||||||
if key in row and str(row[key]) != "nan":
|
if key in row and str(row[key]) != "nan":
|
||||||
val_str = str(row[key]).lower()
|
val_str = str(row[key]).lower()
|
||||||
@@ -96,10 +94,6 @@ def estimate_road_width(row):
|
|||||||
|
|
||||||
|
|
||||||
def classify_building(row, height, area):
|
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()
|
b_type = str(row.get("building", "yes")).lower()
|
||||||
amenity = str(row.get("amenity", "")).lower()
|
amenity = str(row.get("amenity", "")).lower()
|
||||||
office = str(row.get("office", "")).lower()
|
office = str(row.get("office", "")).lower()
|
||||||
@@ -107,7 +101,6 @@ def classify_building(row, height, area):
|
|||||||
|
|
||||||
volume = area * height
|
volume = area * height
|
||||||
|
|
||||||
# Lists of tags
|
|
||||||
residential_tags = [
|
residential_tags = [
|
||||||
"apartments",
|
"apartments",
|
||||||
"residential",
|
"residential",
|
||||||
@@ -136,9 +129,7 @@ def classify_building(row, height, area):
|
|||||||
or (shop != "nan" and shop != "")
|
or (shop != "nan" and shop != "")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Default logic if generic "yes"
|
|
||||||
if not is_res and not is_com:
|
if not is_res and not is_com:
|
||||||
# Small buildings likely houses, big generic likely commercial in city center
|
|
||||||
if volume > 5000:
|
if volume > 5000:
|
||||||
is_com = True
|
is_com = True
|
||||||
else:
|
else:
|
||||||
@@ -152,11 +143,11 @@ def classify_building(row, height, area):
|
|||||||
if is_res:
|
if is_res:
|
||||||
pop = round(volume * POP_DENSITY_FACTOR)
|
pop = round(volume * POP_DENSITY_FACTOR)
|
||||||
category = "residential"
|
category = "residential"
|
||||||
density_score = min(1.0, pop / 500) # Normalize for color (0-1)
|
density_score = min(1.0, pop / 500)
|
||||||
elif is_com:
|
elif is_com:
|
||||||
jobs = round(volume * JOB_DENSITY_FACTOR)
|
jobs = round(volume * JOB_DENSITY_FACTOR)
|
||||||
category = "commercial"
|
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
|
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}...")
|
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 = {
|
tags_visual = {
|
||||||
"building": True,
|
"building": True,
|
||||||
"natural": ["water", "bay"],
|
"natural": NATURAL_TAGS,
|
||||||
"leisure": ["park", "garden"],
|
"leisure": PARK_TAGS_LEISURE,
|
||||||
"landuse": ["grass", "forest", "park"],
|
"landuse": PARK_TAGS_LANDUSE,
|
||||||
"amenity": True, # Fetch amenities to help classify jobs
|
"amenity": True, # Needed for zoning, but MUST be filtered out of geometry
|
||||||
"office": True,
|
"office": True,
|
||||||
"shop": True,
|
"shop": True,
|
||||||
}
|
}
|
||||||
@@ -228,23 +239,26 @@ center_y = gdf_visual.geometry.centroid.y.mean()
|
|||||||
output_visual = {"buildings": [], "water": [], "parks": [], "roads": []}
|
output_visual = {"buildings": [], "water": [], "parks": [], "roads": []}
|
||||||
output_routing = {"nodes": {}, "edges": []}
|
output_routing = {"nodes": {}, "edges": []}
|
||||||
|
|
||||||
# We will store building data to map it to graph nodes later
|
building_data_points = []
|
||||||
building_data_points = [] # (x, y, pop, jobs)
|
|
||||||
|
|
||||||
print("3. Processing Visual Layers & Census Simulation...")
|
print("3. Processing Visual Layers & Census Simulation...")
|
||||||
|
|
||||||
for idx, row in gdf_visual.iterrows():
|
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)
|
polygons = parse_geometry(row.geometry, center_x, center_y)
|
||||||
|
|
||||||
for poly_data in polygons:
|
for poly_data in polygons:
|
||||||
# 1. Buildings (With Zoning Logic)
|
# -----------------------------
|
||||||
|
# 1. BUILDINGS
|
||||||
|
# -----------------------------
|
||||||
if "building" in row and str(row["building"]) != "nan":
|
if "building" in row and str(row["building"]) != "nan":
|
||||||
height = get_height(row)
|
height = get_height(row)
|
||||||
area = row.geometry.area
|
area = row.geometry.area
|
||||||
|
|
||||||
# Zoning / Census Simulation
|
|
||||||
cat, score, pop, jobs = classify_building(row, height, area)
|
cat, score, pop, jobs = classify_building(row, height, area)
|
||||||
|
|
||||||
# Store centroid for graph mapping
|
|
||||||
cx = row.geometry.centroid.x - center_x
|
cx = row.geometry.centroid.x - center_x
|
||||||
cy = row.geometry.centroid.y - center_y
|
cy = row.geometry.centroid.y - center_y
|
||||||
building_data_points.append([cx, cy, pop, jobs])
|
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"
|
"water" in row and str(row["water"]) != "nan"
|
||||||
):
|
):
|
||||||
output_visual["water"].append({"shape": poly_data})
|
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})
|
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...")
|
print(" Buffering roads...")
|
||||||
road_polys = []
|
road_polys = []
|
||||||
for idx, row in gdf_edges.iterrows():
|
for idx, row in gdf_edges.iterrows():
|
||||||
@@ -281,21 +308,18 @@ if road_polys:
|
|||||||
output_visual["roads"].append({"shape": shape})
|
output_visual["roads"].append({"shape": shape})
|
||||||
|
|
||||||
print("4. Mapping Census Data to Graph Nodes...")
|
print("4. Mapping Census Data to Graph Nodes...")
|
||||||
# Create a KDTree of building centroids
|
|
||||||
if building_data_points:
|
if building_data_points:
|
||||||
b_coords = np.array([[b[0], b[1]] for b in 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)
|
tree = cKDTree(b_coords)
|
||||||
|
|
||||||
for node_id, row in gdf_nodes.iterrows():
|
for node_id, row in gdf_nodes.iterrows():
|
||||||
nx = row.geometry.x - center_x
|
nx = row.geometry.x - center_x
|
||||||
ny = row.geometry.y - center_y
|
ny = row.geometry.y - center_y
|
||||||
|
|
||||||
# Find all buildings within 100m of this node
|
|
||||||
if building_data_points:
|
if building_data_points:
|
||||||
indices = tree.query_ball_point([nx, ny], r=100)
|
indices = tree.query_ball_point([nx, ny], r=100)
|
||||||
if indices:
|
if indices:
|
||||||
# Sum pop/jobs of nearby buildings
|
|
||||||
nearby_stats = np.sum(b_data[indices], axis=0)
|
nearby_stats = np.sum(b_data[indices], axis=0)
|
||||||
node_pop = int(nearby_stats[0])
|
node_pop = int(nearby_stats[0])
|
||||||
node_jobs = int(nearby_stats[1])
|
node_jobs = int(nearby_stats[1])
|
||||||
@@ -307,7 +331,7 @@ for node_id, row in gdf_nodes.iterrows():
|
|||||||
output_routing["nodes"][int(node_id)] = {
|
output_routing["nodes"][int(node_id)] = {
|
||||||
"x": round(nx, 2),
|
"x": round(nx, 2),
|
||||||
"y": round(ny, 2),
|
"y": round(ny, 2),
|
||||||
"pop": node_pop, # Store for gameplay later
|
"pop": node_pop,
|
||||||
"jobs": node_jobs,
|
"jobs": node_jobs,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
84
index.html
84
index.html
@@ -9,34 +9,84 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<!-- The UI Overlay -->
|
|
||||||
<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 -->
|
||||||
|
<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 © <a href="https://www.openstreetmap.org/copyright" target="_blank"
|
||||||
|
style="text-decoration: none; color: #333;">OpenStreetMap</a> contributors
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div id="ui-container">
|
<div id="ui-container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h2>Route Planner</h2>
|
<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>
|
||||||
|
|
||||||
<div class="section">
|
<div id="ui-main-menu">
|
||||||
<h3>Current Draft</h3>
|
<div class="section">
|
||||||
<div class="stat-row">
|
<button id="btn-create-route" class="primary"
|
||||||
<span>Length:</span>
|
style="width:100%; padding: 12px; margin-bottom: 15px; font-weight:bold;">
|
||||||
<span id="current-length">0 m</span>
|
+ Create New Route
|
||||||
</div>
|
</button>
|
||||||
<div class="button-row">
|
<h3>Active Routes</h3>
|
||||||
<button id="btn-save" class="primary">Save Route</button>
|
<ul id="route-list"></ul>
|
||||||
<button id="btn-discard" class="danger">Discard</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<!-- DRAFTING STATE (Hidden by default) -->
|
||||||
<h3>Saved Routes</h3>
|
<div id="ui-draft-menu" style="display:none;">
|
||||||
<ul id="route-list">
|
<div class="section" style="border: 2px solid #2563EB; padding: 10px; border-radius: 6px; background: #eff6ff;">
|
||||||
<!-- List items will be injected here -->
|
<h3 style="margin-top:0; color:#2563EB;">Route Planner</h3>
|
||||||
</ul>
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
|||||||
63
presentation.md
Normal file
63
presentation.md
Normal 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
196
src/GameManager.js
Normal 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,21 @@ export class InputManager {
|
|||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
this.domElement = domElement;
|
this.domElement = domElement;
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.controls = controls; // Need access to controls to disable them during drag
|
this.controls = controls;
|
||||||
|
|
||||||
this.raycaster = new THREE.Raycaster();
|
this.raycaster = new THREE.Raycaster();
|
||||||
this.mouse = new THREE.Vector2();
|
this.mouse = new THREE.Vector2();
|
||||||
|
|
||||||
// Interaction State
|
// Interaction State
|
||||||
this.downPosition = new THREE.Vector2();
|
this.downPosition = new THREE.Vector2();
|
||||||
this.dragObject = null; // The object currently being dragged (marker)
|
this.dragObject = null;
|
||||||
this.isPanning = false;
|
this.isPanning = false;
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
this.onClick = null; // (point, object) -> void
|
this.onClick = null; // (point, object) -> void
|
||||||
this.onDrag = null; // (object, newPoint) -> void
|
this.onDrag = null; // (object, newPoint) -> void
|
||||||
this.onDragEnd = null; // () -> void
|
this.onDragEnd = null; // () -> void
|
||||||
|
this.onHover = null; // (point) -> void <-- NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -28,20 +29,17 @@ export class InputManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onPointerDown(event) {
|
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.downPosition.set(event.clientX, event.clientY);
|
||||||
this.isPanning = false;
|
this.isPanning = false;
|
||||||
|
|
||||||
// Raycast to see what we hit (Marker vs Ground)
|
|
||||||
const hit = this.raycast(event);
|
const hit = this.raycast(event);
|
||||||
|
|
||||||
if (hit) {
|
if (hit) {
|
||||||
// Case A: We hit a Marker -> Start Dragging
|
|
||||||
if (hit.object.userData.isMarker) {
|
if (hit.object.userData.isMarker) {
|
||||||
this.dragObject = hit.object;
|
this.dragObject = hit.object;
|
||||||
this.controls.enabled = false; // Disable camera orbit
|
this.controls.enabled = false;
|
||||||
this.domElement.style.cursor = 'grabbing';
|
this.domElement.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +48,6 @@ export class InputManager {
|
|||||||
onPointerMove(event) {
|
onPointerMove(event) {
|
||||||
// Case A: Dragging a Marker
|
// Case A: Dragging a Marker
|
||||||
if (this.dragObject) {
|
if (this.dragObject) {
|
||||||
// Raycast against the GROUND to find where we are dragging to
|
|
||||||
const hit = this.raycastGround(event);
|
const hit = this.raycastGround(event);
|
||||||
if (hit && this.onDrag) {
|
if (hit && this.onDrag) {
|
||||||
this.onDrag(this.dragObject, hit.point);
|
this.onDrag(this.dragObject, hit.point);
|
||||||
@@ -58,31 +55,30 @@ export class InputManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case B: Detecting Pan
|
// Case B: Hovering (Ghost Marker Logic) <-- NEW
|
||||||
// If mouse is down and moving, check distance
|
// We only care about hovering the ground for placing new nodes
|
||||||
// (We don't need continuous logic here, just the final check in pointerUp is usually enough,
|
const hit = this.raycastGround(event);
|
||||||
// but for "floating pointer" later we'd use this.)
|
if (hit && this.onHover) {
|
||||||
|
this.onHover(hit.point);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPointerUp(event) {
|
onPointerUp(event) {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
|
|
||||||
// 1. If we were dragging a marker, stop now.
|
|
||||||
if (this.dragObject) {
|
if (this.dragObject) {
|
||||||
this.dragObject = null;
|
this.dragObject = null;
|
||||||
this.controls.enabled = true; // Re-enable camera
|
this.controls.enabled = true;
|
||||||
this.domElement.style.cursor = 'auto';
|
this.domElement.style.cursor = 'auto';
|
||||||
if (this.onDragEnd) this.onDragEnd();
|
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);
|
const upPosition = new THREE.Vector2(event.clientX, event.clientY);
|
||||||
if (this.downPosition.distanceTo(upPosition) > 3) {
|
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);
|
const hit = this.raycast(event);
|
||||||
if (hit && hit.object.name === "GROUND" && this.onClick) {
|
if (hit && hit.object.name === "GROUND" && this.onClick) {
|
||||||
this.onClick(hit.point, hit.object);
|
this.onClick(hit.point, hit.object);
|
||||||
@@ -100,10 +96,12 @@ export class InputManager {
|
|||||||
|
|
||||||
raycast(event) {
|
raycast(event) {
|
||||||
this.raycaster.setFromCamera(this.getMouse(event), this.camera);
|
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);
|
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
|
||||||
// Return first valid hit (Marker or Ground)
|
return intersects.find(obj =>
|
||||||
return intersects.find(obj => obj.object.name === "GROUND" || obj.object.userData.isMarker);
|
(obj.object.name === "GROUND" || obj.object.userData.isMarker) &&
|
||||||
|
obj.object.name !== "GHOST_MARKER"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
raycastGround(event) {
|
raycastGround(event) {
|
||||||
|
|||||||
@@ -8,189 +8,275 @@ export class RouteManager {
|
|||||||
this.graphData = null;
|
this.graphData = null;
|
||||||
|
|
||||||
// -- State --
|
// -- State --
|
||||||
|
this.isDrafting = false; // New flag
|
||||||
this.currentRouteNodes = [];
|
this.currentRouteNodes = [];
|
||||||
this.savedRoutes = [];
|
this.savedRoutes = [];
|
||||||
|
|
||||||
// -- Visuals --
|
// -- Visuals --
|
||||||
this.markers = [];
|
this.markers = [];
|
||||||
this.currentPathMesh = null;
|
this.currentPathMesh = null;
|
||||||
|
this.ghostMarker = null; // Transparent sphere
|
||||||
|
|
||||||
|
this.servedNodes = new Set();
|
||||||
|
this.servedCoordinates = [];
|
||||||
this.ROAD_OFFSET = 2.5;
|
this.ROAD_OFFSET = 2.5;
|
||||||
|
|
||||||
this.onRouteChanged = null;
|
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) {
|
initGraph(data) {
|
||||||
this.graphData = data;
|
this.graphData = data;
|
||||||
this.graphData.adjacency = {};
|
this.graphData.adjacency = {};
|
||||||
|
|
||||||
// 1. Flip Coordinates
|
|
||||||
for (let key in this.graphData.nodes) {
|
for (let key in this.graphData.nodes) {
|
||||||
this.graphData.nodes[key].y = -this.graphData.nodes[key].y;
|
this.graphData.nodes[key].y = -this.graphData.nodes[key].y;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Build Adjacency
|
|
||||||
this.graphData.edges.forEach((edge, index) => {
|
this.graphData.edges.forEach((edge, index) => {
|
||||||
if (edge.points) edge.points.forEach(p => { p[1] = -p[1]; });
|
if (edge.points) edge.points.forEach(p => { p[1] = -p[1]; });
|
||||||
|
|
||||||
if (!this.graphData.adjacency[edge.u]) this.graphData.adjacency[edge.u] = [];
|
if (!this.graphData.adjacency[edge.u]) this.graphData.adjacency[edge.u] = [];
|
||||||
this.graphData.adjacency[edge.u].push({
|
this.graphData.adjacency[edge.u].push({ to: edge.v, cost: edge.length || 1, edgeIndex: index });
|
||||||
to: edge.v,
|
|
||||||
cost: edge.length || 1, // Fallback if length missing
|
|
||||||
edgeIndex: index
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!edge.oneway) {
|
if (!edge.oneway) {
|
||||||
if (!this.graphData.adjacency[edge.v]) this.graphData.adjacency[edge.v] = [];
|
if (!this.graphData.adjacency[edge.v]) this.graphData.adjacency[edge.v] = [];
|
||||||
this.graphData.adjacency[edge.v].push({
|
this.graphData.adjacency[edge.v].push({ to: edge.u, cost: edge.length || 1, edgeIndex: index, isReverse: true });
|
||||||
to: edge.u,
|
|
||||||
cost: edge.length || 1,
|
|
||||||
edgeIndex: index,
|
|
||||||
isReverse: true
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// API Methods
|
// Draft Mode & Ghost Marker
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
addNodeByWorldPosition(vector3) {
|
initGhostMarker() {
|
||||||
if (!this.graphData) return;
|
const geom = new THREE.SphereGeometry(4);
|
||||||
const nodeId = this.findNearestNode(vector3.x, vector3.z);
|
const mat = new THREE.MeshBasicMaterial({
|
||||||
if (nodeId === null) return;
|
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 &&
|
startDrafting() {
|
||||||
this.currentRouteNodes[this.currentRouteNodes.length - 1] === nodeId) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.currentRouteNodes.push(nodeId);
|
if (!worldPoint) {
|
||||||
this.addMarkerVisual(nodeId);
|
this.ghostMarker.visible = false;
|
||||||
this.updatePathVisuals();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dragNode(markerObject, worldPoint) {
|
const nodeId = this.findNearestNode(worldPoint.x, worldPoint.z);
|
||||||
if (!this.graphData) return;
|
if (nodeId !== null) {
|
||||||
const index = this.markers.indexOf(markerObject);
|
const node = this.graphData.nodes[nodeId];
|
||||||
if (index === -1) return;
|
this.ghostMarker.position.set(node.x, 2, node.y);
|
||||||
|
this.ghostMarker.visible = true;
|
||||||
const newNodeId = this.findNearestNode(worldPoint.x, worldPoint.z);
|
} else {
|
||||||
|
this.ghostMarker.visible = false;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================
|
||||||
|
// 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() {
|
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
|
if (!this.gameManager.canAfford(cost)) {
|
||||||
this.currentPathMesh.material.color.setHex(0x10B981);
|
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({
|
this.savedRoutes.push({
|
||||||
nodes: [...this.currentRouteNodes],
|
nodes: [...this.currentRouteNodes],
|
||||||
length: totalLength,
|
stats: { length, cost, ridership },
|
||||||
mesh: this.currentPathMesh
|
mesh: this.currentPathMesh,
|
||||||
|
color: color
|
||||||
});
|
});
|
||||||
|
|
||||||
this.currentPathMesh = null;
|
// We do NOT call stopDrafting here, UIManager handles the logic to call stopDrafting
|
||||||
this.resetDraftingState();
|
// 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;
|
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];
|
const route = this.savedRoutes[index];
|
||||||
|
route.color = hexColor;
|
||||||
// 2. Load nodes
|
if (route.mesh) route.mesh.material.color.set(hexColor);
|
||||||
this.currentRouteNodes = [...route.nodes];
|
if (this.vehicleSystem) this.vehicleSystem.updateRouteColor(index, hexColor);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSavedRoute(index) {
|
deleteSavedRoute(index) {
|
||||||
if (index < 0 || index >= this.savedRoutes.length) return;
|
if (index < 0 || index >= this.savedRoutes.length) return;
|
||||||
|
|
||||||
const route = this.savedRoutes[index];
|
const route = this.savedRoutes[index];
|
||||||
if (route.mesh) {
|
if (route.mesh) {
|
||||||
this.scene.remove(route.mesh);
|
this.scene.remove(route.mesh);
|
||||||
route.mesh.geometry.dispose();
|
route.mesh.geometry.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.savedRoutes.splice(index, 1);
|
this.savedRoutes.splice(index, 1);
|
||||||
}
|
|
||||||
|
|
||||||
getSavedRoutes() {
|
if (this.vehicleSystem) {
|
||||||
return this.savedRoutes;
|
this.vehicleSystem.clearVehicles();
|
||||||
}
|
this.savedRoutes.forEach((r, idx) => {
|
||||||
|
const pathRes = this.calculateGeometryFromNodes(r.nodes);
|
||||||
// ============================
|
if (pathRes && pathRes.points.length > 0) {
|
||||||
// Visuals & Logic
|
this.vehicleSystem.addBusToRoute(pathRes.points, r.color, idx);
|
||||||
// ============================
|
}
|
||||||
|
});
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 fullPathPoints = [];
|
||||||
let totalDist = 0; // Reset Distance
|
let totalDist = 0;
|
||||||
|
|
||||||
for (let i = 0; i < this.currentRouteNodes.length - 1; i++) {
|
|
||||||
const start = this.currentRouteNodes[i];
|
|
||||||
const end = this.currentRouteNodes[i + 1];
|
|
||||||
|
|
||||||
|
for (let i = 0; i < nodeList.length - 1; i++) {
|
||||||
|
const start = nodeList[i];
|
||||||
|
const end = nodeList[i + 1];
|
||||||
const segmentEdges = this.computePathAStar(start, end);
|
const segmentEdges = this.computePathAStar(start, end);
|
||||||
|
|
||||||
if (!segmentEdges) continue;
|
if (!segmentEdges) continue;
|
||||||
|
|
||||||
segmentEdges.forEach(step => {
|
segmentEdges.forEach(step => {
|
||||||
// --- FIX: Accumulate Distance ---
|
|
||||||
// If Python didn't send 'length', calculate Euclidean
|
|
||||||
let dist = step.edgeData.length;
|
let dist = step.edgeData.length;
|
||||||
if (!dist) {
|
if (!dist) {
|
||||||
const p1 = step.edgeData.points[0];
|
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);
|
dist = Math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2);
|
||||||
}
|
}
|
||||||
totalDist += dist;
|
totalDist += dist;
|
||||||
// --------------------------------
|
|
||||||
|
|
||||||
const rawPoints = step.edgeData.points;
|
const rawPoints = step.edgeData.points;
|
||||||
let segmentPoints = rawPoints.map(p => new THREE.Vector2(p[0], p[1]));
|
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) {
|
if (this.currentPathMesh) {
|
||||||
this.scene.remove(this.currentPathMesh);
|
this.scene.remove(this.currentPathMesh);
|
||||||
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();
|
||||||
|
|
||||||
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() {
|
updateMarkerColors() {
|
||||||
@@ -252,9 +421,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;
|
||||||
@@ -335,3 +530,4 @@ export class RouteManager {
|
|||||||
return newPath;
|
return newPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
241
src/UIManager.js
241
src/UIManager.js
@@ -1,96 +1,255 @@
|
|||||||
export class UIManager {
|
export class UIManager {
|
||||||
constructor(routeManager) {
|
constructor(routeManager) {
|
||||||
this.routeManager = 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.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.elRouteList = document.getElementById('route-list');
|
||||||
this.elContainer = document.getElementById('ui-container');
|
this.elContainer = document.getElementById('ui-container');
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
this.btnCreate = document.getElementById('btn-create-route');
|
||||||
this.btnSave = document.getElementById('btn-save');
|
this.btnSave = document.getElementById('btn-save');
|
||||||
this.btnDiscard = document.getElementById('btn-discard');
|
this.btnDiscard = document.getElementById('btn-discard');
|
||||||
this.btnToggle = document.getElementById('ui-toggle');
|
this.btnToggle = document.getElementById('ui-toggle');
|
||||||
this.btnZoning = document.getElementById('btn-zoning');
|
|
||||||
|
|
||||||
// We need a callback to main.js to actually change colors
|
// Save/Load
|
||||||
this.onToggleZoning = null;
|
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();
|
this.initListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
initListeners() {
|
initListeners() {
|
||||||
|
// --- MODE SWITCHING ---
|
||||||
|
this.btnCreate.addEventListener('click', () => {
|
||||||
|
this.enterDraftMode();
|
||||||
|
});
|
||||||
|
|
||||||
this.btnSave.addEventListener('click', () => {
|
this.btnSave.addEventListener('click', () => {
|
||||||
this.routeManager.saveCurrentRoute();
|
const success = this.routeManager.saveCurrentRoute();
|
||||||
this.renderRouteList();
|
if (success) {
|
||||||
this.updateStats(0);
|
this.renderRouteList();
|
||||||
|
this.exitDraftMode();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.btnDiscard.addEventListener('click', () => {
|
this.btnDiscard.addEventListener('click', () => {
|
||||||
this.routeManager.clearCurrentRoute();
|
this.routeManager.clearCurrentRoute();
|
||||||
this.updateStats(0);
|
this.exitDraftMode();
|
||||||
});
|
});
|
||||||
|
// ----------------------
|
||||||
|
|
||||||
// Toggle Logic
|
|
||||||
this.btnToggle.addEventListener('click', () => {
|
this.btnToggle.addEventListener('click', () => {
|
||||||
this.elContainer.classList.toggle('hidden');
|
this.elContainer.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.btnZoning.addEventListener('click', () => {
|
this.selectViewMode.addEventListener('change', (e) => {
|
||||||
const isActive = this.btnZoning.classList.toggle('active');
|
if (this.onViewModeChanged) {
|
||||||
this.btnZoning.style.background = isActive ? '#4B5563' : ''; // Darken when active
|
this.onViewModeChanged(e.target.value);
|
||||||
this.btnZoning.style.color = isActive ? 'white' : '';
|
|
||||||
|
|
||||||
if (this.onToggleZoning) {
|
|
||||||
this.onToggleZoning(isActive);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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) {
|
enterDraftMode() {
|
||||||
let text = "";
|
this.panelMain.style.display = 'none';
|
||||||
if (lengthInMeters > 1000) {
|
this.panelDraft.style.display = 'block';
|
||||||
text = (lengthInMeters / 1000).toFixed(2) + " km";
|
this.routeManager.startDrafting();
|
||||||
} else {
|
}
|
||||||
text = Math.round(lengthInMeters) + " m";
|
|
||||||
}
|
exitDraftMode() {
|
||||||
this.elCurrentLength.textContent = text;
|
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() {
|
renderRouteList() {
|
||||||
this.elRouteList.innerHTML = '';
|
this.elRouteList.innerHTML = '';
|
||||||
const routes = this.routeManager.getSavedRoutes();
|
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) => {
|
routes.forEach((route, index) => {
|
||||||
const li = document.createElement('li');
|
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
|
// --- BADGE CONTAINER ---
|
||||||
? (route.length / 1000).toFixed(2) + " km"
|
const badgeContainer = document.createElement('div');
|
||||||
: Math.round(route.length) + " m";
|
badgeContainer.style.position = 'relative';
|
||||||
|
badgeContainer.style.width = '28px';
|
||||||
|
badgeContainer.style.height = '28px';
|
||||||
|
badgeContainer.style.marginRight = '10px';
|
||||||
|
|
||||||
// Create Label
|
const badge = document.createElement('div');
|
||||||
const span = document.createElement('span');
|
badge.textContent = (index + 1);
|
||||||
span.innerHTML = `<strong>Route ${index + 1}</strong> (${lenStr})`;
|
badge.style.width = '100%';
|
||||||
li.appendChild(span);
|
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');
|
const btnEdit = document.createElement('button');
|
||||||
btnEdit.textContent = "Edit";
|
btnEdit.textContent = "✎";
|
||||||
btnEdit.className = "btn-icon btn-edit";
|
btnEdit.className = "btn-icon";
|
||||||
|
btnEdit.title = "Redraw Route";
|
||||||
|
btnEdit.style.padding = "4px 8px";
|
||||||
btnEdit.onclick = () => {
|
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.routeManager.editSavedRoute(index);
|
||||||
this.renderRouteList(); // Re-render to remove it from list
|
|
||||||
};
|
};
|
||||||
li.appendChild(btnEdit);
|
|
||||||
|
|
||||||
// Delete Button
|
|
||||||
const btnDel = document.createElement('button');
|
const btnDel = document.createElement('button');
|
||||||
btnDel.textContent = "✕";
|
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 = () => {
|
btnDel.onclick = () => {
|
||||||
this.routeManager.deleteSavedRoute(index);
|
if (confirm("Delete this route?")) {
|
||||||
this.renderRouteList();
|
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);
|
this.elRouteList.appendChild(li);
|
||||||
});
|
});
|
||||||
|
|||||||
109
src/VehicleSystem.js
Normal file
109
src/VehicleSystem.js
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
233
src/main.js
233
src/main.js
@@ -5,7 +5,8 @@ import * as BufferGeometryUtils from 'three/addons/utils/BufferGeometryUtils.js'
|
|||||||
import { InputManager } from './InputManager.js';
|
import { InputManager } from './InputManager.js';
|
||||||
import { RouteManager } from './RouteManager.js';
|
import { RouteManager } from './RouteManager.js';
|
||||||
import { UIManager } from './UIManager.js';
|
import { UIManager } from './UIManager.js';
|
||||||
|
import { GameManager } from './GameManager.js';
|
||||||
|
import { VehicleSystem } from './VehicleSystem.js';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 1. Configuration
|
// 1. Configuration
|
||||||
@@ -16,6 +17,8 @@ const SETTINGS = {
|
|||||||
ground: 0xDDDDDD,
|
ground: 0xDDDDDD,
|
||||||
zoningRes: new THREE.Color(0xA855F7),
|
zoningRes: new THREE.Color(0xA855F7),
|
||||||
zoningCom: new THREE.Color(0x3B82F6),
|
zoningCom: new THREE.Color(0x3B82F6),
|
||||||
|
coverageGood: new THREE.Color(0x10B981),
|
||||||
|
coverageBad: new THREE.Color(0xEF4444),
|
||||||
building: new THREE.Color(0xFFFFFF),
|
building: new THREE.Color(0xFFFFFF),
|
||||||
water: 0xAADAFF,
|
water: 0xAADAFF,
|
||||||
park: 0xC3E6CB,
|
park: 0xC3E6CB,
|
||||||
@@ -31,77 +34,131 @@ const SETTINGS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let scene, camera, renderer, controls;
|
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() {
|
function init() {
|
||||||
setupScene();
|
setupScene();
|
||||||
|
|
||||||
// 1. Managers
|
// 1. Core Systems
|
||||||
routeManager = new RouteManager(scene, SETTINGS);
|
routeManager = new RouteManager(scene, SETTINGS);
|
||||||
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
|
uiManager = new UIManager(routeManager);
|
||||||
uiManager = new UIManager(routeManager); // Wire UI to Route Logic
|
|
||||||
|
|
||||||
// 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();
|
inputManager.init();
|
||||||
|
|
||||||
|
// Wiring Click
|
||||||
inputManager.onClick = (point, object) => {
|
inputManager.onClick = (point, object) => {
|
||||||
if (object.name === "GROUND") {
|
// Only allow adding nodes if we are actually in drafting mode
|
||||||
routeManager.addNodeByWorldPosition(point);
|
// (RouteManager handles the check internally, but we pass the intent)
|
||||||
}
|
if (object.name === "GROUND") routeManager.addNodeByWorldPosition(point);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wiring Drag
|
||||||
inputManager.onDrag = (markerObject, newPoint) => {
|
inputManager.onDrag = (markerObject, newPoint) => {
|
||||||
routeManager.dragNode(markerObject, newPoint);
|
routeManager.dragNode(markerObject, newPoint);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wire RouteManager back to UI (to update stats when dragging)
|
// Wiring Hover (NEW)
|
||||||
routeManager.onRouteChanged = (dist) => {
|
inputManager.onHover = (point) => {
|
||||||
uiManager.updateStats(dist);
|
routeManager.updateGhostMarker(point);
|
||||||
};
|
};
|
||||||
|
|
||||||
uiManager.onToggleZoning = (isActive) => {
|
// Wire UI View Mode
|
||||||
updateBuildingColors(isActive);
|
uiManager.onViewModeChanged = (mode) => {
|
||||||
|
currentViewMode = mode;
|
||||||
|
updateBuildingColors();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 3. Data Load
|
routeManager.onRouteChanged = (stats) => {
|
||||||
|
uiManager.updateDraftStats(stats);
|
||||||
|
if (currentViewMode === 'approval') updateBuildingColors();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Load Data
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch(SETTINGS.files.visual).then(r => r.json()),
|
fetch(SETTINGS.files.visual).then(r => r.json()),
|
||||||
fetch(SETTINGS.files.routing).then(r => r.json())
|
fetch(SETTINGS.files.routing).then(r => r.json())
|
||||||
]).then(([visual, routing]) => {
|
]).then(([visual, routing]) => {
|
||||||
console.log("Data loaded.");
|
|
||||||
renderCity(visual);
|
|
||||||
routeManager.initGraph(routing);
|
routeManager.initGraph(routing);
|
||||||
|
renderCity(visual);
|
||||||
|
gameManager.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
animate();
|
animate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBuildingColors(showZoning) {
|
function updateBuildingColors() {
|
||||||
scene.traverse((obj) => {
|
scene.traverse((obj) => {
|
||||||
// We tagged buildings with userData in renderCity (see below)
|
|
||||||
if (obj.name === 'BUILDING_MESH') {
|
if (obj.name === 'BUILDING_MESH') {
|
||||||
|
const data = obj.userData.cityData;
|
||||||
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
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
if (data.type === 'residential') {
|
// 1. STANDARD VIEW
|
||||||
// Lerp from White to Purple based on density
|
if (currentViewMode === 'none') {
|
||||||
const color = SETTINGS.colors.building.clone();
|
obj.material.color.copy(SETTINGS.colors.building);
|
||||||
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
|
|
||||||
obj.material.color.copy(color);
|
|
||||||
}
|
}
|
||||||
else if (data.type === 'commercial') {
|
|
||||||
// Lerp from White to Blue
|
// 2. ZONING VIEW
|
||||||
const color = SETTINGS.colors.building.clone();
|
else if (currentViewMode === 'zoning') {
|
||||||
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
|
if (data.type === 'residential') {
|
||||||
obj.material.color.copy(color);
|
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);
|
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.water, SETTINGS.colors.water, 0, 0.1, false);
|
||||||
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
|
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
|
||||||
createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, 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() {
|
function animate() {
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
|
const delta = clock.getDelta(); // Get time since last frame
|
||||||
|
|
||||||
controls.update();
|
controls.update();
|
||||||
|
if (vehicleSystem) {
|
||||||
|
vehicleSystem.update(delta);
|
||||||
|
}
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user