From 66fa2cdc65a7fcd7f78a6b1c4d7248a24a70fd1f Mon Sep 17 00:00:00 2001 From: Evan Scamehorn Date: Tue, 16 Dec 2025 21:45:53 -0600 Subject: [PATCH] loading screen --- index.html | 36 ++++++++--- src/UIManager.js | 77 ++++++++++++++++++++--- src/main.js | 58 ++++++----------- style.css | 157 +++++++++++++++++++++++++++++++++++++---------- 4 files changed, 237 insertions(+), 91 deletions(-) diff --git a/index.html b/index.html index b3caf31..efbad60 100644 --- a/index.html +++ b/index.html @@ -4,12 +4,36 @@ - 3D City Sim + Transit Simulator - + + + + + + @@ -26,17 +50,11 @@ style="text-decoration: none; color: #333;">OpenStreetMap contributors - +

Route Planner

- -
- - -
-
diff --git a/src/UIManager.js b/src/UIManager.js index cc183c4..be5ac87 100644 --- a/src/UIManager.js +++ b/src/UIManager.js @@ -2,24 +2,29 @@ export class UIManager { constructor(routeManager) { this.routeManager = routeManager; this.gameManager = null; + this.isSimulationReady = false; - // Panels + // -- MAIN MENU ELEMENTS -- + this.elMainMenu = document.getElementById('main-menu'); + this.btnStart = document.getElementById('btn-start'); + this.btnMenuToggle = document.getElementById('menu-toggle'); + this.selectMap = document.getElementById('map-selector'); + + // -- GAME UI ELEMENTS -- this.panelMain = document.getElementById('ui-main-menu'); this.panelDraft = document.getElementById('ui-draft-menu'); + this.elContainer = document.getElementById('ui-container'); - // UI Elements + // Stats this.elCurrentLength = document.getElementById('current-length'); this.elCurrentCost = document.getElementById('current-cost'); this.elCurrentRiders = document.getElementById('current-riders'); - this.elBudget = document.getElementById('val-budget'); this.elDay = document.getElementById('val-day'); this.elTotalRiders = document.getElementById('val-riders'); this.elApproval = document.getElementById('val-approval'); - this.elIncomeFloat = document.getElementById('income-float'); this.elRouteList = document.getElementById('route-list'); - this.elContainer = document.getElementById('ui-container'); // Buttons this.btnCreate = document.getElementById('btn-create-route'); @@ -36,11 +41,45 @@ export class UIManager { this.selectViewMode = document.getElementById('view-mode'); this.onViewModeChanged = null; + this.initListeners(); + this.initSafetyChecks(); } initListeners() { - // --- MODE SWITCHING --- + // --- MAIN MENU INTERACTIONS --- + + // 1. Start Button + this.btnStart.addEventListener('click', () => { + if (this.isSimulationReady) { + this.elMainMenu.classList.add('hidden'); + } + }); + + // 2. Map Selector + this.selectMap.addEventListener('change', (e) => { + if (confirm("Switching maps will lose unsaved progress. Continue?")) { + // Logic to reload map would go here. + // For now, since we only have one map, we just reload the page to be safe + window.location.reload(); + } else { + // Revert selection if canceled (conceptually simple, hard to do without tracking previous val) + e.target.value = "madison_wi"; + } + }); + + // 3. Menu Toggle (Top Right) + this.btnMenuToggle.addEventListener('click', () => { + // Toggle menu visibility + if (this.elMainMenu.classList.contains('hidden')) { + this.elMainMenu.classList.remove('hidden'); + } else { + this.elMainMenu.classList.add('hidden'); + } + }); + + // --- GAME UI INTERACTIONS --- + this.btnCreate.addEventListener('click', () => { this.enterDraftMode(); }); @@ -57,7 +96,6 @@ export class UIManager { this.routeManager.clearCurrentRoute(); this.exitDraftMode(); }); - // ---------------------- this.btnToggle.addEventListener('click', () => { this.elContainer.classList.toggle('hidden'); @@ -89,6 +127,8 @@ export class UIManager { if (this.routeManager.gameManager) { this.routeManager.gameManager.loadGame(evt.target.result); this.renderRouteList(); + // Auto close menu on load + this.elMainMenu.classList.add('hidden'); } }; reader.readAsText(file); @@ -96,6 +136,23 @@ export class UIManager { }); } + initSafetyChecks() { + // Prompt before closing tab + window.addEventListener('beforeunload', (e) => { + // Modern browsers don't show custom text, but this triggers the generic "Are you sure?" + e.preventDefault(); + e.returnValue = ''; + }); + } + + // Called by Main.js when Promise.all is finished + setLoadingComplete() { + this.isSimulationReady = true; + this.btnStart.disabled = false; + this.btnStart.textContent = "Enter Simulation"; + this.btnStart.classList.add('ready'); + } + enterDraftMode() { this.panelMain.style.display = 'none'; this.panelDraft.style.display = 'block'; @@ -225,10 +282,10 @@ export class UIManager { btnEdit.title = "Redraw Route"; btnEdit.style.padding = "4px 8px"; btnEdit.onclick = () => { - // --- FIX IS HERE --- - // 1. Enter draft mode first (this resets the drafting state) + // Hide menu if open + this.elMainMenu.classList.add('hidden'); + this.enterDraftMode(); - // 2. Load the route data (this populates the drafting state) this.routeManager.editSavedRoute(index); }; diff --git a/src/main.js b/src/main.js index 048a091..c179068 100644 --- a/src/main.js +++ b/src/main.js @@ -34,20 +34,20 @@ const SETTINGS = { graphics: { shadows: true, antialias: true, - maxPixelRatio: 1.0, // lower == more blurry, 2 or 3 for high res - farClip: 6000, // view distance limit + maxPixelRatio: 1.0, + farClip: 6000, } }; let scene, camera, renderer, controls; let inputManager, routeManager, uiManager, gameManager, vehicleSystem; -let cityMesh; // The single mesh containing all buildings -let buildingRegistry = []; // Stores { data, nearestNodeId, startIndex, count } for each building +let cityMesh; +let buildingRegistry = []; const clock = new THREE.Clock(); -let currentViewMode = 'none'; // 'none', 'zoning', 'approval' +let currentViewMode = 'none'; function init() { setupScene(); @@ -72,7 +72,6 @@ function init() { // 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); }; @@ -81,7 +80,7 @@ function init() { routeManager.dragNode(markerObject, newPoint); }; - // Wiring Hover (NEW) + // Wiring Hover inputManager.onHover = (point) => { routeManager.updateGhostMarker(point); }; @@ -105,6 +104,9 @@ function init() { routeManager.initGraph(routing); renderCity(visual); gameManager.start(); + + // --- UPDATE: Notify UI that loading is done --- + uiManager.setLoadingComplete(); }); animate(); @@ -115,22 +117,16 @@ function updateBuildingColors() { const colorAttribute = cityMesh.geometry.attributes.color; const colorArray = colorAttribute.array; - - // Temp variables to avoid creating objects in loop const _color = new THREE.Color(); - // Iterate through every building in our registry for (let i = 0; i < buildingRegistry.length; i++) { const entry = buildingRegistry[i]; const data = entry.data; - // --- 1. Determine Target Color based on Mode --- - // STANDARD VIEW if (currentViewMode === 'none') { _color.copy(SETTINGS.colors.building); } - // ZONING VIEW else if (currentViewMode === 'zoning') { if (data.type === 'residential') { @@ -141,10 +137,8 @@ function updateBuildingColors() { _color.copy(SETTINGS.colors.building); } } - // APPROVAL VIEW else if (currentViewMode === 'approval') { - // Use the pre-calculated nearest ID from registry const node = routeManager.graphData.nodes[entry.nearestNodeId]; if (node) { @@ -155,17 +149,13 @@ function updateBuildingColors() { } else { const MAX_DIST = 600; const factor = Math.min(1.0, dist / MAX_DIST); - // Lerp Good -> Bad _color.copy(SETTINGS.colors.coverageGood).lerp(SETTINGS.colors.coverageBad, factor); } } else { - // Fallback if node not found _color.copy(SETTINGS.colors.coverageBad); } } - // --- 2. Apply Color to Vertices --- - // We update the specific range of vertices belonging to this building const start = entry.startIndex; const end = start + entry.count; @@ -177,7 +167,6 @@ function updateBuildingColors() { } } - // Flag that the geometry colors have changed so GPU updates colorAttribute.needsUpdate = true; } @@ -202,7 +191,7 @@ function setupScene() { document.documentElement.style.height = '100%'; document.body.style.margin = '0'; document.body.style.height = '100%'; - document.body.style.overflow = 'hidden'; // Prevents scrollbars + document.body.style.overflow = 'hidden'; renderer.shadowMap.enabled = SETTINGS.graphics.shadows; document.body.appendChild(renderer.domElement); @@ -220,8 +209,8 @@ function setupScene() { dirLight.shadow.camera.top = 1500; dirLight.shadow.camera.bottom = -1500; dirLight.shadow.camera.near = 0.5; - dirLight.shadow.camera.far = 3000; // Must be > 1225 to reach the ground - dirLight.shadow.bias = -0.0001; // Clean up shadow artifacts + dirLight.shadow.camera.far = 3000; + dirLight.shadow.bias = -0.0001; } scene.add(dirLight); @@ -248,16 +237,11 @@ function resizeRendererToDisplaySize() { const rect = canvas.getBoundingClientRect(); const pixelRatio = Math.min(window.devicePixelRatio, SETTINGS.graphics.maxPixelRatio); - - // Calculate the required resolution const width = Math.round(rect.width * pixelRatio); const height = Math.round(rect.height * pixelRatio); - - // Check if the canvas is already the right size const needResize = canvas.width !== width || canvas.height !== height; if (needResize) { - // Resize the render buffer, but do NOT change CSS style (false) renderer.setSize(width, height, false); } @@ -269,8 +253,6 @@ function resizeRendererToDisplaySize() { // ========================================== function renderCity(data) { - // Helper for non-interactive layers (Water, Parks, Roads) - Optimizing these too is good practice - // We will merge these per type as well to keep draw calls low const createMergedLayer = (items, color, height, lift, isExtruded) => { if (!items || !items.length) return; const geometries = []; @@ -309,7 +291,7 @@ function renderCity(data) { if (geometries.length === 0) return; const mergedGeom = BufferGeometryUtils.mergeGeometries(geometries); - const mat = new THREE.MeshLambertMaterial({ color: color }); // Simple color for static layers + const mat = new THREE.MeshLambertMaterial({ color: color }); const mesh = new THREE.Mesh(mergedGeom, mat); mesh.receiveShadow = SETTINGS.graphics.shadows; if (isExtruded) mesh.castShadow = SETTINGS.graphics.shadows; @@ -321,7 +303,7 @@ function renderCity(data) { if (!buildings || !buildings.length) return; const geometries = []; - buildingRegistry = []; // Reset registry + buildingRegistry = []; let currentVertexOffset = 0; @@ -345,18 +327,17 @@ function renderCity(data) { const geom = new THREE.ExtrudeGeometry(shape, { depth: b.height, bevelEnabled: false }); geom.rotateX(-Math.PI / 2); - // 3. Pre-calculate Logic Data (Nearest Node) + // 3. Pre-calculate Logic Data const bx = b.shape.outer[0][0]; const by = b.shape.outer[0][1]; const nearestId = routeManager.findNearestNode(bx, -by); // 4. Register Metadata - // We need to know how many vertices this building has to color it later const vertexCount = geom.attributes.position.count; buildingRegistry.push({ - data: b.data, // Zoning/Density data - nearestNodeId: nearestId, // For approval view + data: b.data, + nearestNodeId: nearestId, startIndex: currentVertexOffset, count: vertexCount }); @@ -371,7 +352,6 @@ function renderCity(data) { const mergedGeometry = BufferGeometryUtils.mergeGeometries(geometries); // 6. Initialize Vertex Colors Attribute - // Create a color buffer filled with white (1,1,1) by default const count = mergedGeometry.attributes.position.count; const colors = new Float32Array(count * 3); for (let i = 0; i < count * 3; i++) { @@ -381,7 +361,7 @@ function renderCity(data) { // 7. Material Setup const mat = new THREE.MeshLambertMaterial({ - vertexColors: true, // IMPORTANT + vertexColors: true, shadowSide: THREE.BackSide }); @@ -409,7 +389,7 @@ function animate() { camera.updateProjectionMatrix(); } - const delta = clock.getDelta(); // Get time since last frame + const delta = clock.getDelta(); controls.update(); if (vehicleSystem) { diff --git a/style.css b/style.css index 0782c5f..7d6a493 100644 --- a/style.css +++ b/style.css @@ -4,11 +4,109 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; } +/* --- MAIN MENU OVERLAY --- */ +#main-menu { + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(30, 41, 59, 0.95); /* Dark blue-grey, slight opacity */ + backdrop-filter: blur(8px); + z-index: 2000; /* Highest priority */ + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.4s ease-in-out, opacity 0.4s ease-in-out; +} + +#main-menu.hidden { + transform: translateY(-100%); + opacity: 0; + pointer-events: none; +} + +.menu-content { + background: white; + padding: 40px; + border-radius: 16px; + box-shadow: 0 20px 50px rgba(0,0,0,0.5); + width: 100%; + max-width: 400px; + text-align: center; +} + +.game-title { + font-size: 32px; + font-weight: 800; + color: #1e293b; + margin: 0 0 30px 0; + letter-spacing: -1px; + text-transform: uppercase; + background: -webkit-linear-gradient(45deg, #2563EB, #10B981); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} + +.menu-group { + margin-bottom: 20px; + text-align: left; +} + +.menu-group.row { + display: flex; + gap: 10px; +} + +.menu-group label { + display: block; + font-size: 12px; + font-weight: bold; + color: #64748b; + margin-bottom: 5px; + text-transform: uppercase; +} + +select { + width: 100%; + padding: 10px; + border: 1px solid #cbd5e1; + border-radius: 6px; + font-size: 14px; + background: #f8fafc; +} + +/* Start Button Specifics */ +.btn-start { + width: 100%; + padding: 15px; + font-size: 16px; + text-transform: uppercase; + letter-spacing: 1px; + margin-top: 10px; + transition: all 0.3s; + background-color: #94a3b8; /* Gray initially */ + color: white; + cursor: not-allowed; +} + +.btn-start.ready { + background-color: #10B981; /* Green */ + cursor: pointer; + box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); +} + +.btn-start.ready:hover { + background-color: #059669; + transform: translateY(-2px); +} + +/* --- HUD BUTTONS --- */ #ui-toggle { position: absolute; top: 20px; left: 20px; - z-index: 100; /* Above UI container */ + z-index: 100; width: 40px; height: 40px; border-radius: 8px; @@ -23,9 +121,29 @@ body { border: none; } +#menu-toggle { + position: absolute; + top: 20px; + right: 20px; + z-index: 100; + width: 40px; + height: 40px; + border-radius: 8px; + background: white; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + font-size: 20px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border: none; +} + +/* --- EXISTING UI CONTAINER --- */ #ui-container { position: absolute; - top: 70px; /* Moved down to make room for toggle */ + top: 70px; left: 20px; width: 280px; background: rgba(255, 255, 255, 0.95); @@ -36,13 +154,10 @@ body { user-select: none; max-height: 80vh; overflow-y: auto; - - /* Animation for toggling */ transition: transform 0.3s ease, opacity 0.3s ease; transform-origin: top left; } -/* Class to hide UI */ #ui-container.hidden { transform: scale(0.95); opacity: 0; @@ -51,13 +166,11 @@ body { .header h2 { margin: 0 0 5px 0; font-size: 18px; color: #333; } .header p { margin: 0; font-size: 12px; color: #666; } - .section { margin-top: 20px; border-top: 1px solid #eee; padding-top: 15px; } .section h3 { margin: 0 0 10px 0; font-size: 14px; text-transform: uppercase; color: #888; letter-spacing: 0.5px; } - .stat-row { display: flex; justify-content: space-between; font-weight: bold; margin-bottom: 10px; font-size: 16px; } - .button-row { display: flex; gap: 10px; } + button { flex: 1; padding: 8px 12px; @@ -69,12 +182,10 @@ button { transition: opacity 0.2s; } button:hover { opacity: 0.8; } - .primary { background-color: #2563EB; color: white; } .danger { background-color: #ef4444; color: white; } .secondary { background-color: #e5e7eb; color: #374151; } -/* Route List */ #route-list { list-style: none; padding: 0; margin: 0; } #route-list li { background: #f3f4f6; @@ -82,31 +193,11 @@ button:hover { opacity: 0.8; } padding: 10px; border-radius: 6px; display: flex; - flex-wrap: wrap; /* Allow wrapping for buttons */ + flex-wrap: wrap; justify-content: space-between; align-items: center; font-size: 14px; gap: 5px; } -#route-list li span { - flex-grow: 1; - font-weight: 500; -} - -#route-list li button { - flex: 0; - margin-left: 10px; - padding: 4px 8px; - font-size: 12px; - background: #fee2e2; - color: #991b1b; -} - -.btn-icon { - flex: 0 0 auto; - padding: 4px 8px; - font-size: 12px; - margin-left: 2px; -} -.btn-edit { background: #dbeafe; color: #1e40af; } -.btn-del { background: #fee2e2; color: #991b1b; } +#route-list li span { flex-grow: 1; font-weight: 500; } +.btn-icon { flex: 0 0 auto; padding: 4px 8px; font-size: 12px; margin-left: 2px; }