gameplay mechanics

This commit is contained in:
Evan Scamehorn
2025-12-02 14:40:18 -06:00
parent a3e057ef37
commit 68ca45f58e
8 changed files with 316 additions and 140 deletions

86
src/GameManager.js Normal file
View 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
});
}
}

View File

@@ -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() {

View File

@@ -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";

View File

@@ -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);
});