gameplay mechanics
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
37
index.html
37
index.html
@@ -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
86
src/GameManager.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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";
|
||||
|
||||
38
src/main.js
38
src/main.js
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user