-
-
+
+
+
+
Active Routes
+
-
diff --git a/src/InputManager.js b/src/InputManager.js
index 6f5329e..41d8187 100644
--- a/src/InputManager.js
+++ b/src/InputManager.js
@@ -5,20 +5,21 @@ export class InputManager {
this.camera = camera;
this.domElement = domElement;
this.scene = scene;
- this.controls = controls; // Need access to controls to disable them during drag
+ this.controls = controls;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
// Interaction State
this.downPosition = new THREE.Vector2();
- this.dragObject = null; // The object currently being dragged (marker)
+ this.dragObject = null;
this.isPanning = false;
// Callbacks
this.onClick = null; // (point, object) -> void
this.onDrag = null; // (object, newPoint) -> void
this.onDragEnd = null; // () -> void
+ this.onHover = null; // (point) -> void <-- NEW
}
init() {
@@ -28,20 +29,17 @@ export class InputManager {
}
onPointerDown(event) {
- if (event.button !== 0) return; // Left click only
+ if (event.button !== 0) return;
- // Record start position for Pan detection
this.downPosition.set(event.clientX, event.clientY);
this.isPanning = false;
- // Raycast to see what we hit (Marker vs Ground)
const hit = this.raycast(event);
if (hit) {
- // Case A: We hit a Marker -> Start Dragging
if (hit.object.userData.isMarker) {
this.dragObject = hit.object;
- this.controls.enabled = false; // Disable camera orbit
+ this.controls.enabled = false;
this.domElement.style.cursor = 'grabbing';
}
}
@@ -50,7 +48,6 @@ export class InputManager {
onPointerMove(event) {
// Case A: Dragging a Marker
if (this.dragObject) {
- // Raycast against the GROUND to find where we are dragging to
const hit = this.raycastGround(event);
if (hit && this.onDrag) {
this.onDrag(this.dragObject, hit.point);
@@ -58,31 +55,30 @@ export class InputManager {
return;
}
- // Case B: Detecting Pan
- // If mouse is down and moving, check distance
- // (We don't need continuous logic here, just the final check in pointerUp is usually enough,
- // but for "floating pointer" later we'd use this.)
+ // Case B: Hovering (Ghost Marker Logic) <-- NEW
+ // We only care about hovering the ground for placing new nodes
+ const hit = this.raycastGround(event);
+ if (hit && this.onHover) {
+ this.onHover(hit.point);
+ }
}
onPointerUp(event) {
if (event.button !== 0) return;
- // 1. If we were dragging a marker, stop now.
if (this.dragObject) {
this.dragObject = null;
- this.controls.enabled = true; // Re-enable camera
+ this.controls.enabled = true;
this.domElement.style.cursor = 'auto';
if (this.onDragEnd) this.onDragEnd();
- return; // Don't trigger a click
+ return;
}
- // 2. Check if it was a Camera Pan (move > 3px)
const upPosition = new THREE.Vector2(event.clientX, event.clientY);
if (this.downPosition.distanceTo(upPosition) > 3) {
- return; // It was a pan, ignore
+ return;
}
- // 3. It was a clean Click (Place new node)
const hit = this.raycast(event);
if (hit && hit.object.name === "GROUND" && this.onClick) {
this.onClick(hit.point, hit.object);
@@ -100,10 +96,12 @@ export class InputManager {
raycast(event) {
this.raycaster.setFromCamera(this.getMouse(event), this.camera);
- // Intersection order: Markers (sorted by dist) -> Ground
+ // Ignore Ghost Marker in standard raycast interaction
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
- // Return first valid hit (Marker or Ground)
- return intersects.find(obj => obj.object.name === "GROUND" || obj.object.userData.isMarker);
+ return intersects.find(obj =>
+ (obj.object.name === "GROUND" || obj.object.userData.isMarker) &&
+ obj.object.name !== "GHOST_MARKER"
+ );
}
raycastGround(event) {
diff --git a/src/RouteManager.js b/src/RouteManager.js
index 91cf9c7..cf2939f 100644
--- a/src/RouteManager.js
+++ b/src/RouteManager.js
@@ -8,12 +8,14 @@ export class RouteManager {
this.graphData = null;
// -- State --
+ this.isDrafting = false; // New flag
this.currentRouteNodes = [];
- this.savedRoutes = []; // { nodes, stats, mesh, color }
+ this.savedRoutes = [];
// -- Visuals --
this.markers = [];
this.currentPathMesh = null;
+ this.ghostMarker = null; // Transparent sphere
this.servedNodes = new Set();
this.servedCoordinates = [];
@@ -25,6 +27,8 @@ export class RouteManager {
// Draft state
this.latestPathPoints = [];
+
+ this.initGhostMarker();
}
setVehicleSystem(vs) {
@@ -52,13 +56,61 @@ export class RouteManager {
});
}
+ // ============================
+ // Draft Mode & Ghost Marker
+ // ============================
+
+ initGhostMarker() {
+ const geom = new THREE.SphereGeometry(4);
+ const mat = new THREE.MeshBasicMaterial({
+ color: this.settings.colors.pathStart,
+ transparent: true,
+ opacity: 0.5
+ });
+ this.ghostMarker = new THREE.Mesh(geom, mat);
+ this.ghostMarker.visible = false;
+ this.ghostMarker.name = "GHOST_MARKER"; // Ignore in raycasting
+ this.scene.add(this.ghostMarker);
+ }
+
+ startDrafting() {
+ this.isDrafting = true;
+ this.resetDraftingState();
+ }
+
+ stopDrafting() {
+ this.isDrafting = false;
+ this.ghostMarker.visible = false;
+ this.clearCurrentRoute(); // Clean up visuals
+ }
+
+ // Called by Main.js input listener on mouse move
+ updateGhostMarker(worldPoint) {
+ if (!this.isDrafting || !this.graphData) {
+ this.ghostMarker.visible = false;
+ return;
+ }
+
+ if (!worldPoint) {
+ this.ghostMarker.visible = false;
+ return;
+ }
+
+ const nodeId = this.findNearestNode(worldPoint.x, worldPoint.z);
+ if (nodeId !== null) {
+ const node = this.graphData.nodes[nodeId];
+ this.ghostMarker.position.set(node.x, 2, node.y);
+ this.ghostMarker.visible = true;
+ } else {
+ this.ghostMarker.visible = false;
+ }
+ }
+
// ============================
// Save / Load / Serialization
// ============================
getSerializableRoutes() {
- // We only save the node IDs and the color.
- // Mesh and stats can be rebuilt.
return this.savedRoutes.map(r => ({
nodes: r.nodes,
color: r.color
@@ -66,7 +118,6 @@ export class RouteManager {
}
loadRoutes(routesData) {
- // 1. Cleanup existing
this.savedRoutes.forEach(r => {
if (r.mesh) {
this.scene.remove(r.mesh);
@@ -78,7 +129,6 @@ export class RouteManager {
if (this.vehicleSystem) this.vehicleSystem.clearVehicles();
- // 2. Rebuild each route
routesData.forEach((data, index) => {
this.rebuildRouteFromData(data.nodes, data.color || this.getRandomColor(), index);
});
@@ -87,27 +137,22 @@ export class RouteManager {
}
rebuildRouteFromData(nodes, color, routeIndex) {
- // 1. Calculate Path Geometry
const pathResult = this.calculateGeometryFromNodes(nodes);
if (!pathResult) return;
- // 2. Create Mesh
const tubeMat = new THREE.MeshBasicMaterial({ color: color });
const mesh = new THREE.Mesh(pathResult.geometry, tubeMat);
this.scene.add(mesh);
- // 3. Spawn Bus
if (this.vehicleSystem && pathResult.points.length > 0) {
this.vehicleSystem.addBusToRoute(pathResult.points, color, routeIndex);
}
- // 4. Calculate Stats
const ridership = this.calculateRidership(nodes);
- // 5. Store
this.savedRoutes.push({
nodes: [...nodes],
- stats: { length: pathResult.length, cost: 0, ridership }, // Cost is sunk history, doesn't matter for load
+ stats: { length: pathResult.length, cost: 0, ridership },
mesh: mesh,
color: color
});
@@ -123,25 +168,25 @@ export class RouteManager {
// ============================
saveCurrentRoute() {
- if (this.currentRouteNodes.length < 2 || !this.currentPathMesh) return;
+ if (!this.isDrafting) return false;
+ if (this.currentRouteNodes.length < 2 || !this.currentPathMesh) {
+ alert("Route must have at least 2 points.");
+ return false;
+ }
const length = this.currentPathMesh.userData.length || 0;
const cost = this.gameManager.getProjectedCost(length);
if (!this.gameManager.canAfford(cost)) {
alert("Insufficient Funds!");
- return;
+ return false;
}
this.gameManager.deductFunds(cost);
- // 1. Define Color (Random default)
const color = this.getRandomColor();
-
- // 2. Finalize Visuals
this.currentPathMesh.material.color.set(color);
- // 3. Register Route
const routeIndex = this.savedRoutes.length;
if (this.vehicleSystem && this.latestPathPoints.length > 0) {
@@ -157,29 +202,22 @@ export class RouteManager {
color: color
});
- // Cleanup draft state
- this.currentPathMesh = null;
- this.resetDraftingState();
+ // We do NOT call stopDrafting here, UIManager handles the logic to call stopDrafting
+ // We just return success
+ this.currentPathMesh = null; // Detach mesh from manager so it stays in scene
this.refreshServedNodes();
this.gameManager.recalculateApproval();
this.gameManager.updateUI();
+
+ return true;
}
updateRouteColor(index, hexColor) {
if (index < 0 || index >= this.savedRoutes.length) return;
-
const route = this.savedRoutes[index];
route.color = hexColor;
-
- // Update Track Mesh
- if (route.mesh) {
- route.mesh.material.color.set(hexColor);
- }
-
- // Update Vehicles
- if (this.vehicleSystem) {
- this.vehicleSystem.updateRouteColor(index, hexColor);
- }
+ if (route.mesh) route.mesh.material.color.set(hexColor);
+ if (this.vehicleSystem) this.vehicleSystem.updateRouteColor(index, hexColor);
}
deleteSavedRoute(index) {
@@ -191,19 +229,11 @@ export class RouteManager {
route.mesh.geometry.dispose();
}
- // We can't easily remove buses for just one route without refactoring array indices
- // Simplification: Reload all buses or mark them as dead.
- // To keep it simple: We will remove the route data, and rebuild the vehicle system's arrays relative to new indices.
-
- // 1. Remove from array
this.savedRoutes.splice(index, 1);
- // 2. Refresh simulation (easiest way to handle index shifts)
- // Save current state -> Clear Vehicles -> Rebuild Vehicles
if (this.vehicleSystem) {
this.vehicleSystem.clearVehicles();
this.savedRoutes.forEach((r, idx) => {
- // Need to regenerate points for the bus system
const pathRes = this.calculateGeometryFromNodes(r.nodes);
if (pathRes && pathRes.points.length > 0) {
this.vehicleSystem.addBusToRoute(pathRes.points, r.color, idx);
@@ -220,15 +250,11 @@ export class RouteManager {
// Delete and pull back to draft
if (index < 0 || index >= this.savedRoutes.length) return;
- // We actually just delete it and put nodes in draft
- // The player loses the "sunk cost" of construction in this simple version
const route = this.savedRoutes[index];
this.currentRouteNodes = [...route.nodes];
-
- // Delete the existing
this.deleteSavedRoute(index);
- // Visualize draft
+ // Visualize draft immediately
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
this.updatePathVisuals();
}
@@ -292,10 +318,10 @@ export class RouteManager {
return Math.floor(synergy * GAME_BALANCE_MULTIPLIER);
}
- // ... (Existing Pathfinding & Drafting Methods) ...
-
addNodeByWorldPosition(vector3) {
+ if (!this.isDrafting) return; // BLOCK INPUT IF NOT DRAFTING
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;
@@ -305,6 +331,7 @@ export class RouteManager {
}
dragNode(markerObject, worldPoint) {
+ if (!this.isDrafting) return; // BLOCK DRAG IF NOT DRAFTING
if (!this.graphData) return;
const index = this.markers.indexOf(markerObject);
if (index === -1) return;
@@ -342,13 +369,11 @@ export class RouteManager {
return;
}
- // Reuse logic
const result = this.calculateGeometryFromNodes(this.currentRouteNodes);
if (!result) return;
this.latestPathPoints = result.points;
- // Rebuild Mesh
if (this.currentPathMesh) {
this.scene.remove(this.currentPathMesh);
this.currentPathMesh.geometry.dispose();
@@ -505,3 +530,4 @@ export class RouteManager {
return newPath;
}
}
+
diff --git a/src/UIManager.js b/src/UIManager.js
index 1c85ea6..29adc61 100644
--- a/src/UIManager.js
+++ b/src/UIManager.js
@@ -3,6 +3,10 @@ export class UIManager {
this.routeManager = routeManager;
this.gameManager = null;
+ // Panels
+ this.panelMain = document.getElementById('ui-main-menu');
+ this.panelDraft = document.getElementById('ui-draft-menu');
+
// UI Elements
this.elCurrentLength = document.getElementById('current-length');
this.elCurrentCost = document.getElementById('current-cost');
@@ -17,6 +21,8 @@ export class UIManager {
this.elRouteList = document.getElementById('route-list');
this.elContainer = document.getElementById('ui-container');
+ // Buttons
+ this.btnCreate = document.getElementById('btn-create-route');
this.btnSave = document.getElementById('btn-save');
this.btnDiscard = document.getElementById('btn-discard');
this.btnToggle = document.getElementById('ui-toggle');
@@ -34,14 +40,24 @@ export class UIManager {
}
initListeners() {
+ // --- MODE SWITCHING ---
+ this.btnCreate.addEventListener('click', () => {
+ this.enterDraftMode();
+ });
+
this.btnSave.addEventListener('click', () => {
- this.routeManager.saveCurrentRoute();
- this.renderRouteList();
+ const success = this.routeManager.saveCurrentRoute();
+ if (success) {
+ this.renderRouteList();
+ this.exitDraftMode();
+ }
});
this.btnDiscard.addEventListener('click', () => {
this.routeManager.clearCurrentRoute();
+ this.exitDraftMode();
});
+ // ----------------------
this.btnToggle.addEventListener('click', () => {
this.elContainer.classList.toggle('hidden');
@@ -80,6 +96,18 @@ export class UIManager {
});
}
+ enterDraftMode() {
+ this.panelMain.style.display = 'none';
+ this.panelDraft.style.display = 'block';
+ this.routeManager.startDrafting();
+ }
+
+ exitDraftMode() {
+ this.panelMain.style.display = 'block';
+ this.panelDraft.style.display = 'none';
+ this.routeManager.stopDrafting();
+ }
+
updateGameStats(stats) {
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
this.elDay.textContent = stats.day;
@@ -114,6 +142,11 @@ export class UIManager {
this.elRouteList.innerHTML = '';
const routes = this.routeManager.getSavedRoutes();
+ if (routes.length === 0) {
+ this.elRouteList.innerHTML = '
No active routes.
Click create to build one.';
+ return;
+ }
+
routes.forEach((route, index) => {
const li = document.createElement('li');
li.style.display = 'flex';
@@ -123,14 +156,12 @@ export class UIManager {
li.style.borderBottom = '1px solid #eee';
// --- BADGE CONTAINER ---
- // This holds both the visual badge and the invisible input on top of it
const badgeContainer = document.createElement('div');
badgeContainer.style.position = 'relative';
badgeContainer.style.width = '28px';
badgeContainer.style.height = '28px';
badgeContainer.style.marginRight = '10px';
- // 1. The Visual Badge (Background)
const badge = document.createElement('div');
badge.textContent = (index + 1);
badge.style.width = '100%';
@@ -145,23 +176,19 @@ export class UIManager {
badge.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
badge.style.textShadow = '0 1px 2px rgba(0,0,0,0.5)';
- // 2. The Invisible Input (Overlay)
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = route.color || "#000000";
-
- // Style to overlay exactly on top of the badge
colorInput.style.position = 'absolute';
colorInput.style.top = '0';
colorInput.style.left = '0';
colorInput.style.width = '100%';
colorInput.style.height = '100%';
- colorInput.style.opacity = '0'; // Visually invisible
- colorInput.style.cursor = 'pointer'; // Show pointer so user knows it's clickable
+ colorInput.style.opacity = '0';
+ colorInput.style.cursor = 'pointer';
colorInput.style.border = 'none';
colorInput.style.padding = '0';
- // Update logic
colorInput.addEventListener('input', (e) => {
const newColor = e.target.value;
badge.style.backgroundColor = newColor;
@@ -198,8 +225,9 @@ export class UIManager {
btnEdit.title = "Redraw Route";
btnEdit.style.padding = "4px 8px";
btnEdit.onclick = () => {
+ // Edit flow: Enter draft mode with existing data
this.routeManager.editSavedRoute(index);
- this.renderRouteList();
+ this.enterDraftMode(); // UI Change
};
const btnDel = document.createElement('button');
@@ -209,8 +237,10 @@ export class UIManager {
btnDel.style.color = "#ef4444";
btnDel.style.padding = "4px 8px";
btnDel.onclick = () => {
- this.routeManager.deleteSavedRoute(index);
- this.renderRouteList();
+ if (confirm("Delete this route?")) {
+ this.routeManager.deleteSavedRoute(index);
+ this.renderRouteList();
+ }
};
btnDiv.appendChild(btnEdit);
diff --git a/src/main.js b/src/main.js
index c5f1440..7eb7424 100644
--- a/src/main.js
+++ b/src/main.js
@@ -49,25 +49,33 @@ function init() {
// 2. Game Logic
gameManager = new GameManager(routeManager, uiManager);
- routeManager.setGameManager(gameManager); // Dependency Injection
+ routeManager.setGameManager(gameManager);
// Vehicle System
vehicleSystem = new VehicleSystem(scene);
- routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager
-
+ routeManager.setVehicleSystem(vehicleSystem);
// 3. Input
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
inputManager.init();
- // Wiring
+ // Wiring Click
inputManager.onClick = (point, object) => {
+ // Only allow adding nodes if we are actually in drafting mode
+ // (RouteManager handles the check internally, but we pass the intent)
if (object.name === "GROUND") routeManager.addNodeByWorldPosition(point);
};
+
+ // Wiring Drag
inputManager.onDrag = (markerObject, newPoint) => {
routeManager.dragNode(markerObject, newPoint);
};
+ // Wiring Hover (NEW)
+ inputManager.onHover = (point) => {
+ routeManager.updateGhostMarker(point);
+ };
+
// Wire UI View Mode
uiManager.onViewModeChanged = (mode) => {
currentViewMode = mode;
@@ -76,7 +84,6 @@ function init() {
routeManager.onRouteChanged = (stats) => {
uiManager.updateDraftStats(stats);
- // If in coverage view, we might need to refresh colors as coverage changes
if (currentViewMode === 'approval') updateBuildingColors();
};
@@ -87,7 +94,7 @@ function init() {
]).then(([visual, routing]) => {
routeManager.initGraph(routing);
renderCity(visual);
- gameManager.start(); // Start the loop
+ gameManager.start();
});
animate();