Add layered system viewer and local coordinates

This commit is contained in:
2026-03-13 00:48:08 -04:00
parent 22a4b18be8
commit a9c08124f5
7 changed files with 576 additions and 94 deletions

View File

@@ -33,7 +33,7 @@ public sealed record SimulationEventRecord(
public sealed record SystemSnapshot( public sealed record SystemSnapshot(
string Id, string Id,
string Label, string Label,
Vector3Dto Position, Vector3Dto GalaxyPosition,
string StarKind, string StarKind,
int StarCount, int StarCount,
string StarColor, string StarColor,
@@ -59,7 +59,7 @@ public sealed record PlanetSnapshot(
public sealed record ResourceNodeSnapshot( public sealed record ResourceNodeSnapshot(
string Id, string Id,
string SystemId, string SystemId,
Vector3Dto Position, Vector3Dto LocalPosition,
string SourceKind, string SourceKind,
float OreRemaining, float OreRemaining,
float MaxOre, float MaxOre,
@@ -68,7 +68,7 @@ public sealed record ResourceNodeSnapshot(
public sealed record ResourceNodeDelta( public sealed record ResourceNodeDelta(
string Id, string Id,
string SystemId, string SystemId,
Vector3Dto Position, Vector3Dto LocalPosition,
string SourceKind, string SourceKind,
float OreRemaining, float OreRemaining,
float MaxOre, float MaxOre,
@@ -79,7 +79,7 @@ public sealed record StationSnapshot(
string Label, string Label,
string Category, string Category,
string SystemId, string SystemId,
Vector3Dto Position, Vector3Dto LocalPosition,
string Color, string Color,
int DockedShips, int DockedShips,
float OreStored, float OreStored,
@@ -91,7 +91,7 @@ public sealed record StationDelta(
string Label, string Label,
string Category, string Category,
string SystemId, string SystemId,
Vector3Dto Position, Vector3Dto LocalPosition,
string Color, string Color,
int DockedShips, int DockedShips,
float OreStored, float OreStored,
@@ -104,9 +104,9 @@ public sealed record ShipSnapshot(
string Role, string Role,
string ShipClass, string ShipClass,
string SystemId, string SystemId,
Vector3Dto Position, Vector3Dto LocalPosition,
Vector3Dto Velocity, Vector3Dto LocalVelocity,
Vector3Dto TargetPosition, Vector3Dto TargetLocalPosition,
string State, string State,
string? OrderKind, string? OrderKind,
string DefaultBehaviorKind, string DefaultBehaviorKind,
@@ -124,9 +124,9 @@ public sealed record ShipDelta(
string Role, string Role,
string ShipClass, string ShipClass,
string SystemId, string SystemId,
Vector3Dto Position, Vector3Dto LocalPosition,
Vector3Dto Velocity, Vector3Dto LocalVelocity,
Vector3Dto TargetPosition, Vector3Dto TargetLocalPosition,
string State, string State,
string? OrderKind, string? OrderKind,
string DefaultBehaviorKind, string DefaultBehaviorKind,

View File

@@ -116,6 +116,8 @@ public readonly record struct Vector3(float X, float Y, float Z)
{ {
public static Vector3 Zero => new(0f, 0f, 0f); public static Vector3 Zero => new(0f, 0f, 0f);
public float LengthSquared() => (X * X) + (Y * Y) + (Z * Z);
public float DistanceTo(Vector3 other) public float DistanceTo(Vector3 other)
{ {
var dx = X - other.X; var dx = X - other.X;

View File

@@ -111,9 +111,9 @@ public sealed class ScenarioLoader
Id = $"node-{++nodeIdCounter}", Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id, SystemId = system.Definition.Id,
Position = new Vector3( Position = new Vector3(
system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset), MathF.Cos(node.Angle) * node.RadiusOffset,
system.Position.Y + balance.YPlane, balance.YPlane,
system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)), MathF.Sin(node.Angle) * node.RadiusOffset),
SourceKind = node.SourceKind, SourceKind = node.SourceKind,
ItemId = node.ItemId, ItemId = node.ItemId,
OreRemaining = node.OreAmount, OreRemaining = node.OreAmount,
@@ -149,7 +149,7 @@ public sealed class ScenarioLoader
var patrolRoutes = scenario.PatrolRoutes.ToDictionary( var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
(route) => route.SystemId, (route) => route.SystemId,
(route) => route.Points.Select(ToVector).ToList(), (route) => route.Points.Select((point) => NormalizeScenarioPoint(systemsById[route.SystemId], point)).ToList(),
StringComparer.Ordinal); StringComparer.Ordinal);
var shipsRuntime = new List<ShipRuntime>(); var shipsRuntime = new List<ShipRuntime>();
@@ -164,7 +164,7 @@ public sealed class ScenarioLoader
for (var index = 0; index < formation.Count; index += 1) for (var index = 0; index < formation.Count; index += 1)
{ {
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f); 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 shipsRuntime.Add(new ShipRuntime
{ {
Id = $"ship-{++shipIdCounter}", Id = $"ship-{++shipIdCounter}",
@@ -832,7 +832,7 @@ public sealed class ScenarioLoader
{ {
if (plan.Position is { Length: 3 }) 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) 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 planet = system.Definition.Planets[planetIndex];
var side = plan.LagrangeSide ?? 1; var side = plan.LagrangeSide ?? 1;
return new Vector3( return new Vector3(
system.Position.X + planet.OrbitRadius + (side * 72f), planet.OrbitRadius + (side * 72f),
system.Position.Y + balance.YPlane, balance.YPlane,
system.Position.Z + ((planetIndex + 1) * 42f * side)); (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 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); private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
} }

View File

@@ -77,7 +77,7 @@ public sealed class SimulationEngine
world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot( world.Nodes.Select(ToNodeDelta).Select((node) => new ResourceNodeSnapshot(
node.Id, node.Id,
node.SystemId, node.SystemId,
node.Position, node.LocalPosition,
node.SourceKind, node.SourceKind,
node.OreRemaining, node.OreRemaining,
node.MaxOre, node.MaxOre,
@@ -87,7 +87,7 @@ public sealed class SimulationEngine
station.Label, station.Label,
station.Category, station.Category,
station.SystemId, station.SystemId,
station.Position, station.LocalPosition,
station.Color, station.Color,
station.DockedShips, station.DockedShips,
station.OreStored, station.OreStored,
@@ -99,9 +99,9 @@ public sealed class SimulationEngine
ship.Role, ship.Role,
ship.ShipClass, ship.ShipClass,
ship.SystemId, ship.SystemId,
ship.Position, ship.LocalPosition,
ship.Velocity, ship.LocalVelocity,
ship.TargetPosition, ship.TargetLocalPosition,
ship.State, ship.State,
ship.OrderKind, ship.OrderKind,
ship.DefaultBehaviorKind, ship.DefaultBehaviorKind,

View File

@@ -53,17 +53,43 @@ interface MoonVisual {
orbit: THREE.LineLoop; 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 { interface StructureVisual {
systemId: string; systemId: string;
mesh: THREE.Mesh; mesh: THREE.Mesh;
icon: THREE.Sprite; icon: THREE.Sprite;
worldPosition: THREE.Vector3; anchor: OrbitalAnchor;
orbitRadius: number;
orbitPhase: number;
orbitInclination: number;
localPosition: THREE.Vector3;
} }
interface SystemVisual { interface SystemVisual {
root: THREE.Group; root: THREE.Group;
starCluster: THREE.Group;
icon: THREE.Sprite;
shellReticle: THREE.Sprite;
shellReticleBaseScale: number;
detailGroup: THREE.Group; detailGroup: THREE.Group;
summary: SystemSummaryVisual; summary: SystemSummaryVisual;
galaxyPosition: THREE.Vector3;
} }
interface WorldState { interface WorldState {
@@ -128,8 +154,11 @@ const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
system: 3200, system: 3200,
universe: 26000, 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 ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000;
const PROJECTED_GALAXY_RADIUS = 65000;
const STAR_RENDER_SCALE = 0.18;
const MIN_CAMERA_DISTANCE = 450; const MIN_CAMERA_DISTANCE = 450;
const MAX_CAMERA_DISTANCE = 42000; const MAX_CAMERA_DISTANCE = 42000;
@@ -143,11 +172,12 @@ 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, 100000); private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 160000);
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();
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 cameraOffset = new THREE.Vector3();
private readonly keyState = new Set<string>(); private readonly keyState = new Set<string>();
private readonly systemGroup = new THREE.Group(); private readonly systemGroup = new THREE.Group();
@@ -157,7 +187,7 @@ export class GameViewer {
private readonly ambienceGroup = new THREE.Group(); private readonly ambienceGroup = new THREE.Group();
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>(); private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly presentationEntries: PresentationEntry[] = []; private readonly presentationEntries: PresentationEntry[] = [];
private readonly nodeVisuals = new Map<string, StructureVisual>(); private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly stationVisuals = new Map<string, StructureVisual>(); private readonly stationVisuals = new Map<string, StructureVisual>();
private readonly shipVisuals = new Map<string, ShipVisual>(); private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemVisuals = new Map<string, SystemVisual>(); private readonly systemVisuals = new Map<string, SystemVisual>();
@@ -175,6 +205,7 @@ export class GameViewer {
private readonly performancePanelEl: HTMLDivElement; private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement; private readonly errorEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement; private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
private world?: WorldState; private world?: WorldState;
private worldTimeSyncMs = performance.now(); private worldTimeSyncMs = performance.now();
@@ -257,6 +288,7 @@ export class GameViewer {
</div> </div>
<section class="faction-strip"></section> <section class="faction-strip"></section>
<div class="marquee-box"></div> <div class="marquee-box"></div>
<div class="hover-label" hidden></div>
`; `;
this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement; this.statusEl = hud.querySelector(".topbar-body") as HTMLDivElement;
@@ -270,6 +302,7 @@ export class GameViewer {
this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement; this.performancePanelEl = hud.querySelector(".performance-body") as HTMLDivElement;
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement; this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
this.marqueeEl = hud.querySelector(".marquee-box") 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); this.container.append(this.renderer.domElement, hud);
@@ -423,16 +456,27 @@ export class GameViewer {
for (const system of systems) { for (const system of systems) {
const root = new THREE.Group(); 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 detailGroup = new THREE.Group();
const renderedStarSize = Math.max(system.starSize * STAR_RENDER_SCALE, 8);
const starCluster = this.createStarCluster(system); const starCluster = this.createStarCluster(system);
const systemIcon = this.createTacticalIcon(system.starColor, 96); 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)); const shellReticle = this.createShellReticle("#ff3b30", 400);
summaryVisual.sprite.position.set(0, system.starSize + 110, 0); const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z));
root.add(starCluster, systemIcon, summaryVisual.sprite, detailGroup); summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0);
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup);
this.registerPresentation(starCluster, systemIcon, true); 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); this.systemSummaryVisuals.set(system.id, summaryVisual);
starCluster.traverse((child) => { starCluster.traverse((child) => {
if (child instanceof THREE.Mesh) { if (child instanceof THREE.Mesh) {
@@ -440,6 +484,7 @@ export class GameViewer {
} }
}); });
this.selectableTargets.set(systemIcon, { kind: "system", id: system.id }); 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()) { for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = this.createPlanetOrbit(planet); const orbit = this.createPlanetOrbit(planet);
@@ -493,11 +538,19 @@ export class GameViewer {
const mesh = this.createNodeMesh(node); const mesh = this.createNodeMesh(node);
const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20); const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
icon.position.copy(mesh.position); 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, { this.nodeVisuals.set(node.id, {
systemId: node.systemId, systemId: node.systemId,
mesh, mesh,
icon, 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.nodeGroup.add(mesh, icon);
this.registerPresentation(mesh, icon, true, true, node.systemId); this.registerPresentation(mesh, icon, true, true, node.systemId);
@@ -514,11 +567,18 @@ export class GameViewer {
const mesh = this.createStationMesh(station); const mesh = this.createStationMesh(station);
const icon = this.createTacticalIcon(station.color, 26); const icon = this.createTacticalIcon(station.color, 26);
icon.position.copy(mesh.position); 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, { this.stationVisuals.set(station.id, {
systemId: station.systemId, systemId: station.systemId,
mesh, mesh,
icon, icon,
worldPosition: this.toThreeVector(station.position), anchor,
orbitRadius: orbital.radius,
orbitPhase: orbital.phase,
orbitInclination: orbital.inclination,
localPosition,
}); });
this.stationGroup.add(mesh, icon); this.stationGroup.add(mesh, icon);
this.registerPresentation(mesh, icon, true, true, station.systemId); this.registerPresentation(mesh, icon, true, true, station.systemId);
@@ -534,7 +594,7 @@ export class GameViewer {
for (const ship of ships) { for (const ship of ships) {
const mesh = this.createShipMesh(ship); const mesh = this.createShipMesh(ship);
const icon = this.createTacticalIcon(this.shipColor(ship.role), 18); 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); icon.position.copy(position);
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 });
@@ -546,8 +606,8 @@ export class GameViewer {
icon, icon,
startPosition: position.clone(), startPosition: position.clone(),
authoritativePosition: position.clone(), authoritativePosition: position.clone(),
targetPosition: this.toThreeVector(ship.targetPosition), targetPosition: this.toThreeVector(ship.targetLocalPosition),
velocity: this.toThreeVector(ship.velocity), velocity: this.toThreeVector(ship.localVelocity),
receivedAtMs: performance.now(), receivedAtMs: performance.now(),
blendDurationMs: Math.max(tickIntervalMs, 80), blendDurationMs: Math.max(tickIntervalMs, 80),
}); });
@@ -562,7 +622,13 @@ export class GameViewer {
} }
visual.systemId = node.systemId; 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); 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.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; const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(station.color); material.color.set(station.color);
material.emissive = new THREE.Color(station.color).multiplyScalar(0.1); material.emissive = new THREE.Color(station.color).multiplyScalar(0.1);
@@ -591,9 +662,9 @@ export class GameViewer {
visual.systemId = ship.systemId; visual.systemId = ship.systemId;
visual.startPosition.copy(visual.authoritativePosition); visual.startPosition.copy(visual.authoritativePosition);
visual.authoritativePosition.copy(this.toThreeVector(ship.position)); visual.authoritativePosition.copy(this.toThreeVector(ship.localPosition));
visual.targetPosition.copy(this.toThreeVector(ship.targetPosition)); visual.targetPosition.copy(this.toThreeVector(ship.targetLocalPosition));
visual.velocity.copy(this.toThreeVector(ship.velocity)); visual.velocity.copy(this.toThreeVector(ship.localVelocity));
visual.receivedAtMs = performance.now(); visual.receivedAtMs = performance.now();
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100); visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
(visual.mesh.material as THREE.MeshStandardMaterial).color.set(this.shipColor(ship.role)); (visual.mesh.material as THREE.MeshStandardMaterial).color.set(this.shipColor(ship.role));
@@ -649,12 +720,14 @@ export class GameViewer {
if (!ship) { if (!ship) {
return; return;
} }
const parent = this.describeSelectionParent(selected);
this.detailTitleEl.textContent = ship.label; this.detailTitleEl.textContent = ship.label;
this.detailBodyEl.innerHTML = ` this.detailBodyEl.innerHTML = `
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p> <p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
<p>Parent ${parent}</p>
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p> <p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p> <p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
<p>Velocity ${this.formatVector(ship.velocity)}</p> <p>Velocity ${this.formatVector(ship.localVelocity)}</p>
<p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p> <p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p>
<p class="history">${ship.history.join("<br>")}</p> <p class="history">${ship.history.join("<br>")}</p>
`; `;
@@ -666,9 +739,11 @@ export class GameViewer {
if (!station) { if (!station) {
return; return;
} }
const parent = this.describeSelectionParent(selected);
this.detailTitleEl.textContent = station.label; this.detailTitleEl.textContent = station.label;
this.detailBodyEl.innerHTML = ` this.detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p> <p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p> <p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
<p class="history">${this.renderRecentEvents("station", station.id)}</p> <p class="history">${this.renderRecentEvents("station", station.id)}</p>
`; `;
@@ -680,9 +755,11 @@ export class GameViewer {
if (!node) { if (!node) {
return; return;
} }
const parent = this.describeSelectionParent(selected);
this.detailTitleEl.textContent = `Node ${node.id}`; this.detailTitleEl.textContent = `Node ${node.id}`;
this.detailBodyEl.innerHTML = ` this.detailBodyEl.innerHTML = `
<p>${node.systemId}</p> <p>${node.systemId}</p>
<p>Parent ${parent}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p> <p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p> <p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`; `;
@@ -695,9 +772,11 @@ export class GameViewer {
if (!system || !planet) { if (!system || !planet) {
return; return;
} }
const parent = this.describeSelectionParent(selected);
this.detailTitleEl.textContent = planet.label; this.detailTitleEl.textContent = planet.label;
this.detailBodyEl.innerHTML = ` this.detailBodyEl.innerHTML = `
<p>${system.label}</p> <p>${system.label}</p>
<p>Parent ${parent}</p>
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p> <p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p>
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p> <p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p> <p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
@@ -710,7 +789,10 @@ export class GameViewer {
return; return;
} }
this.detailTitleEl.textContent = system.label; this.detailTitleEl.textContent = system.label;
this.detailBodyEl.innerHTML = this.renderSystemDetails(system, false); this.detailBodyEl.innerHTML = `
<p>Parent galaxy</p>
${this.renderSystemDetails(system, false)}
`;
} }
private render() { private render() {
@@ -742,13 +824,14 @@ export class GameViewer {
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.currentDistance * Math.cos(this.orbitPitch); const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
const focus = this.getCameraFocusWorldPosition();
this.cameraOffset.set( this.cameraOffset.set(
Math.cos(this.orbitYaw) * horizontalDistance, Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * 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(focus).add(this.cameraOffset);
this.camera.lookAt(this.focus); this.camera.lookAt(focus);
} }
private updatePanFromKeyboard(delta: number) { private updatePanFromKeyboard(delta: number) {
@@ -778,7 +861,11 @@ export class GameViewer {
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 = THREE.MathUtils.mapLinear(this.currentDistance, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE, 320, 6800); 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() { private applyZoomPresentation() {
@@ -787,10 +874,16 @@ export class GameViewer {
for (const entry of this.presentationEntries) { for (const entry of this.presentationEntries) {
const systemId = entry.systemId; const systemId = entry.systemId;
const isActiveDetail = !systemId || systemId === this.activeSystemId; 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 const detailAlpha = entry.hideDetailInUniverse
? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0) ? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0)
: 1; : 1;
const iconAlpha = entry.hideIconInUniverse const iconAlpha = isProjectedSystemIcon
? 0
: entry.hideIconInUniverse
? blend.systemWeight * (isActiveDetail ? 1 : 0) ? blend.systemWeight * (isActiveDetail ? 1 : 0)
: Math.max(blend.systemWeight, blend.universeWeight); : Math.max(blend.systemWeight, blend.universeWeight);
@@ -909,6 +1002,7 @@ export class GameViewer {
private updateShipPresentation() { private updateShipPresentation() {
const now = performance.now(); const now = performance.now();
const worldTimeSeconds = this.currentWorldTimeSeconds();
for (const visual of this.shipVisuals.values()) { for (const visual of this.shipVisuals.values()) {
const elapsedMs = now - visual.receivedAtMs; const elapsedMs = now - visual.receivedAtMs;
const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1); 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); 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); visual.icon.position.copy(visual.mesh.position);
const shipVisible = visual.systemId === this.activeSystemId; const shipVisible = visual.systemId === this.activeSystemId;
visual.mesh.visible = shipVisible; visual.mesh.visible = shipVisible;
visual.icon.visible = shipVisible && visual.icon.visible; 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) { if (desiredHeading.lengthSq() > 0.01) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
} }
} }
for (const visual of this.nodeVisuals.values()) { 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.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === this.activeSystemId; visual.mesh.visible = visual.systemId === this.activeSystemId;
} }
for (const visual of this.stationVisuals.values()) { 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.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === this.activeSystemId; visual.mesh.visible = visual.systemId === this.activeSystemId;
} }
this.updateSystemStarPresentation();
this.updateSystemDetailVisibility(); this.updateSystemDetailVisibility();
this.updateSystemSummaryPresentation(); this.updateSystemSummaryPresentation();
} }
@@ -949,8 +1046,15 @@ export class GameViewer {
const nowSeconds = this.currentWorldTimeSeconds(); const nowSeconds = this.currentWorldTimeSeconds();
for (const visual of this.planetVisuals) { for (const visual of this.planetVisuals) {
const scale = visual.systemId === this.activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1; 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.scale.setScalar(scale);
visual.orbit.position.copy(orbitOffset);
visual.mesh.position.copy(position); visual.mesh.position.copy(position);
visual.icon.position.copy(position); visual.icon.position.copy(position);
if (visual.ring) { if (visual.ring) {
@@ -976,25 +1080,26 @@ export class GameViewer {
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05), 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); mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
return mesh; return mesh;
} }
private createStarCluster(system: SystemSnapshot) { private createStarCluster(system: SystemSnapshot) {
const root = new THREE.Group(); const root = new THREE.Group();
const renderedStarSize = Math.max(system.starSize * STAR_RENDER_SCALE, 8);
const offsets = system.starCount > 1 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)]; : [new THREE.Vector3(0, 0, 0)];
for (const [index, offset] of offsets.entries()) { for (const [index, offset] of offsets.entries()) {
const sizeScale = index === 0 ? 1 : 0.72; const sizeScale = index === 0 ? 1 : 0.72;
const star = new THREE.Mesh( 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 }), new THREE.MeshBasicMaterial({ color: system.starColor }),
); );
const halo = new THREE.Mesh( 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({ new THREE.MeshBasicMaterial({
color: system.starColor, color: system.starColor,
transparent: true, transparent: true,
@@ -1080,7 +1185,7 @@ export class GameViewer {
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }), new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
); );
mesh.rotation.x = Math.PI / 2; mesh.rotation.x = Math.PI / 2;
mesh.position.copy(this.toThreeVector(station.position)); mesh.position.copy(this.toThreeVector(station.localPosition));
return mesh; return mesh;
} }
@@ -1091,7 +1196,7 @@ export class GameViewer {
geometry, geometry,
new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }), new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }),
); );
mesh.position.copy(this.toThreeVector(ship.position)); mesh.position.copy(this.toThreeVector(ship.localPosition));
return mesh; return mesh;
} }
@@ -1262,6 +1367,50 @@ export class GameViewer {
return { sprite, texture, anchor }; 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() { private updateSystemSummaries() {
if (!this.world) { if (!this.world) {
return; return;
@@ -1364,7 +1513,8 @@ export class GameViewer {
private updateSystemSummaryPresentation() { private updateSystemSummaryPresentation() {
const distanceScale = this.activeSystemId ? 0.05 : 0.085; const distanceScale = this.activeSystemId ? 0.05 : 0.085;
for (const [systemId, visual] of this.systemSummaryVisuals.entries()) { 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 minimumScale = this.activeSystemId && systemId !== this.activeSystemId ? 1200 : 1400;
const scale = Math.max(minimumScale, distance * distanceScale); const scale = Math.max(minimumScale, distance * distanceScale);
visual.sprite.scale.set(scale, scale * 0.3125, 1); 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); 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) { private hashUnit(value: string) {
let hash = this.world?.seed ?? 1; let hash = this.world?.seed ?? 1;
for (let index = 0; index < value.length; index += 1) { for (let index = 0; index < value.length; index += 1) {
@@ -1616,6 +1864,8 @@ export class GameViewer {
}; };
private onPointerMove = (event: PointerEvent) => { private onPointerMove = (event: PointerEvent) => {
this.updateHoverLabel(event);
if (this.dragPointerId !== event.pointerId || !this.dragMode) { if (this.dragPointerId !== event.pointerId || !this.dragMode) {
return; return;
} }
@@ -1669,23 +1919,57 @@ export class GameViewer {
return; return;
} }
const bounds = this.renderer.domElement.getBoundingClientRect(); const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
this.mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1; this.selectedItems = picked ? [picked] : [];
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)!] : [];
this.syncFollowStateFromSelection(); this.syncFollowStateFromSelection();
this.updatePanels(); 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 = () => { private onDoubleClick = () => {
if (this.selectedItems.length !== 1) { if (this.selectedItems.length !== 1) {
return; return;
} }
const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]); const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]);
if (nextFocus) { 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(); this.syncFollowStateFromSelection();
}; };
@@ -1728,15 +2012,21 @@ export class GameViewer {
if (selection.kind === "ship") { if (selection.kind === "ship") {
const ship = this.world.ships.get(selection.id); 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") { if (selection.kind === "station") {
const station = this.world.stations.get(selection.id); 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") { if (selection.kind === "node") {
const node = this.world.nodes.get(selection.id); 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") { if (selection.kind === "planet") {
const system = this.world.systems.get(selection.systemId); const system = this.world.systems.get(selection.systemId);
@@ -1746,10 +2036,10 @@ export class GameViewer {
} }
const visual = this.planetVisuals.find((candidate) => const visual = this.planetVisuals.find((candidate) =>
candidate.systemId === selection.systemId && candidate.planet === planet); 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); const system = this.world.systems.get(selection.id);
return system ? this.toThreeVector(system.position) : undefined; return system ? this.toThreeVector(system.galaxyPosition) : undefined;
} }
private updateMarqueeBox() { private updateMarqueeBox() {
@@ -1850,6 +2140,9 @@ export class GameViewer {
return; return;
} }
if (nextActiveSystemId) {
this.seedSystemFocusLocal(nextActiveSystemId);
}
this.activeSystemId = nextActiveSystemId; this.activeSystemId = nextActiveSystemId;
this.updateSystemDetailVisibility(); this.updateSystemDetailVisibility();
this.updatePanels(); this.updatePanels();
@@ -1882,8 +2175,8 @@ export class GameViewer {
let nearestSystemId: string | undefined; let nearestSystemId: string | undefined;
let nearestDistance = Number.POSITIVE_INFINITY; let nearestDistance = Number.POSITIVE_INFINITY;
for (const system of this.world.systems.values()) { for (const system of this.world.systems.values()) {
const center = this.toThreeVector(system.position); const center = this.toThreeVector(system.galaxyPosition);
const distance = center.distanceTo(this.focus); const distance = center.distanceTo(this.galaxyFocus);
if (distance < nearestDistance) { if (distance < nearestDistance) {
nearestDistance = distance; nearestDistance = distance;
nearestSystemId = system.id; nearestSystemId = system.id;
@@ -1906,8 +2199,8 @@ export class GameViewer {
return; return;
} }
const target = this.toDisplayPosition(this.toThreeVector(ship.position), ship.systemId); const target = this.toThreeVector(ship.localPosition);
this.focus.lerp(target, 1 - Math.exp(-delta * 8)); this.systemFocusLocal.lerp(target, 1 - Math.exp(-delta * 8));
} }
private syncFollowStateFromSelection() { 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) { private resolveSelectableSystemId(selection: Selectable) {
if (!this.world) { if (!this.world) {
return undefined; return undefined;
@@ -1946,18 +2291,122 @@ export class GameViewer {
return selection.id; return selection.id;
} }
private toDisplayPosition(worldPosition: THREE.Vector3, systemId?: string) { private describeSelectionParent(selection: Selectable) {
if (!this.world || !systemId || systemId !== this.activeSystemId) { if (!this.world) {
return worldPosition.clone(); 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); const system = this.world.systems.get(systemId);
if (!system) { if (!system) {
return worldPosition.clone(); return systemId;
} }
const center = this.toThreeVector(system.position); if (!anchor || anchor.kind === "star") {
return worldPosition.clone().sub(center).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE).add(center); 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) { private renderSystemDetails(system: SystemSnapshot, activeContext: boolean) {
@@ -1997,7 +2446,7 @@ export class GameViewer {
<p>${system.id}${activeContext ? " · active system" : ""}</p> <p>${system.id}${activeContext ? " · active system" : ""}</p>
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p> <p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
<p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}<br>Nodes ${nodeCount}</p> <p>Planets ${system.planets.length}<br>Moons ${moonCount}<br>Ships ${shipCount}<br>Stations ${stationCount}<br>Nodes ${nodeCount}</p>
<p>Height ${system.position.y.toFixed(0)}</p> <p>Height ${system.galaxyPosition.y.toFixed(0)}</p>
<p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p> <p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p>
${followText} ${followText}
`; `;
@@ -2019,6 +2468,6 @@ export class GameViewer {
} }
this.systemTitleEl.textContent = activeSystem.label; this.systemTitleEl.textContent = activeSystem.label;
this.systemBodyEl.innerHTML = this.renderSystemDetails(activeSystem, true); this.systemBodyEl.innerHTML = `<p>${activeSystem.starKind}</p>`;
} }
} }

View File

@@ -40,7 +40,7 @@ export interface Vector3Dto {
export interface SystemSnapshot { export interface SystemSnapshot {
id: string; id: string;
label: string; label: string;
position: Vector3Dto; galaxyPosition: Vector3Dto;
starKind: string; starKind: string;
starCount: number; starCount: number;
starColor: string; starColor: string;
@@ -68,7 +68,7 @@ export interface PlanetSnapshot {
export interface ResourceNodeSnapshot { export interface ResourceNodeSnapshot {
id: string; id: string;
systemId: string; systemId: string;
position: Vector3Dto; localPosition: Vector3Dto;
sourceKind: string; sourceKind: string;
oreRemaining: number; oreRemaining: number;
maxOre: number; maxOre: number;
@@ -82,7 +82,7 @@ export interface StationSnapshot {
label: string; label: string;
category: string; category: string;
systemId: string; systemId: string;
position: Vector3Dto; localPosition: Vector3Dto;
color: string; color: string;
dockedShips: number; dockedShips: number;
oreStored: number; oreStored: number;
@@ -98,9 +98,9 @@ export interface ShipSnapshot {
role: string; role: string;
shipClass: string; shipClass: string;
systemId: string; systemId: string;
position: Vector3Dto; localPosition: Vector3Dto;
velocity: Vector3Dto; localVelocity: Vector3Dto;
targetPosition: Vector3Dto; targetLocalPosition: Vector3Dto;
state: string; state: string;
orderKind: string | null; orderKind: string | null;
defaultBehaviorKind: string; defaultBehaviorKind: string;

View File

@@ -65,6 +65,24 @@ canvas {
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); 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, .topbar,
.info-panel, .info-panel,
.network-panel, .network-panel,