From e57378ad2a17ee4fefaf05a904eb5f5b657e29fd Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 12 Mar 2026 21:27:05 -0400 Subject: [PATCH] Improve viewer zoom transitions and system summaries --- NEXT-STEPS.md | 3 + SESSION.md | 300 ++++++++++------------------------ apps/viewer/src/GameViewer.ts | 269 +++++++++++++++++++++++++++--- 3 files changed, 335 insertions(+), 237 deletions(-) diff --git a/NEXT-STEPS.md b/NEXT-STEPS.md index d8b1f52..9db5041 100644 --- a/NEXT-STEPS.md +++ b/NEXT-STEPS.md @@ -141,5 +141,8 @@ Recommended work: - cargo transfer - combat hit / kill - improve interpolation and extrapolation policies per entity type +- add per-layer presentation tuning in the viewer + - smoother fade bands between local / system / universe + - better visual density control at galaxy scale - add resync handling when a client falls too far behind - consider switching from SSE to websocket transport if bidirectional command traffic becomes heavy diff --git a/SESSION.md b/SESSION.md index b5f69c6..73fb763 100644 --- a/SESSION.md +++ b/SESSION.md @@ -2,7 +2,64 @@ ## Current State -The project is a Three.js/Vite space simulation with: +The repository is now split into two apps that live side-by-side: + +- [apps/backend](/home/jbourdon/repos/space-game/apps/backend) + - authoritative ASP.NET Core simulation +- [apps/viewer](/home/jbourdon/repos/space-game/apps/viewer) + - Three.js/Vite observer client +- [shared/data](/home/jbourdon/repos/space-game/shared/data) + - shared scenario data + +The complete simulation runs in the backend. The viewer fetches one world snapshot, then subscribes to an SSE delta stream and renders the world as an observer. + +## Runtime / Networking + +The backend currently provides: + +- `GET /api/world` + - initial authoritative snapshot +- `GET /api/world/stream` + - incremental SSE deltas after a sequence number + +The viewer currently does: + +1. fetch the world once +2. connect to the stream +3. apply deltas into a local render model +4. interpolate and briefly extrapolate moving ships for presentation + +This supports multiple simultaneous observers on the same world. Interest management is not implemented yet, so every observer still receives full-world deltas. + +## Viewer Status + +The viewer currently supports: + +- single-click selection for ships, stations, nodes, planets, and stars +- rectangular marquee selection + - constrained to one group at a time: + - ships + - structures + - celestials +- `WASD` panning on the `XZ` plane +- middle-mouse orbit camera +- smooth wheel zoom across local, system, and universe scales +- presentation fades between zoom bands instead of hard switches + +Universe-level presentation is now star-centric: + +- solar-system internals fade out as the camera pulls back +- star names remain readable +- system summary panels show icon-plus-count rollups only when entities are present + +The viewer also includes plain-text HUD readouts for: + +- game state +- network statistics + +## Simulation Status + +The backend simulation already includes: - autonomous ships - orbital travel @@ -10,230 +67,53 @@ The project is a Three.js/Vite space simulation with: - mining and refinery delivery - refining / fabrication - faction growth through ship and outpost production -- observer-focused debugging tools +- pirate pressure and combat -The active runtime model now follows the intended layered architecture more closely: - -- `order` -- `defaultBehavior` -- `assignment` -- `controllerTask` -- `state` - -The previous `captainGoal` layer has been removed. - -## Ship Runtime Model - -Ships now carry: - -- `order` - - direct one-shot instruction such as `move-to`, `mine-this`, `dock-at` -- `defaultBehavior` - - standing automation such as `auto-mine`, `patrol`, `escort-assigned`, `idle` -- `assignment` - - contextual ownership / doctrine such as `unassigned`, `commander-subordinate`, `station-based`, `mining-group` -- `controllerTask` - - immediate executable task such as `travel`, `dock`, `extract`, `unload`, `follow`, `undock` -- `state` - - physical ship state such as `spooling-warp`, `warping`, `arriving`, `docking`, `docked`, `undocking`, `transferring` - -Current precedence is: +The runtime model still follows the intended layered control architecture: 1. `order` 2. `defaultBehavior` -3. assignment-derived fallback behavior -4. idle fallback - -The main loop in [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) is now: - -- `refreshControlLayers()` -- `planControllerTask()` -- `updateControllerTask()` -- `advanceControlState()` - -## Travel Model - -Travel is destination-driven and orbital-centric. - -- same-system travel: - - `spooling-warp -> warping -> arriving` -- inter-system travel: - - `spooling-ftl -> ftl -> arriving` -- arrival anchors the ship to the destination orbital when appropriate - -Destination ownership lives in the `controllerTask`. - -Examples: - -- `travel(destination)` -- `dock(host, bay)` -- `extract(node)` -- `unload(station)` -- `undock(host)` - -## Mining / Delivery / Refining - -Current industrial loop: - -1. miner travels to node -2. miner extracts ore -3. miner travels to refinery -4. miner docks -5. miner unloads over time -6. miner undocks -7. loop repeats - -Important details: - -- `mine-this` is a one-shot order and currently completes when cargo is full -- `auto-mine` is persistent behavior and includes its own internal phase state -- unloading is time-based through `transferRate` in [src/game/data/balance.json](/home/jbourdon/repos/space-game/src/game/data/balance.json) -- unload state is now `transferring` -- unload completion emits `` - -Refineries and fabricators feed faction production. - -The faction economy now uses fabricated goods to: - -- build new ships -- build defense outposts in valuable systems - -Current production behavior lives in: - -- [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) - - `tryBuildShipForFaction()` - - `tryBuildOutpostForFaction()` - -## Faction Growth Loop - -The active empire growth loop is: - -1. mine ore -2. refine / fabricate goods -3. spend goods on ships -4. spend goods on military outposts -5. project power into central / contested systems - -This means the simulation is no longer missing a use for refined goods. - -What is still missing is stronger strategic prioritization, for example: - -- when to build more miners vs escorts vs warships -- how to react to throughput shortages -- how to react to pirate pressure - -## Pirates / Threats - -Pirates already exist as an active faction and can raid / fight. - -Current pirate support includes: - -- pirate faction command logic -- hostile target selection -- ship combat and destruction - -What is still underdeveloped: - -- explicit preference for miners, haulers, and refinery traffic -- clearer harassment behavior around resource chains - -## Debug History - -The debug window is focused on the selected ship and includes: - -- `order` -- `defaultBehavior` -- `assignment` -- `controllerTask` -- `state` -- task target -- anchor - -History is event-oriented plus explicit state lines. - -Current notation includes: - -- controller commands: - - `[travel]`, `[dock]`, `[unload]`, `[undock]` -- state snapshots: - - `state=move-to:.../travel-to-node [travel]/(warping)` -- events: - - `` - - `` - - `` - - `` - - `` - - `` - - `` - - `` - - `` - - `` - - `` - - `` - -History remains HTML-escaped before rendering and same-tick changes are still batched. - -Copy-to-clipboard includes: - -- current live summary block -- event history - -## Selection / HUD - -The HUD currently supports selecting: - -- ships -- stations -- systems -- planets -- asteroid field nodes - -Notable UI status: - -- ship cards show cargo and current layered control summary -- station cards show ore stored and refined stock -- Fleet and Debug window toggle buttons exist -- debug history is scrollable and copyable +3. `assignment` +4. `controllerTask` +5. `state` ## Important Recent Changes -- removed the old `captainGoal` layer -- planner now derives `controllerTask` directly from `order` and `defaultBehavior` -- moved mining / patrol progress state into `order` and `defaultBehavior` -- updated debug / selection UI to show the active layered model -- removed confirmed dead code found by strict TypeScript unused checks +- split the old monolith into `apps/backend` and `apps/viewer` +- moved simulation authority fully into .NET +- replaced frontend polling with snapshot-plus-delta SSE replication +- added viewer-side interpolation / short extrapolation for movement +- added a plain-text network statistics readout +- reworked the camera with smoother zoom, orbit, panning, and marquee selection +- cleaned up several viewer HUD elements and removed redundant panel content ## Current Known Limitations -- [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) is still too large and owns too much simulation responsibility -- order types are still narrow - - currently focused on `move-to`, `mine-this`, `dock-at` -- default behavior set is still narrow - - currently focused on `idle`, `auto-mine`, `patrol`, `escort-assigned` -- pirate harassment exists but is not yet economically targeted enough -- faction production logic is timer-driven and only lightly reactive -- no persistence for saves, seeds, or layouts +- replication is still world-wide + - no observer-scoped interest management yet +- the viewer is still observer-focused + - no command submission UI yet +- system/universe transitions are improved but still need tuning in feel and art direction +- piracy and faction growth are still functional rather than strategically deep +- no persistence for saves, seeds, or reconnect state ## Important Files -- [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts) - - main simulation loop - - layered planning - - travel, docking, mining, unloading, faction growth, combat, debug history -- [src/game/types.ts](/home/jbourdon/repos/space-game/src/game/types.ts) - - `order` / `defaultBehavior` / `assignment` / `controllerTask` / `state` model -- [src/game/world/worldFactory.ts](/home/jbourdon/repos/space-game/src/game/world/worldFactory.ts) - - ship and station instancing -- [src/game/ui/presenters.ts](/home/jbourdon/repos/space-game/src/game/ui/presenters.ts) - - selection cards - - station cards - - debug history markup -- [src/game/data/balance.json](/home/jbourdon/repos/space-game/src/game/data/balance.json) - - travel, docking, transfer rates +- [apps/backend/Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs) + - backend API endpoints +- [apps/backend/Simulation/WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/WorldService.cs) + - authoritative world state and stream coordination +- [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) + - simulation advancement +- [apps/viewer/src/GameViewer.ts](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) + - camera, selection, streaming integration, and presentation +- [apps/viewer/src/api.ts](/home/jbourdon/repos/space-game/apps/viewer/src/api.ts) + - snapshot fetch and SSE stream integration +- [shared/data](/home/jbourdon/repos/space-game/shared/data) + - scenario and world data definitions ## Validation Validation passing at the end of this session: -- `npx tsc --noEmit --noUnusedLocals --noUnusedParameters` -- `npm run build` +- `cd apps/viewer && npm run build` diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 1ae64fa..06ce6d3 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -72,20 +72,34 @@ interface PresentationEntry { detail: THREE.Object3D; icon: THREE.Sprite; hideDetailInUniverse?: boolean; + hideIconInUniverse?: boolean; +} + +interface SystemSummaryVisual { + sprite: THREE.Sprite; + texture: THREE.CanvasTexture; + anchor: THREE.Vector3; } -const ZOOM_ORDER: ZoomLevel[] = ["local", "system", "universe"]; const ZOOM_DISTANCE: Record = { local: 900, system: 3200, - universe: 9800, + universe: 26000, }; +const MIN_CAMERA_DISTANCE = 450; +const MAX_CAMERA_DISTANCE = 42000; + +interface ZoomBlend { + localWeight: number; + systemWeight: number; + universeWeight: number; +} export class GameViewer { private readonly container: HTMLElement; private readonly renderer = new THREE.WebGLRenderer({ antialias: true }); private readonly scene = new THREE.Scene(); - private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 50000); + private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100000); private readonly clock = new THREE.Clock(); private readonly raycaster = new THREE.Raycaster(); private readonly mouse = new THREE.Vector2(); @@ -101,6 +115,8 @@ export class GameViewer { private readonly nodeMeshes = new Map(); private readonly stationMeshes = new Map(); private readonly shipVisuals = new Map(); + private readonly systemSummaryVisuals = new Map(); + private readonly orbitLines: THREE.Object3D[] = []; private readonly statusEl: HTMLDivElement; private readonly detailTitleEl: HTMLHeadingElement; private readonly detailBodyEl: HTMLDivElement; @@ -125,6 +141,7 @@ export class GameViewer { private selectedItems: Selectable[] = []; private worldSignature = ""; private zoomLevel: ZoomLevel = "system"; + private currentDistance = ZOOM_DISTANCE.system; private desiredDistance = ZOOM_DISTANCE.system; private orbitYaw = -2.3; private orbitPitch = 0.62; @@ -279,6 +296,7 @@ export class GameViewer { this.syncStations(snapshot.stations); this.syncShips(snapshot.ships, snapshot.tickIntervalMs); this.rebuildFactions(snapshot.factions); + this.updateSystemSummaries(); this.applyZoomPresentation(); this.updateNetworkPanel(); } @@ -312,12 +330,15 @@ export class GameViewer { if (delta.factions.length > 0) { this.rebuildFactions([...this.world.factions.values()]); } + this.updateSystemSummaries(); } private rebuildSystems(systems: SystemSnapshot[]) { this.systemGroup.clear(); this.selectableTargets.clear(); this.presentationEntries.length = 0; + this.orbitLines.length = 0; + this.systemSummaryVisuals.clear(); for (const system of systems) { const root = new THREE.Group(); @@ -337,8 +358,12 @@ export class GameViewer { }), ); const systemIcon = this.createTacticalIcon(system.starColor, 96); - root.add(star, halo, systemIcon); - this.registerPresentation(star, systemIcon, false); + const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 110, system.position.z)); + summaryVisual.sprite.position.set(0, system.starSize + 110, 0); + root.add(star, halo, systemIcon, summaryVisual.sprite); + this.registerPresentation(star, systemIcon, true); + this.registerPresentation(halo, systemIcon, true); + this.systemSummaryVisuals.set(system.id, summaryVisual); this.selectableTargets.set(star, { kind: "system", id: system.id }); this.selectableTargets.set(halo, { kind: "system", id: system.id }); this.selectableTargets.set(systemIcon, { kind: "system", id: system.id }); @@ -369,7 +394,8 @@ export class GameViewer { const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, planet.size * 2)); planetIcon.position.copy(planetMesh.position); root.add(orbit, planetMesh, planetIcon); - this.registerPresentation(planetMesh, planetIcon, true); + this.orbitLines.push(orbit); + this.registerPresentation(planetMesh, planetIcon, true, true); this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex }); this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex }); } @@ -388,7 +414,7 @@ export class GameViewer { icon.position.copy(mesh.position); this.nodeMeshes.set(node.id, mesh); this.nodeGroup.add(mesh, icon); - this.registerPresentation(mesh, icon, true); + this.registerPresentation(mesh, icon, true, true); this.selectableTargets.set(mesh, { kind: "node", id: node.id }); this.selectableTargets.set(icon, { kind: "node", id: node.id }); } @@ -404,7 +430,7 @@ export class GameViewer { icon.position.copy(mesh.position); this.stationMeshes.set(station.id, mesh); this.stationGroup.add(mesh, icon); - this.registerPresentation(mesh, icon, true); + this.registerPresentation(mesh, icon, true, true); this.selectableTargets.set(mesh, { kind: "station", id: station.id }); this.selectableTargets.set(icon, { kind: "station", id: station.id }); } @@ -422,7 +448,7 @@ export class GameViewer { this.shipGroup.add(mesh, icon); this.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); this.selectableTargets.set(icon, { kind: "ship", id: ship.id }); - this.registerPresentation(mesh, icon, true); + this.registerPresentation(mesh, icon, true, true); this.shipVisuals.set(ship.id, { mesh, icon, @@ -585,7 +611,6 @@ export class GameViewer { this.detailTitleEl.textContent = system.label; this.detailBodyEl.innerHTML = `

${system.id}

-

Planets ${system.planets.length}

`; } @@ -599,14 +624,15 @@ export class GameViewer { } private updateCamera(delta: number) { - this.desiredDistance = THREE.MathUtils.lerp(this.desiredDistance, ZOOM_DISTANCE[this.zoomLevel], Math.min(1, delta * 6)); + this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta); + this.zoomLevel = this.classifyZoomLevel(this.currentDistance); this.updatePanFromKeyboard(delta); this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); - const horizontalDistance = this.desiredDistance * Math.cos(this.orbitPitch); + const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); this.cameraOffset.set( Math.cos(this.orbitYaw) * horizontalDistance, - this.desiredDistance * Math.sin(this.orbitPitch), + this.currentDistance * Math.sin(this.orbitPitch), Math.sin(this.orbitYaw) * horizontalDistance, ); this.camera.position.copy(this.focus).add(this.cameraOffset); @@ -635,20 +661,38 @@ export class GameViewer { const forward = new THREE.Vector3(Math.cos(this.orbitYaw), 0, Math.sin(this.orbitYaw)); const right = new THREE.Vector3(-forward.z, 0, forward.x); const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z)); - const speed = this.zoomLevel === "local" ? 420 : this.zoomLevel === "system" ? 1600 : 4200; + const speed = THREE.MathUtils.mapLinear(this.currentDistance, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, 320, 6800); this.focus.addScaledVector(pan, speed * delta); } private applyZoomPresentation() { - const system = this.zoomLevel === "system"; - const universe = this.zoomLevel === "universe"; + const blend = this.computeZoomBlend(this.currentDistance); for (const entry of this.presentationEntries) { - entry.icon.visible = system || universe; - entry.detail.visible = universe ? !entry.hideDetailInUniverse : true; + const detailAlpha = entry.hideDetailInUniverse + ? Math.max(blend.localWeight, blend.systemWeight) + : 1; + const iconAlpha = entry.hideIconInUniverse + ? blend.systemWeight + : Math.max(blend.systemWeight, blend.universeWeight); + + this.setObjectOpacity(entry.detail, detailAlpha); + this.setObjectOpacity(entry.icon, iconAlpha); } - this.scene.fog = new THREE.FogExp2(0x040912, this.zoomLevel === "local" ? 0.00011 : this.zoomLevel === "system" ? 0.000045 : 0.000012); + for (const orbitLine of this.orbitLines) { + const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight); + this.setObjectOpacity(orbitLine, alpha); + } + + for (const summaryVisual of this.systemSummaryVisuals.values()) { + this.setObjectOpacity(summaryVisual.sprite, blend.universeWeight); + } + + this.scene.fog = new THREE.FogExp2( + 0x040912, + THREE.MathUtils.lerp(0.00011, 0.000012, blend.universeWeight), + ); } private recordDeltaStats(delta: WorldDelta, rawBytes: number) { @@ -722,6 +766,8 @@ export class GameViewer { const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh); entry?.icon.position.copy(mesh.position); } + + this.updateSystemSummaryPresentation(); } private createNodeMesh(node: ResourceNodeSnapshot) { @@ -790,8 +836,131 @@ export class GameViewer { return sprite; } - private registerPresentation(detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean) { - this.presentationEntries.push({ detail, icon, hideDetailInUniverse }); + private createSystemSummaryVisual(anchor: THREE.Vector3): SystemSummaryVisual { + const canvas = document.createElement("canvas"); + canvas.width = 512; + canvas.height = 160; + const texture = new THREE.CanvasTexture(canvas); + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + depthTest: false, + })); + sprite.scale.set(520, 160, 1); + sprite.visible = false; + return { sprite, texture, anchor }; + } + + private updateSystemSummaries() { + if (!this.world) { + return; + } + + const shipCounts = new Map(); + const stationCounts = new Map(); + const structureCounts = new Map(); + + for (const ship of this.world.ships.values()) { + shipCounts.set(ship.systemId, (shipCounts.get(ship.systemId) ?? 0) + 1); + } + for (const station of this.world.stations.values()) { + stationCounts.set(station.systemId, (stationCounts.get(station.systemId) ?? 0) + 1); + structureCounts.set(station.systemId, (structureCounts.get(station.systemId) ?? 0) + 1); + } + for (const node of this.world.nodes.values()) { + structureCounts.set(node.systemId, (structureCounts.get(node.systemId) ?? 0) + 1); + } + + for (const [systemId, system] of this.world.systems.entries()) { + const visual = this.systemSummaryVisuals.get(systemId); + if (!visual) { + continue; + } + + const canvas = visual.texture.image as HTMLCanvasElement; + const context = canvas.getContext("2d"); + if (!context) { + continue; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = "#eaf4ff"; + context.font = "600 34px Space Grotesk, sans-serif"; + context.textAlign = "center"; + context.fillText(system.label, canvas.width / 2, 40); + + const ships = shipCounts.get(systemId) ?? 0; + const stations = stationCounts.get(systemId) ?? 0; + const structures = structureCounts.get(systemId) ?? 0; + const total = ships + stations + structures; + if (total > 0) { + context.fillStyle = "rgba(3, 8, 18, 0.72)"; + context.fillRect(56, 64, canvas.width - 112, 68); + context.strokeStyle = "rgba(132, 196, 255, 0.22)"; + context.strokeRect(56, 64, canvas.width - 112, 68); + + this.drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff"); + this.drawCountIcon(context, "station", 256, 98, stations, "#ffbf69"); + this.drawCountIcon(context, "structure", 386, 98, structures, "#98adc4"); + } + + visual.texture.needsUpdate = true; + } + } + + private drawCountIcon( + context: CanvasRenderingContext2D, + kind: "ship" | "station" | "structure", + x: number, + y: number, + value: number, + color: string, + ) { + context.save(); + context.strokeStyle = color; + context.fillStyle = color; + context.lineWidth = 3; + + if (kind === "ship") { + context.beginPath(); + context.moveTo(x - 14, y + 10); + context.lineTo(x, y - 14); + context.lineTo(x + 14, y + 10); + context.closePath(); + context.stroke(); + } else if (kind === "station") { + context.strokeRect(x - 14, y - 14, 28, 28); + } else { + context.beginPath(); + context.arc(x, y, 14, 0, Math.PI * 2); + context.stroke(); + context.beginPath(); + context.moveTo(x - 8, y); + context.lineTo(x + 8, y); + context.moveTo(x, y - 8); + context.lineTo(x, y + 8); + context.stroke(); + } + + context.fillStyle = "#eaf4ff"; + context.font = "600 26px IBM Plex Mono, monospace"; + context.textAlign = "left"; + context.fillText(String(value), x + 24, y + 9); + context.restore(); + } + + private updateSystemSummaryPresentation() { + const distanceScale = this.zoomLevel === "universe" ? 0.11 : 0.05; + for (const visual of this.systemSummaryVisuals.values()) { + const distance = this.camera.position.distanceTo(visual.anchor); + const scale = Math.max(1400, distance * distanceScale); + visual.sprite.scale.set(scale, scale * 0.3125, 1); + } + } + + private registerPresentation(detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse = false) { + this.presentationEntries.push({ detail, icon, hideDetailInUniverse, hideIconInUniverse }); } private renderRecentEvents(entityKind: string, entityId: string) { @@ -862,6 +1031,52 @@ export class GameViewer { ].join("\n"); } + private classifyZoomLevel(distance: number): ZoomLevel { + const blend = this.computeZoomBlend(distance); + if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) { + return "local"; + } + if (blend.systemWeight >= blend.universeWeight) { + return "system"; + } + return "universe"; + } + + private computeZoomBlend(distance: number): ZoomBlend { + const localToSystem = this.smoothBand(distance, 1200, 5200); + const systemToUniverse = this.smoothBand(distance, 9000, 22000); + + return { + localWeight: 1 - localToSystem, + systemWeight: Math.min(localToSystem, 1 - systemToUniverse), + universeWeight: systemToUniverse, + }; + } + + private smoothBand(value: number, start: number, end: number) { + const t = THREE.MathUtils.clamp((value - start) / Math.max(end - start, 1), 0, 1); + return t * t * (3 - (2 * t)); + } + + private setObjectOpacity(object: THREE.Object3D, opacity: number) { + const visible = opacity > 0.02; + object.visible = visible; + object.traverse((child) => { + if (!("material" in child)) { + return; + } + const materials = Array.isArray(child.material) ? child.material : [child.material]; + for (const material of materials) { + if (!("opacity" in material)) { + continue; + } + material.transparent = true; + material.opacity = opacity; + material.needsUpdate = true; + } + }); + } + private toThreeVector(vector: Vector3Dto) { return new THREE.Vector3(vector.x, vector.y, vector.z); } @@ -967,9 +1182,9 @@ export class GameViewer { private onWheel = (event: WheelEvent) => { event.preventDefault(); - const direction = event.deltaY > 0 ? 1 : -1; - const nextIndex = THREE.MathUtils.clamp(ZOOM_ORDER.indexOf(this.zoomLevel) + direction, 0, ZOOM_ORDER.length - 1); - this.zoomLevel = ZOOM_ORDER[nextIndex]; + const deltaY = THREE.MathUtils.clamp(event.deltaY, -180, 180); + const zoomFactor = Math.exp(deltaY * 0.00135); + this.desiredDistance = THREE.MathUtils.clamp(this.desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE); this.updateGamePanel("Live"); }; @@ -980,11 +1195,11 @@ export class GameViewer { const key = event.key.toLowerCase(); this.keyState.add(key); if (key === "1") { - this.zoomLevel = "local"; + this.desiredDistance = ZOOM_DISTANCE.local; } else if (key === "2") { - this.zoomLevel = "system"; + this.desiredDistance = ZOOM_DISTANCE.system; } else if (key === "3") { - this.zoomLevel = "universe"; + this.desiredDistance = ZOOM_DISTANCE.universe; } this.updateGamePanel("Live"); };