Improve viewer zoom transitions and system summaries
This commit is contained in:
@@ -141,5 +141,8 @@ Recommended work:
|
|||||||
- cargo transfer
|
- cargo transfer
|
||||||
- combat hit / kill
|
- combat hit / kill
|
||||||
- improve interpolation and extrapolation policies per entity type
|
- 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
|
- add resync handling when a client falls too far behind
|
||||||
- consider switching from SSE to websocket transport if bidirectional command traffic becomes heavy
|
- 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
|
## 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
|
- autonomous ships
|
||||||
- orbital travel
|
- orbital travel
|
||||||
@@ -10,230 +67,53 @@ The project is a Three.js/Vite space simulation with:
|
|||||||
- mining and refinery delivery
|
- mining and refinery delivery
|
||||||
- refining / fabrication
|
- refining / fabrication
|
||||||
- faction growth through ship and outpost production
|
- 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:
|
The runtime model still follows the intended layered control architecture:
|
||||||
|
|
||||||
- `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:
|
|
||||||
|
|
||||||
1. `order`
|
1. `order`
|
||||||
2. `defaultBehavior`
|
2. `defaultBehavior`
|
||||||
3. assignment-derived fallback behavior
|
3. `assignment`
|
||||||
4. idle fallback
|
4. `controllerTask`
|
||||||
|
5. `state`
|
||||||
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
|
|
||||||
|
|
||||||
## Important Recent Changes
|
## Important Recent Changes
|
||||||
|
|
||||||
- removed the old `captainGoal` layer
|
- split the old monolith into `apps/backend` and `apps/viewer`
|
||||||
- planner now derives `controllerTask` directly from `order` and `defaultBehavior`
|
- moved simulation authority fully into .NET
|
||||||
- moved mining / patrol progress state into `order` and `defaultBehavior`
|
- replaced frontend polling with snapshot-plus-delta SSE replication
|
||||||
- updated debug / selection UI to show the active layered model
|
- added viewer-side interpolation / short extrapolation for movement
|
||||||
- removed confirmed dead code found by strict TypeScript unused checks
|
- 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
|
## 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
|
- replication is still world-wide
|
||||||
- order types are still narrow
|
- no observer-scoped interest management yet
|
||||||
- currently focused on `move-to`, `mine-this`, `dock-at`
|
- the viewer is still observer-focused
|
||||||
- default behavior set is still narrow
|
- no command submission UI yet
|
||||||
- currently focused on `idle`, `auto-mine`, `patrol`, `escort-assigned`
|
- system/universe transitions are improved but still need tuning in feel and art direction
|
||||||
- pirate harassment exists but is not yet economically targeted enough
|
- piracy and faction growth are still functional rather than strategically deep
|
||||||
- faction production logic is timer-driven and only lightly reactive
|
- no persistence for saves, seeds, or reconnect state
|
||||||
- no persistence for saves, seeds, or layouts
|
|
||||||
|
|
||||||
## Important Files
|
## Important Files
|
||||||
|
|
||||||
- [src/game/GameApp.ts](/home/jbourdon/repos/space-game/src/game/GameApp.ts)
|
- [apps/backend/Program.cs](/home/jbourdon/repos/space-game/apps/backend/Program.cs)
|
||||||
- main simulation loop
|
- backend API endpoints
|
||||||
- layered planning
|
- [apps/backend/Simulation/WorldService.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/WorldService.cs)
|
||||||
- travel, docking, mining, unloading, faction growth, combat, debug history
|
- authoritative world state and stream coordination
|
||||||
- [src/game/types.ts](/home/jbourdon/repos/space-game/src/game/types.ts)
|
- [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs)
|
||||||
- `order` / `defaultBehavior` / `assignment` / `controllerTask` / `state` model
|
- simulation advancement
|
||||||
- [src/game/world/worldFactory.ts](/home/jbourdon/repos/space-game/src/game/world/worldFactory.ts)
|
- [apps/viewer/src/GameViewer.ts](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts)
|
||||||
- ship and station instancing
|
- camera, selection, streaming integration, and presentation
|
||||||
- [src/game/ui/presenters.ts](/home/jbourdon/repos/space-game/src/game/ui/presenters.ts)
|
- [apps/viewer/src/api.ts](/home/jbourdon/repos/space-game/apps/viewer/src/api.ts)
|
||||||
- selection cards
|
- snapshot fetch and SSE stream integration
|
||||||
- station cards
|
- [shared/data](/home/jbourdon/repos/space-game/shared/data)
|
||||||
- debug history markup
|
- scenario and world data definitions
|
||||||
- [src/game/data/balance.json](/home/jbourdon/repos/space-game/src/game/data/balance.json)
|
|
||||||
- travel, docking, transfer rates
|
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
Validation passing at the end of this session:
|
Validation passing at the end of this session:
|
||||||
|
|
||||||
- `npx tsc --noEmit --noUnusedLocals --noUnusedParameters`
|
- `cd apps/viewer && npm run build`
|
||||||
- `npm run build`
|
|
||||||
|
|||||||
@@ -72,20 +72,34 @@ interface PresentationEntry {
|
|||||||
detail: THREE.Object3D;
|
detail: THREE.Object3D;
|
||||||
icon: THREE.Sprite;
|
icon: THREE.Sprite;
|
||||||
hideDetailInUniverse?: boolean;
|
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> = {
|
const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
|
||||||
local: 900,
|
local: 900,
|
||||||
system: 3200,
|
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 {
|
export class GameViewer {
|
||||||
private readonly container: HTMLElement;
|
private readonly container: HTMLElement;
|
||||||
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
private readonly scene = new THREE.Scene();
|
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 clock = new THREE.Clock();
|
||||||
private readonly raycaster = new THREE.Raycaster();
|
private readonly raycaster = new THREE.Raycaster();
|
||||||
private readonly mouse = new THREE.Vector2();
|
private readonly mouse = new THREE.Vector2();
|
||||||
@@ -101,6 +115,8 @@ export class GameViewer {
|
|||||||
private readonly nodeMeshes = new Map<string, THREE.Mesh>();
|
private readonly nodeMeshes = new Map<string, THREE.Mesh>();
|
||||||
private readonly stationMeshes = new Map<string, THREE.Mesh>();
|
private readonly stationMeshes = new Map<string, THREE.Mesh>();
|
||||||
private readonly shipVisuals = new Map<string, ShipVisual>();
|
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 statusEl: HTMLDivElement;
|
||||||
private readonly detailTitleEl: HTMLHeadingElement;
|
private readonly detailTitleEl: HTMLHeadingElement;
|
||||||
private readonly detailBodyEl: HTMLDivElement;
|
private readonly detailBodyEl: HTMLDivElement;
|
||||||
@@ -125,6 +141,7 @@ export class GameViewer {
|
|||||||
private selectedItems: Selectable[] = [];
|
private selectedItems: Selectable[] = [];
|
||||||
private worldSignature = "";
|
private worldSignature = "";
|
||||||
private zoomLevel: ZoomLevel = "system";
|
private zoomLevel: ZoomLevel = "system";
|
||||||
|
private currentDistance = ZOOM_DISTANCE.system;
|
||||||
private desiredDistance = ZOOM_DISTANCE.system;
|
private desiredDistance = ZOOM_DISTANCE.system;
|
||||||
private orbitYaw = -2.3;
|
private orbitYaw = -2.3;
|
||||||
private orbitPitch = 0.62;
|
private orbitPitch = 0.62;
|
||||||
@@ -279,6 +296,7 @@ export class GameViewer {
|
|||||||
this.syncStations(snapshot.stations);
|
this.syncStations(snapshot.stations);
|
||||||
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
|
this.syncShips(snapshot.ships, snapshot.tickIntervalMs);
|
||||||
this.rebuildFactions(snapshot.factions);
|
this.rebuildFactions(snapshot.factions);
|
||||||
|
this.updateSystemSummaries();
|
||||||
this.applyZoomPresentation();
|
this.applyZoomPresentation();
|
||||||
this.updateNetworkPanel();
|
this.updateNetworkPanel();
|
||||||
}
|
}
|
||||||
@@ -312,12 +330,15 @@ export class GameViewer {
|
|||||||
if (delta.factions.length > 0) {
|
if (delta.factions.length > 0) {
|
||||||
this.rebuildFactions([...this.world.factions.values()]);
|
this.rebuildFactions([...this.world.factions.values()]);
|
||||||
}
|
}
|
||||||
|
this.updateSystemSummaries();
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildSystems(systems: SystemSnapshot[]) {
|
private rebuildSystems(systems: SystemSnapshot[]) {
|
||||||
this.systemGroup.clear();
|
this.systemGroup.clear();
|
||||||
this.selectableTargets.clear();
|
this.selectableTargets.clear();
|
||||||
this.presentationEntries.length = 0;
|
this.presentationEntries.length = 0;
|
||||||
|
this.orbitLines.length = 0;
|
||||||
|
this.systemSummaryVisuals.clear();
|
||||||
|
|
||||||
for (const system of systems) {
|
for (const system of systems) {
|
||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
@@ -337,8 +358,12 @@ export class GameViewer {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const systemIcon = this.createTacticalIcon(system.starColor, 96);
|
const systemIcon = this.createTacticalIcon(system.starColor, 96);
|
||||||
root.add(star, halo, systemIcon);
|
const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 110, system.position.z));
|
||||||
this.registerPresentation(star, systemIcon, false);
|
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(star, { kind: "system", id: system.id });
|
||||||
this.selectableTargets.set(halo, { kind: "system", id: system.id });
|
this.selectableTargets.set(halo, { kind: "system", id: system.id });
|
||||||
this.selectableTargets.set(systemIcon, { 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));
|
const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, planet.size * 2));
|
||||||
planetIcon.position.copy(planetMesh.position);
|
planetIcon.position.copy(planetMesh.position);
|
||||||
root.add(orbit, planetMesh, planetIcon);
|
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(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
||||||
this.selectableTargets.set(planetIcon, { 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);
|
icon.position.copy(mesh.position);
|
||||||
this.nodeMeshes.set(node.id, mesh);
|
this.nodeMeshes.set(node.id, mesh);
|
||||||
this.nodeGroup.add(mesh, icon);
|
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(mesh, { kind: "node", id: node.id });
|
||||||
this.selectableTargets.set(icon, { 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);
|
icon.position.copy(mesh.position);
|
||||||
this.stationMeshes.set(station.id, mesh);
|
this.stationMeshes.set(station.id, mesh);
|
||||||
this.stationGroup.add(mesh, icon);
|
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(mesh, { kind: "station", id: station.id });
|
||||||
this.selectableTargets.set(icon, { 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.shipGroup.add(mesh, icon);
|
||||||
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
||||||
this.selectableTargets.set(icon, { 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, {
|
this.shipVisuals.set(ship.id, {
|
||||||
mesh,
|
mesh,
|
||||||
icon,
|
icon,
|
||||||
@@ -585,7 +611,6 @@ export class GameViewer {
|
|||||||
this.detailTitleEl.textContent = system.label;
|
this.detailTitleEl.textContent = system.label;
|
||||||
this.detailBodyEl.innerHTML = `
|
this.detailBodyEl.innerHTML = `
|
||||||
<p>${system.id}</p>
|
<p>${system.id}</p>
|
||||||
<p>Planets ${system.planets.length}</p>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,14 +624,15 @@ export class GameViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateCamera(delta: number) {
|
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.updatePanFromKeyboard(delta);
|
||||||
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
|
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(
|
this.cameraOffset.set(
|
||||||
Math.cos(this.orbitYaw) * horizontalDistance,
|
Math.cos(this.orbitYaw) * horizontalDistance,
|
||||||
this.desiredDistance * Math.sin(this.orbitPitch),
|
this.currentDistance * Math.sin(this.orbitPitch),
|
||||||
Math.sin(this.orbitYaw) * horizontalDistance,
|
Math.sin(this.orbitYaw) * horizontalDistance,
|
||||||
);
|
);
|
||||||
this.camera.position.copy(this.focus).add(this.cameraOffset);
|
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 forward = new THREE.Vector3(Math.cos(this.orbitYaw), 0, Math.sin(this.orbitYaw));
|
||||||
const right = new THREE.Vector3(-forward.z, 0, forward.x);
|
const right = new THREE.Vector3(-forward.z, 0, forward.x);
|
||||||
const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z));
|
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);
|
this.focus.addScaledVector(pan, speed * delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyZoomPresentation() {
|
private applyZoomPresentation() {
|
||||||
const system = this.zoomLevel === "system";
|
const blend = this.computeZoomBlend(this.currentDistance);
|
||||||
const universe = this.zoomLevel === "universe";
|
|
||||||
|
|
||||||
for (const entry of this.presentationEntries) {
|
for (const entry of this.presentationEntries) {
|
||||||
entry.icon.visible = system || universe;
|
const detailAlpha = entry.hideDetailInUniverse
|
||||||
entry.detail.visible = universe ? !entry.hideDetailInUniverse : true;
|
? 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) {
|
private recordDeltaStats(delta: WorldDelta, rawBytes: number) {
|
||||||
@@ -722,6 +766,8 @@ export class GameViewer {
|
|||||||
const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh);
|
const entry = this.presentationEntries.find((candidate) => candidate.detail === mesh);
|
||||||
entry?.icon.position.copy(mesh.position);
|
entry?.icon.position.copy(mesh.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateSystemSummaryPresentation();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createNodeMesh(node: ResourceNodeSnapshot) {
|
private createNodeMesh(node: ResourceNodeSnapshot) {
|
||||||
@@ -790,8 +836,131 @@ export class GameViewer {
|
|||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
private registerPresentation(detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean) {
|
private createSystemSummaryVisual(anchor: THREE.Vector3): SystemSummaryVisual {
|
||||||
this.presentationEntries.push({ detail, icon, hideDetailInUniverse });
|
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) {
|
private renderRecentEvents(entityKind: string, entityId: string) {
|
||||||
@@ -862,6 +1031,52 @@ export class GameViewer {
|
|||||||
].join("\n");
|
].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) {
|
private toThreeVector(vector: Vector3Dto) {
|
||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||||
}
|
}
|
||||||
@@ -967,9 +1182,9 @@ export class GameViewer {
|
|||||||
|
|
||||||
private onWheel = (event: WheelEvent) => {
|
private onWheel = (event: WheelEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const direction = event.deltaY > 0 ? 1 : -1;
|
const deltaY = THREE.MathUtils.clamp(event.deltaY, -180, 180);
|
||||||
const nextIndex = THREE.MathUtils.clamp(ZOOM_ORDER.indexOf(this.zoomLevel) + direction, 0, ZOOM_ORDER.length - 1);
|
const zoomFactor = Math.exp(deltaY * 0.00135);
|
||||||
this.zoomLevel = ZOOM_ORDER[nextIndex];
|
this.desiredDistance = THREE.MathUtils.clamp(this.desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
|
||||||
this.updateGamePanel("Live");
|
this.updateGamePanel("Live");
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -980,11 +1195,11 @@ export class GameViewer {
|
|||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
this.keyState.add(key);
|
this.keyState.add(key);
|
||||||
if (key === "1") {
|
if (key === "1") {
|
||||||
this.zoomLevel = "local";
|
this.desiredDistance = ZOOM_DISTANCE.local;
|
||||||
} else if (key === "2") {
|
} else if (key === "2") {
|
||||||
this.zoomLevel = "system";
|
this.desiredDistance = ZOOM_DISTANCE.system;
|
||||||
} else if (key === "3") {
|
} else if (key === "3") {
|
||||||
this.zoomLevel = "universe";
|
this.desiredDistance = ZOOM_DISTANCE.universe;
|
||||||
}
|
}
|
||||||
this.updateGamePanel("Live");
|
this.updateGamePanel("Live");
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user