approval rating

This commit is contained in:
Evan Scamehorn
2025-12-02 16:36:55 -06:00
parent dc0659b1df
commit e501a2c480
5 changed files with 286 additions and 140 deletions

View File

@@ -28,10 +28,17 @@
<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>
<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 class="section">

View File

@@ -4,43 +4,106 @@ export class GameManager {
this.uiManager = uiManager;
// Game State
this.budget = 1000000; // Start with $1M
this.budget = 1000000;
this.day = 1;
this.ticketPrice = 2.50;
// Constants
this.COST_PER_METER = 200; // Construction cost
this.BUS_COST = 50000; // Cost per vehicle
// Approval
this.approvalRating = 0;
// Timer for "Daily" cycle (every 5 seconds)
// 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(); // Initial calc
this.updateUI();
// Start the game loop: Every 5 seconds = 1 Day
this.gameLoopInterval = setInterval(() => {
this.processDay();
}, 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() {
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);
}
@@ -48,17 +111,16 @@ export class GameManager {
this.updateUI();
}
/**
* Estimates cost for a route based on length and needed buses
*/
getLastKnownRiders() {
const savedRoutes = this.routeManager.getSavedRoutes();
let total = 0;
savedRoutes.forEach(r => total += r.stats.ridership);
return total;
}
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;
const fleet = Math.ceil(lengthInMeters / 800) * this.BUS_COST;
return Math.floor(construction + fleet);
}
@@ -72,15 +134,11 @@ export class GameManager {
}
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
totalRiders: this.getLastKnownRiders(),
approval: this.approvalRating
});
}
}

View File

@@ -15,6 +15,10 @@ export class RouteManager {
this.markers = [];
this.currentPathMesh = null;
this.servedNodes = new Set();
this.servedCoordinates = [];
this.ROAD_OFFSET = 2.5;
this.onRouteChanged = null;
@@ -36,35 +40,65 @@ export class RouteManager {
initGraph(data) {
this.graphData = data;
this.graphData.adjacency = {};
// 1. Flip Coordinates
for (let key in this.graphData.nodes) {
this.graphData.nodes[key].y = -this.graphData.nodes[key].y;
}
// 2. Build Adjacency
this.graphData.edges.forEach((edge, index) => {
if (edge.points) edge.points.forEach(p => { p[1] = -p[1]; });
if (!this.graphData.adjacency[edge.u]) this.graphData.adjacency[edge.u] = [];
this.graphData.adjacency[edge.u].push({
to: edge.v,
cost: edge.length || 1, // Fallback if length missing
edgeIndex: index
});
this.graphData.adjacency[edge.u].push({ to: edge.v, cost: edge.length || 1, edgeIndex: index });
if (!edge.oneway) {
if (!this.graphData.adjacency[edge.v]) this.graphData.adjacency[edge.v] = [];
this.graphData.adjacency[edge.v].push({
to: edge.u,
cost: edge.length || 1,
edgeIndex: index,
isReverse: true
});
this.graphData.adjacency[edge.v].push({ to: edge.u, cost: edge.length || 1, edgeIndex: index, isReverse: true });
}
});
}
// 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) {
if (!this.graphData || nodeList.length < 2) return 0;
@@ -153,6 +187,8 @@ export class RouteManager {
this.currentPathMesh = null;
this.resetDraftingState();
this.refreshServedNodes();
// Force UI update to show new total riders
this.gameManager.updateUI();
}
@@ -164,6 +200,9 @@ export class RouteManager {
this.currentRouteNodes = [...route.nodes];
if (route.mesh) { this.scene.remove(route.mesh); route.mesh.geometry.dispose(); }
this.savedRoutes.splice(index, 1);
this.refreshServedNodes();
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
this.updatePathVisuals();
this.gameManager.updateUI(); // Update UI since we removed a route (income drops)
@@ -186,6 +225,7 @@ export class RouteManager {
const route = this.savedRoutes[index];
if (route.mesh) { this.scene.remove(route.mesh); route.mesh.geometry.dispose(); }
this.savedRoutes.splice(index, 1);
this.refreshServedNodes();
this.gameManager.updateUI();
}

View File

@@ -4,23 +4,26 @@ export class UIManager {
// UI Elements
this.elCurrentLength = document.getElementById('current-length');
this.elCurrentCost = document.getElementById('current-cost'); // NEW
this.elCurrentRiders = document.getElementById('current-riders'); // NEW
this.elCurrentCost = document.getElementById('current-cost');
this.elCurrentRiders = document.getElementById('current-riders');
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.elBudget = document.getElementById('val-budget');
this.elDay = document.getElementById('val-day');
this.elTotalRiders = document.getElementById('val-riders');
this.elApproval = document.getElementById('val-approval'); // NEW
this.elIncomeFloat = document.getElementById('income-float');
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');
this.onToggleZoning = null;
// NEW: View Mode
this.selectViewMode = document.getElementById('view-mode');
this.onViewModeChanged = null;
this.initListeners();
}
@@ -38,45 +41,43 @@ export class UIManager {
this.elContainer.classList.toggle('hidden');
});
this.btnZoning.addEventListener('click', () => {
const isActive = this.btnZoning.classList.toggle('active');
this.btnZoning.style.background = isActive ? '#4B5563' : '';
this.btnZoning.style.color = isActive ? 'white' : '';
if (this.onToggleZoning) this.onToggleZoning(isActive);
// Handle Dropdown Change
this.selectViewMode.addEventListener('change', (e) => {
if (this.onViewModeChanged) {
this.onViewModeChanged(e.target.value);
}
});
}
// Called by GameManager
updateGameStats(stats) {
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
this.elDay.textContent = stats.day;
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) {
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";
}
@@ -87,7 +88,6 @@ export class UIManager {
routes.forEach((route, index) => {
const li = document.createElement('li');
// Format Length
let lenStr = route.stats.length > 1000
? (route.stats.length / 1000).toFixed(1) + "km"
: Math.round(route.stats.length) + "m";

View File

@@ -17,6 +17,8 @@ const SETTINGS = {
ground: 0xDDDDDD,
zoningRes: new THREE.Color(0xA855F7),
zoningCom: new THREE.Color(0x3B82F6),
coverageGood: new THREE.Color(0x10B981),
coverageBad: new THREE.Color(0xEF4444),
building: new THREE.Color(0xFFFFFF),
water: 0xAADAFF,
park: 0xC3E6CB,
@@ -34,7 +36,9 @@ const SETTINGS = {
let scene, camera, renderer, controls;
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() {
setupScene();
@@ -51,7 +55,6 @@ function init() {
vehicleSystem = new VehicleSystem(scene);
routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager
gameManager.start(); // Start the loop
// 3. Input
@@ -66,11 +69,16 @@ function init() {
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) => {
uiManager.updateDraftStats(stats);
// If in coverage view, we might need to refresh colors as coverage changes
if (currentViewMode === 'approval') updateBuildingColors();
};
// 4. Load Data
@@ -78,39 +86,72 @@ function init() {
fetch(SETTINGS.files.visual).then(r => r.json()),
fetch(SETTINGS.files.routing).then(r => r.json())
]).then(([visual, routing]) => {
renderCity(visual);
routeManager.initGraph(routing);
renderCity(visual);
});
animate();
}
function updateBuildingColors(showZoning) {
function updateBuildingColors() {
scene.traverse((obj) => {
// We tagged buildings with userData in renderCity (see below)
if (obj.name === 'BUILDING_MESH') {
if (!showZoning) {
// Revert to white
obj.material.color.setHex(SETTINGS.colors.building.getHex());
return;
}
// Get Data
const data = obj.userData.cityData; // We need to ensure we save this during creation
const data = obj.userData.cityData;
if (!data) return;
// 1. STANDARD VIEW
if (currentViewMode === 'none') {
obj.material.color.copy(SETTINGS.colors.building);
}
// 2. ZONING VIEW
else if (currentViewMode === 'zoning') {
if (data.type === 'residential') {
// Lerp from White to Purple based on density
const color = SETTINGS.colors.building.clone();
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
obj.material.color.copy(color);
}
else if (data.type === 'commercial') {
// Lerp from White to Blue
} 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);
}
}
}
}
});
@@ -219,24 +260,15 @@ function renderCity(data) {
scene.add(mesh);
};
createBuildingLayer(data.buildings);
createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false);
}
function createBuildingLayer(buildings) {
// 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 => {
@@ -254,22 +286,31 @@ function createBuildingLayer(buildings) {
});
}
const geom = new THREE.ExtrudeGeometry(shape, {
depth: b.height,
bevelEnabled: false
});
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;
// CALCULATE NEAREST NODE FOR APPROVAL MECHANIC
// We use the first point of the outer ring as a proxy for position
const bx = b.shape.outer[0][0];
const by = b.shape.outer[0][1];
const nearestId = routeManager.findNearestNode(bx, -by);
mesh.userData.nearestNodeId = nearestId;
scene.add(mesh);
});
};
createBuildingLayer(data.buildings);
createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
createLayer(data.parks, SETTINGS.colors.park, 0, 0.2, false);
createLayer(data.roads, SETTINGS.colors.road, 0, 0.3, false);
}