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;">
|
||||
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">
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
187
src/main.js
187
src/main.js
@@ -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;
|
||||
|
||||
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);
|
||||
// 1. STANDARD VIEW
|
||||
if (currentViewMode === 'none') {
|
||||
obj.material.color.copy(SETTINGS.colors.building);
|
||||
}
|
||||
else if (data.type === 'commercial') {
|
||||
// Lerp from White to Blue
|
||||
const color = SETTINGS.colors.building.clone();
|
||||
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
|
||||
obj.material.color.copy(color);
|
||||
|
||||
// 2. ZONING VIEW
|
||||
else if (currentViewMode === 'zoning') {
|
||||
if (data.type === 'residential') {
|
||||
const color = SETTINGS.colors.building.clone();
|
||||
color.lerp(SETTINGS.colors.zoningRes, data.density || 0.5);
|
||||
obj.material.color.copy(color);
|
||||
} else if (data.type === 'commercial') {
|
||||
const color = SETTINGS.colors.building.clone();
|
||||
color.lerp(SETTINGS.colors.zoningCom, data.density || 0.5);
|
||||
obj.material.color.copy(color);
|
||||
} else {
|
||||
obj.material.color.copy(SETTINGS.colors.building);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. APPROVAL / COVERAGE VIEW (GRADIENT)
|
||||
else if (currentViewMode === 'approval') {
|
||||
// Get graph node position
|
||||
const nearestId = obj.userData.nearestNodeId;
|
||||
// RouteManager has logic for this
|
||||
const node = routeManager.graphData.nodes[nearestId];
|
||||
|
||||
if (node) {
|
||||
// Calculate distance to nearest transit
|
||||
// node.y is Z in world space
|
||||
const dist = routeManager.getDistanceToNearestTransit(node.x, node.y);
|
||||
|
||||
// Color Logic:
|
||||
// < 100m = Green (Great)
|
||||
// < 300m = Yellow (Okay)
|
||||
// > 600m = Red (Bad)
|
||||
|
||||
if (dist === Infinity) {
|
||||
obj.material.color.copy(SETTINGS.colors.coverageBad); // Deep Red
|
||||
} else {
|
||||
const MAX_DIST = 600;
|
||||
const factor = Math.min(1.0, dist / MAX_DIST); // 0.0 (Close) to 1.0 (Far)
|
||||
|
||||
// Lerp from Green to Red
|
||||
// (Green at 0, Red at 1)
|
||||
const color = SETTINGS.colors.coverageGood.clone();
|
||||
// We can lerp to Red.
|
||||
// Or use a Yellow midpoint?
|
||||
// Simple lerp: Green -> Red
|
||||
color.lerp(SETTINGS.colors.coverageBad, factor);
|
||||
obj.material.color.copy(color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -219,57 +260,57 @@ function renderCity(data) {
|
||||
scene.add(mesh);
|
||||
};
|
||||
|
||||
createBuildingLayer(data.buildings);
|
||||
// Dedicated Building Creator to cache Nearest Node ID
|
||||
const createBuildingLayer = (buildings) => {
|
||||
if (!buildings || !buildings.length) return;
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: SETTINGS.colors.building,
|
||||
roughness: 0.6,
|
||||
side: THREE.DoubleSide,
|
||||
shadowSide: THREE.DoubleSide
|
||||
});
|
||||
|
||||
buildings.forEach(b => {
|
||||
const shape = new THREE.Shape();
|
||||
if (b.shape.outer.length < 3) return;
|
||||
shape.moveTo(b.shape.outer[0][0], b.shape.outer[0][1]);
|
||||
for (let i = 1; i < b.shape.outer.length; i++) shape.lineTo(b.shape.outer[i][0], b.shape.outer[i][1]);
|
||||
|
||||
if (b.shape.holes) {
|
||||
b.shape.holes.forEach(h => {
|
||||
const path = new THREE.Path();
|
||||
path.moveTo(h[0][0], h[0][1]);
|
||||
for (let k = 1; k < h.length; k++) path.lineTo(h[k][0], h[k][1]);
|
||||
shape.holes.push(path);
|
||||
});
|
||||
}
|
||||
|
||||
const geom = new THREE.ExtrudeGeometry(shape, { depth: b.height, bevelEnabled: false });
|
||||
geom.rotateX(-Math.PI / 2);
|
||||
|
||||
const mesh = new THREE.Mesh(geom, mat.clone());
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
mesh.name = 'BUILDING_MESH';
|
||||
mesh.userData.cityData = b.data;
|
||||
|
||||
// CALCULATE NEAREST NODE FOR APPROVAL MECHANIC
|
||||
// We use the first point of the outer ring as a proxy for position
|
||||
const bx = b.shape.outer[0][0];
|
||||
const by = b.shape.outer[0][1];
|
||||
|
||||
const nearestId = routeManager.findNearestNode(bx, -by);
|
||||
mesh.userData.nearestNodeId = nearestId;
|
||||
|
||||
scene.add(mesh);
|
||||
});
|
||||
};
|
||||
|
||||
createBuildingLayer(data.buildings);
|
||||
createLayer(data.water, SETTINGS.colors.water, 0, 0.1, false);
|
||||
createLayer(data.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;
|
||||
|
||||
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: SETTINGS.colors.building,
|
||||
roughness: 0.6,
|
||||
side: THREE.DoubleSide,
|
||||
shadowSide: THREE.DoubleSide
|
||||
|
||||
});
|
||||
|
||||
buildings.forEach(b => {
|
||||
const shape = new THREE.Shape();
|
||||
if (b.shape.outer.length < 3) return;
|
||||
shape.moveTo(b.shape.outer[0][0], b.shape.outer[0][1]);
|
||||
for (let i = 1; i < b.shape.outer.length; i++) shape.lineTo(b.shape.outer[i][0], b.shape.outer[i][1]);
|
||||
|
||||
if (b.shape.holes) {
|
||||
b.shape.holes.forEach(h => {
|
||||
const path = new THREE.Path();
|
||||
path.moveTo(h[0][0], h[0][1]);
|
||||
for (let k = 1; k < h.length; k++) path.lineTo(h[k][0], h[k][1]);
|
||||
shape.holes.push(path);
|
||||
});
|
||||
}
|
||||
|
||||
const geom = new THREE.ExtrudeGeometry(shape, {
|
||||
depth: b.height,
|
||||
bevelEnabled: false
|
||||
});
|
||||
geom.rotateX(-Math.PI / 2);
|
||||
|
||||
const mesh = new THREE.Mesh(geom, mat.clone());
|
||||
mesh.castShadow = true;
|
||||
mesh.receiveShadow = true;
|
||||
|
||||
// Store metadata for the Zoning Toggle
|
||||
mesh.name = 'BUILDING_MESH';
|
||||
mesh.userData.cityData = b.data;
|
||||
|
||||
scene.add(mesh);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user