gameplay mechanics
This commit is contained in:
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