From a9c08124f55fc8e8c3ea7a73c681c1c41af4b7bc Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Fri, 13 Mar 2026 00:48:08 -0400 Subject: [PATCH] Add layered system viewer and local coordinates --- apps/backend/Contracts/WorldContracts.cs | 22 +- apps/backend/Simulation/RuntimeModels.cs | 2 + apps/backend/Simulation/ScenarioLoader.cs | 33 +- apps/backend/Simulation/SimulationEngine.cs | 10 +- apps/viewer/src/GameViewer.ts | 573 +++++++++++++++++--- apps/viewer/src/contracts.ts | 12 +- apps/viewer/src/style.css | 18 + 7 files changed, 576 insertions(+), 94 deletions(-) diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs index 3adf3c8..94e516d 100644 --- a/apps/backend/Contracts/WorldContracts.cs +++ b/apps/backend/Contracts/WorldContracts.cs @@ -33,7 +33,7 @@ public sealed record SimulationEventRecord( public sealed record SystemSnapshot( string Id, string Label, - Vector3Dto Position, + Vector3Dto GalaxyPosition, string StarKind, int StarCount, string StarColor, @@ -59,7 +59,7 @@ public sealed record PlanetSnapshot( public sealed record ResourceNodeSnapshot( string Id, string SystemId, - Vector3Dto Position, + Vector3Dto LocalPosition, string SourceKind, float OreRemaining, float MaxOre, @@ -68,7 +68,7 @@ public sealed record ResourceNodeSnapshot( public sealed record ResourceNodeDelta( string Id, string SystemId, - Vector3Dto Position, + Vector3Dto LocalPosition, string SourceKind, float OreRemaining, float MaxOre, @@ -79,7 +79,7 @@ public sealed record StationSnapshot( string Label, string Category, string SystemId, - Vector3Dto Position, + Vector3Dto LocalPosition, string Color, int DockedShips, float OreStored, @@ -91,7 +91,7 @@ public sealed record StationDelta( string Label, string Category, string SystemId, - Vector3Dto Position, + Vector3Dto LocalPosition, string Color, int DockedShips, float OreStored, @@ -104,9 +104,9 @@ public sealed record ShipSnapshot( string Role, string ShipClass, string SystemId, - Vector3Dto Position, - Vector3Dto Velocity, - Vector3Dto TargetPosition, + Vector3Dto LocalPosition, + Vector3Dto LocalVelocity, + Vector3Dto TargetLocalPosition, string State, string? OrderKind, string DefaultBehaviorKind, @@ -124,9 +124,9 @@ public sealed record ShipDelta( string Role, string ShipClass, string SystemId, - Vector3Dto Position, - Vector3Dto Velocity, - Vector3Dto TargetPosition, + Vector3Dto LocalPosition, + Vector3Dto LocalVelocity, + Vector3Dto TargetLocalPosition, string State, string? OrderKind, string DefaultBehaviorKind, diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs index fd0b291..4df5334 100644 --- a/apps/backend/Simulation/RuntimeModels.cs +++ b/apps/backend/Simulation/RuntimeModels.cs @@ -116,6 +116,8 @@ public readonly record struct Vector3(float X, float Y, float Z) { public static Vector3 Zero => new(0f, 0f, 0f); + public float LengthSquared() => (X * X) + (Y * Y) + (Z * Z); + public float DistanceTo(Vector3 other) { var dx = X - other.X; diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index 2bea185..aaf74c3 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -111,9 +111,9 @@ public sealed class ScenarioLoader Id = $"node-{++nodeIdCounter}", SystemId = system.Definition.Id, Position = new Vector3( - system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset), - system.Position.Y + balance.YPlane, - system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)), + MathF.Cos(node.Angle) * node.RadiusOffset, + balance.YPlane, + MathF.Sin(node.Angle) * node.RadiusOffset), SourceKind = node.SourceKind, ItemId = node.ItemId, OreRemaining = node.OreAmount, @@ -149,7 +149,7 @@ public sealed class ScenarioLoader var patrolRoutes = scenario.PatrolRoutes.ToDictionary( (route) => route.SystemId, - (route) => route.Points.Select(ToVector).ToList(), + (route) => route.Points.Select((point) => NormalizeScenarioPoint(systemsById[route.SystemId], point)).ToList(), StringComparer.Ordinal); var shipsRuntime = new List(); @@ -164,7 +164,7 @@ public sealed class ScenarioLoader for (var index = 0; index < formation.Count; index += 1) { var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); - var position = Add(ToVector(formation.Center), offset); + var position = Add(NormalizeScenarioPoint(systemsById[formation.SystemId], formation.Center), offset); shipsRuntime.Add(new ShipRuntime { Id = $"ship-{++shipIdCounter}", @@ -832,7 +832,7 @@ public sealed class ScenarioLoader { if (plan.Position is { Length: 3 }) { - return ToVector(plan.Position); + return NormalizeScenarioPoint(system, plan.Position); } if (plan.PlanetIndex is int planetIndex && planetIndex >= 0 && planetIndex < system.Definition.Planets.Count) @@ -840,15 +840,28 @@ public sealed class ScenarioLoader var planet = system.Definition.Planets[planetIndex]; var side = plan.LagrangeSide ?? 1; return new Vector3( - system.Position.X + planet.OrbitRadius + (side * 72f), - system.Position.Y + balance.YPlane, - system.Position.Z + ((planetIndex + 1) * 42f * side)); + planet.OrbitRadius + (side * 72f), + balance.YPlane, + (planetIndex + 1) * 42f * side); } - return new Vector3(system.Position.X + 180f, system.Position.Y + balance.YPlane, system.Position.Z); + return new Vector3(180f, balance.YPlane, 0f); } private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]); + private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values) + { + var raw = ToVector(values); + var relativeToSystem = new Vector3( + raw.X - system.Position.X, + raw.Y - system.Position.Y, + raw.Z - system.Position.Z); + + return relativeToSystem.LengthSquared() < raw.LengthSquared() + ? relativeToSystem + : raw; + } + private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); } diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index c9d12d7..cd5ef3b 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -77,7 +77,7 @@ public sealed class SimulationEngine world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot( node.Id, node.SystemId, - node.Position, + node.LocalPosition, node.SourceKind, node.OreRemaining, node.MaxOre, @@ -87,7 +87,7 @@ public sealed class SimulationEngine station.Label, station.Category, station.SystemId, - station.Position, + station.LocalPosition, station.Color, station.DockedShips, station.OreStored, @@ -99,9 +99,9 @@ public sealed class SimulationEngine ship.Role, ship.ShipClass, ship.SystemId, - ship.Position, - ship.Velocity, - ship.TargetPosition, + ship.LocalPosition, + ship.LocalVelocity, + ship.TargetLocalPosition, ship.State, ship.OrderKind, ship.DefaultBehaviorKind, diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 6d6b3da..fc0b134 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -53,17 +53,43 @@ interface MoonVisual { orbit: THREE.LineLoop; } +type OrbitalAnchor = + | { kind: "star" } + | { kind: "planet"; planetIndex: number } + | { kind: "moon"; planetIndex: number; moonIndex: number }; + +interface NodeVisual { + systemId: string; + mesh: THREE.Mesh; + icon: THREE.Sprite; + sourceKind: string; + anchor: OrbitalAnchor; + localPosition: THREE.Vector3; + orbitRadius: number; + orbitPhase: number; + orbitInclination: number; +} + interface StructureVisual { systemId: string; mesh: THREE.Mesh; icon: THREE.Sprite; - worldPosition: THREE.Vector3; + anchor: OrbitalAnchor; + orbitRadius: number; + orbitPhase: number; + orbitInclination: number; + localPosition: THREE.Vector3; } interface SystemVisual { root: THREE.Group; + starCluster: THREE.Group; + icon: THREE.Sprite; + shellReticle: THREE.Sprite; + shellReticleBaseScale: number; detailGroup: THREE.Group; summary: SystemSummaryVisual; + galaxyPosition: THREE.Vector3; } interface WorldState { @@ -128,8 +154,11 @@ const ZOOM_DISTANCE: Record = { system: 3200, universe: 26000, }; -const ACTIVE_SYSTEM_DETAIL_SCALE = 2.2; +const ACTIVE_SYSTEM_DETAIL_SCALE = 10; +const GALAXY_PARALLAX_FACTOR = 0.025; const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000; +const PROJECTED_GALAXY_RADIUS = 65000; +const STAR_RENDER_SCALE = 0.18; const MIN_CAMERA_DISTANCE = 450; const MAX_CAMERA_DISTANCE = 42000; @@ -143,11 +172,12 @@ 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, 100000); + private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 160000); private readonly clock = new THREE.Clock(); private readonly raycaster = new THREE.Raycaster(); private readonly mouse = new THREE.Vector2(); - private readonly focus = new THREE.Vector3(2200, 0, 300); + private readonly galaxyFocus = new THREE.Vector3(2200, 0, 300); + private readonly systemFocusLocal = new THREE.Vector3(); private readonly cameraOffset = new THREE.Vector3(); private readonly keyState = new Set(); private readonly systemGroup = new THREE.Group(); @@ -157,7 +187,7 @@ export class GameViewer { private readonly ambienceGroup = new THREE.Group(); private readonly selectableTargets = new Map(); private readonly presentationEntries: PresentationEntry[] = []; - private readonly nodeVisuals = new Map(); + private readonly nodeVisuals = new Map(); private readonly stationVisuals = new Map(); private readonly shipVisuals = new Map(); private readonly systemVisuals = new Map(); @@ -175,6 +205,7 @@ export class GameViewer { private readonly performancePanelEl: HTMLDivElement; private readonly errorEl: HTMLDivElement; private readonly marqueeEl: HTMLDivElement; + private readonly hoverLabelEl: HTMLDivElement; private world?: WorldState; private worldTimeSyncMs = performance.now(); @@ -257,6 +288,7 @@ export class GameViewer {
+ `; this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement; @@ -270,6 +302,7 @@ export class GameViewer { this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement; this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement; this.marqueeEl = hud.querySelector(".marquee-box") as HTMLDivElement; + this.hoverLabelEl = hud.querySelector(".hover-label") as HTMLDivElement; this.container.append(this.renderer.domElement, hud); @@ -423,16 +456,27 @@ export class GameViewer { for (const system of systems) { const root = new THREE.Group(); - root.position.set(system.position.x, system.position.y, system.position.z); + root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z); const detailGroup = new THREE.Group(); + const renderedStarSize = Math.max(system.starSize * STAR_RENDER_SCALE, 8); const starCluster = this.createStarCluster(system); const systemIcon = this.createTacticalIcon(system.starColor, 96); - const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 140, system.position.z)); - summaryVisual.sprite.position.set(0, system.starSize + 110, 0); - root.add(starCluster, systemIcon, summaryVisual.sprite, detailGroup); + const shellReticle = this.createShellReticle("#ff3b30", 400); + const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z)); + summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0); + root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup); this.registerPresentation(starCluster, systemIcon, true); - this.systemVisuals.set(system.id, { root, detailGroup, summary: summaryVisual }); + this.systemVisuals.set(system.id, { + root, + starCluster, + icon: systemIcon, + shellReticle, + shellReticleBaseScale: 400, + detailGroup, + summary: summaryVisual, + galaxyPosition: this.toThreeVector(system.galaxyPosition), + }); this.systemSummaryVisuals.set(system.id, summaryVisual); starCluster.traverse((child) => { if (child instanceof THREE.Mesh) { @@ -440,6 +484,7 @@ export class GameViewer { } }); this.selectableTargets.set(systemIcon, { kind: "system", id: system.id }); + this.selectableTargets.set(shellReticle, { kind: "system", id: system.id }); for (const [planetIndex, planet] of system.planets.entries()) { const orbit = this.createPlanetOrbit(planet); @@ -493,11 +538,19 @@ export class GameViewer { const mesh = this.createNodeMesh(node); const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); icon.position.copy(mesh.position); + const localPosition = this.toThreeVector(node.localPosition); + const anchor = this.resolveOrbitalAnchor(node.systemId, localPosition); + const orbital = this.deriveNodeOrbital(node, anchor); this.nodeVisuals.set(node.id, { systemId: node.systemId, mesh, icon, - worldPosition: this.toThreeVector(node.position), + sourceKind: node.sourceKind, + anchor, + localPosition, + orbitRadius: orbital.radius, + orbitPhase: orbital.phase, + orbitInclination: orbital.inclination, }); this.nodeGroup.add(mesh, icon); this.registerPresentation(mesh, icon, true, true, node.systemId); @@ -514,11 +567,18 @@ export class GameViewer { const mesh = this.createStationMesh(station); const icon = this.createTacticalIcon(station.color, 26); icon.position.copy(mesh.position); + const localPosition = this.toThreeVector(station.localPosition); + const anchor = this.resolveOrbitalAnchor(station.systemId, localPosition); + const orbital = this.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor); this.stationVisuals.set(station.id, { systemId: station.systemId, mesh, icon, - worldPosition: this.toThreeVector(station.position), + anchor, + orbitRadius: orbital.radius, + orbitPhase: orbital.phase, + orbitInclination: orbital.inclination, + localPosition, }); this.stationGroup.add(mesh, icon); this.registerPresentation(mesh, icon, true, true, station.systemId); @@ -534,7 +594,7 @@ export class GameViewer { for (const ship of ships) { const mesh = this.createShipMesh(ship); const icon = this.createTacticalIcon(this.shipColor(ship.role), 18); - const position = this.toThreeVector(ship.position); + const position = this.toThreeVector(ship.localPosition); icon.position.copy(position); this.shipGroup.add(mesh, icon); this.selectableTargets.set(mesh, { kind: "ship", id: ship.id }); @@ -546,8 +606,8 @@ export class GameViewer { icon, startPosition: position.clone(), authoritativePosition: position.clone(), - targetPosition: this.toThreeVector(ship.targetPosition), - velocity: this.toThreeVector(ship.velocity), + targetPosition: this.toThreeVector(ship.targetLocalPosition), + velocity: this.toThreeVector(ship.localVelocity), receivedAtMs: performance.now(), blendDurationMs: Math.max(tickIntervalMs, 80), }); @@ -562,7 +622,13 @@ export class GameViewer { } visual.systemId = node.systemId; - visual.worldPosition.copy(this.toThreeVector(node.position)); + visual.sourceKind = node.sourceKind; + visual.localPosition.copy(this.toThreeVector(node.localPosition)); + visual.anchor = this.resolveOrbitalAnchor(node.systemId, visual.localPosition); + const orbital = this.deriveNodeOrbital(node, visual.anchor); + visual.orbitRadius = orbital.radius; + visual.orbitPhase = orbital.phase; + visual.orbitInclination = orbital.inclination; visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); } } @@ -575,7 +641,12 @@ export class GameViewer { } visual.systemId = station.systemId; - visual.worldPosition.copy(this.toThreeVector(station.position)); + visual.localPosition.copy(this.toThreeVector(station.localPosition)); + visual.anchor = this.resolveOrbitalAnchor(station.systemId, visual.localPosition); + const orbital = this.deriveOrbitalFromLocalPosition(visual.localPosition, station.systemId, visual.anchor); + visual.orbitRadius = orbital.radius; + visual.orbitPhase = orbital.phase; + visual.orbitInclination = orbital.inclination; const material = visual.mesh.material as THREE.MeshStandardMaterial; material.color.set(station.color); material.emissive = new THREE.Color(station.color).multiplyScalar(0.1); @@ -591,9 +662,9 @@ export class GameViewer { visual.systemId = ship.systemId; visual.startPosition.copy(visual.authoritativePosition); - visual.authoritativePosition.copy(this.toThreeVector(ship.position)); - visual.targetPosition.copy(this.toThreeVector(ship.targetPosition)); - visual.velocity.copy(this.toThreeVector(ship.velocity)); + visual.authoritativePosition.copy(this.toThreeVector(ship.localPosition)); + visual.targetPosition.copy(this.toThreeVector(ship.targetLocalPosition)); + visual.velocity.copy(this.toThreeVector(ship.localVelocity)); visual.receivedAtMs = performance.now(); visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100); (visual.mesh.material as THREE.MeshStandardMaterial).color.set(this.shipColor(ship.role)); @@ -649,12 +720,14 @@ export class GameViewer { if (!ship) { return; } + const parent = this.describeSelectionParent(selected); this.detailTitleEl.textContent = ship.label; this.detailBodyEl.innerHTML = `

${ship.shipClass} · ${ship.role} · ${ship.systemId}

+

Parent ${parent}

State ${ship.state}
Behavior ${ship.defaultBehaviorKind}
Task ${ship.controllerTaskKind}

Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}

-

Velocity ${this.formatVector(ship.velocity)}

+

Velocity ${this.formatVector(ship.localVelocity)}

${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}

${ship.history.join("
")}

`; @@ -666,9 +739,11 @@ export class GameViewer { if (!station) { return; } + const parent = this.describeSelectionParent(selected); this.detailTitleEl.textContent = station.label; this.detailBodyEl.innerHTML = `

${station.category} · ${station.systemId}

+

Parent ${parent}

Ore ${station.oreStored.toFixed(0)}
Refined ${station.refinedStock.toFixed(0)}
Docked ${station.dockedShips}

${this.renderRecentEvents("station", station.id)}

`; @@ -680,9 +755,11 @@ export class GameViewer { if (!node) { return; } + const parent = this.describeSelectionParent(selected); this.detailTitleEl.textContent = `Node ${node.id}`; this.detailBodyEl.innerHTML = `

${node.systemId}

+

Parent ${parent}

Source ${node.sourceKind}
Resource ${node.itemId}

Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

`; @@ -695,9 +772,11 @@ export class GameViewer { if (!system || !planet) { return; } + const parent = this.describeSelectionParent(selected); this.detailTitleEl.textContent = planet.label; this.detailBodyEl.innerHTML = `

${system.label}

+

Parent ${parent}

${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}

Orbit ${planet.orbitRadius.toFixed(0)}
Speed ${planet.orbitSpeed.toFixed(3)}
Ecc ${planet.orbitEccentricity.toFixed(3)}
Inc ${planet.orbitInclination.toFixed(1)}°

Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°

@@ -710,7 +789,10 @@ export class GameViewer { return; } this.detailTitleEl.textContent = system.label; - this.detailBodyEl.innerHTML = this.renderSystemDetails(system, false); + this.detailBodyEl.innerHTML = ` +

Parent galaxy

+ ${this.renderSystemDetails(system, false)} + `; } private render() { @@ -742,13 +824,14 @@ export class GameViewer { this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch); + const focus = this.getCameraFocusWorldPosition(); this.cameraOffset.set( Math.cos(this.orbitYaw) * horizontalDistance, this.currentDistance * Math.sin(this.orbitPitch), Math.sin(this.orbitYaw) * horizontalDistance, ); - this.camera.position.copy(this.focus).add(this.cameraOffset); - this.camera.lookAt(this.focus); + this.camera.position.copy(focus).add(this.cameraOffset); + this.camera.lookAt(focus); } private updatePanFromKeyboard(delta: number) { @@ -778,7 +861,11 @@ export class GameViewer { const right = new THREE.Vector3(-forward.z, 0, forward.x); const pan = right.multiplyScalar(move.x).add(forward.multiplyScalar(move.z)); const speed = THREE.MathUtils.mapLinear(this.currentDistance, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, 320, 6800); - this.focus.addScaledVector(pan, speed * delta); + if (this.activeSystemId) { + this.systemFocusLocal.addScaledVector(pan, speed * delta); + return; + } + this.galaxyFocus.addScaledVector(pan, speed * delta); } private applyZoomPresentation() { @@ -787,10 +874,16 @@ export class GameViewer { for (const entry of this.presentationEntries) { const systemId = entry.systemId; const isActiveDetail = !systemId || systemId === this.activeSystemId; + const isProjectedSystemIcon = !!this.activeSystemId + && !!systemId + && systemId !== this.activeSystemId + && this.systemVisuals.get(systemId)?.icon === entry.icon; const detailAlpha = entry.hideDetailInUniverse ? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0) : 1; - const iconAlpha = entry.hideIconInUniverse + const iconAlpha = isProjectedSystemIcon + ? 0 + : entry.hideIconInUniverse ? blend.systemWeight * (isActiveDetail ? 1 : 0) : Math.max(blend.systemWeight, blend.universeWeight); @@ -909,6 +1002,7 @@ export class GameViewer { private updateShipPresentation() { const now = performance.now(); + const worldTimeSeconds = this.currentWorldTimeSeconds(); for (const visual of this.shipVisuals.values()) { const elapsedMs = now - visual.receivedAtMs; const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); @@ -919,28 +1013,31 @@ export class GameViewer { worldPosition.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds); } - visual.mesh.position.copy(this.toDisplayPosition(worldPosition, visual.systemId)); + visual.mesh.position.copy(this.toDisplayLocalPosition(worldPosition, visual.systemId)); visual.icon.position.copy(visual.mesh.position); const shipVisible = visual.systemId === this.activeSystemId; visual.mesh.visible = shipVisible; visual.icon.visible = shipVisible && visual.icon.visible; - const desiredHeading = visual.targetPosition.clone().sub(visual.mesh.position); + const desiredHeading = visual.targetPosition.clone().sub(worldPosition); if (desiredHeading.lengthSq() > 0.01) { visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); } } for (const visual of this.nodeVisuals.values()) { - visual.mesh.position.copy(this.toDisplayPosition(visual.worldPosition, visual.systemId)); + const animatedLocalPosition = this.computeNodeLocalPosition(visual, worldTimeSeconds); + visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.icon.position.copy(visual.mesh.position); visual.mesh.visible = visual.systemId === this.activeSystemId; } for (const visual of this.stationVisuals.values()) { - visual.mesh.position.copy(this.toDisplayPosition(visual.worldPosition, visual.systemId)); + const animatedLocalPosition = this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09); + visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId)); visual.icon.position.copy(visual.mesh.position); visual.mesh.visible = visual.systemId === this.activeSystemId; } + this.updateSystemStarPresentation(); this.updateSystemDetailVisibility(); this.updateSystemSummaryPresentation(); } @@ -949,8 +1046,15 @@ export class GameViewer { const nowSeconds = this.currentWorldTimeSeconds(); for (const visual of this.planetVisuals) { const scale = visual.systemId === this.activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1; - const position = this.computePlanetLocalPosition(visual.planet, nowSeconds).multiplyScalar(scale); + const localPosition = this.computePlanetLocalPosition(visual.planet, nowSeconds); + const orbitOffset = visual.systemId === this.activeSystemId + ? this.systemFocusLocal.clone().multiplyScalar(-scale) + : new THREE.Vector3(); + const position = visual.systemId === this.activeSystemId + ? localPosition.clone().sub(this.systemFocusLocal).multiplyScalar(scale) + : localPosition.multiplyScalar(scale); visual.orbit.scale.setScalar(scale); + visual.orbit.position.copy(orbitOffset); visual.mesh.position.copy(position); visual.icon.position.copy(position); if (visual.ring) { @@ -976,25 +1080,26 @@ export class GameViewer { emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05), }), ); - mesh.position.copy(this.toThreeVector(node.position)); + mesh.position.copy(this.toThreeVector(node.localPosition)); mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); return mesh; } private createStarCluster(system: SystemSnapshot) { const root = new THREE.Group(); + const renderedStarSize = Math.max(system.starSize * STAR_RENDER_SCALE, 8); const offsets = system.starCount > 1 - ? [new THREE.Vector3(-system.starSize * 0.55, 0, 0), new THREE.Vector3(system.starSize * 0.75, system.starSize * 0.08, 0)] + ? [new THREE.Vector3(-renderedStarSize * 0.55, 0, 0), new THREE.Vector3(renderedStarSize * 0.75, renderedStarSize * 0.08, 0)] : [new THREE.Vector3(0, 0, 0)]; for (const [index, offset] of offsets.entries()) { const sizeScale = index === 0 ? 1 : 0.72; const star = new THREE.Mesh( - new THREE.SphereGeometry(system.starSize * sizeScale, 28, 28), + new THREE.SphereGeometry(renderedStarSize * sizeScale, 24, 24), new THREE.MeshBasicMaterial({ color: system.starColor }), ); const halo = new THREE.Mesh( - new THREE.SphereGeometry(system.starSize * sizeScale * 1.72, 24, 24), + new THREE.SphereGeometry(renderedStarSize * sizeScale * 1.45, 20, 20), new THREE.MeshBasicMaterial({ color: system.starColor, transparent: true, @@ -1080,7 +1185,7 @@ export class GameViewer { new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }), ); mesh.rotation.x = Math.PI / 2; - mesh.position.copy(this.toThreeVector(station.position)); + mesh.position.copy(this.toThreeVector(station.localPosition)); return mesh; } @@ -1091,7 +1196,7 @@ export class GameViewer { geometry, new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }), ); - mesh.position.copy(this.toThreeVector(ship.position)); + mesh.position.copy(this.toThreeVector(ship.localPosition)); return mesh; } @@ -1262,6 +1367,50 @@ export class GameViewer { return { sprite, texture, anchor }; } + private createShellReticle(color: string, size: number) { + const canvas = document.createElement("canvas"); + canvas.width = 128; + canvas.height = 128; + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Unable to create shell reticle"); + } + + context.clearRect(0, 0, 128, 128); + context.strokeStyle = color; + context.lineWidth = 6; + context.globalAlpha = 0.58; + context.beginPath(); + context.arc(64, 64, 48, 0.12 * Math.PI, 0.34 * Math.PI); + context.stroke(); + context.beginPath(); + context.arc(64, 64, 48, 0.62 * Math.PI, 0.84 * Math.PI); + context.stroke(); + context.beginPath(); + context.arc(64, 64, 48, 1.12 * Math.PI, 1.34 * Math.PI); + context.stroke(); + context.beginPath(); + context.arc(64, 64, 48, 1.62 * Math.PI, 1.84 * Math.PI); + context.stroke(); + + const texture = new THREE.CanvasTexture(canvas); + const material = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false, + depthTest: false, + color, + opacity: 1, + blending: THREE.AdditiveBlending, + fog: false, + }); + const sprite = new THREE.Sprite(material); + sprite.scale.setScalar(size); + sprite.visible = false; + sprite.renderOrder = 1000; + return sprite; + } + private updateSystemSummaries() { if (!this.world) { return; @@ -1364,7 +1513,8 @@ export class GameViewer { private updateSystemSummaryPresentation() { const distanceScale = this.activeSystemId ? 0.05 : 0.085; for (const [systemId, visual] of this.systemSummaryVisuals.entries()) { - const distance = this.camera.position.distanceTo(visual.anchor); + const worldPosition = visual.sprite.getWorldPosition(new THREE.Vector3()); + const distance = this.camera.position.distanceTo(worldPosition); const minimumScale = this.activeSystemId && systemId !== this.activeSystemId ? 1200 : 1400; const scale = Math.max(minimumScale, distance * distanceScale); visual.sprite.scale.set(scale, scale * 0.3125, 1); @@ -1566,6 +1716,104 @@ export class GameViewer { return Math.min(base + variance, planet.size * 0.42); } + private deriveNodeOrbital(node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) { + return this.deriveOrbitalFromLocalPosition(this.toThreeVector(node.localPosition), node.systemId, anchor); + } + + private deriveOrbitalFromLocalPosition(localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) { + const anchorPosition = this.resolveOrbitalAnchorPosition(systemId, anchor, this.currentWorldTimeSeconds()); + const relativePosition = localPosition.clone().sub(anchorPosition); + const radius = Math.max(Math.sqrt((relativePosition.x * relativePosition.x) + (relativePosition.z * relativePosition.z)), 24); + const phase = Math.atan2(relativePosition.z, relativePosition.x); + const inclination = Math.atan2(relativePosition.y, radius); + return { radius, phase, inclination }; + } + + private computeNodeLocalPosition(node: NodeVisual, timeSeconds: number) { + const speed = this.computeNodeOrbitSpeed(node); + const angle = node.orbitPhase + (timeSeconds * speed); + const orbit = new THREE.Vector3( + Math.cos(angle) * node.orbitRadius, + 0, + Math.sin(angle) * node.orbitRadius, + ); + orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), node.orbitInclination); + return orbit.add(this.resolveOrbitalAnchorPosition(node.systemId, node.anchor, timeSeconds)); + } + + private computeNodeOrbitSpeed(node: NodeVisual) { + const base = node.sourceKind === "gas-cloud" ? 0.16 : 0.24; + return base / Math.sqrt(Math.max(node.orbitRadius / 140, 0.4)); + } + + private computeStructureLocalPosition(structure: StructureVisual, timeSeconds: number, baseSpeed: number) { + const angle = structure.orbitPhase + (timeSeconds * (baseSpeed / Math.sqrt(Math.max(structure.orbitRadius / 180, 0.45)))); + const orbit = new THREE.Vector3( + Math.cos(angle) * structure.orbitRadius, + 0, + Math.sin(angle) * structure.orbitRadius, + ); + orbit.applyAxisAngle(new THREE.Vector3(1, 0, 0), structure.orbitInclination); + return orbit.add(this.resolveOrbitalAnchorPosition(structure.systemId, structure.anchor, timeSeconds)); + } + + private resolveOrbitalAnchor(systemId: string, localPosition: THREE.Vector3): OrbitalAnchor { + if (!this.world) { + return { kind: "star" }; + } + + const system = this.world.systems.get(systemId); + if (!system) { + return { kind: "star" }; + } + + const nowSeconds = this.currentWorldTimeSeconds(); + let bestAnchor: OrbitalAnchor = { kind: "star" }; + let bestDistance = Number.POSITIVE_INFINITY; + + for (const [planetIndex, planet] of system.planets.entries()) { + const planetPosition = this.computePlanetLocalPosition(planet, nowSeconds); + const planetDistance = localPosition.distanceTo(planetPosition); + const planetThreshold = Math.max(planet.size * 10, 180); + if (planetDistance < planetThreshold && planetDistance < bestDistance) { + bestDistance = planetDistance; + bestAnchor = { kind: "planet", planetIndex }; + } + + const moonCount = Math.min(planet.moonCount, 12); + for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) { + const moonPosition = planetPosition.clone().add(this.computeMoonLocalPosition(planet, moonIndex, nowSeconds)); + const moonDistance = localPosition.distanceTo(moonPosition); + const moonThreshold = Math.max(this.computeMoonSize(planet, moonIndex) * 14, 80); + if (moonDistance < moonThreshold && moonDistance < bestDistance) { + bestDistance = moonDistance; + bestAnchor = { kind: "moon", planetIndex, moonIndex }; + } + } + } + + return bestAnchor; + } + + private resolveOrbitalAnchorPosition(systemId: string, anchor: OrbitalAnchor, timeSeconds: number) { + if (!this.world || anchor.kind === "star") { + return new THREE.Vector3(); + } + + const system = this.world.systems.get(systemId); + const planet = system?.planets[anchor.planetIndex]; + if (!system || !planet) { + return new THREE.Vector3(); + } + + const planetPosition = this.computePlanetLocalPosition(planet, timeSeconds); + if (anchor.kind === "planet") { + return planetPosition; + } + + return planetPosition.add(this.computeMoonLocalPosition(planet, anchor.moonIndex, timeSeconds)); + } + private hashUnit(value: string) { let hash = this.world?.seed ?? 1; for (let index = 0; index < value.length; index += 1) { @@ -1616,6 +1864,8 @@ export class GameViewer { }; private onPointerMove = (event: PointerEvent) => { + this.updateHoverLabel(event); + if (this.dragPointerId !== event.pointerId || !this.dragMode) { return; } @@ -1669,23 +1919,57 @@ export class GameViewer { return; } - const bounds = this.renderer.domElement.getBoundingClientRect(); - this.mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; - this.mouse.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1); - this.raycaster.setFromCamera(this.mouse, this.camera); - const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0]; - this.selectedItems = hit ? [this.selectableTargets.get(hit.object)!] : []; + const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY); + this.selectedItems = picked ? [picked] : []; this.syncFollowStateFromSelection(); this.updatePanels(); }; + private updateHoverLabel(event: PointerEvent) { + if (this.dragMode) { + this.hoverLabelEl.hidden = true; + return; + } + + const selection = this.pickSelectableAtClientPosition(event.clientX, event.clientY); + if (!selection || selection.kind !== "system" || selection.id === this.activeSystemId) { + this.hoverLabelEl.hidden = true; + return; + } + + const system = this.world?.systems.get(selection.id); + if (!system) { + this.hoverLabelEl.hidden = true; + return; + } + + this.hoverLabelEl.hidden = false; + this.hoverLabelEl.textContent = system.label; + const point = this.screenPointFromClient(event.clientX, event.clientY); + this.hoverLabelEl.style.left = `${point.x + 14}px`; + this.hoverLabelEl.style.top = `${point.y + 14}px`; + } + + private pickSelectableAtClientPosition(clientX: number, clientY: number) { + const bounds = this.renderer.domElement.getBoundingClientRect(); + this.mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1; + this.mouse.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1); + this.raycaster.setFromCamera(this.mouse, this.camera); + const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0]; + return hit ? this.selectableTargets.get(hit.object) : undefined; + } + private onDoubleClick = () => { if (this.selectedItems.length !== 1) { return; } const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]); if (nextFocus) { - this.focus.copy(nextFocus); + if (this.activeSystemId && this.isSelectionInActiveSystem(this.selectedItems[0])) { + this.systemFocusLocal.copy(nextFocus); + } else { + this.galaxyFocus.copy(nextFocus); + } } this.syncFollowStateFromSelection(); }; @@ -1728,15 +2012,21 @@ export class GameViewer { if (selection.kind === "ship") { const ship = this.world.ships.get(selection.id); - return ship ? this.toDisplayPosition(this.toThreeVector(ship.position), ship.systemId) : undefined; + return ship ? this.toThreeVector(ship.localPosition) : undefined; } if (selection.kind === "station") { const station = this.world.stations.get(selection.id); - return station ? this.toDisplayPosition(this.toThreeVector(station.position), station.systemId) : undefined; + const visual = station ? this.stationVisuals.get(station.id) : undefined; + return visual + ? this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09) + : (station ? this.toThreeVector(station.localPosition) : undefined); } if (selection.kind === "node") { const node = this.world.nodes.get(selection.id); - return node ? this.toDisplayPosition(this.toThreeVector(node.position), node.systemId) : undefined; + const visual = node ? this.nodeVisuals.get(node.id) : undefined; + return visual + ? this.computeNodeLocalPosition(visual, this.currentWorldTimeSeconds()) + : (node ? this.toThreeVector(node.localPosition) : undefined); } if (selection.kind === "planet") { const system = this.world.systems.get(selection.systemId); @@ -1746,10 +2036,10 @@ export class GameViewer { } const visual = this.planetVisuals.find((candidate) => candidate.systemId === selection.systemId && candidate.planet === planet); - return visual?.mesh.getWorldPosition(new THREE.Vector3()); + return visual?.mesh.position.clone() ?? this.computePlanetLocalPosition(planet, this.currentWorldTimeSeconds()); } const system = this.world.systems.get(selection.id); - return system ? this.toThreeVector(system.position) : undefined; + return system ? this.toThreeVector(system.galaxyPosition) : undefined; } private updateMarqueeBox() { @@ -1850,6 +2140,9 @@ export class GameViewer { return; } + if (nextActiveSystemId) { + this.seedSystemFocusLocal(nextActiveSystemId); + } this.activeSystemId = nextActiveSystemId; this.updateSystemDetailVisibility(); this.updatePanels(); @@ -1882,8 +2175,8 @@ export class GameViewer { let nearestSystemId: string | undefined; let nearestDistance = Number.POSITIVE_INFINITY; for (const system of this.world.systems.values()) { - const center = this.toThreeVector(system.position); - const distance = center.distanceTo(this.focus); + const center = this.toThreeVector(system.galaxyPosition); + const distance = center.distanceTo(this.galaxyFocus); if (distance < nearestDistance) { nearestDistance = distance; nearestSystemId = system.id; @@ -1906,8 +2199,8 @@ export class GameViewer { return; } - const target = this.toDisplayPosition(this.toThreeVector(ship.position), ship.systemId); - this.focus.lerp(target, 1 - Math.exp(-delta * 8)); + const target = this.toThreeVector(ship.localPosition); + this.systemFocusLocal.lerp(target, 1 - Math.exp(-delta * 8)); } private syncFollowStateFromSelection() { @@ -1926,6 +2219,58 @@ export class GameViewer { } } + private updateSystemStarPresentation() { + const activeSystem = this.activeSystemId ? this.systemVisuals.get(this.activeSystemId) : undefined; + + for (const [systemId, visual] of this.systemVisuals.entries()) { + visual.root.position.copy(visual.galaxyPosition); + visual.shellReticle.scale.setScalar(visual.shellReticleBaseScale); + + if (!activeSystem) { + visual.starCluster.position.set(0, 0, 0); + visual.icon.position.set(0, 0, 0); + visual.icon.visible = true; + visual.shellReticle.position.set(0, 0, 0); + visual.shellReticle.visible = false; + this.setShellReticleOpacity(visual.shellReticle, 0); + continue; + } + + if (systemId !== this.activeSystemId) { + visual.starCluster.position.set(0, 0, 0); + visual.icon.position.set(0, 0, 0); + visual.icon.visible = false; + visual.shellReticle.position.set(0, 0, 0); + visual.shellReticle.visible = true; + this.setShellReticleOpacity(visual.shellReticle, 1); + const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition); + if (direction.lengthSq() > 0.0001) { + visual.root.position.copy( + activeSystem.galaxyPosition.clone().add(direction.normalize().multiplyScalar(PROJECTED_GALAXY_RADIUS)), + ); + } + const reticleWorldPosition = visual.root.getWorldPosition(new THREE.Vector3()); + const reticleDistance = this.camera.position.distanceTo(reticleWorldPosition); + const reticleScale = Math.max(900, reticleDistance * 0.032); + visual.shellReticle.scale.setScalar(reticleScale); + continue; + } + + const offset = this.systemFocusLocal.clone().multiplyScalar(-ACTIVE_SYSTEM_DETAIL_SCALE); + visual.starCluster.position.copy(offset); + visual.icon.position.copy(offset); + visual.icon.visible = true; + visual.shellReticle.visible = false; + this.setShellReticleOpacity(visual.shellReticle, 0); + } + } + + private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) { + sprite.visible = opacity > 0.02; + sprite.material.opacity = opacity; + sprite.material.needsUpdate = true; + } + private resolveSelectableSystemId(selection: Selectable) { if (!this.world) { return undefined; @@ -1946,18 +2291,122 @@ export class GameViewer { return selection.id; } - private toDisplayPosition(worldPosition: THREE.Vector3, systemId?: string) { - if (!this.world || !systemId || systemId !== this.activeSystemId) { - return worldPosition.clone(); + private describeSelectionParent(selection: Selectable) { + if (!this.world) { + return "unknown"; + } + + if (selection.kind === "system") { + return "galaxy"; + } + + if (selection.kind === "planet") { + const system = this.world.systems.get(selection.systemId); + return system ? `${system.label} star` : selection.systemId; + } + + if (selection.kind === "ship") { + const ship = this.world.ships.get(selection.id); + if (!ship) { + return "unknown"; + } + const system = this.world.systems.get(ship.systemId); + return system ? `${system.label} system` : ship.systemId; + } + + if (selection.kind === "station") { + const station = this.world.stations.get(selection.id); + const visual = station ? this.stationVisuals.get(selection.id) : undefined; + return this.describeOrbitalParent(station?.systemId, visual?.anchor); + } + + const node = this.world.nodes.get(selection.id); + const visual = node ? this.nodeVisuals.get(selection.id) : undefined; + return this.describeOrbitalParent(node?.systemId, visual?.anchor); + } + + private describeOrbitalParent(systemId?: string, anchor?: OrbitalAnchor) { + if (!this.world || !systemId) { + return "unknown"; } const system = this.world.systems.get(systemId); if (!system) { - return worldPosition.clone(); + return systemId; } - const center = this.toThreeVector(system.position); - return worldPosition.clone().sub(center).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE).add(center); + if (!anchor || anchor.kind === "star") { + return `${system.label} star`; + } + + const planet = system.planets[anchor.planetIndex]; + if (!planet) { + return `${system.label} star`; + } + + if (anchor.kind === "planet") { + return planet.label; + } + + return `${planet.label} moon ${anchor.moonIndex + 1}`; + } + + private isSelectionInActiveSystem(selection: Selectable) { + return !!this.activeSystemId && this.resolveSelectableSystemId(selection) === this.activeSystemId; + } + + private getCameraFocusWorldPosition() { + if (!this.activeSystemId || !this.world) { + return this.galaxyFocus; + } + + const system = this.world.systems.get(this.activeSystemId); + return system + ? this.toThreeVector(system.galaxyPosition).add(this.systemFocusLocal.clone().multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR)) + : this.galaxyFocus; + } + + private seedSystemFocusLocal(systemId: string) { + if (!this.world) { + return; + } + + if (this.followedShipId) { + const followedShip = this.world.ships.get(this.followedShipId); + if (followedShip?.systemId === systemId) { + this.systemFocusLocal.copy(this.toThreeVector(followedShip.localPosition)); + return; + } + } + + const selected = this.selectedItems[0]; + if (selected && this.resolveSelectableSystemId(selected) === systemId) { + const selectedPosition = this.resolveSelectionPosition(selected); + if (selectedPosition) { + this.systemFocusLocal.copy(selectedPosition); + return; + } + } + + this.systemFocusLocal.set(0, 0, 0); + } + + private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) { + if (!this.world || !systemId) { + return localPosition.clone(); + } + + const system = this.world.systems.get(systemId); + if (!system) { + return localPosition.clone(); + } + + const center = this.toThreeVector(system.galaxyPosition); + if (systemId !== this.activeSystemId) { + return center.clone().add(localPosition); + } + + return center.clone().add(localPosition.clone().sub(this.systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE)); } private renderSystemDetails(system: SystemSnapshot, activeContext: boolean) { @@ -1997,7 +2446,7 @@ export class GameViewer {

${system.id}${activeContext ? " · active system" : ""}

${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}

Planets ${system.planets.length}
Moons ${moonCount}
Ships ${shipCount}
Stations ${stationCount}
Nodes ${nodeCount}

-

Height ${system.position.y.toFixed(0)}

+

Height ${system.galaxyPosition.y.toFixed(0)}

${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("
")}

${followText} `; @@ -2019,6 +2468,6 @@ export class GameViewer { } this.systemTitleEl.textContent = activeSystem.label; - this.systemBodyEl.innerHTML = this.renderSystemDetails(activeSystem, true); + this.systemBodyEl.innerHTML = `

${activeSystem.starKind}

`; } } diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index 33a357a..3e82ea4 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -40,7 +40,7 @@ export interface Vector3Dto { export interface SystemSnapshot { id: string; label: string; - position: Vector3Dto; + galaxyPosition: Vector3Dto; starKind: string; starCount: number; starColor: string; @@ -68,7 +68,7 @@ export interface PlanetSnapshot { export interface ResourceNodeSnapshot { id: string; systemId: string; - position: Vector3Dto; + localPosition: Vector3Dto; sourceKind: string; oreRemaining: number; maxOre: number; @@ -82,7 +82,7 @@ export interface StationSnapshot { label: string; category: string; systemId: string; - position: Vector3Dto; + localPosition: Vector3Dto; color: string; dockedShips: number; oreStored: number; @@ -98,9 +98,9 @@ export interface ShipSnapshot { role: string; shipClass: string; systemId: string; - position: Vector3Dto; - velocity: Vector3Dto; - targetPosition: Vector3Dto; + localPosition: Vector3Dto; + localVelocity: Vector3Dto; + targetLocalPosition: Vector3Dto; state: string; orderKind: string | null; defaultBehaviorKind: string; diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css index aa001d7..e4ab798 100644 --- a/apps/viewer/src/style.css +++ b/apps/viewer/src/style.css @@ -65,6 +65,24 @@ canvas { box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); } +.hover-label { + position: absolute; + padding: 8px 10px; + border-radius: 999px; + background: rgba(7, 15, 28, 0.88); + border: 1px solid rgba(255, 88, 72, 0.5); + color: #fff2ef; + font-family: "IBM Plex Mono", "SFMono-Regular", monospace; + font-size: 0.75rem; + line-height: 1; + white-space: nowrap; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.32); +} + +.hover-label[hidden] { + display: none; +} + .topbar, .info-panel, .network-panel,