approval rating
This commit is contained in:
@@ -28,10 +28,17 @@
|
|||||||
<div style="grid-column: span 2; border-top:1px solid #ddd; margin-top:4px; padding-top:4px;">
|
<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>
|
Total Daily Riders: <strong id="val-riders">0</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="color:#D97706">Approval: <strong id="val-approval">50%</strong></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p style="font-size:12px; color:#666; margin-bottom:5px;">Left Click: Add Point | Drag: Move</p>
|
<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>
|
<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 class="section">
|
||||||
|
|||||||
@@ -4,43 +4,106 @@ export class GameManager {
|
|||||||
this.uiManager = uiManager;
|
this.uiManager = uiManager;
|
||||||
|
|
||||||
// Game State
|
// Game State
|
||||||
this.budget = 1000000; // Start with $1M
|
this.budget = 1000000;
|
||||||
this.day = 1;
|
this.day = 1;
|
||||||
this.ticketPrice = 2.50;
|
this.ticketPrice = 2.50;
|
||||||
|
|
||||||
// Constants
|
// Approval
|
||||||
this.COST_PER_METER = 200; // Construction cost
|
this.approvalRating = 0;
|
||||||
this.BUS_COST = 50000; // Cost per vehicle
|
|
||||||
|
|
||||||
// Timer for "Daily" cycle (every 5 seconds)
|
// Config
|
||||||
|
this.COST_PER_METER = 200;
|
||||||
|
this.BUS_COST = 50000;
|
||||||
this.gameLoopInterval = null;
|
this.gameLoopInterval = null;
|
||||||
|
|
||||||
|
// Cache for nodes that have people or jobs
|
||||||
|
this.censusNodes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
start() {
|
start() {
|
||||||
|
this.buildCensusArrays();
|
||||||
|
this.recalculateApproval(); // Initial calc
|
||||||
this.updateUI();
|
this.updateUI();
|
||||||
|
|
||||||
// Start the game loop: Every 5 seconds = 1 Day
|
|
||||||
this.gameLoopInterval = setInterval(() => {
|
this.gameLoopInterval = setInterval(() => {
|
||||||
this.processDay();
|
this.processDay();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPDATED: Weighted Approval Calculation
|
||||||
|
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 = 600; // 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)
|
||||||
|
// A high-rise (count=100) at 50% satisfaction adds 50 points.
|
||||||
|
// A house (count=3) at 50% satisfaction adds 1.5 points.
|
||||||
|
totalWeightedScore += (satisfaction * node.count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Approval % = (Actual Weighted Score / Max Possible Weighted Score)
|
||||||
|
if (totalMaxScore > 0) {
|
||||||
|
this.approvalRating = Math.floor((totalWeightedScore / totalMaxScore) * 100);
|
||||||
|
} else {
|
||||||
|
this.approvalRating = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
processDay() {
|
processDay() {
|
||||||
this.day++;
|
this.day++;
|
||||||
|
|
||||||
// Calculate total income from all active routes
|
|
||||||
const savedRoutes = this.routeManager.getSavedRoutes();
|
const savedRoutes = this.routeManager.getSavedRoutes();
|
||||||
let dailyIncome = 0;
|
let dailyIncome = 0;
|
||||||
let totalRiders = 0;
|
|
||||||
|
|
||||||
savedRoutes.forEach(route => {
|
savedRoutes.forEach(route => {
|
||||||
dailyIncome += route.stats.ridership * this.ticketPrice;
|
dailyIncome += route.stats.ridership * this.ticketPrice;
|
||||||
totalRiders += route.stats.ridership;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.budget += dailyIncome;
|
this.budget += dailyIncome;
|
||||||
|
|
||||||
// Flash visual feedback if income > 0
|
|
||||||
if (dailyIncome > 0) {
|
if (dailyIncome > 0) {
|
||||||
this.uiManager.showIncomeFeedback(dailyIncome);
|
this.uiManager.showIncomeFeedback(dailyIncome);
|
||||||
}
|
}
|
||||||
@@ -48,17 +111,16 @@ export class GameManager {
|
|||||||
this.updateUI();
|
this.updateUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
getLastKnownRiders() {
|
||||||
* Estimates cost for a route based on length and needed buses
|
const savedRoutes = this.routeManager.getSavedRoutes();
|
||||||
*/
|
let total = 0;
|
||||||
|
savedRoutes.forEach(r => total += r.stats.ridership);
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
getProjectedCost(lengthInMeters) {
|
getProjectedCost(lengthInMeters) {
|
||||||
// Construction Cost
|
|
||||||
const construction = lengthInMeters * this.COST_PER_METER;
|
const construction = lengthInMeters * this.COST_PER_METER;
|
||||||
|
const fleet = Math.ceil(lengthInMeters / 800) * this.BUS_COST;
|
||||||
// Fleet Cost: 1 Bus per 800m
|
|
||||||
const busesNeeded = Math.ceil(lengthInMeters / 800);
|
|
||||||
const fleet = busesNeeded * this.BUS_COST;
|
|
||||||
|
|
||||||
return Math.floor(construction + fleet);
|
return Math.floor(construction + fleet);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,15 +134,11 @@ export class GameManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateUI() {
|
updateUI() {
|
||||||
// Calculate aggregate stats
|
|
||||||
const savedRoutes = this.routeManager.getSavedRoutes();
|
|
||||||
let totalRiders = 0;
|
|
||||||
savedRoutes.forEach(r => totalRiders += r.stats.ridership);
|
|
||||||
|
|
||||||
this.uiManager.updateGameStats({
|
this.uiManager.updateGameStats({
|
||||||
budget: this.budget,
|
budget: this.budget,
|
||||||
day: this.day,
|
day: this.day,
|
||||||
totalRiders: totalRiders
|
totalRiders: this.getLastKnownRiders(),
|
||||||
|
approval: this.approvalRating
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export class RouteManager {
|
|||||||
this.markers = [];
|
this.markers = [];
|
||||||
this.currentPathMesh = null;
|
this.currentPathMesh = null;
|
||||||
|
|
||||||
|
this.servedNodes = new Set();
|
||||||
|
|
||||||
|
this.servedCoordinates = [];
|
||||||
|
|
||||||
this.ROAD_OFFSET = 2.5;
|
this.ROAD_OFFSET = 2.5;
|
||||||
|
|
||||||
this.onRouteChanged = null;
|
this.onRouteChanged = null;
|
||||||
@@ -36,35 +40,65 @@ export class RouteManager {
|
|||||||
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
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to check if a node is covered
|
||||||
|
isNodeServed(nodeId) {
|
||||||
|
return this.servedNodes.has(parseInt(nodeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the Set of served nodes
|
||||||
|
refreshServedNodes() {
|
||||||
|
this.servedNodes.clear();
|
||||||
|
this.servedCoordinates = [];
|
||||||
|
|
||||||
|
this.savedRoutes.forEach(route => {
|
||||||
|
route.nodes.forEach(nodeId => {
|
||||||
|
if (!this.servedNodes.has(nodeId)) {
|
||||||
|
this.servedNodes.add(nodeId);
|
||||||
|
|
||||||
|
const node = this.graphData.nodes[nodeId];
|
||||||
|
if (node) {
|
||||||
|
// Cache World Coordinates (x, z)
|
||||||
|
// Note: node.y in graphData is already flipped to match World Z
|
||||||
|
this.servedCoordinates.push({ x: node.x, z: node.y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns distance in meters. Returns Infinity if no routes exist.
|
||||||
|
getDistanceToNearestTransit(x, z) {
|
||||||
|
if (this.servedCoordinates.length === 0) return Infinity;
|
||||||
|
|
||||||
|
let minSq = Infinity;
|
||||||
|
|
||||||
|
// Optimization: Standard loop is faster than forEach for high freq calls
|
||||||
|
for (let i = 0; i < this.servedCoordinates.length; i++) {
|
||||||
|
const sc = this.servedCoordinates[i];
|
||||||
|
const dx = sc.x - x;
|
||||||
|
const dz = sc.z - z;
|
||||||
|
const d2 = dx * dx + dz * dz;
|
||||||
|
if (d2 < minSq) minSq = d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.sqrt(minSq);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
calculateRidership(nodeList) {
|
calculateRidership(nodeList) {
|
||||||
if (!this.graphData || nodeList.length < 2) return 0;
|
if (!this.graphData || nodeList.length < 2) return 0;
|
||||||
|
|
||||||
@@ -153,6 +187,8 @@ export class RouteManager {
|
|||||||
this.currentPathMesh = null;
|
this.currentPathMesh = null;
|
||||||
this.resetDraftingState();
|
this.resetDraftingState();
|
||||||
|
|
||||||
|
this.refreshServedNodes();
|
||||||
|
|
||||||
// Force UI update to show new total riders
|
// Force UI update to show new total riders
|
||||||
this.gameManager.updateUI();
|
this.gameManager.updateUI();
|
||||||
}
|
}
|
||||||
@@ -164,6 +200,9 @@ export class RouteManager {
|
|||||||
this.currentRouteNodes = [...route.nodes];
|
this.currentRouteNodes = [...route.nodes];
|
||||||
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.savedRoutes.splice(index, 1);
|
||||||
|
|
||||||
|
this.refreshServedNodes();
|
||||||
|
|
||||||
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
|
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
|
||||||
this.updatePathVisuals();
|
this.updatePathVisuals();
|
||||||
this.gameManager.updateUI(); // Update UI since we removed a route (income drops)
|
this.gameManager.updateUI(); // Update UI since we removed a route (income drops)
|
||||||
@@ -186,6 +225,7 @@ export class RouteManager {
|
|||||||
const route = this.savedRoutes[index];
|
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.savedRoutes.splice(index, 1);
|
||||||
|
this.refreshServedNodes();
|
||||||
this.gameManager.updateUI();
|
this.gameManager.updateUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,26 @@ export class UIManager {
|
|||||||
|
|
||||||
// UI Elements
|
// UI Elements
|
||||||
this.elCurrentLength = document.getElementById('current-length');
|
this.elCurrentLength = document.getElementById('current-length');
|
||||||
this.elCurrentCost = document.getElementById('current-cost'); // NEW
|
this.elCurrentCost = document.getElementById('current-cost');
|
||||||
this.elCurrentRiders = document.getElementById('current-riders'); // NEW
|
this.elCurrentRiders = document.getElementById('current-riders');
|
||||||
|
|
||||||
this.elBudget = document.getElementById('val-budget'); // NEW
|
this.elBudget = document.getElementById('val-budget');
|
||||||
this.elDay = document.getElementById('val-day'); // NEW
|
this.elDay = document.getElementById('val-day');
|
||||||
this.elTotalRiders = document.getElementById('val-riders'); // NEW
|
this.elTotalRiders = document.getElementById('val-riders');
|
||||||
this.elIncomeFloat = document.getElementById('income-float'); // NEW
|
this.elApproval = document.getElementById('val-approval'); // NEW
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
this.onToggleZoning = null;
|
// NEW: View Mode
|
||||||
|
this.selectViewMode = document.getElementById('view-mode');
|
||||||
|
|
||||||
|
this.onViewModeChanged = null;
|
||||||
this.initListeners();
|
this.initListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,45 +41,43 @@ export class UIManager {
|
|||||||
this.elContainer.classList.toggle('hidden');
|
this.elContainer.classList.toggle('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
this.btnZoning.addEventListener('click', () => {
|
// Handle Dropdown Change
|
||||||
const isActive = this.btnZoning.classList.toggle('active');
|
this.selectViewMode.addEventListener('change', (e) => {
|
||||||
this.btnZoning.style.background = isActive ? '#4B5563' : '';
|
if (this.onViewModeChanged) {
|
||||||
this.btnZoning.style.color = isActive ? 'white' : '';
|
this.onViewModeChanged(e.target.value);
|
||||||
if (this.onToggleZoning) this.onToggleZoning(isActive);
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by GameManager
|
|
||||||
updateGameStats(stats) {
|
updateGameStats(stats) {
|
||||||
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
|
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
|
||||||
this.elDay.textContent = stats.day;
|
this.elDay.textContent = stats.day;
|
||||||
this.elTotalRiders.textContent = stats.totalRiders.toLocaleString();
|
this.elTotalRiders.textContent = stats.totalRiders.toLocaleString();
|
||||||
|
|
||||||
|
// Update Approval
|
||||||
|
this.elApproval.textContent = stats.approval + "%";
|
||||||
|
// Color code it
|
||||||
|
if (stats.approval > 75) this.elApproval.style.color = "#10B981"; // Green
|
||||||
|
else if (stats.approval < 40) this.elApproval.style.color = "#EF4444"; // Red
|
||||||
|
else this.elApproval.style.color = "#D97706"; // Orange
|
||||||
}
|
}
|
||||||
|
|
||||||
showIncomeFeedback(amount) {
|
showIncomeFeedback(amount) {
|
||||||
this.elIncomeFloat.textContent = "+ $" + amount.toLocaleString();
|
this.elIncomeFloat.textContent = "+ $" + amount.toLocaleString();
|
||||||
this.elIncomeFloat.style.opacity = 1;
|
this.elIncomeFloat.style.opacity = 1;
|
||||||
this.elIncomeFloat.style.top = "40px";
|
this.elIncomeFloat.style.top = "40px";
|
||||||
|
|
||||||
// Reset animation
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.elIncomeFloat.style.opacity = 0;
|
this.elIncomeFloat.style.opacity = 0;
|
||||||
this.elIncomeFloat.style.top = "60px";
|
this.elIncomeFloat.style.top = "60px";
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called by RouteManager on path change
|
|
||||||
updateDraftStats(stats) {
|
updateDraftStats(stats) {
|
||||||
// Length
|
|
||||||
let lenText = stats.length > 1000
|
let lenText = stats.length > 1000
|
||||||
? (stats.length / 1000).toFixed(2) + " km"
|
? (stats.length / 1000).toFixed(2) + " km"
|
||||||
: Math.round(stats.length) + " m";
|
: Math.round(stats.length) + " m";
|
||||||
this.elCurrentLength.textContent = lenText;
|
this.elCurrentLength.textContent = lenText;
|
||||||
|
|
||||||
// Cost
|
|
||||||
this.elCurrentCost.textContent = "$" + stats.cost.toLocaleString();
|
this.elCurrentCost.textContent = "$" + stats.cost.toLocaleString();
|
||||||
|
|
||||||
// Ridership
|
|
||||||
this.elCurrentRiders.textContent = stats.ridership.toLocaleString() + " / day";
|
this.elCurrentRiders.textContent = stats.ridership.toLocaleString() + " / day";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +88,6 @@ export class UIManager {
|
|||||||
routes.forEach((route, index) => {
|
routes.forEach((route, index) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
|
|
||||||
// Format Length
|
|
||||||
let lenStr = route.stats.length > 1000
|
let lenStr = route.stats.length > 1000
|
||||||
? (route.stats.length / 1000).toFixed(1) + "km"
|
? (route.stats.length / 1000).toFixed(1) + "km"
|
||||||
: Math.round(route.stats.length) + "m";
|
: Math.round(route.stats.length) + "m";
|
||||||
|
|||||||
115
src/main.js
115
src/main.js
@@ -17,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,
|
||||||
@@ -34,7 +36,9 @@ const SETTINGS = {
|
|||||||
let scene, camera, renderer, controls;
|
let scene, camera, renderer, controls;
|
||||||
let inputManager, routeManager, uiManager, gameManager, vehicleSystem;
|
let inputManager, routeManager, uiManager, gameManager, vehicleSystem;
|
||||||
|
|
||||||
const clock = new THREE.Clock(); // Added Clock
|
const clock = new THREE.Clock();
|
||||||
|
|
||||||
|
let currentViewMode = 'none'; // 'none', 'zoning', 'approval'
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
setupScene();
|
setupScene();
|
||||||
@@ -51,7 +55,6 @@ function init() {
|
|||||||
vehicleSystem = new VehicleSystem(scene);
|
vehicleSystem = new VehicleSystem(scene);
|
||||||
routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager
|
routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager
|
||||||
|
|
||||||
|
|
||||||
gameManager.start(); // Start the loop
|
gameManager.start(); // Start the loop
|
||||||
|
|
||||||
// 3. Input
|
// 3. Input
|
||||||
@@ -66,11 +69,16 @@ function init() {
|
|||||||
routeManager.dragNode(markerObject, newPoint);
|
routeManager.dragNode(markerObject, newPoint);
|
||||||
};
|
};
|
||||||
|
|
||||||
uiManager.onToggleZoning = (isActive) => updateBuildingColors(isActive);
|
// Wire UI View Mode
|
||||||
|
uiManager.onViewModeChanged = (mode) => {
|
||||||
|
currentViewMode = mode;
|
||||||
|
updateBuildingColors();
|
||||||
|
};
|
||||||
|
|
||||||
// When path updates, show new stats in UI
|
|
||||||
routeManager.onRouteChanged = (stats) => {
|
routeManager.onRouteChanged = (stats) => {
|
||||||
uiManager.updateDraftStats(stats);
|
uiManager.updateDraftStats(stats);
|
||||||
|
// If in coverage view, we might need to refresh colors as coverage changes
|
||||||
|
if (currentViewMode === 'approval') updateBuildingColors();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. Load Data
|
// 4. Load Data
|
||||||
@@ -78,39 +86,72 @@ function init() {
|
|||||||
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]) => {
|
||||||
renderCity(visual);
|
|
||||||
routeManager.initGraph(routing);
|
routeManager.initGraph(routing);
|
||||||
|
renderCity(visual);
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
||||||
|
|
||||||
|
// 1. STANDARD VIEW
|
||||||
|
if (currentViewMode === 'none') {
|
||||||
|
obj.material.color.copy(SETTINGS.colors.building);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ZONING VIEW
|
||||||
|
else if (currentViewMode === 'zoning') {
|
||||||
if (data.type === 'residential') {
|
if (data.type === 'residential') {
|
||||||
// Lerp from White to Purple based on density
|
|
||||||
const color = SETTINGS.colors.building.clone();
|
const color = SETTINGS.colors.building.clone();
|
||||||
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
|
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
|
||||||
obj.material.color.copy(color);
|
obj.material.color.copy(color);
|
||||||
}
|
} else if (data.type === 'commercial') {
|
||||||
else if (data.type === 'commercial') {
|
|
||||||
// Lerp from White to Blue
|
|
||||||
const color = SETTINGS.colors.building.clone();
|
const color = SETTINGS.colors.building.clone();
|
||||||
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
|
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
|
||||||
obj.material.color.copy(color);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -219,24 +260,15 @@ function renderCity(data) {
|
|||||||
scene.add(mesh);
|
scene.add(mesh);
|
||||||
};
|
};
|
||||||
|
|
||||||
createBuildingLayer(data.buildings);
|
// Dedicated Building Creator to cache Nearest Node ID
|
||||||
|
const createBuildingLayer = (buildings) => {
|
||||||
createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
|
|
||||||
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
|
|
||||||
createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function createBuildingLayer(buildings) {
|
|
||||||
if (!buildings || !buildings.length) return;
|
if (!buildings || !buildings.length) return;
|
||||||
|
|
||||||
|
|
||||||
const mat = new THREE.MeshStandardMaterial({
|
const mat = new THREE.MeshStandardMaterial({
|
||||||
color: SETTINGS.colors.building,
|
color: SETTINGS.colors.building,
|
||||||
roughness: 0.6,
|
roughness: 0.6,
|
||||||
side: THREE.DoubleSide,
|
side: THREE.DoubleSide,
|
||||||
shadowSide: THREE.DoubleSide
|
shadowSide: THREE.DoubleSide
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
buildings.forEach(b => {
|
buildings.forEach(b => {
|
||||||
@@ -254,22 +286,31 @@ function createBuildingLayer(buildings) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const geom = new THREE.ExtrudeGeometry(shape, {
|
const geom = new THREE.ExtrudeGeometry(shape, { depth: b.height, bevelEnabled: false });
|
||||||
depth: b.height,
|
|
||||||
bevelEnabled: false
|
|
||||||
});
|
|
||||||
geom.rotateX(-Math.PI / 2);
|
geom.rotateX(-Math.PI / 2);
|
||||||
|
|
||||||
const mesh = new THREE.Mesh(geom, mat.clone());
|
const mesh = new THREE.Mesh(geom, mat.clone());
|
||||||
mesh.castShadow = true;
|
mesh.castShadow = true;
|
||||||
mesh.receiveShadow = true;
|
mesh.receiveShadow = true;
|
||||||
|
|
||||||
// Store metadata for the Zoning Toggle
|
|
||||||
mesh.name = 'BUILDING_MESH';
|
mesh.name = 'BUILDING_MESH';
|
||||||
mesh.userData.cityData = b.data;
|
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);
|
scene.add(mesh);
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
createBuildingLayer(data.buildings);
|
||||||
|
createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
|
||||||
|
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
|
||||||
|
createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user