UI: main/draft menu separation, ghost node indicator
This commit is contained in:
39
index.html
39
index.html
@@ -50,24 +50,35 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<div id="ui-main-menu">
|
||||||
<h3>Current Draft</h3>
|
<div class="section">
|
||||||
|
<button id="btn-create-route" class="primary"
|
||||||
<div class="stat-row"><span>Length:</span> <span id="current-length">0 m</span></div>
|
style="width:100%; padding: 12px; margin-bottom: 15px; font-weight:bold;">
|
||||||
<div class="stat-row"><span>Cost:</span> <span id="current-cost" style="color:#ef4444">$0</span></div>
|
+ Create New Route
|
||||||
<div class="stat-row"><span>Est. Riders:</span> <span id="current-riders" style="color:#10B981">0 / day</span>
|
</button>
|
||||||
</div>
|
<h3>Active Routes</h3>
|
||||||
|
<ul id="route-list"></ul>
|
||||||
<div class="button-row">
|
|
||||||
<button id="btn-save" class="primary">Build Route</button>
|
|
||||||
<button id="btn-discard" class="danger">Discard</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="section">
|
<!-- DRAFTING STATE (Hidden by default) -->
|
||||||
<h3>Active Routes</h3>
|
<div id="ui-draft-menu" style="display:none;">
|
||||||
<ul id="route-list"></ul>
|
<div class="section" style="border: 2px solid #2563EB; padding: 10px; border-radius: 6px; background: #eff6ff;">
|
||||||
|
<h3 style="margin-top:0; color:#2563EB;">Route Planner</h3>
|
||||||
|
<p style="font-size:12px; color:#666; margin-bottom:5px;">Left Click: Add Point | Drag: Move</p>
|
||||||
|
|
||||||
|
<div class="stat-row"><span>Length:</span> <span id="current-length">0 m</span></div>
|
||||||
|
<div class="stat-row"><span>Cost:</span> <span id="current-cost" style="color:#ef4444">$0</span></div>
|
||||||
|
<div class="stat-row"><span>Est. Riders:</span> <span id="current-riders" style="color:#10B981">0 / day</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-row">
|
||||||
|
<button id="btn-save" class="primary">Build Route</button>
|
||||||
|
<button id="btn-discard" class="danger">Discard / Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="module" src="/src/main.js"></script>
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
|||||||
@@ -5,20 +5,21 @@ export class InputManager {
|
|||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
this.domElement = domElement;
|
this.domElement = domElement;
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.controls = controls; // Need access to controls to disable them during drag
|
this.controls = controls;
|
||||||
|
|
||||||
this.raycaster = new THREE.Raycaster();
|
this.raycaster = new THREE.Raycaster();
|
||||||
this.mouse = new THREE.Vector2();
|
this.mouse = new THREE.Vector2();
|
||||||
|
|
||||||
// Interaction State
|
// Interaction State
|
||||||
this.downPosition = new THREE.Vector2();
|
this.downPosition = new THREE.Vector2();
|
||||||
this.dragObject = null; // The object currently being dragged (marker)
|
this.dragObject = null;
|
||||||
this.isPanning = false;
|
this.isPanning = false;
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
this.onClick = null; // (point, object) -> void
|
this.onClick = null; // (point, object) -> void
|
||||||
this.onDrag = null; // (object, newPoint) -> void
|
this.onDrag = null; // (object, newPoint) -> void
|
||||||
this.onDragEnd = null; // () -> void
|
this.onDragEnd = null; // () -> void
|
||||||
|
this.onHover = null; // (point) -> void <-- NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -28,20 +29,17 @@ export class InputManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onPointerDown(event) {
|
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.downPosition.set(event.clientX, event.clientY);
|
||||||
this.isPanning = false;
|
this.isPanning = false;
|
||||||
|
|
||||||
// Raycast to see what we hit (Marker vs Ground)
|
|
||||||
const hit = this.raycast(event);
|
const hit = this.raycast(event);
|
||||||
|
|
||||||
if (hit) {
|
if (hit) {
|
||||||
// Case A: We hit a Marker -> Start Dragging
|
|
||||||
if (hit.object.userData.isMarker) {
|
if (hit.object.userData.isMarker) {
|
||||||
this.dragObject = hit.object;
|
this.dragObject = hit.object;
|
||||||
this.controls.enabled = false; // Disable camera orbit
|
this.controls.enabled = false;
|
||||||
this.domElement.style.cursor = 'grabbing';
|
this.domElement.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +48,6 @@ export class InputManager {
|
|||||||
onPointerMove(event) {
|
onPointerMove(event) {
|
||||||
// Case A: Dragging a Marker
|
// Case A: Dragging a Marker
|
||||||
if (this.dragObject) {
|
if (this.dragObject) {
|
||||||
// Raycast against the GROUND to find where we are dragging to
|
|
||||||
const hit = this.raycastGround(event);
|
const hit = this.raycastGround(event);
|
||||||
if (hit && this.onDrag) {
|
if (hit && this.onDrag) {
|
||||||
this.onDrag(this.dragObject, hit.point);
|
this.onDrag(this.dragObject, hit.point);
|
||||||
@@ -58,31 +55,30 @@ export class InputManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case B: Detecting Pan
|
// Case B: Hovering (Ghost Marker Logic) <-- NEW
|
||||||
// If mouse is down and moving, check distance
|
// We only care about hovering the ground for placing new nodes
|
||||||
// (We don't need continuous logic here, just the final check in pointerUp is usually enough,
|
const hit = this.raycastGround(event);
|
||||||
// but for "floating pointer" later we'd use this.)
|
if (hit && this.onHover) {
|
||||||
|
this.onHover(hit.point);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onPointerUp(event) {
|
onPointerUp(event) {
|
||||||
if (event.button !== 0) return;
|
if (event.button !== 0) return;
|
||||||
|
|
||||||
// 1. If we were dragging a marker, stop now.
|
|
||||||
if (this.dragObject) {
|
if (this.dragObject) {
|
||||||
this.dragObject = null;
|
this.dragObject = null;
|
||||||
this.controls.enabled = true; // Re-enable camera
|
this.controls.enabled = true;
|
||||||
this.domElement.style.cursor = 'auto';
|
this.domElement.style.cursor = 'auto';
|
||||||
if (this.onDragEnd) this.onDragEnd();
|
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);
|
const upPosition = new THREE.Vector2(event.clientX, event.clientY);
|
||||||
if (this.downPosition.distanceTo(upPosition) > 3) {
|
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);
|
const hit = this.raycast(event);
|
||||||
if (hit && hit.object.name === "GROUND" && this.onClick) {
|
if (hit && hit.object.name === "GROUND" && this.onClick) {
|
||||||
this.onClick(hit.point, hit.object);
|
this.onClick(hit.point, hit.object);
|
||||||
@@ -100,10 +96,12 @@ export class InputManager {
|
|||||||
|
|
||||||
raycast(event) {
|
raycast(event) {
|
||||||
this.raycaster.setFromCamera(this.getMouse(event), this.camera);
|
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);
|
const intersects = this.raycaster.intersectObjects(this.scene.children, true);
|
||||||
// Return first valid hit (Marker or Ground)
|
return intersects.find(obj =>
|
||||||
return intersects.find(obj => obj.object.name === "GROUND" || obj.object.userData.isMarker);
|
(obj.object.name === "GROUND" || obj.object.userData.isMarker) &&
|
||||||
|
obj.object.name !== "GHOST_MARKER"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
raycastGround(event) {
|
raycastGround(event) {
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ export class RouteManager {
|
|||||||
this.graphData = null;
|
this.graphData = null;
|
||||||
|
|
||||||
// -- State --
|
// -- State --
|
||||||
|
this.isDrafting = false; // New flag
|
||||||
this.currentRouteNodes = [];
|
this.currentRouteNodes = [];
|
||||||
this.savedRoutes = []; // { nodes, stats, mesh, color }
|
this.savedRoutes = [];
|
||||||
|
|
||||||
// -- Visuals --
|
// -- Visuals --
|
||||||
this.markers = [];
|
this.markers = [];
|
||||||
this.currentPathMesh = null;
|
this.currentPathMesh = null;
|
||||||
|
this.ghostMarker = null; // Transparent sphere
|
||||||
|
|
||||||
this.servedNodes = new Set();
|
this.servedNodes = new Set();
|
||||||
this.servedCoordinates = [];
|
this.servedCoordinates = [];
|
||||||
@@ -25,6 +27,8 @@ export class RouteManager {
|
|||||||
|
|
||||||
// Draft state
|
// Draft state
|
||||||
this.latestPathPoints = [];
|
this.latestPathPoints = [];
|
||||||
|
|
||||||
|
this.initGhostMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
setVehicleSystem(vs) {
|
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
|
// Save / Load / Serialization
|
||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
getSerializableRoutes() {
|
getSerializableRoutes() {
|
||||||
// We only save the node IDs and the color.
|
|
||||||
// Mesh and stats can be rebuilt.
|
|
||||||
return this.savedRoutes.map(r => ({
|
return this.savedRoutes.map(r => ({
|
||||||
nodes: r.nodes,
|
nodes: r.nodes,
|
||||||
color: r.color
|
color: r.color
|
||||||
@@ -66,7 +118,6 @@ export class RouteManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadRoutes(routesData) {
|
loadRoutes(routesData) {
|
||||||
// 1. Cleanup existing
|
|
||||||
this.savedRoutes.forEach(r => {
|
this.savedRoutes.forEach(r => {
|
||||||
if (r.mesh) {
|
if (r.mesh) {
|
||||||
this.scene.remove(r.mesh);
|
this.scene.remove(r.mesh);
|
||||||
@@ -78,7 +129,6 @@ export class RouteManager {
|
|||||||
|
|
||||||
if (this.vehicleSystem) this.vehicleSystem.clearVehicles();
|
if (this.vehicleSystem) this.vehicleSystem.clearVehicles();
|
||||||
|
|
||||||
// 2. Rebuild each route
|
|
||||||
routesData.forEach((data, index) => {
|
routesData.forEach((data, index) => {
|
||||||
this.rebuildRouteFromData(data.nodes, data.color || this.getRandomColor(), index);
|
this.rebuildRouteFromData(data.nodes, data.color || this.getRandomColor(), index);
|
||||||
});
|
});
|
||||||
@@ -87,27 +137,22 @@ export class RouteManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rebuildRouteFromData(nodes, color, routeIndex) {
|
rebuildRouteFromData(nodes, color, routeIndex) {
|
||||||
// 1. Calculate Path Geometry
|
|
||||||
const pathResult = this.calculateGeometryFromNodes(nodes);
|
const pathResult = this.calculateGeometryFromNodes(nodes);
|
||||||
if (!pathResult) return;
|
if (!pathResult) return;
|
||||||
|
|
||||||
// 2. Create Mesh
|
|
||||||
const tubeMat = new THREE.MeshBasicMaterial({ color: color });
|
const tubeMat = new THREE.MeshBasicMaterial({ color: color });
|
||||||
const mesh = new THREE.Mesh(pathResult.geometry, tubeMat);
|
const mesh = new THREE.Mesh(pathResult.geometry, tubeMat);
|
||||||
this.scene.add(mesh);
|
this.scene.add(mesh);
|
||||||
|
|
||||||
// 3. Spawn Bus
|
|
||||||
if (this.vehicleSystem && pathResult.points.length > 0) {
|
if (this.vehicleSystem && pathResult.points.length > 0) {
|
||||||
this.vehicleSystem.addBusToRoute(pathResult.points, color, routeIndex);
|
this.vehicleSystem.addBusToRoute(pathResult.points, color, routeIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Calculate Stats
|
|
||||||
const ridership = this.calculateRidership(nodes);
|
const ridership = this.calculateRidership(nodes);
|
||||||
|
|
||||||
// 5. Store
|
|
||||||
this.savedRoutes.push({
|
this.savedRoutes.push({
|
||||||
nodes: [...nodes],
|
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,
|
mesh: mesh,
|
||||||
color: color
|
color: color
|
||||||
});
|
});
|
||||||
@@ -123,25 +168,25 @@ export class RouteManager {
|
|||||||
// ============================
|
// ============================
|
||||||
|
|
||||||
saveCurrentRoute() {
|
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 length = this.currentPathMesh.userData.length || 0;
|
||||||
const cost = this.gameManager.getProjectedCost(length);
|
const cost = this.gameManager.getProjectedCost(length);
|
||||||
|
|
||||||
if (!this.gameManager.canAfford(cost)) {
|
if (!this.gameManager.canAfford(cost)) {
|
||||||
alert("Insufficient Funds!");
|
alert("Insufficient Funds!");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.gameManager.deductFunds(cost);
|
this.gameManager.deductFunds(cost);
|
||||||
|
|
||||||
// 1. Define Color (Random default)
|
|
||||||
const color = this.getRandomColor();
|
const color = this.getRandomColor();
|
||||||
|
|
||||||
// 2. Finalize Visuals
|
|
||||||
this.currentPathMesh.material.color.set(color);
|
this.currentPathMesh.material.color.set(color);
|
||||||
|
|
||||||
// 3. Register Route
|
|
||||||
const routeIndex = this.savedRoutes.length;
|
const routeIndex = this.savedRoutes.length;
|
||||||
|
|
||||||
if (this.vehicleSystem && this.latestPathPoints.length > 0) {
|
if (this.vehicleSystem && this.latestPathPoints.length > 0) {
|
||||||
@@ -157,29 +202,22 @@ export class RouteManager {
|
|||||||
color: color
|
color: color
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cleanup draft state
|
// We do NOT call stopDrafting here, UIManager handles the logic to call stopDrafting
|
||||||
this.currentPathMesh = null;
|
// We just return success
|
||||||
this.resetDraftingState();
|
this.currentPathMesh = null; // Detach mesh from manager so it stays in scene
|
||||||
this.refreshServedNodes();
|
this.refreshServedNodes();
|
||||||
this.gameManager.recalculateApproval();
|
this.gameManager.recalculateApproval();
|
||||||
this.gameManager.updateUI();
|
this.gameManager.updateUI();
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateRouteColor(index, hexColor) {
|
updateRouteColor(index, hexColor) {
|
||||||
if (index < 0 || index >= this.savedRoutes.length) return;
|
if (index < 0 || index >= this.savedRoutes.length) return;
|
||||||
|
|
||||||
const route = this.savedRoutes[index];
|
const route = this.savedRoutes[index];
|
||||||
route.color = hexColor;
|
route.color = hexColor;
|
||||||
|
if (route.mesh) route.mesh.material.color.set(hexColor);
|
||||||
// Update Track Mesh
|
if (this.vehicleSystem) this.vehicleSystem.updateRouteColor(index, hexColor);
|
||||||
if (route.mesh) {
|
|
||||||
route.mesh.material.color.set(hexColor);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Vehicles
|
|
||||||
if (this.vehicleSystem) {
|
|
||||||
this.vehicleSystem.updateRouteColor(index, hexColor);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteSavedRoute(index) {
|
deleteSavedRoute(index) {
|
||||||
@@ -191,19 +229,11 @@ export class RouteManager {
|
|||||||
route.mesh.geometry.dispose();
|
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);
|
this.savedRoutes.splice(index, 1);
|
||||||
|
|
||||||
// 2. Refresh simulation (easiest way to handle index shifts)
|
|
||||||
// Save current state -> Clear Vehicles -> Rebuild Vehicles
|
|
||||||
if (this.vehicleSystem) {
|
if (this.vehicleSystem) {
|
||||||
this.vehicleSystem.clearVehicles();
|
this.vehicleSystem.clearVehicles();
|
||||||
this.savedRoutes.forEach((r, idx) => {
|
this.savedRoutes.forEach((r, idx) => {
|
||||||
// Need to regenerate points for the bus system
|
|
||||||
const pathRes = this.calculateGeometryFromNodes(r.nodes);
|
const pathRes = this.calculateGeometryFromNodes(r.nodes);
|
||||||
if (pathRes && pathRes.points.length > 0) {
|
if (pathRes && pathRes.points.length > 0) {
|
||||||
this.vehicleSystem.addBusToRoute(pathRes.points, r.color, idx);
|
this.vehicleSystem.addBusToRoute(pathRes.points, r.color, idx);
|
||||||
@@ -220,15 +250,11 @@ export class RouteManager {
|
|||||||
// Delete and pull back to draft
|
// Delete and pull back to draft
|
||||||
if (index < 0 || index >= this.savedRoutes.length) return;
|
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];
|
const route = this.savedRoutes[index];
|
||||||
this.currentRouteNodes = [...route.nodes];
|
this.currentRouteNodes = [...route.nodes];
|
||||||
|
|
||||||
// Delete the existing
|
|
||||||
this.deleteSavedRoute(index);
|
this.deleteSavedRoute(index);
|
||||||
|
|
||||||
// Visualize draft
|
// Visualize draft immediately
|
||||||
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
|
this.currentRouteNodes.forEach(nodeId => this.addMarkerVisual(nodeId));
|
||||||
this.updatePathVisuals();
|
this.updatePathVisuals();
|
||||||
}
|
}
|
||||||
@@ -292,10 +318,10 @@ export class RouteManager {
|
|||||||
return Math.floor(synergy * GAME_BALANCE_MULTIPLIER);
|
return Math.floor(synergy * GAME_BALANCE_MULTIPLIER);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ... (Existing Pathfinding & Drafting Methods) ...
|
|
||||||
|
|
||||||
addNodeByWorldPosition(vector3) {
|
addNodeByWorldPosition(vector3) {
|
||||||
|
if (!this.isDrafting) return; // BLOCK INPUT IF NOT DRAFTING
|
||||||
if (!this.graphData) return;
|
if (!this.graphData) return;
|
||||||
|
|
||||||
const nodeId = this.findNearestNode(vector3.x, vector3.z);
|
const nodeId = this.findNearestNode(vector3.x, vector3.z);
|
||||||
if (nodeId === null) return;
|
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;
|
||||||
@@ -305,6 +331,7 @@ export class RouteManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dragNode(markerObject, worldPoint) {
|
dragNode(markerObject, worldPoint) {
|
||||||
|
if (!this.isDrafting) return; // BLOCK DRAG IF NOT DRAFTING
|
||||||
if (!this.graphData) return;
|
if (!this.graphData) return;
|
||||||
const index = this.markers.indexOf(markerObject);
|
const index = this.markers.indexOf(markerObject);
|
||||||
if (index === -1) return;
|
if (index === -1) return;
|
||||||
@@ -342,13 +369,11 @@ export class RouteManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse logic
|
|
||||||
const result = this.calculateGeometryFromNodes(this.currentRouteNodes);
|
const result = this.calculateGeometryFromNodes(this.currentRouteNodes);
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
this.latestPathPoints = result.points;
|
this.latestPathPoints = result.points;
|
||||||
|
|
||||||
// Rebuild Mesh
|
|
||||||
if (this.currentPathMesh) {
|
if (this.currentPathMesh) {
|
||||||
this.scene.remove(this.currentPathMesh);
|
this.scene.remove(this.currentPathMesh);
|
||||||
this.currentPathMesh.geometry.dispose();
|
this.currentPathMesh.geometry.dispose();
|
||||||
@@ -505,3 +530,4 @@ export class RouteManager {
|
|||||||
return newPath;
|
return newPath;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ export class UIManager {
|
|||||||
this.routeManager = routeManager;
|
this.routeManager = routeManager;
|
||||||
this.gameManager = null;
|
this.gameManager = null;
|
||||||
|
|
||||||
|
// Panels
|
||||||
|
this.panelMain = document.getElementById('ui-main-menu');
|
||||||
|
this.panelDraft = document.getElementById('ui-draft-menu');
|
||||||
|
|
||||||
// UI Elements
|
// UI Elements
|
||||||
this.elCurrentLength = document.getElementById('current-length');
|
this.elCurrentLength = document.getElementById('current-length');
|
||||||
this.elCurrentCost = document.getElementById('current-cost');
|
this.elCurrentCost = document.getElementById('current-cost');
|
||||||
@@ -17,6 +21,8 @@ export class UIManager {
|
|||||||
this.elRouteList = document.getElementById('route-list');
|
this.elRouteList = document.getElementById('route-list');
|
||||||
this.elContainer = document.getElementById('ui-container');
|
this.elContainer = document.getElementById('ui-container');
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
this.btnCreate = document.getElementById('btn-create-route');
|
||||||
this.btnSave = document.getElementById('btn-save');
|
this.btnSave = document.getElementById('btn-save');
|
||||||
this.btnDiscard = document.getElementById('btn-discard');
|
this.btnDiscard = document.getElementById('btn-discard');
|
||||||
this.btnToggle = document.getElementById('ui-toggle');
|
this.btnToggle = document.getElementById('ui-toggle');
|
||||||
@@ -34,14 +40,24 @@ export class UIManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initListeners() {
|
initListeners() {
|
||||||
|
// --- MODE SWITCHING ---
|
||||||
|
this.btnCreate.addEventListener('click', () => {
|
||||||
|
this.enterDraftMode();
|
||||||
|
});
|
||||||
|
|
||||||
this.btnSave.addEventListener('click', () => {
|
this.btnSave.addEventListener('click', () => {
|
||||||
this.routeManager.saveCurrentRoute();
|
const success = this.routeManager.saveCurrentRoute();
|
||||||
this.renderRouteList();
|
if (success) {
|
||||||
|
this.renderRouteList();
|
||||||
|
this.exitDraftMode();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.btnDiscard.addEventListener('click', () => {
|
this.btnDiscard.addEventListener('click', () => {
|
||||||
this.routeManager.clearCurrentRoute();
|
this.routeManager.clearCurrentRoute();
|
||||||
|
this.exitDraftMode();
|
||||||
});
|
});
|
||||||
|
// ----------------------
|
||||||
|
|
||||||
this.btnToggle.addEventListener('click', () => {
|
this.btnToggle.addEventListener('click', () => {
|
||||||
this.elContainer.classList.toggle('hidden');
|
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) {
|
updateGameStats(stats) {
|
||||||
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
|
this.elBudget.textContent = "$" + stats.budget.toLocaleString();
|
||||||
this.elDay.textContent = stats.day;
|
this.elDay.textContent = stats.day;
|
||||||
@@ -114,6 +142,11 @@ export class UIManager {
|
|||||||
this.elRouteList.innerHTML = '';
|
this.elRouteList.innerHTML = '';
|
||||||
const routes = this.routeManager.getSavedRoutes();
|
const routes = this.routeManager.getSavedRoutes();
|
||||||
|
|
||||||
|
if (routes.length === 0) {
|
||||||
|
this.elRouteList.innerHTML = '<li style="color:#999; text-align:center; font-style:italic; padding:10px;">No active routes.<br>Click create to build one.</li>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
routes.forEach((route, index) => {
|
routes.forEach((route, index) => {
|
||||||
const li = document.createElement('li');
|
const li = document.createElement('li');
|
||||||
li.style.display = 'flex';
|
li.style.display = 'flex';
|
||||||
@@ -123,14 +156,12 @@ export class UIManager {
|
|||||||
li.style.borderBottom = '1px solid #eee';
|
li.style.borderBottom = '1px solid #eee';
|
||||||
|
|
||||||
// --- BADGE CONTAINER ---
|
// --- BADGE CONTAINER ---
|
||||||
// This holds both the visual badge and the invisible input on top of it
|
|
||||||
const badgeContainer = document.createElement('div');
|
const badgeContainer = document.createElement('div');
|
||||||
badgeContainer.style.position = 'relative';
|
badgeContainer.style.position = 'relative';
|
||||||
badgeContainer.style.width = '28px';
|
badgeContainer.style.width = '28px';
|
||||||
badgeContainer.style.height = '28px';
|
badgeContainer.style.height = '28px';
|
||||||
badgeContainer.style.marginRight = '10px';
|
badgeContainer.style.marginRight = '10px';
|
||||||
|
|
||||||
// 1. The Visual Badge (Background)
|
|
||||||
const badge = document.createElement('div');
|
const badge = document.createElement('div');
|
||||||
badge.textContent = (index + 1);
|
badge.textContent = (index + 1);
|
||||||
badge.style.width = '100%';
|
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.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
|
||||||
badge.style.textShadow = '0 1px 2px rgba(0,0,0,0.5)';
|
badge.style.textShadow = '0 1px 2px rgba(0,0,0,0.5)';
|
||||||
|
|
||||||
// 2. The Invisible Input (Overlay)
|
|
||||||
const colorInput = document.createElement('input');
|
const colorInput = document.createElement('input');
|
||||||
colorInput.type = 'color';
|
colorInput.type = 'color';
|
||||||
colorInput.value = route.color || "#000000";
|
colorInput.value = route.color || "#000000";
|
||||||
|
|
||||||
// Style to overlay exactly on top of the badge
|
|
||||||
colorInput.style.position = 'absolute';
|
colorInput.style.position = 'absolute';
|
||||||
colorInput.style.top = '0';
|
colorInput.style.top = '0';
|
||||||
colorInput.style.left = '0';
|
colorInput.style.left = '0';
|
||||||
colorInput.style.width = '100%';
|
colorInput.style.width = '100%';
|
||||||
colorInput.style.height = '100%';
|
colorInput.style.height = '100%';
|
||||||
colorInput.style.opacity = '0'; // Visually invisible
|
colorInput.style.opacity = '0';
|
||||||
colorInput.style.cursor = 'pointer'; // Show pointer so user knows it's clickable
|
colorInput.style.cursor = 'pointer';
|
||||||
colorInput.style.border = 'none';
|
colorInput.style.border = 'none';
|
||||||
colorInput.style.padding = '0';
|
colorInput.style.padding = '0';
|
||||||
|
|
||||||
// Update logic
|
|
||||||
colorInput.addEventListener('input', (e) => {
|
colorInput.addEventListener('input', (e) => {
|
||||||
const newColor = e.target.value;
|
const newColor = e.target.value;
|
||||||
badge.style.backgroundColor = newColor;
|
badge.style.backgroundColor = newColor;
|
||||||
@@ -198,8 +225,9 @@ export class UIManager {
|
|||||||
btnEdit.title = "Redraw Route";
|
btnEdit.title = "Redraw Route";
|
||||||
btnEdit.style.padding = "4px 8px";
|
btnEdit.style.padding = "4px 8px";
|
||||||
btnEdit.onclick = () => {
|
btnEdit.onclick = () => {
|
||||||
|
// Edit flow: Enter draft mode with existing data
|
||||||
this.routeManager.editSavedRoute(index);
|
this.routeManager.editSavedRoute(index);
|
||||||
this.renderRouteList();
|
this.enterDraftMode(); // UI Change
|
||||||
};
|
};
|
||||||
|
|
||||||
const btnDel = document.createElement('button');
|
const btnDel = document.createElement('button');
|
||||||
@@ -209,8 +237,10 @@ export class UIManager {
|
|||||||
btnDel.style.color = "#ef4444";
|
btnDel.style.color = "#ef4444";
|
||||||
btnDel.style.padding = "4px 8px";
|
btnDel.style.padding = "4px 8px";
|
||||||
btnDel.onclick = () => {
|
btnDel.onclick = () => {
|
||||||
this.routeManager.deleteSavedRoute(index);
|
if (confirm("Delete this route?")) {
|
||||||
this.renderRouteList();
|
this.routeManager.deleteSavedRoute(index);
|
||||||
|
this.renderRouteList();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
btnDiv.appendChild(btnEdit);
|
btnDiv.appendChild(btnEdit);
|
||||||
|
|||||||
19
src/main.js
19
src/main.js
@@ -49,25 +49,33 @@ function init() {
|
|||||||
|
|
||||||
// 2. Game Logic
|
// 2. Game Logic
|
||||||
gameManager = new GameManager(routeManager, uiManager);
|
gameManager = new GameManager(routeManager, uiManager);
|
||||||
routeManager.setGameManager(gameManager); // Dependency Injection
|
routeManager.setGameManager(gameManager);
|
||||||
|
|
||||||
// Vehicle System
|
// Vehicle System
|
||||||
vehicleSystem = new VehicleSystem(scene);
|
vehicleSystem = new VehicleSystem(scene);
|
||||||
routeManager.setVehicleSystem(vehicleSystem); // Inject into RouteManager
|
routeManager.setVehicleSystem(vehicleSystem);
|
||||||
|
|
||||||
|
|
||||||
// 3. Input
|
// 3. Input
|
||||||
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
|
inputManager = new InputManager(camera, renderer.domElement, scene, controls);
|
||||||
inputManager.init();
|
inputManager.init();
|
||||||
|
|
||||||
// Wiring
|
// Wiring Click
|
||||||
inputManager.onClick = (point, object) => {
|
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);
|
if (object.name === "GROUND") routeManager.addNodeByWorldPosition(point);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wiring Drag
|
||||||
inputManager.onDrag = (markerObject, newPoint) => {
|
inputManager.onDrag = (markerObject, newPoint) => {
|
||||||
routeManager.dragNode(markerObject, newPoint);
|
routeManager.dragNode(markerObject, newPoint);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Wiring Hover (NEW)
|
||||||
|
inputManager.onHover = (point) => {
|
||||||
|
routeManager.updateGhostMarker(point);
|
||||||
|
};
|
||||||
|
|
||||||
// Wire UI View Mode
|
// Wire UI View Mode
|
||||||
uiManager.onViewModeChanged = (mode) => {
|
uiManager.onViewModeChanged = (mode) => {
|
||||||
currentViewMode = mode;
|
currentViewMode = mode;
|
||||||
@@ -76,7 +84,6 @@ function init() {
|
|||||||
|
|
||||||
routeManager.onRouteChanged = (stats) => {
|
routeManager.onRouteChanged = (stats) => {
|
||||||
uiManager.updateDraftStats(stats);
|
uiManager.updateDraftStats(stats);
|
||||||
// If in coverage view, we might need to refresh colors as coverage changes
|
|
||||||
if (currentViewMode === 'approval') updateBuildingColors();
|
if (currentViewMode === 'approval') updateBuildingColors();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,7 +94,7 @@ function init() {
|
|||||||
]).then(([visual, routing]) => {
|
]).then(([visual, routing]) => {
|
||||||
routeManager.initGraph(routing);
|
routeManager.initGraph(routing);
|
||||||
renderCity(visual);
|
renderCity(visual);
|
||||||
gameManager.start(); // Start the loop
|
gameManager.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
animate();
|
animate();
|
||||||
|
|||||||
Reference in New Issue
Block a user