gameplay mechanics

This commit is contained in:
Evan Scamehorn
2025-12-02 14:40:18 -06:00
parent a3e057ef37
commit 68ca45f58e
8 changed files with 316 additions and 140 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,33 +9,48 @@
</head>
<body>
<!-- The UI Overlay -->
<button id="ui-toggle" title="Toggle Menu"></button>
<!-- Floating Income Feedback -->
<div id="income-float"
style="position:absolute; top:60px; left:220px; color:#10B981; font-weight:bold; font-size:20px; opacity:0; transition: all 1s ease-out; z-index:90; text-shadow:0 1px 2px white;">
+$0</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>
<!-- 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>
<p style="font-size:12px; color:#666; margin-bottom:5px;">Left Click: Add Point | Drag: Move</p>
<button id="btn-zoning" class="secondary" style="width:100%">Toggle Zoning View</button>
</div>
<div class="section">
<h3>Current Draft</h3>
<div class="stat-row">
<span>Length:</span>
<span id="current-length">0 m</span>
<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 Route</button>
<button id="btn-save" class="primary">Build Route</button>
<button id="btn-discard" class="danger">Discard</button>
</div>
</div>
<div class="section">
<h3>Saved Routes</h3>
<ul id="route-list">
<!-- List items will be injected here -->
</ul>
<h3>Active Routes</h3>
<ul id="route-list"></ul>
</div>
</div>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

86
src/GameManager.js Normal file
View File

@@ -0,0 +1,86 @@
export class GameManager {
constructor(routeManager, uiManager) {
this.routeManager = routeManager;
this.uiManager = uiManager;
// Game State
this.budget = 1000000; // Start with $1M
this.day = 1;
this.ticketPrice = 2.50;
// Constants
this.COST_PER_METER = 200; // Construction cost
this.BUS_COST = 50000; // Cost per vehicle
// Timer for "Daily" cycle (every 5 seconds)
this.gameLoopInterval = null;
}
start() {
this.updateUI();
// Start the game loop: Every 5 seconds = 1 Day
this.gameLoopInterval = setInterval(() => {
this.processDay();
}, 5000);
}
processDay() {
this.day++;
// Calculate total income from all active routes
const savedRoutes = this.routeManager.getSavedRoutes();
let dailyIncome = 0;
let totalRiders = 0;
savedRoutes.forEach(route => {
dailyIncome += route.stats.ridership * this.ticketPrice;
totalRiders += route.stats.ridership;
});
this.budget += dailyIncome;
// Flash visual feedback if income > 0
if (dailyIncome > 0) {
this.uiManager.showIncomeFeedback(dailyIncome);
}
this.updateUI();
}
/**
* Estimates cost for a route based on length and needed buses
*/
getProjectedCost(lengthInMeters) {
// Construction Cost
const construction = lengthInMeters * this.COST_PER_METER;
// Fleet Cost: 1 Bus per 800m
const busesNeeded = Math.ceil(lengthInMeters / 800);
const fleet = busesNeeded * this.BUS_COST;
return Math.floor(construction + fleet);
}
canAfford(cost) {
return this.budget >= cost;
}
deductFunds(amount) {
this.budget -= amount;
this.updateUI();
}
updateUI() {
// Calculate aggregate stats
const savedRoutes = this.routeManager.getSavedRoutes();
let totalRiders = 0;
savedRoutes.forEach(r => totalRiders += r.stats.ridership);
this.uiManager.updateGameStats({
budget: this.budget,
day: this.day,
totalRiders: totalRiders
});
}
}

View File

@@ -18,8 +18,14 @@ export class RouteManager {
this.ROAD_OFFSET = 2.5;
this.onRouteChanged = null;
this.gameManager = null;
}
setGameManager(gm) {
this.gameManager = gm;
}
initGraph(data) {
this.graphData = data;
this.graphData.adjacency = {};
@@ -52,6 +58,31 @@ export class RouteManager {
});
}
calculateRidership(nodeList) {
if (!this.graphData || nodeList.length < 2) return 0;
let totalPop = 0;
let totalJobs = 0;
// Sum census data for all nodes traversed by the route
nodeList.forEach(nodeId => {
const node = this.graphData.nodes[nodeId];
if (node) {
totalPop += (node.pop || 0);
totalJobs += (node.jobs || 0);
}
});
const synergy = Math.min(totalPop, totalJobs);
// Efficiency Factor: How balanced is the route?
// + Base multiplier (arbitrary game balance constant)
const GAME_BALANCE_MULTIPLIER = 5.0;
return Math.floor(synergy * GAME_BALANCE_MULTIPLIER);
}
// ============================
// API Methods
// ============================
@@ -60,12 +91,7 @@ export class RouteManager {
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;
}
if (this.currentRouteNodes.length > 0 && this.currentRouteNodes[this.currentRouteNodes.length - 1] === nodeId) return;
this.currentRouteNodes.push(nodeId);
this.addMarkerVisual(nodeId);
this.updatePathVisuals();
@@ -75,69 +101,64 @@ export class RouteManager {
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();
}
}
saveCurrentRoute() {
if (this.currentRouteNodes.length < 2 || !this.currentPathMesh) return;
const totalLength = this.currentPathMesh.userData.length || 0;
const length = this.currentPathMesh.userData.length || 0;
const cost = this.gameManager.getProjectedCost(length);
// Freeze mesh color
// 1. Check Funds
if (!this.gameManager.canAfford(cost)) {
alert("Insufficient Funds!");
return;
}
// 2. Pay
this.gameManager.deductFunds(cost);
// 3. Freeze & Save
this.currentPathMesh.material.color.setHex(0x10B981);
const ridership = this.calculateRidership(this.currentRouteNodes);
this.savedRoutes.push({
nodes: [...this.currentRouteNodes],
length: totalLength,
stats: { length, cost, ridership },
mesh: this.currentPathMesh
});
this.currentPathMesh = null;
this.resetDraftingState();
// Force UI update to show new total riders
this.gameManager.updateUI();
}
editSavedRoute(index) {
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
if (route.mesh) { this.scene.remove(route.mesh); route.mesh.geometry.dispose(); }
this.savedRoutes.splice(index, 1);
// 5. Restore Visuals (Markers & Path)
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
this.updatePathVisuals();
this.gameManager.updateUI(); // Update UI since we removed a route (income drops)
}
clearCurrentRoute() {
if (this.currentPathMesh) {
this.scene.remove(this.currentPathMesh);
this.currentPathMesh.geometry.dispose();
this.currentPathMesh = null;
}
if (this.currentPathMesh) { this.scene.remove(this.currentPathMesh); this.currentPathMesh.geometry.dispose(); this.currentPathMesh = null; }
this.resetDraftingState();
}
@@ -145,52 +166,45 @@ export class RouteManager {
this.currentRouteNodes = [];
this.markers.forEach(m => this.scene.remove(m));
this.markers = [];
if (this.onRouteChanged) this.onRouteChanged(0);
if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 });
}
deleteSavedRoute(index) {
if (index < 0 || index >= this.savedRoutes.length) return;
const route = this.savedRoutes[index];
if (route.mesh) {
this.scene.remove(route.mesh);
route.mesh.geometry.dispose();
}
if (route.mesh) { this.scene.remove(route.mesh); route.mesh.geometry.dispose(); }
this.savedRoutes.splice(index, 1);
this.gameManager.updateUI();
}
getSavedRoutes() {
return this.savedRoutes;
}
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);
// Report 0 stats
if (this.onRouteChanged) this.onRouteChanged({ length: 0, cost: 0, ridership: 0 });
return;
}
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];
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 +212,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,6 +222,7 @@ export class RouteManager {
});
}
// Rebuild Mesh
if (this.currentPathMesh) {
this.scene.remove(this.currentPathMesh);
this.currentPathMesh.geometry.dispose();
@@ -222,11 +236,21 @@ export class RouteManager {
this.currentPathMesh = new THREE.Mesh(tubeGeom, tubeMat);
this.currentPathMesh.userData.length = totalDist;
this.scene.add(this.currentPathMesh);
this.updateMarkerColors();
if (this.onRouteChanged) this.onRouteChanged(totalDist);
// -- CALCULATE LIVE GAMEPLAY STATS --
const projectedRiders = this.calculateRidership(this.currentRouteNodes);
const projectedCost = this.gameManager ? this.gameManager.getProjectedCost(totalDist) : 0;
if (this.onRouteChanged) {
this.onRouteChanged({
length: totalDist,
cost: projectedCost,
ridership: projectedRiders
});
}
}
updateMarkerColors() {

View File

@@ -2,18 +2,25 @@ export class UIManager {
constructor(routeManager) {
this.routeManager = routeManager;
// DOM Elements
// UI Elements
this.elCurrentLength = document.getElementById('current-length');
this.elCurrentCost = document.getElementById('current-cost'); // NEW
this.elCurrentRiders = document.getElementById('current-riders'); // NEW
this.elBudget = document.getElementById('val-budget'); // NEW
this.elDay = document.getElementById('val-day'); // NEW
this.elTotalRiders = document.getElementById('val-riders'); // NEW
this.elIncomeFloat = document.getElementById('income-float'); // NEW
this.elRouteList = document.getElementById('route-list');
this.elContainer = document.getElementById('ui-container');
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;
this.initListeners();
}
@@ -21,39 +28,56 @@ export class UIManager {
this.btnSave.addEventListener('click', () => {
this.routeManager.saveCurrentRoute();
this.renderRouteList();
this.updateStats(0);
});
this.btnDiscard.addEventListener('click', () => {
this.routeManager.clearCurrentRoute();
this.updateStats(0);
});
// 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.background = isActive ? '#4B5563' : '';
this.btnZoning.style.color = isActive ? 'white' : '';
if (this.onToggleZoning) {
this.onToggleZoning(isActive);
}
if (this.onToggleZoning) this.onToggleZoning(isActive);
});
}
updateStats(lengthInMeters) {
let text = "";
if (lengthInMeters > 1000) {
text = (lengthInMeters / 1000).toFixed(2) + " km";
} else {
text = Math.round(lengthInMeters) + " m";
}
this.elCurrentLength.textContent = text;
// Called by GameManager
updateGameStats(stats) {
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
this.elDay.textContent = stats.day;
this.elTotalRiders.textContent = stats.totalRiders.toLocaleString();
}
showIncomeFeedback(amount) {
this.elIncomeFloat.textContent = "+ $" + amount.toLocaleString();
this.elIncomeFloat.style.opacity = 1;
this.elIncomeFloat.style.top = "40px";
// Reset animation
setTimeout(() => {
this.elIncomeFloat.style.opacity = 0;
this.elIncomeFloat.style.top = "60px";
}, 2000);
}
// Called by RouteManager on path change
updateDraftStats(stats) {
// Length
let lenText = stats.length > 1000
? (stats.length / 1000).toFixed(2) + " km"
: Math.round(stats.length) + " m";
this.elCurrentLength.textContent = lenText;
// Cost
this.elCurrentCost.textContent = "$" + stats.cost.toLocaleString();
// Ridership
this.elCurrentRiders.textContent = stats.ridership.toLocaleString() + " / day";
}
renderRouteList() {
@@ -63,26 +87,27 @@ export class UIManager {
routes.forEach((route, index) => {
const li = document.createElement('li');
let lenStr = route.length > 1000
? (route.length / 1000).toFixed(2) + " km"
: Math.round(route.length) + " m";
// Format Length
let lenStr = route.stats.length > 1000
? (route.stats.length / 1000).toFixed(1) + "km"
: Math.round(route.stats.length) + "m";
// Create Label
const span = document.createElement('span');
span.innerHTML = `<strong>Route ${index + 1}</strong> (${lenStr})`;
span.innerHTML = `
<strong>Route ${index + 1}</strong> <br>
<small>${lenStr} | ${route.stats.ridership} riders</small>
`;
li.appendChild(span);
// Edit Button
const btnEdit = document.createElement('button');
btnEdit.textContent = "Edit";
btnEdit.className = "btn-icon btn-edit";
btnEdit.onclick = () => {
this.routeManager.editSavedRoute(index);
this.renderRouteList(); // Re-render to remove it from list
this.renderRouteList();
};
li.appendChild(btnEdit);
// Delete Button
const btnDel = document.createElement('button');
btnDel.textContent = "✕";
btnDel.className = "btn-icon btn-del";

View File

@@ -5,6 +5,7 @@ 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';
// ==========================================
@@ -31,44 +32,45 @@ const SETTINGS = {
};
let scene, camera, renderer, controls;
let inputManager, routeManager, uiManager;
let inputManager, routeManager, uiManager, gameManager;
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); // Dependency Injection
gameManager.start(); // Start the loop
// 3. Input
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
inputManager.init();
// Wiring
inputManager.onClick = (point, object) => {
if (object.name === "GROUND") {
routeManager.addNodeByWorldPosition(point);
}
if (object.name === "GROUND") routeManager.addNodeByWorldPosition(point);
};
inputManager.onDrag = (markerObject, newPoint) => {
routeManager.dragNode(markerObject, newPoint);
};
// Wire RouteManager back to UI (to update stats when dragging)
routeManager.onRouteChanged = (dist) => {
uiManager.updateStats(dist);
uiManager.onToggleZoning = (isActive) => updateBuildingColors(isActive);
// When path updates, show new stats in UI
routeManager.onRouteChanged = (stats) => {
uiManager.updateDraftStats(stats);
};
uiManager.onToggleZoning = (isActive) => {
updateBuildingColors(isActive);
};
// 3. Data Load
// 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);
});