From 04167c656aa395bd0df3758ee05cc1915302baae Mon Sep 17 00:00:00 2001 From: Evan Scamehorn Date: Thu, 4 Dec 2025 14:28:53 -0600 Subject: [PATCH] UI: main/draft menu separation, ghost node indicator --- index.html | 39 +++++++++----- src/InputManager.js | 40 +++++++-------- src/RouteManager.js | 122 +++++++++++++++++++++++++++----------------- src/UIManager.js | 56 +++++++++++++++----- src/main.js | 19 ++++--- 5 files changed, 174 insertions(+), 102 deletions(-) diff --git a/index.html b/index.html index 49a958c..9119a04 100644 --- a/index.html +++ b/index.html @@ -50,24 +50,35 @@ -
-

Current Draft

- -
Length: 0 m
-
Cost: $0
-
Est. Riders: 0 / day -
- -
- - +
+
+ +

Active Routes

+
    -
    -

    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();