Improve viewer zoom transitions and system summaries

This commit is contained in:
2026-03-12 21:27:05 -04:00
parent 7fbe7cce1a
commit e57378ad2a
3 changed files with 335 additions and 237 deletions

View File

@@ -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

View File

@@ -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 `<unloaded>`
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:
- `<arrived ...>`
- `<docked>`
- `<unloaded>`
- `<undocked>`
- `<cargo-full>`
- `<cargo-empty>`
- `<order ...>`
- `<default-behavior ...>`
- `<assignment ...>`
- `<docking-clearance ...>`
- `<docking-bay ...>`
- `<anchor ...>`
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`

View File

@@ -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<ZoomLevel, number> = {
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<string, THREE.Mesh>();
private readonly stationMeshes = new Map<string, THREE.Mesh>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
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 = `
<p>${system.id}</p>
<p>Planets ${system.planets.length}</p>
`;
}
@@ -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<string, number>();
const stationCounts = new Map<string, number>();
const structureCounts = new Map<string, number>();
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");
};