Improve viewer zoom transitions and system summaries
This commit is contained in:
@@ -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
|
||||
|
||||
300
SESSION.md
300
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 `<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`
|
||||
|
||||
@@ -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");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user