Refactor simulation and viewer architecture

This commit is contained in:
2026-03-14 15:08:49 -04:00
parent ddca4a16d5
commit 651556c916
71 changed files with 11472 additions and 9031 deletions

View File

@@ -0,0 +1,85 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record SystemSnapshot(
string Id,
string Label,
Vector3Dto GalaxyPosition,
string StarKind,
int StarCount,
string StarColor,
float StarSize,
IReadOnlyList<PlanetSnapshot> Planets);
public sealed record PlanetSnapshot(
string Label,
string PlanetType,
string Shape,
int MoonCount,
float OrbitRadius,
float OrbitSpeed,
float OrbitEccentricity,
float OrbitInclination,
float OrbitLongitudeOfAscendingNode,
float OrbitArgumentOfPeriapsis,
float OrbitPhaseAtEpoch,
float Size,
string Color,
bool HasRing);
public sealed record ResourceNodeSnapshot(
string Id,
string SystemId,
Vector3Dto LocalPosition,
string SourceKind,
float OreRemaining,
float MaxOre,
string ItemId);
public sealed record ResourceNodeDelta(
string Id,
string SystemId,
Vector3Dto LocalPosition,
string SourceKind,
float OreRemaining,
float MaxOre,
string ItemId);
public sealed record SpatialNodeSnapshot(
string Id,
string SystemId,
string Kind,
Vector3Dto LocalPosition,
string BubbleId,
string? ParentNodeId,
string? OccupyingStructureId,
string? OrbitReferenceId);
public sealed record SpatialNodeDelta(
string Id,
string SystemId,
string Kind,
Vector3Dto LocalPosition,
string BubbleId,
string? ParentNodeId,
string? OccupyingStructureId,
string? OrbitReferenceId);
public sealed record LocalBubbleSnapshot(
string Id,
string NodeId,
string SystemId,
float Radius,
IReadOnlyList<string> OccupantShipIds,
IReadOnlyList<string> OccupantStationIds,
IReadOnlyList<string> OccupantClaimIds,
IReadOnlyList<string> OccupantConstructionSiteIds);
public sealed record LocalBubbleDelta(
string Id,
string NodeId,
string SystemId,
float Radius,
IReadOnlyList<string> OccupantShipIds,
IReadOnlyList<string> OccupantStationIds,
IReadOnlyList<string> OccupantClaimIds,
IReadOnlyList<string> OccupantConstructionSiteIds);

View File

@@ -0,0 +1,4 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record Vector3Dto(float X, float Y, float Z);

View File

@@ -0,0 +1,47 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record MarketOrderSnapshot(
string Id,
string FactionId,
string? StationId,
string? ConstructionSiteId,
string Kind,
string ItemId,
float Amount,
float RemainingAmount,
float Valuation,
float? ReserveThreshold,
string? PolicySetId,
string State);
public sealed record MarketOrderDelta(
string Id,
string FactionId,
string? StationId,
string? ConstructionSiteId,
string Kind,
string ItemId,
float Amount,
float RemainingAmount,
float Valuation,
float? ReserveThreshold,
string? PolicySetId,
string State);
public sealed record PolicySetSnapshot(
string Id,
string OwnerKind,
string OwnerId,
string TradeAccessPolicy,
string DockingAccessPolicy,
string ConstructionAccessPolicy,
string OperationalRangePolicy);
public sealed record PolicySetDelta(
string Id,
string OwnerKind,
string OwnerId,
string TradeAccessPolicy,
string DockingAccessPolicy,
string ConstructionAccessPolicy,
string OperationalRangePolicy);

View File

@@ -0,0 +1,25 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record FactionSnapshot(
string Id,
string Label,
string Color,
float Credits,
float PopulationTotal,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId);
public sealed record FactionDelta(
string Id,
string Label,
string Color,
float Credits,
float PopulationTotal,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId);

View File

@@ -0,0 +1,113 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record InventoryEntry(
string ItemId,
float Amount);
public sealed record StationSnapshot(
string Id,
string Label,
string Category,
string SystemId,
Vector3Dto LocalPosition,
string? NodeId,
string? BubbleId,
string? AnchorNodeId,
string Color,
int DockedShips,
int DockingPads,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
string? CommanderId,
string? PolicySetId,
float Population,
float PopulationCapacity,
float WorkforceRequired,
float WorkforceEffectiveRatio,
IReadOnlyList<string> InstalledModules,
IReadOnlyList<string> MarketOrderIds);
public sealed record StationDelta(
string Id,
string Label,
string Category,
string SystemId,
Vector3Dto LocalPosition,
string? NodeId,
string? BubbleId,
string? AnchorNodeId,
string Color,
int DockedShips,
int DockingPads,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
string? CommanderId,
string? PolicySetId,
float Population,
float PopulationCapacity,
float WorkforceRequired,
float WorkforceEffectiveRatio,
IReadOnlyList<string> InstalledModules,
IReadOnlyList<string> MarketOrderIds);
public sealed record ClaimSnapshot(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string State,
float Health,
DateTimeOffset PlacedAtUtc,
DateTimeOffset ActivatesAtUtc);
public sealed record ClaimDelta(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string State,
float Health,
DateTimeOffset PlacedAtUtc,
DateTimeOffset ActivatesAtUtc);
public sealed record ConstructionSiteSnapshot(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string TargetKind,
string TargetDefinitionId,
string? BlueprintId,
string? ClaimId,
string? StationId,
string State,
float Progress,
IReadOnlyList<InventoryEntry> Inventory,
IReadOnlyList<InventoryEntry> RequiredItems,
IReadOnlyList<InventoryEntry> DeliveredItems,
IReadOnlyList<string> AssignedConstructorShipIds,
IReadOnlyList<string> MarketOrderIds);
public sealed record ConstructionSiteDelta(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string TargetKind,
string TargetDefinitionId,
string? BlueprintId,
string? ClaimId,
string? StationId,
string State,
float Progress,
IReadOnlyList<InventoryEntry> Inventory,
IReadOnlyList<InventoryEntry> RequiredItems,
IReadOnlyList<InventoryEntry> DeliveredItems,
IReadOnlyList<string> AssignedConstructorShipIds,
IReadOnlyList<string> MarketOrderIds);

View File

@@ -0,0 +1,74 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record ShipSnapshot(
string Id,
string Label,
string Role,
string ShipClass,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string ControllerTaskKind,
string? NodeId,
string? BubbleId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float WorkerPopulation,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipDelta(
string Id,
string Label,
string Role,
string ShipClass,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string ControllerTaskKind,
string? NodeId,
string? BubbleId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float WorkerPopulation,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipSpatialStateSnapshot(
string SpaceLayer,
string CurrentSystemId,
string? CurrentNodeId,
string? CurrentBubbleId,
Vector3Dto? LocalPosition,
Vector3Dto? SystemPosition,
string MovementRegime,
string? DestinationNodeId,
ShipTransitSnapshot? Transit);
public sealed record ShipTransitSnapshot(
string Regime,
string? OriginNodeId,
string? DestinationNodeId,
DateTimeOffset? StartedAtUtc,
DateTimeOffset? ArrivalDueAtUtc,
float Progress);

View File

@@ -0,0 +1,53 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record WorldSnapshot(
string Label,
int Seed,
long Sequence,
int TickIntervalMs,
DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<SpatialNodeSnapshot> SpatialNodes,
IReadOnlyList<LocalBubbleSnapshot> LocalBubbles,
IReadOnlyList<ResourceNodeSnapshot> Nodes,
IReadOnlyList<StationSnapshot> Stations,
IReadOnlyList<ClaimSnapshot> Claims,
IReadOnlyList<ConstructionSiteSnapshot> ConstructionSites,
IReadOnlyList<MarketOrderSnapshot> MarketOrders,
IReadOnlyList<PolicySetSnapshot> Policies,
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions);
public sealed record WorldDelta(
long Sequence,
int TickIntervalMs,
DateTimeOffset GeneratedAtUtc,
bool RequiresSnapshotRefresh,
IReadOnlyList<SimulationEventRecord> Events,
IReadOnlyList<SpatialNodeDelta> SpatialNodes,
IReadOnlyList<LocalBubbleDelta> LocalBubbles,
IReadOnlyList<ResourceNodeDelta> Nodes,
IReadOnlyList<StationDelta> Stations,
IReadOnlyList<ClaimDelta> Claims,
IReadOnlyList<ConstructionSiteDelta> ConstructionSites,
IReadOnlyList<MarketOrderDelta> MarketOrders,
IReadOnlyList<PolicySetDelta> Policies,
IReadOnlyList<ShipDelta> Ships,
IReadOnlyList<FactionDelta> Factions,
ObserverScope? Scope = null);
public sealed record SimulationEventRecord(
string EntityKind,
string EntityId,
string Kind,
string Message,
DateTimeOffset OccurredAtUtc,
string Family = "simulation",
string ScopeKind = "universe",
string? ScopeEntityId = null,
string Visibility = "public");
public sealed record ObserverScope(
string ScopeKind,
string? SystemId = null,
string? BubbleId = null);

View File

@@ -1,394 +0,0 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record WorldSnapshot(
string Label,
int Seed,
long Sequence,
int TickIntervalMs,
DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<SpatialNodeSnapshot> SpatialNodes,
IReadOnlyList<LocalBubbleSnapshot> LocalBubbles,
IReadOnlyList<ResourceNodeSnapshot> Nodes,
IReadOnlyList<StationSnapshot> Stations,
IReadOnlyList<ClaimSnapshot> Claims,
IReadOnlyList<ConstructionSiteSnapshot> ConstructionSites,
IReadOnlyList<MarketOrderSnapshot> MarketOrders,
IReadOnlyList<PolicySetSnapshot> Policies,
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions);
public sealed record WorldDelta(
long Sequence,
int TickIntervalMs,
DateTimeOffset GeneratedAtUtc,
bool RequiresSnapshotRefresh,
IReadOnlyList<SimulationEventRecord> Events,
IReadOnlyList<SpatialNodeDelta> SpatialNodes,
IReadOnlyList<LocalBubbleDelta> LocalBubbles,
IReadOnlyList<ResourceNodeDelta> Nodes,
IReadOnlyList<StationDelta> Stations,
IReadOnlyList<ClaimDelta> Claims,
IReadOnlyList<ConstructionSiteDelta> ConstructionSites,
IReadOnlyList<MarketOrderDelta> MarketOrders,
IReadOnlyList<PolicySetDelta> Policies,
IReadOnlyList<ShipDelta> Ships,
IReadOnlyList<FactionDelta> Factions,
ObserverScope? Scope = null);
public sealed record SimulationEventRecord(
string EntityKind,
string EntityId,
string Kind,
string Message,
DateTimeOffset OccurredAtUtc,
string Family = "simulation",
string ScopeKind = "universe",
string? ScopeEntityId = null,
string Visibility = "public");
public sealed record ObserverScope(
string ScopeKind,
string? SystemId = null,
string? BubbleId = null);
public sealed record SystemSnapshot(
string Id,
string Label,
Vector3Dto GalaxyPosition,
string StarKind,
int StarCount,
string StarColor,
float StarSize,
IReadOnlyList<PlanetSnapshot> Planets);
public sealed record PlanetSnapshot(
string Label,
string PlanetType,
string Shape,
int MoonCount,
float OrbitRadius,
float OrbitSpeed,
float OrbitEccentricity,
float OrbitInclination,
float OrbitLongitudeOfAscendingNode,
float OrbitArgumentOfPeriapsis,
float OrbitPhaseAtEpoch,
float Size,
string Color,
bool HasRing);
public sealed record ResourceNodeSnapshot(
string Id,
string SystemId,
Vector3Dto LocalPosition,
string SourceKind,
float OreRemaining,
float MaxOre,
string ItemId);
public sealed record SpatialNodeSnapshot(
string Id,
string SystemId,
string Kind,
Vector3Dto LocalPosition,
string BubbleId,
string? ParentNodeId,
string? OccupyingStructureId,
string? OrbitReferenceId);
public sealed record SpatialNodeDelta(
string Id,
string SystemId,
string Kind,
Vector3Dto LocalPosition,
string BubbleId,
string? ParentNodeId,
string? OccupyingStructureId,
string? OrbitReferenceId);
public sealed record LocalBubbleSnapshot(
string Id,
string NodeId,
string SystemId,
float Radius,
IReadOnlyList<string> OccupantShipIds,
IReadOnlyList<string> OccupantStationIds,
IReadOnlyList<string> OccupantClaimIds,
IReadOnlyList<string> OccupantConstructionSiteIds);
public sealed record LocalBubbleDelta(
string Id,
string NodeId,
string SystemId,
float Radius,
IReadOnlyList<string> OccupantShipIds,
IReadOnlyList<string> OccupantStationIds,
IReadOnlyList<string> OccupantClaimIds,
IReadOnlyList<string> OccupantConstructionSiteIds);
public sealed record ResourceNodeDelta(
string Id,
string SystemId,
Vector3Dto LocalPosition,
string SourceKind,
float OreRemaining,
float MaxOre,
string ItemId);
public sealed record InventoryEntry(
string ItemId,
float Amount);
public sealed record StationSnapshot(
string Id,
string Label,
string Category,
string SystemId,
Vector3Dto LocalPosition,
string? NodeId,
string? BubbleId,
string? AnchorNodeId,
string Color,
int DockedShips,
int DockingPads,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
string? CommanderId,
string? PolicySetId,
float Population,
float PopulationCapacity,
float WorkforceRequired,
float WorkforceEffectiveRatio,
IReadOnlyList<string> InstalledModules,
IReadOnlyList<string> MarketOrderIds);
public sealed record StationDelta(
string Id,
string Label,
string Category,
string SystemId,
Vector3Dto LocalPosition,
string? NodeId,
string? BubbleId,
string? AnchorNodeId,
string Color,
int DockedShips,
int DockingPads,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
string? CommanderId,
string? PolicySetId,
float Population,
float PopulationCapacity,
float WorkforceRequired,
float WorkforceEffectiveRatio,
IReadOnlyList<string> InstalledModules,
IReadOnlyList<string> MarketOrderIds);
public sealed record ClaimSnapshot(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string State,
float Health,
DateTimeOffset PlacedAtUtc,
DateTimeOffset ActivatesAtUtc);
public sealed record ClaimDelta(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string State,
float Health,
DateTimeOffset PlacedAtUtc,
DateTimeOffset ActivatesAtUtc);
public sealed record ConstructionSiteSnapshot(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string TargetKind,
string TargetDefinitionId,
string? BlueprintId,
string? ClaimId,
string? StationId,
string State,
float Progress,
IReadOnlyList<InventoryEntry> Inventory,
IReadOnlyList<InventoryEntry> RequiredItems,
IReadOnlyList<InventoryEntry> DeliveredItems,
IReadOnlyList<string> AssignedConstructorShipIds,
IReadOnlyList<string> MarketOrderIds);
public sealed record ConstructionSiteDelta(
string Id,
string FactionId,
string SystemId,
string NodeId,
string BubbleId,
string TargetKind,
string TargetDefinitionId,
string? BlueprintId,
string? ClaimId,
string? StationId,
string State,
float Progress,
IReadOnlyList<InventoryEntry> Inventory,
IReadOnlyList<InventoryEntry> RequiredItems,
IReadOnlyList<InventoryEntry> DeliveredItems,
IReadOnlyList<string> AssignedConstructorShipIds,
IReadOnlyList<string> MarketOrderIds);
public sealed record MarketOrderSnapshot(
string Id,
string FactionId,
string? StationId,
string? ConstructionSiteId,
string Kind,
string ItemId,
float Amount,
float RemainingAmount,
float Valuation,
float? ReserveThreshold,
string? PolicySetId,
string State);
public sealed record MarketOrderDelta(
string Id,
string FactionId,
string? StationId,
string? ConstructionSiteId,
string Kind,
string ItemId,
float Amount,
float RemainingAmount,
float Valuation,
float? ReserveThreshold,
string? PolicySetId,
string State);
public sealed record PolicySetSnapshot(
string Id,
string OwnerKind,
string OwnerId,
string TradeAccessPolicy,
string DockingAccessPolicy,
string ConstructionAccessPolicy,
string OperationalRangePolicy);
public sealed record PolicySetDelta(
string Id,
string OwnerKind,
string OwnerId,
string TradeAccessPolicy,
string DockingAccessPolicy,
string ConstructionAccessPolicy,
string OperationalRangePolicy);
public sealed record ShipSnapshot(
string Id,
string Label,
string Role,
string ShipClass,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string ControllerTaskKind,
string? NodeId,
string? BubbleId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float WorkerPopulation,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipDelta(
string Id,
string Label,
string Role,
string ShipClass,
string SystemId,
Vector3Dto LocalPosition,
Vector3Dto LocalVelocity,
Vector3Dto TargetLocalPosition,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string ControllerTaskKind,
string? NodeId,
string? BubbleId,
string? DockedStationId,
string? CommanderId,
string? PolicySetId,
float CargoCapacity,
float WorkerPopulation,
float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory,
string FactionId,
float Health,
IReadOnlyList<string> History,
ShipSpatialStateSnapshot SpatialState);
public sealed record ShipSpatialStateSnapshot(
string SpaceLayer,
string CurrentSystemId,
string? CurrentNodeId,
string? CurrentBubbleId,
Vector3Dto? LocalPosition,
Vector3Dto? SystemPosition,
string MovementRegime,
string? DestinationNodeId,
ShipTransitSnapshot? Transit);
public sealed record ShipTransitSnapshot(
string Regime,
string? OriginNodeId,
string? DestinationNodeId,
DateTimeOffset? StartedAtUtc,
DateTimeOffset? ArrivalDueAtUtc,
float Progress);
public sealed record FactionSnapshot(
string Id,
string Label,
string Color,
float Credits,
float PopulationTotal,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId);
public sealed record FactionDelta(
string Id,
string Label,
string Color,
float Credits,
float PopulationTotal,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost,
string? DefaultPolicySetId);
public sealed record Vector3Dto(float X, float Y, float Z);

View File

@@ -0,0 +1,10 @@
namespace SpaceGame.Simulation.Api.Simulation;
internal interface IShipBehaviorState
{
string Kind { get; }
void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world);
void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent);
}

View File

@@ -0,0 +1,39 @@
namespace SpaceGame.Simulation.Api.Simulation;
internal sealed class ShipBehaviorStateMachine
{
private readonly IReadOnlyDictionary<string, IShipBehaviorState> states;
private readonly IShipBehaviorState fallbackState;
private ShipBehaviorStateMachine(IReadOnlyDictionary<string, IShipBehaviorState> states, IShipBehaviorState fallbackState)
{
this.states = states;
this.fallbackState = fallbackState;
}
public static ShipBehaviorStateMachine CreateDefault()
{
var idleState = new IdleShipBehaviorState();
var knownStates = new IShipBehaviorState[]
{
idleState,
new PatrolShipBehaviorState(),
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining-turret"),
new ResourceHarvestShipBehaviorState("auto-harvest-gas", "gas", "gas-extractor"),
new ConstructStationShipBehaviorState(),
};
return new ShipBehaviorStateMachine(
knownStates.ToDictionary(state => state.Kind, StringComparer.Ordinal),
idleState);
}
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
Resolve(ship.DefaultBehavior.Kind).Plan(engine, ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent) =>
Resolve(ship.DefaultBehavior.Kind).ApplyEvent(engine, ship, world, controllerEvent);
private IShipBehaviorState Resolve(string kind) =>
states.TryGetValue(kind, out var state) ? state : fallbackState;
}

View File

@@ -0,0 +1,135 @@
namespace SpaceGame.Simulation.Api.Simulation;
internal sealed class IdleShipBehaviorState : IShipBehaviorState
{
public string Kind => "idle";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
}
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
}
}
internal sealed class PatrolShipBehaviorState : IShipBehaviorState
{
public string Kind => "patrol";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world)
{
if (ship.DefaultBehavior.PatrolPoints.Count == 0)
{
ship.DefaultBehavior.Kind = "idle";
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
Threshold = world.Balance.ArrivalThreshold,
Status = WorkStatus.Pending,
};
return;
}
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
TargetSystemId = ship.SystemId,
Threshold = 18f,
};
}
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
if (controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
{
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
}
}
}
internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
{
private readonly string resourceItemId;
private readonly string requiredModule;
public ResourceHarvestShipBehaviorState(string kind, string resourceItemId, string requiredModule)
{
Kind = kind;
this.resourceItemId = resourceItemId;
this.requiredModule = requiredModule;
}
public string Kind { get; }
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanResourceHarvest(ship, world, resourceItemId, requiredModule);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-node", "arrived"):
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
break;
case ("extract", "cargo-full"):
ship.DefaultBehavior.Phase = "travel-to-station";
break;
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "unload" : "refuel";
break;
case ("unload", "unloaded"):
ship.DefaultBehavior.Phase = "refuel";
break;
case ("refuel", "refueled"):
ship.DefaultBehavior.Phase = "undock";
break;
case ("undock", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
}
}
}
internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
{
public string Kind => "construct-station";
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
engine.PlanStationConstruction(ship, world);
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship) ? "refuel" : "deliver-to-site";
break;
case ("refuel", "refueled"):
ship.DefaultBehavior.Phase = "deliver-to-site";
break;
case ("deliver-to-site", "construction-delivered"):
ship.DefaultBehavior.Phase = "build-site";
break;
case ("construct-module", "module-constructed"):
case ("build-site", "site-constructed"):
ship.DefaultBehavior.Phase = "travel-to-station";
ship.DefaultBehavior.ModuleId = null;
break;
}
}
}

View File

@@ -0,0 +1,30 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class MarketOrderRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public string? StationId { get; init; }
public string? ConstructionSiteId { get; init; }
public required string Kind { get; init; }
public required string ItemId { get; init; }
public float Amount { get; init; }
public float RemainingAmount { get; set; }
public float Valuation { get; set; }
public float? ReserveThreshold { get; set; }
public string? PolicySetId { get; set; }
public string State { get; set; } = MarketOrderStateKinds.Open;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class PolicySetRuntime
{
public required string Id { get; init; }
public required string OwnerKind { get; init; }
public required string OwnerId { get; init; }
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
public string ConstructionAccessPolicy { get; set; } = "owner-only";
public string OperationalRangePolicy { get; set; } = "unrestricted";
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,38 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class ClaimRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string NodeId { get; init; }
public required string BubbleId { get; init; }
public string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; }
public string State { get; set; } = ClaimStateKinds.Placed;
public float Health { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ConstructionSiteRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string NodeId { get; init; }
public required string BubbleId { get; init; }
public required string TargetKind { get; init; }
public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; }
public string? ClaimId { get; set; }
public string? StationId { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> RequiredItems { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> DeliveredItems { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedConstructorShipIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float Progress { get; set; }
public string State { get; set; } = ConstructionSiteStateKinds.Planned;
public string LastDeltaSignature { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,66 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class FactionRuntime
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public float Credits { get; set; }
public float PopulationTotal { get; set; }
public float OreMined { get; set; }
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
public string? DefaultPolicySetId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class CommanderRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string FactionId { get; init; }
public string? ParentCommanderId { get; set; }
public string? ControlledEntityId { get; set; }
public string? PolicySetId { get; set; }
public string? Doctrine { get; set; }
public List<string> Goals { get; } = [];
public CommanderBehaviorRuntime? ActiveBehavior { get; set; }
public CommanderOrderRuntime? ActiveOrder { get; set; }
public CommanderTaskRuntime? ActiveTask { get; set; }
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
public bool IsAlive { get; set; } = true;
}
public sealed class CommanderBehaviorRuntime
{
public required string Kind { get; set; }
public string? Phase { get; set; }
public string? NodeId { get; set; }
public string? StationId { get; set; }
public string? ModuleId { get; set; }
public string? AreaSystemId { get; set; }
public int PatrolIndex { get; set; }
}
public sealed class CommanderOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public string? TargetEntityId { get; set; }
public string? DestinationNodeId { get; set; }
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class CommanderTaskRuntime
{
public required string Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}

View File

@@ -0,0 +1,64 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class ShipRuntime
{
public required string Id { get; init; }
public required string SystemId { get; set; }
public required ShipDefinition Definition { get; init; }
public required string FactionId { get; init; }
public required Vector3 Position { get; set; }
public required Vector3 TargetPosition { get; set; }
public required ShipSpatialStateRuntime SpatialState { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero;
public string State { get; set; } = "idle";
public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; }
public float ActionTimer { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public float WorkerPopulation { get; set; }
public float EnergyStored { get; set; }
public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public float Health { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderRuntime
{
public required string Kind { get; init; }
public OrderStatus Status { get; set; } = OrderStatus.Accepted;
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? AreaSystemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }
public string? ModuleId { get; set; }
public string? Phase { get; set; }
public List<Vector3> PatrolPoints { get; set; } = [];
public int PatrolIndex { get; set; }
}
public sealed class ControllerTaskRuntime
{
public required string Kind { get; set; }
public WorkStatus Status { get; set; } = WorkStatus.Pending;
public string? CommanderId { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}

View File

@@ -0,0 +1,148 @@
namespace SpaceGame.Simulation.Api.Simulation;
public enum SpatialNodeKind
{
Star,
Planet,
Moon,
LagrangePoint,
Station,
ResourceSite,
}
public enum WorkStatus
{
Pending,
Active,
Completed,
}
public enum OrderStatus
{
Queued,
Accepted,
Completed,
}
public static class SpaceLayerKinds
{
public const string UniverseSpace = "universe-space";
public const string GalaxySpace = "galaxy-space";
public const string SystemSpace = "system-space";
public const string LocalSpace = "local-space";
}
public static class MovementRegimeKinds
{
public const string LocalFlight = "local-flight";
public const string Warp = "warp";
public const string StargateTransit = "stargate-transit";
public const string FtlTransit = "ftl-transit";
}
public static class CommanderKind
{
public const string Faction = "faction";
public const string Station = "station";
public const string Ship = "ship";
public const string Fleet = "fleet";
public const string Sector = "sector";
public const string TaskGroup = "task-group";
}
public static class ShipTaskKinds
{
public const string Idle = "idle";
public const string LocalMove = "local-move";
public const string WarpToNode = "warp-to-node";
public const string UseStargate = "use-stargate";
public const string UseFtl = "use-ftl";
public const string Dock = "dock";
public const string Undock = "undock";
public const string LoadCargo = "load-cargo";
public const string UnloadCargo = "unload-cargo";
public const string LoadWorkers = "load-workers";
public const string UnloadWorkers = "unload-workers";
public const string MineNode = "mine-node";
public const string HarvestGas = "harvest-gas";
public const string DeliverToStation = "deliver-to-station";
public const string ClaimLagrangePoint = "claim-lagrange-point";
public const string BuildConstructionSite = "build-construction-site";
public const string EscortTarget = "escort-target";
public const string AttackTarget = "attack-target";
public const string DefendBubble = "defend-bubble";
public const string Retreat = "retreat";
public const string HoldPosition = "hold-position";
}
public static class ShipOrderKinds
{
public const string DirectMove = "direct-move";
public const string TravelToNode = "travel-to-node";
public const string DockAtStation = "dock-at-station";
public const string DeliverCargo = "deliver-cargo";
public const string BuildAtSite = "build-at-site";
public const string AttackTarget = "attack-target";
public const string HoldPosition = "hold-position";
}
public static class ClaimStateKinds
{
public const string Placed = "placed";
public const string Activating = "activating";
public const string Active = "active";
public const string Destroyed = "destroyed";
}
public static class ConstructionSiteStateKinds
{
public const string Planned = "planned";
public const string Active = "active";
public const string Paused = "paused";
public const string Completed = "completed";
public const string Destroyed = "destroyed";
}
public static class MarketOrderKinds
{
public const string Buy = "buy";
public const string Sell = "sell";
}
public static class MarketOrderStateKinds
{
public const string Open = "open";
public const string PartiallyFilled = "partially-filled";
public const string Filled = "filled";
public const string Cancelled = "cancelled";
}
public static class SimulationEnumMappings
{
public static string ToContractValue(this SpatialNodeKind kind) => kind switch
{
SpatialNodeKind.Star => "star",
SpatialNodeKind.Planet => "planet",
SpatialNodeKind.Moon => "moon",
SpatialNodeKind.LagrangePoint => "lagrange-point",
SpatialNodeKind.Station => "station",
SpatialNodeKind.ResourceSite => "resource-site",
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
};
public static string ToContractValue(this WorkStatus status) => status switch
{
WorkStatus.Pending => "pending",
WorkStatus.Active => "active",
WorkStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
public static string ToContractValue(this OrderStatus status) => status switch
{
OrderStatus.Queued => "queued",
OrderStatus.Accepted => "accepted",
OrderStatus.Completed => "completed",
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
};
}

View File

@@ -0,0 +1,28 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SimulationWorld
{
public required string Label { get; init; }
public required int Seed { get; init; }
public required BalanceDefinition Balance { get; init; }
public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<NodeRuntime> SpatialNodes { get; init; }
public required List<LocalBubbleRuntime> LocalBubbles { get; init; }
public required List<StationRuntime> Stations { get; init; }
public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; }
public required List<CommanderRuntime> Commanders { get; init; }
public required List<ClaimRuntime> Claims { get; init; }
public required List<ConstructionSiteRuntime> ConstructionSites { get; init; }
public required List<MarketOrderRuntime> MarketOrders { get; init; }
public required List<PolicySetRuntime> Policies { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
public required Dictionary<string, ItemDefinition> ItemDefinitions { get; init; }
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
public int TickIntervalMs { get; init; } = 200;
public DateTimeOffset GeneratedAtUtc { get; set; }
}

View File

@@ -0,0 +1,70 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SystemRuntime
{
public required SolarSystemDefinition Definition { get; init; }
public required Vector3 Position { get; init; }
}
public sealed class ResourceNodeRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required Vector3 Position { get; init; }
public required string SourceKind { get; init; }
public required string ItemId { get; init; }
public float OreRemaining { get; set; }
public float MaxOre { get; init; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class NodeRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required SpatialNodeKind Kind { get; init; }
public required Vector3 Position { get; set; }
public required string BubbleId { get; init; }
public string? ParentNodeId { get; set; }
public string? OccupyingStructureId { get; set; }
public string? OrbitReferenceId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class LocalBubbleRuntime
{
public required string Id { get; init; }
public required string NodeId { get; init; }
public required string SystemId { get; init; }
public float Radius { get; init; }
public HashSet<string> OccupantShipIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> OccupantStationIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> OccupantClaimIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> OccupantConstructionSiteIds { get; } = new(StringComparer.Ordinal);
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipSpatialStateRuntime
{
public string SpaceLayer { get; set; } = SpaceLayerKinds.LocalSpace;
public required string CurrentSystemId { get; set; }
public string? CurrentNodeId { get; set; }
public string? CurrentBubbleId { get; set; }
public Vector3? LocalPosition { get; set; }
public Vector3? SystemPosition { get; set; }
public string MovementRegime { get; set; } = MovementRegimeKinds.LocalFlight;
public string? DestinationNodeId { get; set; }
public ShipTransitRuntime? Transit { get; set; }
}
public sealed class ShipTransitRuntime
{
public required string Regime { get; init; }
public string? OriginNodeId { get; init; }
public string? DestinationNodeId { get; init; }
public DateTimeOffset? StartedAtUtc { get; set; }
public DateTimeOffset? ArrivalDueAtUtc { get; set; }
public float Progress { get; set; }
}

View File

@@ -0,0 +1,39 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class StationRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required ConstructibleDefinition Definition { get; init; }
public required Vector3 Position { get; set; }
public required string FactionId { get; init; }
public string? NodeId { get; set; }
public string? BubbleId { get; set; }
public string? AnchorNodeId { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public HashSet<string> InstalledModules { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<int, string> DockingPadAssignments { get; } = new();
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float EnergyStored { get; set; }
public float ProcessTimer { get; set; }
public float Population { get; set; }
public float PopulationCapacity { get; set; }
public float WorkforceRequired { get; set; }
public float WorkforceEffectiveRatio { get; set; } = 0.1f;
public float PopulationGrowthProgress { get; set; }
public HashSet<string> DockedShipIds { get; } = [];
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ModuleConstructionRuntime
{
public required string ModuleId { get; init; }
public float ProgressSeconds { get; set; }
public float RequiredSeconds { get; init; }
public string AssignedConstructorShipId { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,36 @@
namespace SpaceGame.Simulation.Api.Simulation;
public readonly record struct Vector3(float X, float Y, float Z)
{
public static Vector3 Zero => new(0f, 0f, 0f);
public float DistanceTo(Vector3 other)
{
var dx = X - other.X;
var dy = Y - other.Y;
var dz = Z - other.Z;
return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
}
public float LengthSquared() => (X * X) + (Y * Y) + (Z * Z);
public Vector3 MoveToward(Vector3 target, float maxDistance)
{
var delta = target.Subtract(this);
var distance = MathF.Sqrt(delta.LengthSquared());
if (distance <= 0.0001f || distance <= maxDistance)
{
return target;
}
var scale = maxDistance / distance;
return new Vector3(
X + (delta.X * scale),
Y + (delta.Y * scale),
Z + (delta.Z * scale));
}
public Vector3 Subtract(Vector3 other) => new(X - other.X, Y - other.Y, Z - other.Z);
public Vector3 Divide(float scalar) => MathF.Abs(scalar) <= 0.0001f ? Zero : new Vector3(X / scalar, Y / scalar, Z / scalar);
}

View File

@@ -1,458 +0,0 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SimulationWorld
{
public required string Label { get; init; }
public required int Seed { get; init; }
public required BalanceDefinition Balance { get; init; }
public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<NodeRuntime> SpatialNodes { get; init; }
public required List<LocalBubbleRuntime> LocalBubbles { get; init; }
public required List<StationRuntime> Stations { get; init; }
public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; }
public required List<CommanderRuntime> Commanders { get; init; }
public required List<ClaimRuntime> Claims { get; init; }
public required List<ConstructionSiteRuntime> ConstructionSites { get; init; }
public required List<MarketOrderRuntime> MarketOrders { get; init; }
public required List<PolicySetRuntime> Policies { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
public required Dictionary<string, ItemDefinition> ItemDefinitions { get; init; }
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
public int TickIntervalMs { get; init; } = 200;
public DateTimeOffset GeneratedAtUtc { get; set; }
}
public sealed class SystemRuntime
{
public required SolarSystemDefinition Definition { get; init; }
public required Vector3 Position { get; init; }
}
public sealed class ResourceNodeRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required Vector3 Position { get; init; }
public required string SourceKind { get; init; }
public required string ItemId { get; init; }
public float OreRemaining { get; set; }
public float MaxOre { get; init; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class NodeRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required string Kind { get; init; }
public required Vector3 Position { get; set; }
public required string BubbleId { get; init; }
public string? ParentNodeId { get; set; }
public string? OccupyingStructureId { get; set; }
public string? OrbitReferenceId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class LocalBubbleRuntime
{
public required string Id { get; init; }
public required string NodeId { get; init; }
public required string SystemId { get; init; }
public float Radius { get; init; }
public HashSet<string> OccupantShipIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> OccupantStationIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> OccupantClaimIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> OccupantConstructionSiteIds { get; } = new(StringComparer.Ordinal);
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class StationRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required ConstructibleDefinition Definition { get; init; }
public required Vector3 Position { get; set; }
public required string FactionId { get; init; }
public string? NodeId { get; set; }
public string? BubbleId { get; set; }
public string? AnchorNodeId { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public HashSet<string> InstalledModules { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<int, string> DockingPadAssignments { get; } = new();
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float EnergyStored { get; set; }
public float ProcessTimer { get; set; }
public float Population { get; set; }
public float PopulationCapacity { get; set; }
public float WorkforceRequired { get; set; }
public float WorkforceEffectiveRatio { get; set; } = 0.1f;
public float PopulationGrowthProgress { get; set; }
public HashSet<string> DockedShipIds { get; } = [];
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ModuleConstructionRuntime
{
public required string ModuleId { get; init; }
public float ProgressSeconds { get; set; }
public float RequiredSeconds { get; init; }
public string AssignedConstructorShipId { get; set; } = string.Empty;
}
public sealed class ShipRuntime
{
public required string Id { get; init; }
public required string SystemId { get; set; }
public required ShipDefinition Definition { get; init; }
public required string FactionId { get; init; }
public required Vector3 Position { get; set; }
public required Vector3 TargetPosition { get; set; }
public required ShipSpatialStateRuntime SpatialState { get; set; }
public Vector3 Velocity { get; set; } = Vector3.Zero;
public string State { get; set; } = "idle";
public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; }
public float ActionTimer { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public float WorkerPopulation { get; set; }
public float EnergyStored { get; set; }
public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public string? CommanderId { get; set; }
public string? PolicySetId { get; set; }
public float Health { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class FactionRuntime
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public float Credits { get; set; }
public float PopulationTotal { get; set; }
public float OreMined { get; set; }
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
public HashSet<string> CommanderIds { get; } = new(StringComparer.Ordinal);
public string? DefaultPolicySetId { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class CommanderRuntime
{
public required string Id { get; init; }
public required string Kind { get; set; }
public required string FactionId { get; init; }
public string? ParentCommanderId { get; set; }
public string? ControlledEntityId { get; set; }
public string? PolicySetId { get; set; }
public string? Doctrine { get; set; }
public List<string> Goals { get; } = [];
public CommanderBehaviorRuntime? ActiveBehavior { get; set; }
public CommanderOrderRuntime? ActiveOrder { get; set; }
public CommanderTaskRuntime? ActiveTask { get; set; }
public HashSet<string> SubordinateCommanderIds { get; } = new(StringComparer.Ordinal);
public bool IsAlive { get; set; } = true;
}
public sealed class CommanderBehaviorRuntime
{
public required string Kind { get; set; }
public string? Phase { get; set; }
public string? NodeId { get; set; }
public string? StationId { get; set; }
public string? ModuleId { get; set; }
public string? AreaSystemId { get; set; }
public int PatrolIndex { get; set; }
}
public sealed class CommanderOrderRuntime
{
public required string Kind { get; init; }
public string Status { get; set; } = "accepted";
public string? TargetEntityId { get; set; }
public string? DestinationNodeId { get; set; }
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class CommanderTaskRuntime
{
public required string Kind { get; set; }
public string Status { get; set; } = "pending";
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}
public sealed class ClaimRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string NodeId { get; init; }
public required string BubbleId { get; init; }
public string? CommanderId { get; set; }
public DateTimeOffset PlacedAtUtc { get; init; }
public DateTimeOffset ActivatesAtUtc { get; set; }
public string State { get; set; } = ClaimStateKinds.Placed;
public float Health { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ConstructionSiteRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public required string SystemId { get; init; }
public required string NodeId { get; init; }
public required string BubbleId { get; init; }
public required string TargetKind { get; init; }
public required string TargetDefinitionId { get; init; }
public string? BlueprintId { get; set; }
public string? ClaimId { get; set; }
public string? StationId { get; set; }
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> RequiredItems { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> DeliveredItems { get; } = new(StringComparer.Ordinal);
public HashSet<string> AssignedConstructorShipIds { get; } = new(StringComparer.Ordinal);
public HashSet<string> MarketOrderIds { get; } = new(StringComparer.Ordinal);
public float Progress { get; set; }
public string State { get; set; } = ConstructionSiteStateKinds.Planned;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class MarketOrderRuntime
{
public required string Id { get; init; }
public required string FactionId { get; init; }
public string? StationId { get; init; }
public string? ConstructionSiteId { get; init; }
public required string Kind { get; init; }
public required string ItemId { get; init; }
public float Amount { get; init; }
public float RemainingAmount { get; set; }
public float Valuation { get; set; }
public float? ReserveThreshold { get; set; }
public string? PolicySetId { get; set; }
public string State { get; set; } = MarketOrderStateKinds.Open;
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class PolicySetRuntime
{
public required string Id { get; init; }
public required string OwnerKind { get; init; }
public required string OwnerId { get; init; }
public string TradeAccessPolicy { get; set; } = "owner-and-allies";
public string DockingAccessPolicy { get; set; } = "owner-and-allies";
public string ConstructionAccessPolicy { get; set; } = "owner-only";
public string OperationalRangePolicy { get; set; } = "unrestricted";
public string LastDeltaSignature { get; set; } = string.Empty;
}
public sealed class ShipOrderRuntime
{
public required string Kind { get; init; }
public string Status { get; set; } = "accepted";
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? AreaSystemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }
public string? ModuleId { get; set; }
public string? Phase { get; set; }
public List<Vector3> PatrolPoints { get; set; } = [];
public int PatrolIndex { get; set; }
}
public sealed class ControllerTaskRuntime
{
public required string Kind { get; set; }
public string Status { get; set; } = "pending";
public string? CommanderId { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public string? TargetNodeId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}
public sealed class ShipSpatialStateRuntime
{
public string SpaceLayer { get; set; } = SpaceLayerKinds.LocalSpace;
public required string CurrentSystemId { get; set; }
public string? CurrentNodeId { get; set; }
public string? CurrentBubbleId { get; set; }
public Vector3? LocalPosition { get; set; }
public Vector3? SystemPosition { get; set; }
public string MovementRegime { get; set; } = MovementRegimeKinds.LocalFlight;
public string? DestinationNodeId { get; set; }
public ShipTransitRuntime? Transit { get; set; }
}
public sealed class ShipTransitRuntime
{
public required string Regime { get; init; }
public string? OriginNodeId { get; init; }
public string? DestinationNodeId { get; init; }
public DateTimeOffset? StartedAtUtc { get; set; }
public DateTimeOffset? ArrivalDueAtUtc { get; set; }
public float Progress { get; set; }
}
public static class SpaceLayerKinds
{
public const string UniverseSpace = "universe-space";
public const string GalaxySpace = "galaxy-space";
public const string SystemSpace = "system-space";
public const string LocalSpace = "local-space";
}
public static class MovementRegimeKinds
{
public const string LocalFlight = "local-flight";
public const string Warp = "warp";
public const string StargateTransit = "stargate-transit";
public const string FtlTransit = "ftl-transit";
}
public static class CommanderKind
{
public const string Faction = "faction";
public const string Station = "station";
public const string Ship = "ship";
public const string Fleet = "fleet";
public const string Sector = "sector";
public const string TaskGroup = "task-group";
}
public static class ShipTaskKinds
{
public const string Idle = "idle";
public const string LocalMove = "local-move";
public const string WarpToNode = "warp-to-node";
public const string UseStargate = "use-stargate";
public const string UseFtl = "use-ftl";
public const string Dock = "dock";
public const string Undock = "undock";
public const string LoadCargo = "load-cargo";
public const string UnloadCargo = "unload-cargo";
public const string LoadWorkers = "load-workers";
public const string UnloadWorkers = "unload-workers";
public const string MineNode = "mine-node";
public const string HarvestGas = "harvest-gas";
public const string DeliverToStation = "deliver-to-station";
public const string ClaimLagrangePoint = "claim-lagrange-point";
public const string BuildConstructionSite = "build-construction-site";
public const string EscortTarget = "escort-target";
public const string AttackTarget = "attack-target";
public const string DefendBubble = "defend-bubble";
public const string Retreat = "retreat";
public const string HoldPosition = "hold-position";
}
public static class ShipOrderKinds
{
public const string DirectMove = "direct-move";
public const string TravelToNode = "travel-to-node";
public const string DockAtStation = "dock-at-station";
public const string DeliverCargo = "deliver-cargo";
public const string BuildAtSite = "build-at-site";
public const string AttackTarget = "attack-target";
public const string HoldPosition = "hold-position";
}
public static class ClaimStateKinds
{
public const string Placed = "placed";
public const string Activating = "activating";
public const string Active = "active";
public const string Destroyed = "destroyed";
}
public static class ConstructionSiteStateKinds
{
public const string Planned = "planned";
public const string Active = "active";
public const string Paused = "paused";
public const string Completed = "completed";
public const string Destroyed = "destroyed";
}
public static class MarketOrderKinds
{
public const string Buy = "buy";
public const string Sell = "sell";
}
public static class MarketOrderStateKinds
{
public const string Open = "open";
public const string PartiallyFilled = "partially-filled";
public const string Filled = "filled";
public const string Cancelled = "cancelled";
}
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;
var dy = Y - other.Y;
var dz = Z - other.Z;
return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
}
public Vector3 MoveToward(Vector3 target, float maxDistance)
{
var distance = DistanceTo(target);
if (distance <= maxDistance || distance <= 0.0001f)
{
return target;
}
var t = maxDistance / distance;
return new Vector3(
X + ((target.X - X) * t),
Y + ((target.Y - Y) * t),
Z + ((target.Z - Z) * t));
}
public Vector3 Subtract(Vector3 other) => new(X - other.X, Y - other.Y, Z - other.Z);
public Vector3 Divide(float value)
{
if (value == 0f)
{
return Zero;
}
return new(X / value, Y / value, Z / value);
}
}

View File

@@ -0,0 +1,509 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class ScenarioLoader
{
private static List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (systems.All((system) => system.Id != "sol"))
{
systems.Add(CreateSolSystem());
}
return systems;
}
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
{
var systems = authoredSystems
.Select(CloneSystemDefinition)
.ToList();
if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0)
{
return systems;
}
var existingIds = systems
.Select((system) => system.Id)
.ToHashSet(StringComparer.Ordinal);
var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count);
for (var index = systems.Count; index < TargetSystemCount; index += 1)
{
var template = authoredSystems[index % authoredSystems.Count];
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
var id = BuildGeneratedSystemId(name, index + 1);
while (!existingIds.Add(id))
{
id = $"{id}-x";
}
systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count]));
}
return systems;
}
private static SolarSystemDefinition CreateGeneratedSystem(
SolarSystemDefinition template,
string label,
string id,
int generatedIndex,
Vector3 position)
{
var starProfile = SelectStarProfile(generatedIndex);
var planets = BuildGeneratedPlanets(template, generatedIndex);
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList();
return new SolarSystemDefinition
{
Id = id,
Label = label,
Position = [position.X, position.Y, position.Z],
StarKind = starProfile.Kind,
StarCount = starProfile.StarCount,
StarColor = starProfile.StarColor,
StarGlow = starProfile.StarGlow,
StarSize = starProfile.BaseSize + ((generatedIndex % 4) * 2f),
GravityWellRadius = template.GravityWellRadius + ((generatedIndex % 3) * 12f),
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10),
RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18f),
RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12f),
HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4f),
},
ResourceNodes = resourceNodes,
Planets = planets,
};
}
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
{
return new SolarSystemDefinition
{
Id = definition.Id,
Label = definition.Label,
Position = definition.Position.ToArray(),
StarKind = definition.StarKind,
StarCount = definition.StarCount,
StarColor = definition.StarColor,
StarGlow = definition.StarGlow,
StarSize = definition.StarSize,
GravityWellRadius = definition.GravityWellRadius,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = definition.AsteroidField.DecorationCount,
RadiusOffset = definition.AsteroidField.RadiusOffset,
RadiusVariance = definition.AsteroidField.RadiusVariance,
HeightVariance = definition.AsteroidField.HeightVariance,
},
ResourceNodes = definition.ResourceNodes
.Select((node) => new ResourceNodeDefinition
{
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
})
.ToList(),
Planets = definition.Planets
.Select((planet) => new PlanetDefinition
{
Label = planet.Label,
PlanetType = planet.PlanetType,
Shape = planet.Shape,
MoonCount = planet.MoonCount,
OrbitRadius = planet.OrbitRadius,
OrbitSpeed = planet.OrbitSpeed,
OrbitEccentricity = planet.OrbitEccentricity,
OrbitInclination = planet.OrbitInclination,
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
Size = planet.Size,
Color = planet.Color,
Tilt = planet.Tilt,
HasRing = planet.HasRing,
})
.ToList(),
};
}
private static List<ResourceNodeDefinition> BuildProceduralResourceNodes(
SolarSystemDefinition template,
IReadOnlyList<PlanetDefinition> planets,
int generatedIndex)
{
var nodes = new List<ResourceNodeDefinition>();
if (template.ResourceNodes.Count > 0)
{
nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition
{
SourceKind = node.SourceKind,
Angle = node.Angle,
RadiusOffset = node.RadiusOffset,
OreAmount = node.OreAmount,
ItemId = node.ItemId,
ShardCount = node.ShardCount,
}));
}
nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets));
nodes.AddRange(BuildGasCloudNodes(generatedIndex, planets));
return nodes;
}
private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
{
var allPositions = occupiedPositions.ToList();
var generated = new List<Vector3>(count);
for (var index = 0; index < count; index += 1)
{
Vector3? accepted = null;
for (var attempt = 0; attempt < 64; attempt += 1)
{
var candidate = ComputeGeneratedSystemPosition(index, attempt);
if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
{
accepted = candidate;
break;
}
}
accepted ??= ComputeFallbackGeneratedSystemPosition(index);
generated.Add(accepted.Value);
allPositions.Add(accepted.Value);
}
return generated;
}
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)
{
const int armCount = 4;
const float baseInnerRadius = 9000f;
const float radiusStep = 540f;
const float armOffset = MathF.PI * 2f / armCount;
var armIndex = (generatedIndex + attempt) % armCount;
var armDepth = generatedIndex / armCount;
var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 900f);
var angle = (armIndex * armOffset) + (radius / 8200f) + Jitter(generatedIndex, 1 + attempt, 0.16f);
var x = MathF.Cos(angle) * radius;
var z = MathF.Sin(angle) * radius * 0.58f;
var y = ComputeSystemHeight(radius, generatedIndex, attempt);
return new Vector3(x, y, z);
}
private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex)
{
const int ringCount = 5;
const float fallbackRadius = 42000f;
var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f;
var radius = fallbackRadius + (generatedIndex / ringCount) * 1800f;
return new Vector3(
MathF.Cos(angle) * radius,
ComputeSystemHeight(radius, generatedIndex, 99),
MathF.Sin(angle) * radius * 0.6f);
}
private static string BuildGeneratedSystemId(string label, int ordinal)
{
var slug = string.Concat(label
.ToLowerInvariant()
.Select((character) => char.IsLetterOrDigit(character) ? character : '-'))
.Trim('-');
return $"gen-{ordinal}-{slug}";
}
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex);
var nodeCount = 4 + (generatedIndex % 4);
var oreAmount = 2800f + ((generatedIndex % 5) * 320f);
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "asteroid-belt",
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f),
OreAmount = oreAmount,
ItemId = "ore",
ShardCount = 6 + (index % 4),
};
}
}
private static IEnumerable<ResourceNodeDefinition> BuildGasCloudNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
{
var gasAnchor = planets
.Where((planet) => planet.PlanetType is "gas-giant" or "ice-giant")
.OrderByDescending((planet) => planet.OrbitRadius)
.FirstOrDefault();
if (gasAnchor is null)
{
yield break;
}
var nodeCount = 2 + (generatedIndex % 3);
var gasAmount = 2200f + ((generatedIndex % 4) * 260f);
for (var index = 0; index < nodeCount; index += 1)
{
yield return new ResourceNodeDefinition
{
SourceKind = "gas-cloud",
Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f),
RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f),
OreAmount = gasAmount,
ItemId = "gas",
ShardCount = 10 + index,
};
}
}
private static float ResolveAsteroidBeltRadius(IReadOnlyList<PlanetDefinition> planets, int generatedIndex)
{
var gap = planets
.Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius))
.OrderByDescending((entry) => entry.Gap)
.FirstOrDefault();
if (gap.Gap > 1f)
{
return gap.LeftOrbitRadius + (gap.Gap * 0.52f);
}
return 420f + ((generatedIndex % 5) * 60f);
}
private static List<PlanetDefinition> BuildGeneratedPlanets(
SolarSystemDefinition template,
int generatedIndex)
{
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
var planets = new List<PlanetDefinition>(planetCount);
var orbitRadius = 140f + (Hash01(generatedIndex, 3) * 35f);
var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null;
for (var index = 0; index < planetCount; index += 1)
{
var profile = SelectPlanetProfile(generatedIndex, index);
var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0
? sourcePlanets[index % sourcePlanets.Count]
: null;
orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin));
var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f);
var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f);
var moonVariance = (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f);
planets.Add(new PlanetDefinition
{
Label = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}",
PlanetType = profile.Type,
Shape = profile.Shape,
MoonCount = profile.BaseMoonCount + moonVariance,
OrbitRadius = orbitRadius,
OrbitSpeed = 0.22f / MathF.Sqrt(MathF.Max(1f, orbitRadius / 120f)),
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f,
OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f,
OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f,
Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * 10f),
Color = templatePlanet?.Color ?? profile.Color,
Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f),
HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f,
});
}
return planets;
}
private static StarProfile SelectStarProfile(int generatedIndex)
{
var value = Hash01(generatedIndex, 80);
return value switch
{
< 0.32f => StarProfiles[0],
< 0.54f => StarProfiles[1],
< 0.68f => StarProfiles[5],
< 0.8f => StarProfiles[2],
< 0.9f => StarProfiles[3],
< 0.97f => StarProfiles[6],
_ => StarProfiles[4],
};
}
private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex)
{
var value = Hash01(generatedIndex, 90 + planetIndex);
return value switch
{
< 0.14f => PlanetProfiles[7],
< 0.28f => PlanetProfiles[0],
< 0.46f => PlanetProfiles[3],
< 0.62f => PlanetProfiles[1],
< 0.74f => PlanetProfiles[2],
< 0.86f => PlanetProfiles[4],
< 0.94f => PlanetProfiles[6],
_ => PlanetProfiles[5],
};
}
private static string BuildPlanetBaseName(int generatedIndex, int planetIndex)
{
var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length]
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0];
return source[..Math.Min(source.Length, 6)];
}
private static float ComputeSystemHeight(float radius, int generatedIndex, int salt)
{
var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8000f) / 28000f));
var band = 220f + (normalized * 760f);
return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band;
}
private static float Jitter(int index, int salt, float amplitude) =>
(Hash01(index, salt) * 2f - 1f) * amplitude;
private static float Hash01(int index, int salt)
{
uint value = (uint)(index + 1);
value ^= (uint)(salt + 0x9e3779b9);
value *= 0x85ebca6b;
value ^= value >> 13;
value *= 0xc2b2ae35;
value ^= value >> 16;
return (value & 0x00ffffff) / 16777215f;
}
private sealed record StarProfile(
string Kind,
string StarColor,
string StarGlow,
float BaseSize,
int StarCount);
private sealed record PlanetProfile(
string Type,
string Shape,
string Color,
float BaseSize,
float OrbitGapMin,
int BaseMoonCount,
bool CanHaveRing)
{
public float OrbitGapMax => OrbitGapMin + 44f;
}
private static SolarSystemDefinition CreateSolSystem()
{
return new SolarSystemDefinition
{
Id = "sol",
Label = "Sol",
Position = [18200f, 24f, -11800f],
StarKind = "main-sequence",
StarCount = 1,
StarColor = "#fff1b8",
StarGlow = "#ffd35a",
StarSize = 58f,
GravityWellRadius = 240f,
AsteroidField = new AsteroidFieldDefinition
{
DecorationCount = 240,
RadiusOffset = 780f,
RadiusVariance = 180f,
HeightVariance = 22f,
},
ResourceNodes =
[
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 },
],
Planets =
[
CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
],
};
}
private static PlanetDefinition CreateSolPlanet(
string label,
string planetType,
string shape,
int moonCount,
float orbitRadius,
float orbitSpeed,
float orbitEccentricity,
float orbitInclination,
float ascendingNode,
float argumentOfPeriapsis,
float phaseAtEpoch,
string color,
float tilt,
bool hasRing)
{
return new PlanetDefinition
{
Label = label,
PlanetType = planetType,
Shape = shape,
MoonCount = moonCount,
OrbitRadius = orbitRadius,
OrbitSpeed = orbitSpeed,
OrbitEccentricity = orbitEccentricity,
OrbitInclination = orbitInclination,
OrbitLongitudeOfAscendingNode = ascendingNode,
OrbitArgumentOfPeriapsis = argumentOfPeriapsis,
OrbitPhaseAtEpoch = phaseAtEpoch,
Size = planetType switch
{
"gas-giant" => label == "Saturn" ? 66f : 72f,
"ice-giant" => 48f,
_ => label == "Earth" ? 28f : label == "Mars" ? 22f : label == "Venus" ? 26f : 20f,
},
Color = color,
Tilt = tilt,
HasRing = hasRing,
};
}
}

View File

@@ -0,0 +1,413 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class ScenarioLoader
{
private static List<FactionRuntime> CreateFactions(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var factionIds = stations
.Select((station) => station.FactionId)
.Concat(ships.Select((ship) => ship.FactionId))
.Where((factionId) => !string.IsNullOrWhiteSpace(factionId))
.Distinct(StringComparer.Ordinal)
.OrderBy((factionId) => factionId, StringComparer.Ordinal)
.ToList();
if (factionIds.Count == 0)
{
factionIds.Add(DefaultFactionId);
}
return factionIds.Select(CreateFaction).ToList();
}
private static FactionRuntime CreateFaction(string factionId)
{
return factionId switch
{
DefaultFactionId => new FactionRuntime
{
Id = factionId,
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = MinimumFactionCredits,
},
_ => new FactionRuntime
{
Id = factionId,
Label = ToFactionLabel(factionId),
Color = "#c7d2e0",
Credits = MinimumFactionCredits,
},
};
}
private static void BootstrapFactionEconomy(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations)
{
foreach (var faction in factions)
{
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
var ownedStations = stations
.Where((station) => station.FactionId == faction.Id)
.ToList();
var refineries = ownedStations
.Where((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank"))
.ToList();
if (refineries.Count > 0)
{
foreach (var refinery in refineries)
{
refinery.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(refinery.Inventory, "refined-metals"), MinimumRefineryStock);
}
if (refineries.All((station) => GetInventoryAmount(station.Inventory, "ore") < MinimumRefineryOre))
{
refineries[0].Inventory["ore"] = MinimumRefineryOre;
}
}
foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard"))
{
shipyard.Inventory["refined-metals"] = MathF.Max(GetInventoryAmount(shipyard.Inventory, "refined-metals"), MinimumShipyardStock);
}
}
}
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
private static List<ClaimRuntime> CreateClaims(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<NodeRuntime> nodes,
DateTimeOffset nowUtc)
{
var claims = new List<ClaimRuntime>(stations.Count);
foreach (var station in stations)
{
if (station.AnchorNodeId is null)
{
continue;
}
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
if (anchorNode is null)
{
continue;
}
claims.Add(new ClaimRuntime
{
Id = $"claim-{station.Id}",
FactionId = station.FactionId,
SystemId = station.SystemId,
NodeId = anchorNode.Id,
BubbleId = anchorNode.BubbleId,
PlacedAtUtc = nowUtc,
ActivatesAtUtc = nowUtc.AddSeconds(8),
State = ClaimStateKinds.Activating,
Health = 100f,
});
}
return claims;
}
private static (List<ConstructionSiteRuntime> ConstructionSites, List<MarketOrderRuntime> MarketOrders) CreateConstructionSites(
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ClaimRuntime> claims,
IReadOnlyCollection<NodeRuntime> nodes,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
var sites = new List<ConstructionSiteRuntime>();
var orders = new List<MarketOrderRuntime>();
foreach (var station in stations)
{
var moduleId = GetNextConstructionSiteModule(station, moduleRecipes);
if (moduleId is null || station.AnchorNodeId is null)
{
continue;
}
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
var claim = claims.FirstOrDefault((candidate) => candidate.Id == $"claim-{station.Id}");
if (anchorNode is null || claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
{
continue;
}
var site = new ConstructionSiteRuntime
{
Id = $"site-{station.Id}",
FactionId = station.FactionId,
SystemId = station.SystemId,
NodeId = anchorNode.Id,
BubbleId = anchorNode.BubbleId,
TargetKind = "station-module",
TargetDefinitionId = station.Definition.Id,
BlueprintId = moduleId,
ClaimId = claim.Id,
StationId = station.Id,
State = claim.State == ClaimStateKinds.Active ? ConstructionSiteStateKinds.Active : ConstructionSiteStateKinds.Planned,
};
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{station.Id}-{moduleId}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
station.MarketOrderIds.Add(orderId);
orders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = station.FactionId,
StationId = station.Id,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1f,
State = MarketOrderStateKinds.Open,
});
}
sites.Add(site);
}
return (sites, orders);
}
private static string? GetNextConstructionSiteModule(
StationRuntime station,
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
{
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
{
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
&& moduleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static void InitializeStationPopulation(StationRuntime station)
{
var habitatModules = CountModules(station.InstalledModules, "habitat-ring");
station.PopulationCapacity = 40f + (habitatModules * 220f);
station.WorkforceRequired = MathF.Max(12f, station.InstalledModules.Count * 14f);
station.Population = habitatModules > 0
? MathF.Min(station.PopulationCapacity * 0.65f, station.WorkforceRequired * 1.05f)
: MathF.Min(28f, station.PopulationCapacity);
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private static List<PolicySetRuntime> CreatePolicies(IReadOnlyCollection<FactionRuntime> factions)
{
var policies = new List<PolicySetRuntime>(factions.Count);
foreach (var faction in factions)
{
var policyId = $"policy-{faction.Id}";
faction.DefaultPolicySetId = policyId;
policies.Add(new PolicySetRuntime
{
Id = policyId,
OwnerKind = "faction",
OwnerId = faction.Id,
});
}
return policies;
}
private static List<CommanderRuntime> CreateCommanders(
IReadOnlyCollection<FactionRuntime> factions,
IReadOnlyCollection<StationRuntime> stations,
IReadOnlyCollection<ShipRuntime> ships)
{
var commanders = new List<CommanderRuntime>();
var factionCommanders = new Dictionary<string, CommanderRuntime>(StringComparer.Ordinal);
var factionsById = factions.ToDictionary((faction) => faction.Id, StringComparer.Ordinal);
foreach (var faction in factions)
{
var commander = new CommanderRuntime
{
Id = $"commander-faction-{faction.Id}",
Kind = CommanderKind.Faction,
FactionId = faction.Id,
ControlledEntityId = faction.Id,
PolicySetId = faction.DefaultPolicySetId,
Doctrine = "strategic-default",
};
commanders.Add(commander);
factionCommanders[faction.Id] = commander;
faction.CommanderIds.Add(commander.Id);
}
foreach (var station in stations)
{
if (!factionCommanders.TryGetValue(station.FactionId, out var parentCommander))
{
continue;
}
var commander = new CommanderRuntime
{
Id = $"commander-station-{station.Id}",
Kind = CommanderKind.Station,
FactionId = station.FactionId,
ParentCommanderId = parentCommander.Id,
ControlledEntityId = station.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "station-default",
};
station.CommanderId = commander.Id;
station.PolicySetId = parentCommander.PolicySetId;
parentCommander.SubordinateCommanderIds.Add(commander.Id);
factionsById[station.FactionId].CommanderIds.Add(commander.Id);
commanders.Add(commander);
}
foreach (var ship in ships)
{
if (!factionCommanders.TryGetValue(ship.FactionId, out var parentCommander))
{
continue;
}
var commander = new CommanderRuntime
{
Id = $"commander-ship-{ship.Id}",
Kind = CommanderKind.Ship,
FactionId = ship.FactionId,
ParentCommanderId = parentCommander.Id,
ControlledEntityId = ship.Id,
PolicySetId = parentCommander.PolicySetId,
Doctrine = "ship-default",
ActiveBehavior = CopyBehavior(ship.DefaultBehavior),
ActiveTask = CopyTask(ship.ControllerTask, null),
};
if (ship.Order is not null)
{
commander.ActiveOrder = CopyOrder(ship.Order);
}
ship.CommanderId = commander.Id;
ship.PolicySetId = parentCommander.PolicySetId;
parentCommander.SubordinateCommanderIds.Add(commander.Id);
factionsById[ship.FactionId].CommanderIds.Add(commander.Id);
commanders.Add(commander);
}
return commanders;
}
private static string ToFactionLabel(string factionId)
{
return string.Join(" ",
factionId
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..]));
}
private static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery)
{
if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
StationId = refinery.Id,
Phase = "travel-to-station",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-harvest-gas",
StationId = refinery.Id,
Phase = "travel-to-node",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-mine",
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
StationId = refinery.Id,
Phase = "travel-to-node",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
PatrolPoints = route,
PatrolIndex = 0,
};
}
return new DefaultBehaviorRuntime
{
Kind = "idle",
};
}
private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new()
{
Kind = behavior.Kind,
AreaSystemId = behavior.AreaSystemId,
ModuleId = behavior.ModuleId,
NodeId = behavior.NodeId,
Phase = behavior.Phase,
PatrolIndex = behavior.PatrolIndex,
StationId = behavior.StationId,
};
private static CommanderOrderRuntime CopyOrder(ShipOrderRuntime order) => new()
{
Kind = order.Kind,
Status = order.Status,
DestinationSystemId = order.DestinationSystemId,
DestinationPosition = order.DestinationPosition,
};
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
{
Kind = task.Kind,
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = targetNodeId ?? task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}

View File

@@ -0,0 +1,217 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class ScenarioLoader
{
private static SystemSpatialGraph BuildSystemSpatialGraph(SystemRuntime system)
{
var nodes = new List<NodeRuntime>();
var bubbles = new List<LocalBubbleRuntime>();
var lagrangeNodesByPlanetIndex = new Dictionary<int, Dictionary<string, NodeRuntime>>();
var starNode = AddSpatialNode(
nodes,
bubbles,
id: $"node-{system.Definition.Id}-star",
systemId: system.Definition.Id,
kind: SpatialNodeKind.Star,
position: Vector3.Zero,
radius: MathF.Max(system.Definition.GravityWellRadius + StarBubbleRadiusPadding, 180f));
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
{
var planet = system.Definition.Planets[planetIndex];
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
var planetPosition = ComputePlanetPosition(planet);
var planetNode = AddSpatialNode(
nodes,
bubbles,
id: planetNodeId,
systemId: system.Definition.Id,
kind: SpatialNodeKind.Planet,
position: planetPosition,
radius: MathF.Max(planet.Size + PlanetBubbleRadiusPadding, 120f),
parentNodeId: starNode.Id);
var lagrangeNodes = new Dictionary<string, NodeRuntime>(StringComparer.Ordinal);
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
{
var lagrangeNode = AddSpatialNode(
nodes,
bubbles,
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{point.Designation.ToLowerInvariant()}",
systemId: system.Definition.Id,
kind: SpatialNodeKind.LagrangePoint,
position: point.Position,
radius: LagrangeBubbleRadius,
parentNodeId: planetNode.Id,
orbitReferenceId: point.Designation);
lagrangeNodes[point.Designation] = lagrangeNode;
}
lagrangeNodesByPlanetIndex[planetIndex] = lagrangeNodes;
if (planet.MoonCount <= 0)
{
continue;
}
var moonOrbitRadius = MathF.Max(planet.Size + 48f, 42f);
for (var moonIndex = 0; moonIndex < planet.MoonCount; moonIndex += 1)
{
var moonPosition = ComputeMoonPosition(planetPosition, moonOrbitRadius, moonIndex, planetIndex);
AddSpatialNode(
nodes,
bubbles,
id: $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}",
systemId: system.Definition.Id,
kind: SpatialNodeKind.Moon,
position: moonPosition,
radius: MoonBubbleRadiusPadding + 24f,
parentNodeId: planetNode.Id);
moonOrbitRadius += 30f;
}
}
return new SystemSpatialGraph(system.Definition.Id, nodes, bubbles, lagrangeNodesByPlanetIndex);
}
private static NodeRuntime AddSpatialNode(
ICollection<NodeRuntime> nodes,
ICollection<LocalBubbleRuntime> bubbles,
string id,
string systemId,
SpatialNodeKind kind,
Vector3 position,
float radius,
string? parentNodeId = null,
string? orbitReferenceId = null)
{
var bubbleId = $"bubble-{id}";
var node = new NodeRuntime
{
Id = id,
SystemId = systemId,
Kind = kind,
Position = position,
BubbleId = bubbleId,
ParentNodeId = parentNodeId,
OrbitReferenceId = orbitReferenceId,
};
nodes.Add(node);
bubbles.Add(new LocalBubbleRuntime
{
Id = bubbleId,
NodeId = id,
SystemId = systemId,
Radius = radius,
});
return node;
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f));
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadius));
yield return new LagrangePointPlacement(
"L4",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
}
private static StationPlacement ResolveStationPlacement(
InitialStationDefinition plan,
SystemRuntime system,
SystemSpatialGraph graph,
IReadOnlyCollection<NodeRuntime> existingNodes)
{
if (plan.PlanetIndex is int planetIndex &&
graph.LagrangeNodesByPlanetIndex.TryGetValue(planetIndex, out var lagrangeNodes))
{
var designation = ResolveLagrangeDesignation(plan.LagrangeSide);
if (lagrangeNodes.TryGetValue(designation, out var lagrangeNode))
{
return new StationPlacement(lagrangeNode, lagrangeNode.Position);
}
}
if (plan.Position is { Length: 3 })
{
var targetPosition = NormalizeScenarioPoint(system, plan.Position);
var preferredNode = existingNodes
.Where((node) => node.SystemId == system.Definition.Id && node.Kind == SpatialNodeKind.LagrangePoint)
.OrderBy((node) => node.Position.DistanceTo(targetPosition))
.FirstOrDefault()
?? existingNodes
.Where((node) => node.SystemId == system.Definition.Id)
.OrderBy((node) => node.Position.DistanceTo(targetPosition))
.First();
return new StationPlacement(preferredNode, preferredNode.Position);
}
var fallbackNode = graph.Nodes
.FirstOrDefault((node) => node.Kind == SpatialNodeKind.LagrangePoint && string.IsNullOrEmpty(node.OccupyingStructureId))
?? graph.Nodes.First((node) => node.Kind == SpatialNodeKind.Planet);
return new StationPlacement(fallbackNode, fallbackNode.Position);
}
private static string ResolveLagrangeDesignation(int? lagrangeSide) => lagrangeSide switch
{
< 0 => "L4",
> 0 => "L5",
_ => "L1",
};
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
{
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
var x = MathF.Cos(angle) * planet.OrbitRadius;
var z = MathF.Sin(angle) * planet.OrbitRadius;
return new Vector3(x, 0f, z);
}
private static Vector3 ComputeMoonPosition(Vector3 planetPosition, float orbitRadius, int moonIndex, int planetIndex)
{
var angle = ((MathF.PI * 2f) / MathF.Max(1, moonIndex + 3)) * (moonIndex + 1) + (planetIndex * 0.37f);
return Add(planetPosition, new Vector3(MathF.Cos(angle) * orbitRadius, 0f, MathF.Sin(angle) * orbitRadius));
}
private static ShipSpatialStateRuntime CreateInitialShipSpatialState(string systemId, Vector3 position, IReadOnlyCollection<NodeRuntime> nodes)
{
var nearestNode = nodes
.Where((node) => node.SystemId == systemId)
.OrderBy((node) => node.Position.DistanceTo(position))
.FirstOrDefault();
return new ShipSpatialStateRuntime
{
CurrentSystemId = systemId,
SpaceLayer = SpaceLayerKinds.LocalSpace,
CurrentNodeId = nearestNode?.Id,
CurrentBubbleId = nearestNode?.BubbleId,
LocalPosition = position,
SystemPosition = position,
MovementRegime = MovementRegimeKinds.LocalFlight,
};
}
private sealed record SystemSpatialGraph(
string SystemId,
List<NodeRuntime> Nodes,
List<LocalBubbleRuntime> Bubbles,
Dictionary<int, Dictionary<string, NodeRuntime>> LagrangeNodesByPlanetIndex);
private sealed record LagrangePointPlacement(string Designation, Vector3 Position);
private sealed record StationPlacement(NodeRuntime AnchorNode, Vector3 Position);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
return task.Kind switch
{
"idle" => UpdateIdle(ship, world, deltaSeconds),
"travel" => UpdateTravel(ship, world, deltaSeconds),
"extract" => UpdateExtract(ship, world, deltaSeconds),
"dock" => UpdateDock(ship, world, deltaSeconds),
"unload" => UpdateUnload(ship, world, deltaSeconds),
"refuel" => UpdateRefuel(ship, world, deltaSeconds),
"deliver-construction" => UpdateDeliverConstruction(ship, world, deltaSeconds),
"build-construction-site" => UpdateBuildConstructionSite(ship, world, deltaSeconds),
"load-workers" => UpdateLoadWorkers(ship, world, deltaSeconds),
"unload-workers" => UpdateUnloadWorkers(ship, world, deltaSeconds),
"construct-module" => UpdateConstructModule(ship, world, deltaSeconds),
"undock" => UpdateUndock(ship, world, deltaSeconds),
_ => UpdateIdle(ship, world, deltaSeconds),
};
}
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var targetPosition = task.TargetPosition.Value;
var targetNode = ResolveTravelTargetNode(world, task, targetPosition);
ship.TargetPosition = targetPosition;
if (ship.SystemId != task.TargetSystemId)
{
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode);
}
var currentNode = ResolveCurrentNode(world, ship);
if (targetNode is not null && currentNode is not null && !string.Equals(currentNode.Id, targetNode.Id, StringComparison.Ordinal))
{
return UpdateWarpTransit(ship, world, deltaSeconds, targetPosition, targetNode);
}
return UpdateLocalTravel(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode, task.Threshold);
}
private static NodeRuntime? ResolveTravelTargetNode(SimulationWorld world, ControllerTaskRuntime task, Vector3 targetPosition)
{
if (!string.IsNullOrWhiteSpace(task.TargetEntityId))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station?.NodeId is not null)
{
return world.SpatialNodes.FirstOrDefault(candidate => candidate.Id == station.NodeId);
}
var node = world.SpatialNodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is not null)
{
return node;
}
}
return world.SpatialNodes
.Where(candidate => task.TargetSystemId is null || candidate.SystemId == task.TargetSystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(targetPosition))
.FirstOrDefault();
}
private static NodeRuntime? ResolveCurrentNode(SimulationWorld world, ShipRuntime ship)
{
if (ship.SpatialState.CurrentNodeId is not null)
{
return world.SpatialNodes.FirstOrDefault(candidate => candidate.Id == ship.SpatialState.CurrentNodeId);
}
return world.SpatialNodes
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
}
private string UpdateLocalTravel(
ShipRuntime ship,
SimulationWorld world,
float deltaSeconds,
string targetSystemId,
Vector3 targetPosition,
NodeRuntime? targetNode,
float threshold)
{
var distance = ship.Position.DistanceTo(targetPosition);
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.Transit = null;
ship.SpatialState.DestinationNodeId = targetNode?.Id;
if (distance <= threshold)
{
ship.ActionTimer = 0f;
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
ship.Position = targetPosition;
ship.TargetPosition = ship.Position;
ship.SystemId = targetSystemId;
ship.SpatialState.CurrentNodeId = targetNode?.Id;
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
ship.State = "arriving";
return "arrived";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.ActionTimer = 0f;
ship.State = "local-flight";
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
private string UpdateWarpTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, Vector3 targetPosition, NodeRuntime targetNode)
{
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.Warp || transit.DestinationNodeId != targetNode.Id)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.Warp,
OriginNodeId = ship.SpatialState.CurrentNodeId,
DestinationNodeId = targetNode.Id,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.SystemSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.Warp;
ship.SpatialState.CurrentNodeId = null;
ship.SpatialState.CurrentBubbleId = null;
ship.SpatialState.DestinationNodeId = targetNode.Id;
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
if (ship.State != "warping")
{
if (ship.State != "spooling-warp")
{
ship.ActionTimer = 0f;
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "spooling-warp";
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
{
return "none";
}
ship.State = "warping";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
var totalDistance = MathF.Max(0.001f, transit.OriginNodeId is null
? ship.Position.DistanceTo(targetPosition)
: (world.SpatialNodes.FirstOrDefault(candidate => candidate.Id == transit.OriginNodeId)?.Position.DistanceTo(targetPosition) ?? ship.Position.DistanceTo(targetPosition)));
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 18f
? CompleteTransitArrival(ship, targetNode.SystemId, targetPosition, targetNode)
: "none";
}
private string UpdateFtlTransit(ShipRuntime ship, SimulationWorld world, float deltaSeconds, string targetSystemId, Vector3 targetPosition, NodeRuntime? targetNode)
{
var destinationNodeId = targetNode?.Id;
var transit = ship.SpatialState.Transit;
if (transit is null || transit.Regime != MovementRegimeKinds.FtlTransit || transit.DestinationNodeId != destinationNodeId)
{
transit = new ShipTransitRuntime
{
Regime = MovementRegimeKinds.FtlTransit,
OriginNodeId = ship.SpatialState.CurrentNodeId,
DestinationNodeId = destinationNodeId,
StartedAtUtc = world.GeneratedAtUtc,
};
ship.SpatialState.Transit = transit;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.GalaxySpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.FtlTransit;
ship.SpatialState.CurrentNodeId = null;
ship.SpatialState.CurrentBubbleId = null;
ship.SpatialState.DestinationNodeId = destinationNodeId;
if (ship.State != "ftl")
{
if (ship.State != "spooling-ftl")
{
ship.ActionTimer = 0f;
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "spooling-ftl";
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
{
return "none";
}
ship.State = "ftl";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
var totalDistance = MathF.Max(0.001f, ship.Position.DistanceTo(targetPosition));
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.FtlSpeed * deltaSeconds);
transit.Progress = MathF.Min(1f, 1f - (ship.Position.DistanceTo(targetPosition) / totalDistance));
return ship.Position.DistanceTo(targetPosition) <= 24f
? CompleteTransitArrival(ship, targetSystemId, targetPosition, targetNode)
: "none";
}
private static string CompleteTransitArrival(ShipRuntime ship, string targetSystemId, Vector3 targetPosition, NodeRuntime? targetNode)
{
ship.ActionTimer = 0f;
ship.Position = targetPosition;
ship.TargetPosition = targetPosition;
ship.SystemId = targetSystemId;
ship.SpatialState.Transit = null;
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
ship.SpatialState.CurrentNodeId = targetNode?.Id;
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
ship.SpatialState.DestinationNodeId = targetNode?.Id;
ship.State = "arriving";
return "arrived";
}
}

View File

@@ -0,0 +1,256 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private static Vector3 ComputePlanetPosition(PlanetDefinition planet, float timeSeconds)
{
var eccentricity = Math.Clamp(planet.OrbitEccentricity, 0f, 0.85f);
var meanAnomaly = DegreesToRadians(planet.OrbitPhaseAtEpoch) + (timeSeconds * planet.OrbitSpeed);
var eccentricAnomaly = meanAnomaly
+ (eccentricity * MathF.Sin(meanAnomaly))
+ (0.5f * eccentricity * eccentricity * MathF.Sin(2f * meanAnomaly));
var semiMajorAxis = planet.OrbitRadius;
var semiMinorAxis = semiMajorAxis * MathF.Sqrt(MathF.Max(1f - (eccentricity * eccentricity), 0.05f));
var local = new Vector3(
semiMajorAxis * (MathF.Cos(eccentricAnomaly) - eccentricity),
0f,
semiMinorAxis * MathF.Sin(eccentricAnomaly));
local = RotateAroundY(local, DegreesToRadians(planet.OrbitArgumentOfPeriapsis));
local = RotateAroundX(local, DegreesToRadians(planet.OrbitInclination));
local = RotateAroundY(local, DegreesToRadians(planet.OrbitLongitudeOfAscendingNode));
return local;
}
private static Vector3 ComputeMoonOffset(PlanetDefinition planet, int moonIndex, float timeSeconds)
{
var orbitRadius = ComputeMoonOrbitRadius(planet, moonIndex);
var speed = ComputeMoonOrbitSpeed(planet, moonIndex);
var phase = HashUnit($"{planet.Label}:{moonIndex}:phase") * MathF.PI * 2f;
var inclination = DegreesToRadians((HashUnit($"{planet.Label}:{moonIndex}:inclination") - 0.5f) * 28f);
var ascendingNode = DegreesToRadians(HashUnit($"{planet.Label}:{moonIndex}:node") * 360f);
var angle = phase + (timeSeconds * speed);
var local = new Vector3(
MathF.Cos(angle) * orbitRadius,
0f,
MathF.Sin(angle) * orbitRadius);
local = RotateAroundX(local, inclination);
local = RotateAroundY(local, ascendingNode);
return local;
}
private static float ComputeMoonOrbitRadius(PlanetDefinition planet, int moonIndex)
{
var spacing = planet.Size * 1.4f;
var variance = HashUnit($"{planet.Label}:{moonIndex}:radius") * planet.Size * 0.9f;
return (planet.Size * 1.8f) + (moonIndex * spacing) + variance;
}
private static float ComputeMoonOrbitSpeed(PlanetDefinition planet, int moonIndex)
{
var radius = ComputeMoonOrbitRadius(planet, moonIndex);
return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f);
}
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
{
var radial = NormalizeOrFallback(planetPosition, new Vector3(1f, 0f, 0f));
var tangential = new Vector3(-radial.Z, 0f, radial.X);
var offset = MathF.Max(orbitRadius * 0.18f, 72f + (planetIndex * 6f));
var triangularAngle = MathF.PI / 3f;
yield return new LagrangePointPlacement("L1", Add(planetPosition, Scale(radial, -offset)));
yield return new LagrangePointPlacement("L2", Add(planetPosition, Scale(radial, offset)));
yield return new LagrangePointPlacement("L3", Scale(radial, -orbitRadius));
yield return new LagrangePointPlacement(
"L4",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
yield return new LagrangePointPlacement(
"L5",
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
}
private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback)
{
var length = MathF.Sqrt(value.LengthSquared());
if (length <= 0.0001f)
{
return fallback;
}
return value.Divide(length);
}
private static Vector3 RotateAroundX(Vector3 value, float angle)
{
var cos = MathF.Cos(angle);
var sin = MathF.Sin(angle);
return new Vector3(
value.X,
(value.Y * cos) - (value.Z * sin),
(value.Y * sin) + (value.Z * cos));
}
private static Vector3 RotateAroundY(Vector3 value, float angle)
{
var cos = MathF.Cos(angle);
var sin = MathF.Sin(angle);
return new Vector3(
(value.X * cos) + (value.Z * sin),
value.Y,
(-value.X * sin) + (value.Z * cos));
}
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
private static Vector3 Scale(Vector3 value, float scalar) => new(value.X * scalar, value.Y * scalar, value.Z * scalar);
private static float DegreesToRadians(float degrees) => degrees * (MathF.PI / 180f);
private static float HashUnit(string input)
{
unchecked
{
var hash = 2166136261u;
foreach (var character in input)
{
hash ^= character;
hash *= 16777619u;
}
return (hash & 0x00FFFFFF) / (float)0x01000000;
}
}
private static void UpdateOrbitalState(SimulationWorld world, DateTimeOffset nowUtc)
{
var worldTimeSeconds = (float)(nowUtc.ToUnixTimeMilliseconds() / 1000d) + (world.Seed * 97f);
var spatialNodesById = world.SpatialNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
foreach (var system in world.Systems)
{
var starNodeId = $"node-{system.Definition.Id}-star";
if (spatialNodesById.TryGetValue(starNodeId, out var starNode))
{
starNode.Position = Vector3.Zero;
}
for (var planetIndex = 0; planetIndex < system.Definition.Planets.Count; planetIndex += 1)
{
var planet = system.Definition.Planets[planetIndex];
var planetNodeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}";
if (!spatialNodesById.TryGetValue(planetNodeId, out var planetNode))
{
continue;
}
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
planetNode.Position = planetPosition;
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
{
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode))
{
lagrangeNode.Position = lagrange.Position;
}
}
for (var moonIndex = 0; moonIndex < planet.MoonCount; moonIndex += 1)
{
var moonId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
if (!spatialNodesById.TryGetValue(moonId, out var moonNode))
{
continue;
}
moonNode.Position = Add(planetPosition, ComputeMoonOffset(planet, moonIndex, worldTimeSeconds));
}
}
}
foreach (var station in world.Stations)
{
if (station.AnchorNodeId is null || !spatialNodesById.TryGetValue(station.AnchorNodeId, out var anchorNode))
{
continue;
}
station.Position = anchorNode.Position;
if (station.NodeId is not null && spatialNodesById.TryGetValue(station.NodeId, out var stationNode))
{
stationNode.Position = station.Position;
}
}
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
{
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
continue;
}
var dockedPosition = GetShipDockedPosition(ship, station);
ship.Position = dockedPosition;
ship.TargetPosition = dockedPosition;
}
}
private static void SyncSpatialState(SimulationWorld world)
{
foreach (var bubble in world.LocalBubbles)
{
bubble.OccupantShipIds.Clear();
}
foreach (var ship in world.Ships)
{
ship.SpatialState.CurrentSystemId = ship.SystemId;
ship.SpatialState.LocalPosition = ship.Position;
ship.SpatialState.SystemPosition = ship.Position;
if (ship.SpatialState.Transit is not null)
{
ship.SpatialState.CurrentNodeId = null;
ship.SpatialState.CurrentBubbleId = null;
continue;
}
ship.SpatialState.SpaceLayer = SpaceLayerKinds.LocalSpace;
ship.SpatialState.MovementRegime = MovementRegimeKinds.LocalFlight;
var nearestNode = world.SpatialNodes
.Where(candidate => candidate.SystemId == ship.SystemId)
.OrderBy(candidate => candidate.Position.DistanceTo(ship.Position))
.FirstOrDefault();
ship.SpatialState.CurrentNodeId = nearestNode?.Id;
ship.SpatialState.CurrentBubbleId = nearestNode?.BubbleId;
if (nearestNode is not null)
{
var nearestBubble = world.LocalBubbles.FirstOrDefault(candidate => candidate.Id == nearestNode.BubbleId);
nearestBubble?.OccupantShipIds.Add(ship.Id);
}
if (ship.DockedStationId is null)
{
continue;
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station?.BubbleId is null)
{
continue;
}
ship.SpatialState.CurrentNodeId = station.NodeId;
ship.SpatialState.CurrentBubbleId = station.BubbleId;
var bubble = world.LocalBubbles.FirstOrDefault(candidate => candidate.Id == station.BubbleId);
bubble?.OccupantShipIds.Add(ship.Id);
}
}
private readonly record struct LagrangePointPlacement(string Designation, Vector3 Position);
}

View File

@@ -0,0 +1,237 @@
using SpaceGame.Simulation.Api.Contracts;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
private static bool CanTransportWorkers(ShipRuntime ship) =>
CountModules(ship.Definition.Modules, "habitat-ring") > 0;
private static float GetWorkerTransportCapacity(ShipRuntime ship) =>
CountModules(ship.Definition.Modules, "habitat-ring") * 120f;
private static void UpdateStationPower(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
foreach (var station in world.Stations)
{
var previousEnergy = station.EnergyStored;
GenerateStationEnergy(station, deltaSeconds);
if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f)
{
events.Add(new SimulationEventRecord("station", station.Id, "power-lost", $"{station.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
}
}
}
private static void UpdateShipPower(ShipRuntime ship, SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var previousEnergy = ship.EnergyStored;
GenerateShipEnergy(ship, world, deltaSeconds);
if (previousEnergy > 0.01f && ship.EnergyStored <= 0.01f && GetInventoryAmount(ship.Inventory, "fuel") <= 0.01f)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "power-lost", $"{ship.Definition.Label} ran out of fuel and power", DateTimeOffset.UtcNow));
}
}
private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds)
{
var powerCores = CountModules(station.InstalledModules, "power-core");
var tanks = CountModules(station.InstalledModules, "liquid-tank");
if (powerCores <= 0 || tanks <= 0)
{
station.EnergyStored = 0f;
station.Inventory.Remove("fuel");
return;
}
var energyCapacity = powerCores * StationEnergyPerPowerCore;
var fuelStored = GetInventoryAmount(station.Inventory, "fuel");
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
{
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank);
return;
}
var generated = MathF.Min(desiredEnergy, powerCores * 24f * deltaSeconds);
var requiredFuel = generated / StationFuelToEnergyRatio;
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
var actualGenerated = consumedFuel * StationFuelToEnergyRatio;
RemoveInventory(station.Inventory, "fuel", consumedFuel);
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated);
}
private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var reactors = CountModules(ship.Definition.Modules, "reactor-core");
var capacitors = CountModules(ship.Definition.Modules, "capacitor-bank");
if (reactors <= 0 || capacitors <= 0)
{
ship.EnergyStored = 0f;
ship.Inventory.Remove("fuel");
return;
}
var energyCapacity = capacitors * CapacitorEnergyPerModule;
var fuelCapacity = reactors * ShipFuelPerReactor;
var fuelStored = GetInventoryAmount(ship.Inventory, "fuel");
var desiredEnergy = MathF.Max(0f, energyCapacity - ship.EnergyStored);
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
{
ship.EnergyStored = MathF.Min(ship.EnergyStored, energyCapacity);
ship.Inventory["fuel"] = MathF.Min(fuelStored, fuelCapacity);
return;
}
var generated = MathF.Min(desiredEnergy, world.Balance.Energy.ShipRechargeRate * reactors * deltaSeconds);
var requiredFuel = generated / ShipFuelToEnergyRatio;
var consumedFuel = MathF.Min(requiredFuel, fuelStored);
var actualGenerated = consumedFuel * ShipFuelToEnergyRatio;
RemoveInventory(ship.Inventory, "fuel", consumedFuel);
ship.EnergyStored = MathF.Min(energyCapacity, ship.EnergyStored + actualGenerated);
}
private static bool TryConsumeShipEnergy(ShipRuntime ship, float amount)
{
if (ship.EnergyStored + 0.0001f < amount)
{
return false;
}
ship.EnergyStored = MathF.Max(0f, ship.EnergyStored - amount);
return true;
}
private static bool TryConsumeStationEnergy(StationRuntime station, float amount)
{
if (station.EnergyStored + 0.0001f < amount)
{
return false;
}
station.EnergyStored = MathF.Max(0f, station.EnergyStored - amount);
return true;
}
private static int CountModules(IEnumerable<string> modules, string moduleId) =>
modules.Count(candidate => string.Equals(candidate, moduleId, StringComparison.Ordinal));
private static float GetInventoryAmount(IReadOnlyDictionary<string, float> inventory, string itemId) =>
inventory.TryGetValue(itemId, out var amount) ? amount : 0f;
private static void AddInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
if (amount <= 0f)
{
return;
}
inventory[itemId] = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId) + amount;
}
private static float RemoveInventory(IDictionary<string, float> inventory, string itemId, float amount)
{
var current = GetInventoryAmount((IReadOnlyDictionary<string, float>)inventory, itemId);
var removed = MathF.Min(current, amount);
var remaining = current - removed;
if (remaining <= 0.001f)
{
inventory.Remove(itemId);
}
else
{
inventory[itemId] = remaining;
}
return removed;
}
private static bool HasStationModules(StationRuntime station, params string[] modules) =>
modules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
private static bool CanExtractNode(ShipRuntime ship, ResourceNodeRuntime node) =>
node.ItemId switch
{
"ore" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "mining-turret"),
"gas" => HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", "gas-extractor"),
_ => false,
};
private static float GetShipFuelCapacity(ShipRuntime ship) =>
CountModules(ship.Definition.Modules, "reactor-core") * ShipFuelPerReactor;
internal static bool NeedsRefuel(ShipRuntime ship) =>
GetInventoryAmount(ship.Inventory, "fuel") < (GetShipFuelCapacity(ship) * 0.7f);
private static float ComputeWorkforceRatio(float population, float workforceRequired)
{
if (workforceRequired <= 0.01f)
{
return 1f;
}
var staffedRatio = MathF.Min(1f, population / workforceRequired);
return 0.1f + (0.9f * staffedRatio);
}
private static string? GetStorageRequirement(string storageClass) =>
storageClass switch
{
"bulk-solid" => "bulk-bay",
"bulk-liquid" => "liquid-tank",
"bulk-gas" => "gas-tank",
_ => null,
};
private static float TryAddStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
{
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return 0f;
}
var storageClass = itemDefinition.Storage;
var requiredModule = GetStorageRequirement(storageClass);
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
{
return 0f;
}
if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity))
{
return 0f;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == storageClass)
.Sum(entry => entry.Value);
var accepted = MathF.Min(amount, MathF.Max(0f, capacity - used));
if (accepted <= 0.01f)
{
return 0f;
}
AddInventory(station.Inventory, itemId, accepted);
return accepted;
}
private static bool CanStartModuleConstruction(StationRuntime station, ModuleRecipeDefinition recipe) =>
recipe.Inputs.All(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f >= input.Amount);
private static ConstructionSiteRuntime? GetConstructionSiteForStation(SimulationWorld world, string stationId) =>
world.ConstructionSites.FirstOrDefault(site =>
string.Equals(site.StationId, stationId, StringComparison.Ordinal)
&& site.State is not ConstructionSiteStateKinds.Completed and not ConstructionSiteStateKinds.Destroyed);
private static bool IsConstructionSiteReady(ConstructionSiteRuntime site) =>
site.RequiredItems.All(entry => GetInventoryAmount(site.DeliveredItems, entry.Key) + 0.001f >= entry.Value);
}

View File

@@ -0,0 +1,680 @@
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
public WorldSnapshot BuildSnapshot(SimulationWorld world, long sequence)
{
PrimeDeltaBaseline(world);
return new WorldSnapshot(
world.Label,
world.Seed,
sequence,
world.TickIntervalMs,
world.GeneratedAtUtc,
world.Systems.Select(system => new SystemSnapshot(
system.Definition.Id,
system.Definition.Label,
ToDto(system.Position),
system.Definition.StarKind,
system.Definition.StarCount,
system.Definition.StarColor,
system.Definition.StarSize,
system.Definition.Planets.Select(planet => new PlanetSnapshot(
planet.Label,
planet.PlanetType,
planet.Shape,
planet.MoonCount,
planet.OrbitRadius,
planet.OrbitSpeed,
planet.OrbitEccentricity,
planet.OrbitInclination,
planet.OrbitLongitudeOfAscendingNode,
planet.OrbitArgumentOfPeriapsis,
planet.OrbitPhaseAtEpoch,
planet.Size,
planet.Color,
planet.HasRing)).ToList())).ToList(),
world.SpatialNodes.Select(ToSpatialNodeDelta).Select(node => new SpatialNodeSnapshot(
node.Id,
node.SystemId,
node.Kind,
node.LocalPosition,
node.BubbleId,
node.ParentNodeId,
node.OccupyingStructureId,
node.OrbitReferenceId)).ToList(),
world.LocalBubbles.Select(ToLocalBubbleDelta).Select(bubble => new LocalBubbleSnapshot(
bubble.Id,
bubble.NodeId,
bubble.SystemId,
bubble.Radius,
bubble.OccupantShipIds,
bubble.OccupantStationIds,
bubble.OccupantClaimIds,
bubble.OccupantConstructionSiteIds)).ToList(),
world.Nodes.Select(ToNodeDelta).Select(node => new ResourceNodeSnapshot(
node.Id,
node.SystemId,
node.LocalPosition,
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId)).ToList(),
world.Stations.Select(ToStationDelta).Select(station => new StationSnapshot(
station.Id,
station.Label,
station.Category,
station.SystemId,
station.LocalPosition,
station.NodeId,
station.BubbleId,
station.AnchorNodeId,
station.Color,
station.DockedShips,
station.DockingPads,
station.EnergyStored,
station.Inventory,
station.FactionId,
station.CommanderId,
station.PolicySetId,
station.Population,
station.PopulationCapacity,
station.WorkforceRequired,
station.WorkforceEffectiveRatio,
station.InstalledModules,
station.MarketOrderIds)).ToList(),
world.Claims.Select(ToClaimDelta).Select(claim => new ClaimSnapshot(
claim.Id,
claim.FactionId,
claim.SystemId,
claim.NodeId,
claim.BubbleId,
claim.State,
claim.Health,
claim.PlacedAtUtc,
claim.ActivatesAtUtc)).ToList(),
world.ConstructionSites.Select(ToConstructionSiteDelta).Select(site => new ConstructionSiteSnapshot(
site.Id,
site.FactionId,
site.SystemId,
site.NodeId,
site.BubbleId,
site.TargetKind,
site.TargetDefinitionId,
site.BlueprintId,
site.ClaimId,
site.StationId,
site.State,
site.Progress,
site.Inventory,
site.RequiredItems,
site.DeliveredItems,
site.AssignedConstructorShipIds,
site.MarketOrderIds)).ToList(),
world.MarketOrders.Select(ToMarketOrderDelta).Select(order => new MarketOrderSnapshot(
order.Id,
order.FactionId,
order.StationId,
order.ConstructionSiteId,
order.Kind,
order.ItemId,
order.Amount,
order.RemainingAmount,
order.Valuation,
order.ReserveThreshold,
order.PolicySetId,
order.State)).ToList(),
world.Policies.Select(ToPolicySetDelta).Select(policy => new PolicySetSnapshot(
policy.Id,
policy.OwnerKind,
policy.OwnerId,
policy.TradeAccessPolicy,
policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy)).ToList(),
world.Ships.Select(ToShipDelta).Select(ship => new ShipSnapshot(
ship.Id,
ship.Label,
ship.Role,
ship.ShipClass,
ship.SystemId,
ship.LocalPosition,
ship.LocalVelocity,
ship.TargetLocalPosition,
ship.State,
ship.OrderKind,
ship.DefaultBehaviorKind,
ship.ControllerTaskKind,
ship.NodeId,
ship.BubbleId,
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.CargoCapacity,
ship.WorkerPopulation,
ship.EnergyStored,
ship.Inventory,
ship.FactionId,
ship.Health,
ship.History,
ship.SpatialState)).ToList(),
world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot(
faction.Id,
faction.Label,
faction.Color,
faction.Credits,
faction.PopulationTotal,
faction.OreMined,
faction.GoodsProduced,
faction.ShipsBuilt,
faction.ShipsLost,
faction.DefaultPolicySetId)).ToList());
}
public void PrimeDeltaBaseline(SimulationWorld world)
{
foreach (var node in world.Nodes)
{
node.LastDeltaSignature = BuildNodeSignature(node);
}
foreach (var node in world.SpatialNodes)
{
node.LastDeltaSignature = BuildSpatialNodeSignature(node);
}
foreach (var bubble in world.LocalBubbles)
{
bubble.LastDeltaSignature = BuildLocalBubbleSignature(bubble);
}
foreach (var station in world.Stations)
{
station.LastDeltaSignature = BuildStationSignature(station);
}
foreach (var claim in world.Claims)
{
claim.LastDeltaSignature = BuildClaimSignature(claim);
}
foreach (var site in world.ConstructionSites)
{
site.LastDeltaSignature = BuildConstructionSiteSignature(site);
}
foreach (var order in world.MarketOrders)
{
order.LastDeltaSignature = BuildMarketOrderSignature(order);
}
foreach (var policy in world.Policies)
{
policy.LastDeltaSignature = BuildPolicySignature(policy);
}
foreach (var ship in world.Ships)
{
ship.LastDeltaSignature = BuildShipSignature(ship);
}
foreach (var faction in world.Factions)
{
faction.LastDeltaSignature = BuildFactionSignature(faction);
}
}
private static IReadOnlyList<ResourceNodeDelta> BuildNodeDeltas(SimulationWorld world)
{
var deltas = new List<ResourceNodeDelta>();
foreach (var node in world.Nodes)
{
var signature = BuildNodeSignature(node);
if (signature == node.LastDeltaSignature)
{
continue;
}
node.LastDeltaSignature = signature;
deltas.Add(ToNodeDelta(node));
}
return deltas;
}
private static IReadOnlyList<SpatialNodeDelta> BuildSpatialNodeDeltas(SimulationWorld world)
{
var deltas = new List<SpatialNodeDelta>();
foreach (var node in world.SpatialNodes)
{
var signature = BuildSpatialNodeSignature(node);
if (signature == node.LastDeltaSignature)
{
continue;
}
node.LastDeltaSignature = signature;
deltas.Add(ToSpatialNodeDelta(node));
}
return deltas;
}
private static IReadOnlyList<LocalBubbleDelta> BuildLocalBubbleDeltas(SimulationWorld world)
{
var deltas = new List<LocalBubbleDelta>();
foreach (var bubble in world.LocalBubbles)
{
var signature = BuildLocalBubbleSignature(bubble);
if (signature == bubble.LastDeltaSignature)
{
continue;
}
bubble.LastDeltaSignature = signature;
deltas.Add(ToLocalBubbleDelta(bubble));
}
return deltas;
}
private static IReadOnlyList<StationDelta> BuildStationDeltas(SimulationWorld world)
{
var deltas = new List<StationDelta>();
foreach (var station in world.Stations)
{
var signature = BuildStationSignature(station);
if (signature == station.LastDeltaSignature)
{
continue;
}
station.LastDeltaSignature = signature;
deltas.Add(ToStationDelta(station));
}
return deltas;
}
private static IReadOnlyList<ClaimDelta> BuildClaimDeltas(SimulationWorld world)
{
var deltas = new List<ClaimDelta>();
foreach (var claim in world.Claims)
{
var signature = BuildClaimSignature(claim);
if (signature == claim.LastDeltaSignature)
{
continue;
}
claim.LastDeltaSignature = signature;
deltas.Add(ToClaimDelta(claim));
}
return deltas;
}
private static IReadOnlyList<ConstructionSiteDelta> BuildConstructionSiteDeltas(SimulationWorld world)
{
var deltas = new List<ConstructionSiteDelta>();
foreach (var site in world.ConstructionSites)
{
var signature = BuildConstructionSiteSignature(site);
if (signature == site.LastDeltaSignature)
{
continue;
}
site.LastDeltaSignature = signature;
deltas.Add(ToConstructionSiteDelta(site));
}
return deltas;
}
private static IReadOnlyList<MarketOrderDelta> BuildMarketOrderDeltas(SimulationWorld world)
{
var deltas = new List<MarketOrderDelta>();
foreach (var order in world.MarketOrders)
{
var signature = BuildMarketOrderSignature(order);
if (signature == order.LastDeltaSignature)
{
continue;
}
order.LastDeltaSignature = signature;
deltas.Add(ToMarketOrderDelta(order));
}
return deltas;
}
private static IReadOnlyList<PolicySetDelta> BuildPolicyDeltas(SimulationWorld world)
{
var deltas = new List<PolicySetDelta>();
foreach (var policy in world.Policies)
{
var signature = BuildPolicySignature(policy);
if (signature == policy.LastDeltaSignature)
{
continue;
}
policy.LastDeltaSignature = signature;
deltas.Add(ToPolicySetDelta(policy));
}
return deltas;
}
private static IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
{
var deltas = new List<ShipDelta>();
foreach (var ship in world.Ships)
{
var signature = BuildShipSignature(ship);
if (signature == ship.LastDeltaSignature)
{
continue;
}
ship.LastDeltaSignature = signature;
deltas.Add(ToShipDelta(ship));
}
return deltas;
}
private static IReadOnlyList<FactionDelta> BuildFactionDeltas(SimulationWorld world)
{
var deltas = new List<FactionDelta>();
foreach (var faction in world.Factions)
{
var signature = BuildFactionSignature(faction);
if (signature == faction.LastDeltaSignature)
{
continue;
}
faction.LastDeltaSignature = signature;
deltas.Add(ToFactionDelta(faction));
}
return deltas;
}
private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}";
private static string BuildSpatialNodeSignature(NodeRuntime node) =>
$"{node.SystemId}|{node.Kind.ToContractValue()}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.BubbleId}|{node.ParentNodeId}|{node.OccupyingStructureId}|{node.OrbitReferenceId}";
private static string BuildLocalBubbleSignature(LocalBubbleRuntime bubble) =>
$"{bubble.SystemId}|{bubble.NodeId}|{bubble.Radius:0.###}|{string.Join(",", bubble.OccupantShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantStationIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal))}";
private static string BuildStationSignature(StationRuntime station) =>
$"{station.SystemId}|{station.NodeId}|{station.BubbleId}|{station.AnchorNodeId}|{station.CommanderId}|{station.PolicySetId}|{BuildInventorySignature(station.Inventory)}|{station.EnergyStored:0.###}|{station.DockedShipIds.Count}|{station.DockingPadAssignments.Count}|{station.Population:0.###}|{station.PopulationCapacity:0.###}|{station.WorkforceRequired:0.###}|{station.WorkforceEffectiveRatio:0.###}|{string.Join(",", station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal))}|{string.Join(",", station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal))}|{station.ActiveConstruction?.ModuleId ?? "none"}|{station.ActiveConstruction?.ProgressSeconds.ToString("0.###") ?? "0"}";
private static string BuildClaimSignature(ClaimRuntime claim) =>
$"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
private static string BuildConstructionSiteSignature(ConstructionSiteRuntime site) =>
$"{site.FactionId}|{site.SystemId}|{site.NodeId}|{site.BubbleId}|{site.TargetKind}|{site.TargetDefinitionId}|{site.BlueprintId}|{site.ClaimId}|{site.StationId}|{site.State}|{site.Progress:0.###}|{BuildInventorySignature(site.Inventory)}|{BuildInventorySignature(site.RequiredItems)}|{BuildInventorySignature(site.DeliveredItems)}|{string.Join(",", site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal))}|{string.Join(",", site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal))}";
private static string BuildMarketOrderSignature(MarketOrderRuntime order) =>
$"{order.FactionId}|{order.StationId}|{order.ConstructionSiteId}|{order.Kind}|{order.ItemId}|{order.Amount:0.###}|{order.RemainingAmount:0.###}|{order.Valuation:0.###}|{order.ReserveThreshold?.ToString("0.###") ?? "none"}|{order.PolicySetId}|{order.State}";
private static string BuildPolicySignature(PolicySetRuntime policy) =>
$"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}";
private static string BuildShipSignature(ShipRuntime ship) =>
string.Join("|",
ship.SystemId,
ship.Position.X.ToString("0.###"),
ship.Position.Y.ToString("0.###"),
ship.Position.Z.ToString("0.###"),
ship.Velocity.X.ToString("0.###"),
ship.Velocity.Y.ToString("0.###"),
ship.Velocity.Z.ToString("0.###"),
ship.TargetPosition.X.ToString("0.###"),
ship.TargetPosition.Y.ToString("0.###"),
ship.TargetPosition.Z.ToString("0.###"),
ship.State,
ship.Order?.Kind ?? "none",
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
ship.SpatialState.CurrentNodeId ?? "none",
ship.SpatialState.CurrentBubbleId ?? "none",
ship.DockedStationId ?? "none",
ship.CommanderId ?? "none",
ship.PolicySetId ?? "none",
ship.WorkerPopulation.ToString("0.###"),
ship.SpatialState.SpaceLayer,
ship.SpatialState.CurrentNodeId ?? "none",
ship.SpatialState.CurrentBubbleId ?? "none",
ship.SpatialState.MovementRegime,
ship.SpatialState.DestinationNodeId ?? "none",
ship.SpatialState.Transit?.Regime ?? "none",
ship.SpatialState.Transit?.OriginNodeId ?? "none",
ship.SpatialState.Transit?.DestinationNodeId ?? "none",
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
GetShipCargoAmount(ship).ToString("0.###"),
GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"),
ship.EnergyStored.ToString("0.###"),
ship.Health.ToString("0.###"));
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
string.Join(",",
inventory
.Where(entry => entry.Value > 0.001f)
.OrderBy(entry => entry.Key, StringComparer.Ordinal)
.Select(entry => $"{entry.Key}:{entry.Value:0.###}"));
private static string BuildFactionSignature(FactionRuntime faction) =>
$"{faction.Credits:0.###}|{faction.PopulationTotal:0.###}|{faction.OreMined:0.###}|{faction.GoodsProduced:0.###}|{faction.ShipsBuilt}|{faction.ShipsLost}|{faction.DefaultPolicySetId}";
private static ResourceNodeDelta ToNodeDelta(ResourceNodeRuntime node) => new(
node.Id,
node.SystemId,
ToDto(node.Position),
node.SourceKind,
node.OreRemaining,
node.MaxOre,
node.ItemId);
private static SpatialNodeDelta ToSpatialNodeDelta(NodeRuntime node) => new(
node.Id,
node.SystemId,
node.Kind.ToContractValue(),
ToDto(node.Position),
node.BubbleId,
node.ParentNodeId,
node.OccupyingStructureId,
node.OrbitReferenceId);
private static LocalBubbleDelta ToLocalBubbleDelta(LocalBubbleRuntime bubble) => new(
bubble.Id,
bubble.NodeId,
bubble.SystemId,
bubble.Radius,
bubble.OccupantShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
bubble.OccupantStationIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList());
private static StationDelta ToStationDelta(StationRuntime station) => new(
station.Id,
station.Definition.Label,
station.Definition.Category,
station.SystemId,
ToDto(station.Position),
station.NodeId,
station.BubbleId,
station.AnchorNodeId,
station.Definition.Color,
station.DockedShipIds.Count,
GetDockingPadCount(station),
station.EnergyStored,
ToInventoryEntries(station.Inventory),
station.FactionId,
station.CommanderId,
station.PolicySetId,
station.Population,
station.PopulationCapacity,
station.WorkforceRequired,
station.WorkforceEffectiveRatio,
station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(),
station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList());
private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new(
claim.Id,
claim.FactionId,
claim.SystemId,
claim.NodeId,
claim.BubbleId,
claim.State,
claim.Health,
claim.PlacedAtUtc,
claim.ActivatesAtUtc);
private static ConstructionSiteDelta ToConstructionSiteDelta(ConstructionSiteRuntime site) => new(
site.Id,
site.FactionId,
site.SystemId,
site.NodeId,
site.BubbleId,
site.TargetKind,
site.TargetDefinitionId,
site.BlueprintId,
site.ClaimId,
site.StationId,
site.State,
site.Progress,
ToInventoryEntries(site.Inventory),
ToInventoryEntries(site.RequiredItems),
ToInventoryEntries(site.DeliveredItems),
site.AssignedConstructorShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
site.MarketOrderIds.OrderBy(id => id, StringComparer.Ordinal).ToList());
private static MarketOrderDelta ToMarketOrderDelta(MarketOrderRuntime order) => new(
order.Id,
order.FactionId,
order.StationId,
order.ConstructionSiteId,
order.Kind,
order.ItemId,
order.Amount,
order.RemainingAmount,
order.Valuation,
order.ReserveThreshold,
order.PolicySetId,
order.State);
private static PolicySetDelta ToPolicySetDelta(PolicySetRuntime policy) => new(
policy.Id,
policy.OwnerKind,
policy.OwnerId,
policy.TradeAccessPolicy,
policy.DockingAccessPolicy,
policy.ConstructionAccessPolicy,
policy.OperationalRangePolicy);
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
ship.Id,
ship.Definition.Label,
ship.Definition.Role,
ship.Definition.ShipClass,
ship.SystemId,
ToDto(ship.Position),
ToDto(ship.Velocity),
ToDto(ship.TargetPosition),
ship.State,
ship.Order?.Kind,
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
ship.SpatialState.CurrentNodeId,
ship.SpatialState.CurrentBubbleId,
ship.DockedStationId,
ship.CommanderId,
ship.PolicySetId,
ship.Definition.CargoCapacity,
ship.WorkerPopulation,
ship.EnergyStored,
ToInventoryEntries(ship.Inventory),
ship.FactionId,
ship.Health,
ship.History.ToList(),
ToShipSpatialStateSnapshot(ship.SpatialState));
private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) =>
inventory
.Where(entry => entry.Value > 0.001f)
.OrderBy(entry => entry.Key, StringComparer.Ordinal)
.Select(entry => new InventoryEntry(entry.Key, entry.Value))
.ToList();
private static FactionDelta ToFactionDelta(FactionRuntime faction) => new(
faction.Id,
faction.Label,
faction.Color,
faction.Credits,
faction.PopulationTotal,
faction.OreMined,
faction.GoodsProduced,
faction.ShipsBuilt,
faction.ShipsLost,
faction.DefaultPolicySetId);
private static ShipSpatialStateSnapshot ToShipSpatialStateSnapshot(ShipSpatialStateRuntime state) => new(
state.SpaceLayer,
state.CurrentSystemId,
state.CurrentNodeId,
state.CurrentBubbleId,
state.LocalPosition is null ? null : ToDto(state.LocalPosition.Value),
state.SystemPosition is null ? null : ToDto(state.SystemPosition.Value),
state.MovementRegime,
state.DestinationNodeId,
state.Transit is null ? null : new ShipTransitSnapshot(
state.Transit.Regime,
state.Transit.OriginNodeId,
state.Transit.DestinationNodeId,
state.Transit.StartedAtUtc,
state.Transit.ArrivalDueAtUtc,
state.Transit.Progress));
private static void EmitShipStateEvents(
ShipRuntime ship,
string previousState,
string previousBehavior,
string previousTask,
string controllerEvent,
ICollection<SimulationEventRecord> events)
{
var occurredAtUtc = DateTimeOffset.UtcNow;
if (previousState != ship.State)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc));
}
if (previousBehavior != ship.DefaultBehavior.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "behavior-changed", $"{ship.Definition.Label} behavior {previousBehavior} -> {ship.DefaultBehavior.Kind}", occurredAtUtc));
}
if (previousTask != ship.ControllerTask.Kind)
{
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc));
}
if (controllerEvent != "none")
{
events.Add(new SimulationEventRecord("ship", ship.Id, controllerEvent, $"{ship.Definition.Label} {controllerEvent}", occurredAtUtc));
}
}
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
}

View File

@@ -0,0 +1,262 @@
using SpaceGame.Simulation.Api.Contracts;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private static void UpdateClaims(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var claim in world.Claims)
{
if (claim.State == ClaimStateKinds.Destroyed || claim.Health <= 0f)
{
if (claim.State != ClaimStateKinds.Destroyed)
{
claim.State = ClaimStateKinds.Destroyed;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-destroyed", $"Claim {claim.Id} was destroyed.", world.GeneratedAtUtc));
}
foreach (var site in world.ConstructionSites.Where(candidate => candidate.ClaimId == claim.Id))
{
site.State = ConstructionSiteStateKinds.Destroyed;
}
continue;
}
if (claim.State == ClaimStateKinds.Activating && world.GeneratedAtUtc >= claim.ActivatesAtUtc)
{
claim.State = ClaimStateKinds.Active;
events.Add(new SimulationEventRecord("claim", claim.Id, "claim-activated", $"Claim {claim.Id} is now active.", world.GeneratedAtUtc));
}
}
}
private static void UpdateConstructionSites(SimulationWorld world, ICollection<SimulationEventRecord> events)
{
foreach (var site in world.ConstructionSites)
{
if (site.State == ConstructionSiteStateKinds.Destroyed)
{
continue;
}
var claim = site.ClaimId is null
? null
: world.Claims.FirstOrDefault(candidate => candidate.Id == site.ClaimId);
if (claim?.State == ClaimStateKinds.Destroyed)
{
site.State = ConstructionSiteStateKinds.Destroyed;
continue;
}
if (claim?.State == ClaimStateKinds.Active && site.State == ConstructionSiteStateKinds.Planned)
{
site.State = ConstructionSiteStateKinds.Active;
events.Add(new SimulationEventRecord("construction-site", site.Id, "site-active", $"Construction site {site.Id} is active.", world.GeneratedAtUtc));
}
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is null || !site.RequiredItems.TryGetValue(order.ItemId, out var required))
{
continue;
}
var remaining = MathF.Max(0f, required - GetInventoryAmount(site.DeliveredItems, order.ItemId));
order.RemainingAmount = remaining;
order.State = remaining <= 0.01f
? MarketOrderStateKinds.Filled
: remaining < order.Amount
? MarketOrderStateKinds.PartiallyFilled
: MarketOrderStateKinds.Open;
}
}
}
private static bool TryEnsureModuleConstructionStarted(StationRuntime station, ModuleRecipeDefinition recipe, string shipId)
{
if (station.InstalledModules.Contains(recipe.ModuleId, StringComparer.Ordinal))
{
return true;
}
if (station.ActiveConstruction is not null)
{
return string.Equals(station.ActiveConstruction.ModuleId, recipe.ModuleId, StringComparison.Ordinal)
&& string.Equals(station.ActiveConstruction.AssignedConstructorShipId, shipId, StringComparison.Ordinal);
}
if (!CanStartModuleConstruction(station, recipe))
{
return false;
}
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
station.ActiveConstruction = new ModuleConstructionRuntime
{
ModuleId = recipe.ModuleId,
RequiredSeconds = recipe.Duration,
AssignedConstructorShipId = shipId,
};
return true;
}
private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
{
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
{
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
&& world.ModuleRecipes.ContainsKey(moduleId))
{
return moduleId;
}
}
return null;
}
private static void PrepareNextConstructionSiteStep(SimulationWorld world, StationRuntime station, ConstructionSiteRuntime site)
{
var nextModuleId = GetNextStationModuleToBuild(station, world);
foreach (var orderId in site.MarketOrderIds)
{
var order = world.MarketOrders.FirstOrDefault(candidate => candidate.Id == orderId);
if (order is not null)
{
order.State = MarketOrderStateKinds.Cancelled;
order.RemainingAmount = 0f;
}
}
site.MarketOrderIds.Clear();
site.Inventory.Clear();
site.DeliveredItems.Clear();
site.RequiredItems.Clear();
site.AssignedConstructorShipIds.Clear();
site.Progress = 0f;
if (nextModuleId is null || !world.ModuleRecipes.TryGetValue(nextModuleId, out var recipe))
{
site.State = ConstructionSiteStateKinds.Completed;
site.BlueprintId = null;
return;
}
site.BlueprintId = nextModuleId;
site.State = ConstructionSiteStateKinds.Active;
foreach (var input in recipe.Inputs)
{
site.RequiredItems[input.ItemId] = input.Amount;
site.DeliveredItems[input.ItemId] = 0f;
var orderId = $"market-order-{station.Id}-{nextModuleId}-{input.ItemId}";
site.MarketOrderIds.Add(orderId);
station.MarketOrderIds.Add(orderId);
world.MarketOrders.Add(new MarketOrderRuntime
{
Id = orderId,
FactionId = station.FactionId,
StationId = station.Id,
ConstructionSiteId = site.Id,
Kind = MarketOrderKinds.Buy,
ItemId = input.ItemId,
Amount = input.Amount,
RemainingAmount = input.Amount,
Valuation = 1f,
State = MarketOrderStateKinds.Open,
});
}
}
private static int GetDockingPadCount(StationRuntime station) =>
CountModules(station.InstalledModules, "dock-bay-small") * 2;
private static int? ReserveDockingPad(StationRuntime station, string shipId)
{
if (station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal)) is var existing
&& !string.IsNullOrEmpty(existing.Value))
{
return existing.Key;
}
var padCount = GetDockingPadCount(station);
for (var padIndex = 0; padIndex < padCount; padIndex += 1)
{
if (station.DockingPadAssignments.ContainsKey(padIndex))
{
continue;
}
station.DockingPadAssignments[padIndex] = shipId;
return padIndex;
}
return null;
}
private static void ReleaseDockingPad(StationRuntime station, string shipId)
{
var assignment = station.DockingPadAssignments.FirstOrDefault(entry => string.Equals(entry.Value, shipId, StringComparison.Ordinal));
if (!string.IsNullOrEmpty(assignment.Value))
{
station.DockingPadAssignments.Remove(assignment.Key);
}
}
private static Vector3 GetDockingPadPosition(StationRuntime station, int padIndex)
{
var padCount = Math.Max(1, GetDockingPadCount(station));
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
var radius = station.Definition.Radius + 14f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
private static Vector3 GetDockingHoldPosition(StationRuntime station, string shipId)
{
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
var angle = (hash % 360) * (MathF.PI / 180f);
var radius = station.Definition.Radius + 34f;
return new Vector3(
station.Position.X + (MathF.Cos(angle) * radius),
station.Position.Y,
station.Position.Z + (MathF.Sin(angle) * radius));
}
private static Vector3 GetUndockTargetPosition(StationRuntime station, int? padIndex, float distance)
{
if (padIndex is null)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var pad = GetDockingPadPosition(station, padIndex.Value);
var dx = pad.X - station.Position.X;
var dz = pad.Z - station.Position.Z;
var length = MathF.Sqrt((dx * dx) + (dz * dz));
if (length <= 0.001f)
{
return new Vector3(station.Position.X + distance, station.Position.Y, station.Position.Z);
}
var scale = distance / length;
return new Vector3(
pad.X + (dx * scale),
station.Position.Y,
pad.Z + (dz * scale));
}
private static Vector3 GetShipDockedPosition(ShipRuntime ship, StationRuntime station) =>
ship.AssignedDockingPadIndex is int padIndex
? GetDockingPadPosition(station, padIndex)
: station.Position;
}

View File

@@ -0,0 +1,519 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private static bool AdvanceTimedAction(ShipRuntime ship, float deltaSeconds, float requiredSeconds)
{
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < requiredSeconds)
{
return false;
}
ship.ActionTimer = 0f;
return true;
}
internal static float GetShipCargoAmount(ShipRuntime ship)
{
var cargoItemId = ship.Definition.CargoItemId;
return cargoItemId is null ? 0f : GetInventoryAmount(ship.Inventory, cargoItemId);
}
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var node = world.Nodes.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null || !CanExtractNode(ship, node))
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = task.TargetPosition.Value;
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
ship.ActionTimer = 0f;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "mining-approach";
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "mining";
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.MiningCycleSeconds))
{
return "none";
}
var cargoAmount = GetShipCargoAmount(ship);
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - cargoAmount);
mined = MathF.Min(mined, node.OreRemaining);
if (ship.Definition.CargoItemId is not null)
{
AddInventory(ship.Inventory, ship.Definition.CargoItemId, mined);
}
node.OreRemaining -= mined;
if (node.OreRemaining <= 0f)
{
node.OreRemaining = node.MaxOre;
}
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
}
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
if (station is null || task.TargetPosition is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var padIndex = ship.AssignedDockingPadIndex ?? ReserveDockingPad(station, ship.Id);
if (padIndex is null)
{
ship.ActionTimer = 0f;
ship.State = "awaiting-dock";
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.Position = ship.Position.MoveToward(ship.TargetPosition, ship.Definition.Speed * deltaSeconds);
}
return "none";
}
ship.AssignedDockingPadIndex = padIndex;
var padPosition = GetDockingPadPosition(station, padIndex.Value);
ship.TargetPosition = padPosition;
var distance = ship.Position.DistanceTo(padPosition);
if (distance > 4f)
{
ship.ActionTimer = 0f;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "docking-approach";
ship.Position = ship.Position.MoveToward(padPosition, ship.Definition.Speed * deltaSeconds);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "docking";
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
{
return "none";
}
ship.State = "docked";
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = padPosition;
ship.TargetPosition = padPosition;
return "docked";
}
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "transferring";
var cargoItemId = ship.Definition.CargoItemId;
var moved = cargoItemId is null ? 0f : MathF.Min(GetInventoryAmount(ship.Inventory, cargoItemId), world.Balance.TransferRate * deltaSeconds);
if (cargoItemId is not null)
{
var accepted = TryAddStationInventory(world, station, cargoItemId, moved);
RemoveInventory(ship.Inventory, cargoItemId, accepted);
moved = accepted;
}
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == ship.FactionId);
if (faction is not null && cargoItemId == "ore")
{
faction.OreMined += moved;
faction.Credits += moved * 0.4f;
}
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none";
}
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "refueling";
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, GetShipFuelCapacity(ship) - GetInventoryAmount(ship.Inventory, "fuel"));
var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel"));
if (moved > 0.01f)
{
RemoveInventory(station.Inventory, "fuel", moved);
AddInventory(ship.Inventory, "fuel", moved);
}
return !NeedsRefuel(ship) ? "refueled" : "none";
}
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || !world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
{
ship.AssignedDockingPadIndex = null;
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
{
ship.ActionTimer = 0f;
ship.State = "waiting-materials";
ship.TargetPosition = GetShipDockedPosition(ship, station);
return "none";
}
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
{
ship.State = "construction-blocked";
ship.TargetPosition = GetShipDockedPosition(ship, station);
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "constructing";
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
{
return "none";
}
station.InstalledModules.Add(station.ActiveConstruction.ModuleId);
station.ActiveConstruction = null;
return "module-constructed";
}
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "delivering-construction";
foreach (var required in site.RequiredItems)
{
var delivered = GetInventoryAmount(site.DeliveredItems, required.Key);
var remaining = MathF.Max(0f, required.Value - delivered);
if (remaining <= 0.01f)
{
continue;
}
var moved = MathF.Min(remaining, world.Balance.TransferRate * deltaSeconds);
moved = MathF.Min(moved, GetInventoryAmount(station.Inventory, required.Key));
if (moved <= 0.01f)
{
continue;
}
RemoveInventory(station.Inventory, required.Key, moved);
AddInventory(site.Inventory, required.Key, moved);
AddInventory(site.DeliveredItems, required.Key, moved);
return IsConstructionSiteReady(site) ? "construction-delivered" : "none";
}
return IsConstructionSiteReady(site) ? "construction-delivered" : "none";
}
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var site = world.ConstructionSites.FirstOrDefault(candidate => candidate.Id == ship.ControllerTask.TargetEntityId);
if (station is null || site is null || site.BlueprintId is null || site.State != ConstructionSiteStateKinds.Active)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
if (!IsConstructionSiteReady(site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
{
ship.State = "waiting-materials";
ship.TargetPosition = GetShipDockedPosition(ship, station);
return "none";
}
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.TargetPosition = GetShipDockedPosition(ship, station);
ship.Position = ship.TargetPosition;
ship.ActionTimer = 0f;
ship.State = "constructing";
site.AssignedConstructorShipIds.Add(ship.Id);
site.Progress += deltaSeconds;
if (site.Progress < recipe.Duration)
{
return "none";
}
station.InstalledModules.Add(site.BlueprintId);
PrepareNextConstructionSiteStep(world, station, site);
return "site-constructed";
}
private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{
ship.State = "blocked";
return "failed";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || station.Population <= 0.01f)
{
ship.State = "idle";
return "none";
}
var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
transfer = MathF.Min(transfer, 4f * deltaSeconds);
if (transfer <= 0.01f)
{
return "none";
}
station.Population = MathF.Max(0f, station.Population - transfer);
ship.WorkerPopulation += transfer;
ship.State = "loading";
return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none";
}
private string UpdateUnloadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
{
ship.State = "blocked";
return "failed";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
if (station is null || ship.WorkerPopulation <= 0.01f)
{
ship.State = "idle";
return "none";
}
var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population));
transfer = MathF.Min(transfer, 4f * deltaSeconds);
if (transfer <= 0.01f)
{
return "none";
}
ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer);
station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer);
ship.State = "unloading";
return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none";
}
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null)
{
ship.State = "idle";
ship.TargetPosition = ship.Position;
return "none";
}
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
var undockTarget = station is null
? task.TargetPosition.Value
: GetUndockTargetPosition(station, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
ship.TargetPosition = undockTarget;
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
{
ship.State = "power-starved";
ship.TargetPosition = ship.Position;
return "none";
}
ship.State = "undocking";
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
{
if (station is not null)
{
ship.Position = GetShipDockedPosition(ship, station);
}
return "none";
}
ship.Position = ship.Position.MoveToward(undockTarget, world.Balance.UndockDistance);
if (ship.Position.DistanceTo(undockTarget) > task.Threshold)
{
return "none";
}
if (station is not null)
{
station.DockedShipIds.Remove(ship.Id);
ReleaseDockingPad(station, ship.Id);
}
ship.DockedStationId = null;
ship.AssignedDockingPadIndex = null;
return "undocked";
}
}

View File

@@ -0,0 +1,483 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private static CommanderRuntime? GetShipCommander(SimulationWorld world, ShipRuntime ship) =>
ship.CommanderId is null
? null
: world.Commanders.FirstOrDefault(candidate => candidate.Id == ship.CommanderId && candidate.Kind == CommanderKind.Ship);
private static void SyncCommanderToShip(ShipRuntime ship, CommanderRuntime commander)
{
if (commander.ActiveBehavior is not null)
{
ship.DefaultBehavior.Kind = commander.ActiveBehavior.Kind;
ship.DefaultBehavior.AreaSystemId = commander.ActiveBehavior.AreaSystemId;
ship.DefaultBehavior.ModuleId = commander.ActiveBehavior.ModuleId;
ship.DefaultBehavior.NodeId = commander.ActiveBehavior.NodeId;
ship.DefaultBehavior.Phase = commander.ActiveBehavior.Phase;
ship.DefaultBehavior.PatrolIndex = commander.ActiveBehavior.PatrolIndex;
ship.DefaultBehavior.StationId = commander.ActiveBehavior.StationId;
}
if (commander.ActiveOrder is null)
{
ship.Order = null;
}
else
{
ship.Order = new ShipOrderRuntime
{
Kind = commander.ActiveOrder.Kind,
Status = commander.ActiveOrder.Status,
DestinationSystemId = commander.ActiveOrder.DestinationSystemId,
DestinationPosition = commander.ActiveOrder.DestinationPosition,
};
}
if (commander.ActiveTask is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = commander.ActiveTask.Kind,
Status = commander.ActiveTask.Status,
CommanderId = commander.Id,
TargetEntityId = commander.ActiveTask.TargetEntityId,
TargetNodeId = commander.ActiveTask.TargetNodeId,
TargetPosition = commander.ActiveTask.TargetPosition,
TargetSystemId = commander.ActiveTask.TargetSystemId,
Threshold = commander.ActiveTask.Threshold,
};
}
}
private static void SyncShipToCommander(ShipRuntime ship, CommanderRuntime commander)
{
commander.ActiveBehavior ??= new CommanderBehaviorRuntime { Kind = ship.DefaultBehavior.Kind };
commander.ActiveBehavior.Kind = ship.DefaultBehavior.Kind;
commander.ActiveBehavior.AreaSystemId = ship.DefaultBehavior.AreaSystemId;
commander.ActiveBehavior.ModuleId = ship.DefaultBehavior.ModuleId;
commander.ActiveBehavior.NodeId = ship.DefaultBehavior.NodeId;
commander.ActiveBehavior.Phase = ship.DefaultBehavior.Phase;
commander.ActiveBehavior.PatrolIndex = ship.DefaultBehavior.PatrolIndex;
commander.ActiveBehavior.StationId = ship.DefaultBehavior.StationId;
if (ship.Order is null)
{
commander.ActiveOrder = null;
}
else
{
commander.ActiveOrder ??= new CommanderOrderRuntime
{
Kind = ship.Order.Kind,
DestinationSystemId = ship.Order.DestinationSystemId,
DestinationPosition = ship.Order.DestinationPosition,
};
commander.ActiveOrder.Status = ship.Order.Status;
commander.ActiveOrder.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveOrder.DestinationNodeId = ship.ControllerTask.TargetNodeId ?? ship.SpatialState.DestinationNodeId;
}
commander.ActiveTask ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind };
commander.ActiveTask.Kind = ship.ControllerTask.Kind;
commander.ActiveTask.Status = ship.ControllerTask.Status;
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
commander.ActiveTask.TargetPosition = ship.ControllerTask.TargetPosition;
commander.ActiveTask.TargetSystemId = ship.ControllerTask.TargetSystemId;
commander.ActiveTask.Threshold = ship.ControllerTask.Threshold;
}
private void RefreshControlLayers(ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (commander is not null)
{
SyncCommanderToShip(ship, commander);
}
if (ship.Order is not null && ship.Order.Status == OrderStatus.Queued)
{
ship.Order.Status = OrderStatus.Accepted;
if (commander?.ActiveOrder is not null)
{
commander.ActiveOrder.Status = ship.Order.Status;
}
}
if (commander is not null)
{
SyncShipToCommander(ship, commander);
}
}
private void PlanControllerTask(ShipRuntime ship, SimulationWorld world)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
Status = WorkStatus.Active,
CommanderId = commander?.Id,
TargetSystemId = ship.Order.DestinationSystemId,
TargetNodeId = ship.SpatialState.DestinationNodeId,
TargetPosition = ship.Order.DestinationPosition,
Threshold = world.Balance.ArrivalThreshold,
};
SyncCommanderTask(commander, ship.ControllerTask);
return;
}
_shipBehaviorStateMachine.Plan(this, ship, world);
SyncCommanderTask(commander, ship.ControllerTask);
}
internal void PlanResourceHarvest(ShipRuntime ship, SimulationWorld world, string resourceItemId, string requiredModule)
{
var behavior = ship.DefaultBehavior;
var refinery = SelectBestBuyStation(world, ship, resourceItemId, behavior.StationId);
behavior.StationId = refinery?.Id;
var node = behavior.NodeId is null
? world.Nodes
.Where(candidate => (behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) && candidate.ItemId == resourceItemId)
.OrderByDescending(candidate => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId);
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
behavior.NodeId ??= node.Id;
if (ship.DockedStationId == refinery.Id)
{
if (GetShipCargoAmount(ship) > 0.01f)
{
behavior.Phase = "unload";
}
else if (NeedsRefuel(ship))
{
behavior.Phase = "refuel";
}
else if (behavior.Phase is "dock" or "unload" or "refuel")
{
behavior.Phase = "undock";
}
}
else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "extract":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "extract",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 14f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Definition.Radius + 8f,
};
break;
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "dock",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Definition.Radius + 4f,
};
break;
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "unload",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "refuel":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "refuel",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "undock",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
Threshold = 8f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 18f,
};
behavior.Phase = "travel-to-node";
break;
}
}
internal static StationRuntime? SelectBestBuyStation(SimulationWorld world, ShipRuntime ship, string itemId, string? preferredStationId)
{
var preferred = preferredStationId is null
? null
: world.Stations.FirstOrDefault(station => station.Id == preferredStationId);
var bestOrder = world.MarketOrders
.Where(order =>
order.Kind == MarketOrderKinds.Buy &&
order.ConstructionSiteId is null &&
order.State != MarketOrderStateKinds.Cancelled &&
order.ItemId == itemId &&
order.RemainingAmount > 0.01f)
.Select(order => (Order: order, Station: world.Stations.FirstOrDefault(station => station.Id == order.StationId)))
.Where(entry => entry.Station is not null)
.OrderByDescending(entry =>
{
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
return entry.Order.Valuation - distancePenalty;
})
.FirstOrDefault();
return bestOrder.Station ?? preferred;
}
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == behavior.StationId);
var site = station is null ? null : GetConstructionSiteForStation(world, station.Id);
if (station is null)
{
behavior.Kind = "idle";
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
behavior.ModuleId = moduleId;
if (moduleId is null)
{
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
return;
}
if (ship.DockedStationId == station.Id)
{
if (NeedsRefuel(ship))
{
behavior.Phase = "refuel";
}
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(site))
{
behavior.Phase = "deliver-to-site";
}
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(site))
{
behavior.Phase = "build-site";
}
else if (site is not null)
{
behavior.Phase = "wait-for-materials";
}
else if (CanStartModuleConstruction(station, world.ModuleRecipes[moduleId]))
{
behavior.Phase = "construct-module";
}
else
{
behavior.Phase = "wait-for-materials";
}
}
else if (behavior.Phase is not "travel-to-station" and not "dock")
{
behavior.Phase = "travel-to-station";
}
switch (behavior.Phase)
{
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "dock",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = station.Definition.Radius + 4f,
};
break;
case "refuel":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "refuel",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
};
break;
case "construct-module":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "construct-module",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
};
break;
case "deliver-to-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "deliver-construction",
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
};
break;
case "build-site":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "build-construction-site",
TargetEntityId = site?.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
};
break;
case "wait-for-materials":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = 0f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = station.Id,
TargetSystemId = station.SystemId,
TargetPosition = station.Position,
Threshold = station.Definition.Radius + 8f,
};
behavior.Phase = "travel-to-station";
break;
}
}
private void AdvanceControlState(ShipRuntime ship, SimulationWorld world, string controllerEvent)
{
var commander = GetShipCommander(world, ship);
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = "idle";
if (commander is not null)
{
commander.ActiveOrder = null;
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = ShipTaskKinds.Idle,
Status = WorkStatus.Completed,
TargetSystemId = ship.SystemId,
Threshold = 0f,
};
}
return;
}
_shipBehaviorStateMachine.ApplyEvent(this, ship, world, controllerEvent);
if (commander is not null)
{
SyncShipToCommander(ship, commander);
if (commander.ActiveTask is not null)
{
commander.ActiveTask.Status = controllerEvent == "none" ? WorkStatus.Active : WorkStatus.Completed;
}
}
}
private static void TrackHistory(ShipRuntime ship)
{
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}";
if (signature == ship.LastSignature)
{
return;
}
ship.LastSignature = signature;
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={GetShipCargoAmount(ship):0.#}");
if (ship.History.Count > 18)
{
ship.History.RemoveAt(0);
}
}
private static ControllerTaskRuntime CreateIdleTask(float threshold) =>
new()
{
Kind = "idle",
Threshold = threshold,
};
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
{
if (commander is null)
{
return;
}
commander.ActiveTask = new CommanderTaskRuntime
{
Kind = task.Kind,
Status = task.Status,
TargetEntityId = task.TargetEntityId,
TargetNodeId = task.TargetNodeId,
TargetPosition = task.TargetPosition,
TargetSystemId = task.TargetSystemId,
Threshold = task.Threshold,
};
}
}

View File

@@ -0,0 +1,263 @@
using SpaceGame.Simulation.Api.Data;
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed partial class SimulationEngine
{
private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
foreach (var station in world.Stations)
{
UpdateStationPopulation(station, deltaSeconds, events);
ReviewStationMarketOrders(world, station);
RunStationProduction(world, station, deltaSeconds, events);
factionPopulation[station.FactionId] = GetInventoryAmount(factionPopulation, station.FactionId) + station.Population;
}
foreach (var faction in world.Factions)
{
faction.PopulationTotal = GetInventoryAmount(factionPopulation, faction.Id);
}
}
private void UpdateStationPopulation(StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
station.WorkforceRequired = MathF.Max(12f, station.InstalledModules.Count * 14f);
var requiredWater = station.Population * WaterConsumptionPerWorkerPerSecond * deltaSeconds;
var consumedWater = RemoveInventory(station.Inventory, "water", requiredWater);
var waterSatisfied = requiredWater <= 0.01f || consumedWater + 0.001f >= requiredWater;
var hasPower = station.EnergyStored > 0.01f;
var habitatModules = CountModules(station.InstalledModules, "habitat-ring");
station.PopulationCapacity = 40f + (habitatModules * 220f);
if (waterSatisfied && hasPower)
{
if (habitatModules > 0 && station.Population < station.PopulationCapacity)
{
station.Population = MathF.Min(station.PopulationCapacity, station.Population + (PopulationGrowthPerSecond * deltaSeconds));
}
}
else if (station.Population > 0f)
{
var previous = station.Population;
station.Population = MathF.Max(0f, station.Population - (PopulationAttritionPerSecond * deltaSeconds));
if (MathF.Floor(previous) > MathF.Floor(station.Population))
{
events.Add(new SimulationEventRecord("station", station.Id, "population-loss", $"{station.Definition.Label} lost population due to support shortages.", DateTimeOffset.UtcNow));
}
}
station.WorkforceEffectiveRatio = ComputeWorkforceRatio(station.Population, station.WorkforceRequired);
}
private void ReviewStationMarketOrders(SimulationWorld world, StationRuntime station)
{
if (station.CommanderId is null)
{
return;
}
var desiredOrders = new List<DesiredMarketOrder>();
var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f);
var waterReserve = MathF.Max(30f, station.Population * 3f);
var refinedReserve = HasStationModules(station, "fabricator-array") ? 140f : 40f;
var oreReserve = HasRefineryCapability(station) ? 180f : 0f;
var gasReserve = CanProcessFuel(station) ? 120f : 0f;
AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f);
AddDemandOrder(desiredOrders, station, "water", waterReserve, valuationBase: 1.1f);
AddDemandOrder(desiredOrders, station, "ore", oreReserve, valuationBase: 1.0f);
AddDemandOrder(desiredOrders, station, "gas", gasReserve, valuationBase: 0.95f);
AddDemandOrder(desiredOrders, station, "refined-metals", refinedReserve, valuationBase: 1.15f);
AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f);
AddSupplyOrder(desiredOrders, station, "water", waterReserve * 1.5f, reserveFloor: waterReserve, valuationBase: 0.65f);
AddSupplyOrder(desiredOrders, station, "ore", oreReserve * 1.4f, reserveFloor: oreReserve, valuationBase: 0.7f);
AddSupplyOrder(desiredOrders, station, "gas", gasReserve * 1.4f, reserveFloor: gasReserve, valuationBase: 0.72f);
AddSupplyOrder(desiredOrders, station, "refined-metals", refinedReserve * 1.4f, reserveFloor: refinedReserve, valuationBase: 0.95f);
ReconcileStationMarketOrders(world, station, desiredOrders);
}
private void RunStationProduction(SimulationWorld world, StationRuntime station, float deltaSeconds, ICollection<SimulationEventRecord> events)
{
var recipe = SelectProductionRecipe(world, station);
if (recipe is null || station.EnergyStored <= 0.01f)
{
station.ProcessTimer = 0f;
return;
}
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
{
station.ProcessTimer = 0f;
return;
}
station.ProcessTimer += deltaSeconds * station.WorkforceEffectiveRatio;
if (station.ProcessTimer < recipe.Duration)
{
return;
}
station.ProcessTimer = 0f;
foreach (var input in recipe.Inputs)
{
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
}
var produced = 0f;
foreach (var output in recipe.Outputs)
{
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
}
if (produced <= 0.01f)
{
return;
}
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
var faction = world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId);
if (faction is not null)
{
faction.GoodsProduced += produced;
}
}
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station) =>
world.Recipes.Values
.Where(recipe => RecipeAppliesToStation(station, recipe))
.OrderByDescending(recipe => recipe.Priority)
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
{
var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal)
|| (string.Equals(recipe.FacilityCategory, "station", StringComparison.Ordinal)
&& station.Definition.Category is "station" or "shipyard" or "defense" or "gate");
return categoryMatch && recipe.RequiredModules.All(moduleId => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
}
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
{
if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount))
{
return false;
}
return recipe.Outputs.All(output => CanAcceptStationInventory(world, station, output.ItemId, output.Amount));
}
private static bool CanAcceptStationInventory(SimulationWorld world, StationRuntime station, string itemId, float amount)
{
if (amount <= 0f || !world.ItemDefinitions.TryGetValue(itemId, out var itemDefinition))
{
return false;
}
var requiredModule = GetStorageRequirement(itemDefinition.Storage);
if (requiredModule is not null && !station.InstalledModules.Contains(requiredModule, StringComparer.Ordinal))
{
return false;
}
if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity))
{
return false;
}
var used = station.Inventory
.Where(entry => world.ItemDefinitions.TryGetValue(entry.Key, out var definition) && definition.Storage == itemDefinition.Storage)
.Sum(entry => entry.Value);
return used + amount <= capacity + 0.001f;
}
private static void AddDemandOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float targetAmount, float valuationBase)
{
var current = GetInventoryAmount(station.Inventory, itemId);
if (current >= targetAmount - 0.01f)
{
return;
}
var deficit = targetAmount - current;
var scarcity = targetAmount <= 0.01f ? 1f : MathF.Min(1f, deficit / targetAmount);
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Buy, itemId, deficit, valuationBase + scarcity, null));
}
private static void AddSupplyOrder(ICollection<DesiredMarketOrder> desiredOrders, StationRuntime station, string itemId, float triggerAmount, float reserveFloor, float valuationBase)
{
var current = GetInventoryAmount(station.Inventory, itemId);
if (current <= triggerAmount + 0.01f)
{
return;
}
var surplus = current - reserveFloor;
if (surplus <= 0.01f)
{
return;
}
desiredOrders.Add(new DesiredMarketOrder(MarketOrderKinds.Sell, itemId, surplus, valuationBase, reserveFloor));
}
private static void ReconcileStationMarketOrders(SimulationWorld world, StationRuntime station, IReadOnlyCollection<DesiredMarketOrder> desiredOrders)
{
var existingOrders = world.MarketOrders
.Where(order => order.StationId == station.Id && order.ConstructionSiteId is null)
.ToList();
foreach (var desired in desiredOrders)
{
var order = existingOrders.FirstOrDefault(candidate =>
candidate.Kind == desired.Kind &&
candidate.ItemId == desired.ItemId &&
candidate.ConstructionSiteId is null);
if (order is null)
{
order = new MarketOrderRuntime
{
Id = $"market-order-{station.Id}-{desired.Kind}-{desired.ItemId}",
FactionId = station.FactionId,
StationId = station.Id,
Kind = desired.Kind,
ItemId = desired.ItemId,
Amount = desired.Amount,
RemainingAmount = desired.Amount,
Valuation = desired.Valuation,
ReserveThreshold = desired.ReserveThreshold,
State = MarketOrderStateKinds.Open,
};
world.MarketOrders.Add(order);
station.MarketOrderIds.Add(order.Id);
existingOrders.Add(order);
continue;
}
order.RemainingAmount = desired.Amount;
order.Valuation = desired.Valuation;
order.ReserveThreshold = desired.ReserveThreshold;
order.State = desired.Amount <= 0.01f ? MarketOrderStateKinds.Cancelled : MarketOrderStateKinds.Open;
}
foreach (var order in existingOrders.Where(order => desiredOrders.All(desired => desired.Kind != order.Kind || desired.ItemId != order.ItemId)))
{
order.RemainingAmount = 0f;
order.State = MarketOrderStateKinds.Cancelled;
}
}
private static bool HasRefineryCapability(StationRuntime station) =>
HasStationModules(station, "refinery-stack", "power-core", "bulk-bay");
private static bool CanProcessFuel(StationRuntime station) =>
HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank");
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,362 @@
import * as THREE from "three";
import {
MAX_CAMERA_DISTANCE,
MIN_CAMERA_DISTANCE,
ZOOM_DISTANCE,
} from "./viewerConstants";
import { createViewerHud } from "./viewerHud";
import {
classifyZoomLevel,
computeZoomBlend,
formatBytes,
inventoryAmount,
smoothBand,
} from "./viewerMath";
import { updatePanFromKeyboard } from "./viewerCamera";
import {
createCirclePoints,
shipLength,
shipPresentationColor,
shipSize,
spatialNodeColor,
} from "./viewerSceneAppearance";
import {
createBackdropStars,
createNebulaClouds,
createNebulaTexture,
} from "./viewerSceneFactory";
import {
setShellReticleOpacity,
} from "./viewerControls";
import {
recordPerformanceStats,
updateNetworkPanel as renderNetworkPanel,
updatePerformancePanel as renderPerformancePanel,
} from "./viewerTelemetry";
import { renderFrame, resizeViewer, stepCamera } from "./viewerRenderLoop";
import { updatePlanetPresentation } from "./viewerPresentation";
import {
renderRecentEvents,
updateGameStatus,
updateSystemSummaries,
updateWorldPresentation,
} from "./viewerWorldPresentation";
import {
resolveFocusedBubbleId,
} from "./viewerSelection";
import { describeSelectionParent, updateSystemPanel } from "./viewerPanels";
import {
createInitialNetworkStats,
createInitialPerformanceStats,
} from "./viewerState";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerInteractionController } from "./viewerInteractionController";
import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
import type { FactionSnapshot, ShipSnapshot } from "./contracts";
import type {
BubbleVisual,
CameraMode,
ClaimVisual,
ConstructionSiteVisual,
DragMode,
HistoryWindowState,
MoonVisual,
NetworkStats,
NodeVisual,
OrbitalAnchor,
PerformanceStats,
PlanetVisual,
PresentationEntry,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
WorldState,
ZoomLevel,
} from "./viewerTypes";
export class ViewerAppController {
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, 160000);
private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster();
private readonly mouse = new THREE.Vector2();
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<string>();
private readonly systemGroup = new THREE.Group();
private readonly spatialNodeGroup = new THREE.Group();
private readonly bubbleGroup = new THREE.Group();
private readonly nodeGroup = new THREE.Group();
private readonly stationGroup = new THREE.Group();
private readonly claimGroup = new THREE.Group();
private readonly constructionSiteGroup = new THREE.Group();
private readonly shipGroup = new THREE.Group();
private readonly ambienceGroup = new THREE.Group();
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly presentationEntries: PresentationEntry[] = [];
private readonly nodeVisuals = new Map<string, NodeVisual>();
private readonly spatialNodeVisuals = new Map<string, SpatialNodeVisual>();
private readonly bubbleVisuals = new Map<string, BubbleVisual>();
private readonly stationVisuals = new Map<string, StructureVisual>();
private readonly claimVisuals = new Map<string, ClaimVisual>();
private readonly constructionSiteVisuals = new Map<string, ConstructionSiteVisual>();
private readonly shipVisuals = new Map<string, ShipVisual>();
private readonly systemVisuals = new Map<string, SystemVisual>();
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
private readonly planetVisuals: PlanetVisual[] = [];
private readonly orbitLines: THREE.Object3D[] = [];
private readonly statusEl: HTMLDivElement;
private readonly systemPanelEl: HTMLDivElement;
private readonly systemTitleEl: HTMLHeadingElement;
private readonly systemBodyEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement;
private readonly networkPanelEl: HTMLDivElement;
private readonly performancePanelEl: HTMLDivElement;
private readonly errorEl: HTMLDivElement;
private readonly historyLayerEl: HTMLDivElement;
private readonly marqueeEl: HTMLDivElement;
private readonly hoverLabelEl: HTMLDivElement;
private world?: WorldState;
private worldTimeSyncMs = performance.now();
private stream?: EventSource;
private currentStreamScopeKey = "";
private readonly networkStats: NetworkStats = createInitialNetworkStats();
private readonly performanceStats: PerformanceStats = createInitialPerformanceStats();
private selectedItems: Selectable[] = [];
private worldSignature = "";
private zoomLevel: ZoomLevel = "system";
private currentDistance = ZOOM_DISTANCE.system;
private desiredDistance = ZOOM_DISTANCE.system;
private orbitYaw = -2.3;
private orbitPitch = 0.62;
private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode;
private dragPointerId?: number;
private dragStart = new THREE.Vector2();
private dragLast = new THREE.Vector2();
private marqueeActive = false;
private suppressClickSelection = false;
private activeSystemId?: string;
private cameraTargetShipId?: string;
private readonly followCameraPosition = new THREE.Vector3();
private readonly followCameraFocus = new THREE.Vector3();
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraOffset = new THREE.Vector3();
private readonly historyWindows: HistoryWindowState[] = [];
private historyWindowCounter = 0;
private historyWindowZCounter = 10;
private historyWindowDragId?: string;
private historyWindowDragPointerId?: number;
private historyWindowDragOffset = new THREE.Vector2();
private readonly worldLifecycle: ViewerWorldLifecycle;
private readonly interactionController: ViewerInteractionController;
private readonly navigationController: ViewerNavigationController;
private readonly sceneDataController: ViewerSceneDataController;
private readonly presentationController: ViewerPresentationController;
constructor(container: HTMLElement) {
this.container = container;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.scene.background = new THREE.Color(0x040912);
this.scene.fog = new THREE.FogExp2(0x040912, 0.00011);
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
keyLight.position.set(1000, 1200, 800);
this.scene.add(keyLight);
const hud = createViewerHud(document);
this.statusEl = hud.statusEl;
this.systemPanelEl = hud.systemPanelEl;
this.systemTitleEl = hud.systemTitleEl;
this.systemBodyEl = hud.systemBodyEl;
this.detailTitleEl = hud.detailTitleEl;
this.detailBodyEl = hud.detailBodyEl;
this.factionStripEl = hud.factionStripEl;
this.networkPanelEl = hud.networkPanelEl;
this.performancePanelEl = hud.performancePanelEl;
this.errorEl = hud.errorEl;
this.historyLayerEl = hud.historyLayerEl;
this.marqueeEl = hud.marqueeEl;
this.hoverLabelEl = hud.hoverLabelEl;
({
sceneDataController: this.sceneDataController,
navigationController: this.navigationController,
presentationController: this.presentationController,
worldLifecycle: this.worldLifecycle,
interactionController: this.interactionController,
} = createViewerControllers(this));
this.container.append(this.renderer.domElement, hud.root);
wireViewerEvents(this);
this.onResize();
this.updateCamera(0);
}
async start() {
await this.worldLifecycle.bootstrapWorld();
this.renderer.setAnimationLoop(() => this.render());
}
private refreshStreamScopeIfNeeded() {
this.worldLifecycle.refreshStreamScopeIfNeeded();
}
private createWorldPresentationContext() {
return this.sceneDataController.createWorldPresentationContext({
world: this.world,
activeSystemId: this.activeSystemId,
orbitYaw: this.orbitYaw,
camera: this.camera,
systemFocusLocal: this.systemFocusLocal,
toDisplayLocalPosition: this.toDisplayLocalPosition.bind(this),
updateSystemDetailVisibility: () => this.navigationController.updateSystemDetailVisibility(),
setShellReticleOpacity: (sprite, opacity) => this.setShellReticleOpacity(sprite, opacity),
});
}
private rebuildFactions(_factions: FactionSnapshot[]) {
this.worldLifecycle.rebuildFactions(_factions);
}
private updatePanels() {
this.worldLifecycle.updatePanels();
}
private render() {
renderFrame({
clock: this.clock,
renderer: this.renderer,
scene: this.scene,
camera: this.camera,
updateCamera: (delta) => this.updateCamera(delta),
updateAmbience: (delta) => this.presentationController.updateAmbience(delta),
updatePlanetPresentation: () => this.presentationController.updatePlanetPresentation(),
updateShipPresentation: () => this.presentationController.updateShipPresentation(),
updateNetworkPanel: () => this.presentationController.updateNetworkPanel(),
applyZoomPresentation: () => this.presentationController.applyZoomPresentation(),
recordPerformanceStats: (frameMs) => this.presentationController.recordPerformanceStats(frameMs),
updatePerformancePanel: () => this.presentationController.updatePerformancePanel(),
});
}
private updateAmbience(delta: number) {
this.ambienceGroup.position.copy(this.camera.position);
this.ambienceGroup.rotation.y += delta * 0.005;
this.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
private updateCamera(delta: number) {
const nextState = stepCamera({
currentDistance: this.currentDistance,
desiredDistance: this.desiredDistance,
orbitPitch: this.orbitPitch,
delta,
});
this.currentDistance = nextState.currentDistance;
this.zoomLevel = nextState.zoomLevel;
this.orbitPitch = nextState.orbitPitch;
this.navigationController.updateActiveSystem();
if (this.cameraMode === "follow" && this.navigationController.updateFollowCamera(delta)) {
return;
}
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
const horizontalDistance = this.currentDistance * Math.cos(this.orbitPitch);
const focus = this.navigationController.getCameraFocusWorldPosition();
this.cameraOffset.set(
Math.cos(this.orbitYaw) * horizontalDistance,
this.currentDistance * Math.sin(this.orbitPitch),
Math.sin(this.orbitYaw) * horizontalDistance,
);
this.camera.position.copy(focus).add(this.cameraOffset);
this.camera.lookAt(focus);
}
private updatePanFromKeyboard(delta: number) {
updatePanFromKeyboard(
this.keyState,
this.orbitYaw,
this.currentDistance,
this.activeSystemId,
this.systemFocusLocal,
this.galaxyFocus,
delta,
MIN_CAMERA_DISTANCE,
MAX_CAMERA_DISTANCE,
);
}
private updateSystemSummaries() {
this.presentationController.updateSystemSummaries();
}
private registerPresentation(
detail: THREE.Object3D,
icon: THREE.Sprite,
hideDetailInUniverse: boolean,
hideIconInUniverse = false,
systemId?: string,
) {
this.presentationEntries.push({ detail, icon, systemId, hideDetailInUniverse, hideIconInUniverse });
}
private renderRecentEvents(entityKind: string, entityId: string) {
return this.presentationController.renderRecentEvents(entityKind, entityId);
}
private updateGamePanel(mode: string) {
this.presentationController.updateGamePanel(mode);
}
private screenPointFromClient(clientX: number, clientY: number) {
return this.presentationController.screenPointFromClient(clientX, clientY);
}
private refreshHistoryWindows() {
this.interactionController.refreshHistoryWindows();
}
private resolveFocusedBubbleId() {
return resolveFocusedBubbleId(this.world, this.selectedItems);
}
private onResize = () => {
resizeViewer({
renderer: this.renderer,
camera: this.camera,
});
};
private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) {
setShellReticleOpacity(sprite, opacity);
}
private describeSelectionParent(selection: Selectable) {
return describeSelectionParent(this.world, selection, this.stationVisuals, this.nodeVisuals);
}
private toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {
return this.navigationController.toDisplayLocalPosition(localPosition, systemId);
}
private updateSystemPanel() {
this.presentationController.updateSystemPanel();
}
}

View File

@@ -1,290 +1,38 @@
export interface WorldSnapshot {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: SystemSnapshot[];
spatialNodes: SpatialNodeSnapshot[];
localBubbles: LocalBubbleSnapshot[];
nodes: ResourceNodeSnapshot[];
stations: StationSnapshot[];
claims: ClaimSnapshot[];
constructionSites: ConstructionSiteSnapshot[];
marketOrders: MarketOrderSnapshot[];
policies: PolicySetSnapshot[];
ships: ShipSnapshot[];
factions: FactionSnapshot[];
}
export interface WorldDelta {
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[];
spatialNodes: SpatialNodeDelta[];
localBubbles: LocalBubbleDelta[];
nodes: ResourceNodeDelta[];
stations: StationDelta[];
claims: ClaimDelta[];
constructionSites: ConstructionSiteDelta[];
marketOrders: MarketOrderDelta[];
policies: PolicySetDelta[];
ships: ShipDelta[];
factions: FactionDelta[];
scope?: ObserverScope | null;
}
export interface SimulationEventRecord {
entityKind: string;
entityId: string;
kind: string;
message: string;
occurredAtUtc: string;
family?: string;
scopeKind?: string;
scopeEntityId?: string | null;
visibility?: string;
}
export interface ObserverScope {
scopeKind: string;
systemId?: string | null;
bubbleId?: string | null;
}
export interface Vector3Dto {
x: number;
y: number;
z: number;
}
export interface SystemSnapshot {
id: string;
label: string;
galaxyPosition: Vector3Dto;
starKind: string;
starCount: number;
starColor: string;
starSize: number;
planets: PlanetSnapshot[];
}
export interface PlanetSnapshot {
label: string;
planetType: string;
shape: string;
moonCount: number;
orbitRadius: number;
orbitSpeed: number;
orbitEccentricity: number;
orbitInclination: number;
orbitLongitudeOfAscendingNode: number;
orbitArgumentOfPeriapsis: number;
orbitPhaseAtEpoch: number;
size: number;
color: string;
hasRing: boolean;
}
export interface ResourceNodeSnapshot {
id: string;
systemId: string;
localPosition: Vector3Dto;
sourceKind: string;
oreRemaining: number;
maxOre: number;
itemId: string;
}
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface SpatialNodeSnapshot {
id: string;
systemId: string;
kind: string;
localPosition: Vector3Dto;
bubbleId: string;
parentNodeId?: string | null;
occupyingStructureId?: string | null;
orbitReferenceId?: string | null;
}
export interface SpatialNodeDelta extends SpatialNodeSnapshot {}
export interface LocalBubbleSnapshot {
id: string;
nodeId: string;
systemId: string;
radius: number;
occupantShipIds: string[];
occupantStationIds: string[];
occupantClaimIds: string[];
occupantConstructionSiteIds: string[];
}
export interface LocalBubbleDelta extends LocalBubbleSnapshot {}
export interface InventoryEntry {
itemId: string;
amount: number;
}
export interface StationSnapshot {
id: string;
label: string;
category: string;
systemId: string;
localPosition: Vector3Dto;
nodeId?: string | null;
bubbleId?: string | null;
anchorNodeId?: string | null;
color: string;
dockedShips: number;
dockingPads: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
commanderId?: string | null;
policySetId?: string | null;
population: number;
populationCapacity: number;
workforceRequired: number;
workforceEffectiveRatio: number;
installedModules: string[];
marketOrderIds: string[];
}
export interface StationDelta extends StationSnapshot {}
export interface ClaimSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
state: string;
health: number;
placedAtUtc: string;
activatesAtUtc: string;
}
export interface ClaimDelta extends ClaimSnapshot {}
export interface ConstructionSiteSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
targetKind: string;
targetDefinitionId: string;
blueprintId?: string | null;
claimId?: string | null;
stationId?: string | null;
state: string;
progress: number;
inventory: InventoryEntry[];
requiredItems: InventoryEntry[];
deliveredItems: InventoryEntry[];
assignedConstructorShipIds: string[];
marketOrderIds: string[];
}
export interface ConstructionSiteDelta extends ConstructionSiteSnapshot {}
export interface MarketOrderSnapshot {
id: string;
factionId: string;
stationId?: string | null;
constructionSiteId?: string | null;
kind: string;
itemId: string;
amount: number;
remainingAmount: number;
valuation: number;
reserveThreshold?: number | null;
policySetId?: string | null;
state: string;
}
export interface MarketOrderDelta extends MarketOrderSnapshot {}
export interface PolicySetSnapshot {
id: string;
ownerKind: string;
ownerId: string;
tradeAccessPolicy: string;
dockingAccessPolicy: string;
constructionAccessPolicy: string;
operationalRangePolicy: string;
}
export interface PolicySetDelta extends PolicySetSnapshot {}
export interface ShipSnapshot {
id: string;
label: string;
role: string;
shipClass: string;
systemId: string;
localPosition: Vector3Dto;
localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto;
state: string;
orderKind: string | null;
defaultBehaviorKind: string;
controllerTaskKind: string;
nodeId?: string | null;
bubbleId?: string | null;
dockedStationId?: string | null;
commanderId?: string | null;
policySetId?: string | null;
cargoCapacity: number;
workerPopulation: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
health: number;
history: string[];
spatialState: ShipSpatialStateSnapshot;
}
export interface ShipDelta extends ShipSnapshot {}
export interface ShipSpatialStateSnapshot {
spaceLayer: string;
currentSystemId: string;
currentNodeId?: string | null;
currentBubbleId?: string | null;
localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null;
movementRegime: string;
destinationNodeId?: string | null;
transit?: ShipTransitSnapshot | null;
}
export interface ShipTransitSnapshot {
regime: string;
originNodeId?: string | null;
destinationNodeId?: string | null;
startedAtUtc?: string | null;
arrivalDueAtUtc?: string | null;
progress: number;
}
export interface FactionSnapshot {
id: string;
label: string;
color: string;
credits: number;
populationTotal: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
shipsLost: number;
defaultPolicySetId?: string | null;
}
export interface FactionDelta extends FactionSnapshot {}
export type { Vector3Dto, InventoryEntry } from "./contractsCommon";
export type {
WorldSnapshot,
WorldDelta,
SimulationEventRecord,
ObserverScope,
} from "./contractsWorld";
export type {
SystemSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,
ResourceNodeDelta,
SpatialNodeSnapshot,
SpatialNodeDelta,
LocalBubbleSnapshot,
LocalBubbleDelta,
} from "./contractsCelestial";
export type {
StationSnapshot,
StationDelta,
ClaimSnapshot,
ClaimDelta,
ConstructionSiteSnapshot,
ConstructionSiteDelta,
} from "./contractsInfrastructure";
export type {
MarketOrderSnapshot,
MarketOrderDelta,
PolicySetSnapshot,
PolicySetDelta,
} from "./contractsEconomy";
export type {
ShipSnapshot,
ShipDelta,
ShipSpatialStateSnapshot,
ShipTransitSnapshot,
} from "./contractsShips";
export type { FactionSnapshot, FactionDelta } from "./contractsFactions";

View File

@@ -0,0 +1,67 @@
import type { Vector3Dto } from "./contractsCommon";
export interface SystemSnapshot {
id: string;
label: string;
galaxyPosition: Vector3Dto;
starKind: string;
starCount: number;
starColor: string;
starSize: number;
planets: PlanetSnapshot[];
}
export interface PlanetSnapshot {
label: string;
planetType: string;
shape: string;
moonCount: number;
orbitRadius: number;
orbitSpeed: number;
orbitEccentricity: number;
orbitInclination: number;
orbitLongitudeOfAscendingNode: number;
orbitArgumentOfPeriapsis: number;
orbitPhaseAtEpoch: number;
size: number;
color: string;
hasRing: boolean;
}
export interface ResourceNodeSnapshot {
id: string;
systemId: string;
localPosition: Vector3Dto;
sourceKind: string;
oreRemaining: number;
maxOre: number;
itemId: string;
}
export interface ResourceNodeDelta extends ResourceNodeSnapshot {}
export interface SpatialNodeSnapshot {
id: string;
systemId: string;
kind: string;
localPosition: Vector3Dto;
bubbleId: string;
parentNodeId?: string | null;
occupyingStructureId?: string | null;
orbitReferenceId?: string | null;
}
export interface SpatialNodeDelta extends SpatialNodeSnapshot {}
export interface LocalBubbleSnapshot {
id: string;
nodeId: string;
systemId: string;
radius: number;
occupantShipIds: string[];
occupantStationIds: string[];
occupantClaimIds: string[];
occupantConstructionSiteIds: string[];
}
export interface LocalBubbleDelta extends LocalBubbleSnapshot {}

View File

@@ -0,0 +1,10 @@
export interface Vector3Dto {
x: number;
y: number;
z: number;
}
export interface InventoryEntry {
itemId: string;
amount: number;
}

View File

@@ -0,0 +1,28 @@
export interface MarketOrderSnapshot {
id: string;
factionId: string;
stationId?: string | null;
constructionSiteId?: string | null;
kind: string;
itemId: string;
amount: number;
remainingAmount: number;
valuation: number;
reserveThreshold?: number | null;
policySetId?: string | null;
state: string;
}
export interface MarketOrderDelta extends MarketOrderSnapshot {}
export interface PolicySetSnapshot {
id: string;
ownerKind: string;
ownerId: string;
tradeAccessPolicy: string;
dockingAccessPolicy: string;
constructionAccessPolicy: string;
operationalRangePolicy: string;
}
export interface PolicySetDelta extends PolicySetSnapshot {}

View File

@@ -0,0 +1,14 @@
export interface FactionSnapshot {
id: string;
label: string;
color: string;
credits: number;
populationTotal: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
shipsLost: number;
defaultPolicySetId?: string | null;
}
export interface FactionDelta extends FactionSnapshot {}

View File

@@ -0,0 +1,64 @@
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
export interface StationSnapshot {
id: string;
label: string;
category: string;
systemId: string;
localPosition: Vector3Dto;
nodeId?: string | null;
bubbleId?: string | null;
anchorNodeId?: string | null;
color: string;
dockedShips: number;
dockingPads: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
commanderId?: string | null;
policySetId?: string | null;
population: number;
populationCapacity: number;
workforceRequired: number;
workforceEffectiveRatio: number;
installedModules: string[];
marketOrderIds: string[];
}
export interface StationDelta extends StationSnapshot {}
export interface ClaimSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
state: string;
health: number;
placedAtUtc: string;
activatesAtUtc: string;
}
export interface ClaimDelta extends ClaimSnapshot {}
export interface ConstructionSiteSnapshot {
id: string;
factionId: string;
systemId: string;
nodeId: string;
bubbleId: string;
targetKind: string;
targetDefinitionId: string;
blueprintId?: string | null;
claimId?: string | null;
stationId?: string | null;
state: string;
progress: number;
inventory: InventoryEntry[];
requiredItems: InventoryEntry[];
deliveredItems: InventoryEntry[];
assignedConstructorShipIds: string[];
marketOrderIds: string[];
}
export interface ConstructionSiteDelta extends ConstructionSiteSnapshot {}

View File

@@ -0,0 +1,52 @@
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
export interface ShipSnapshot {
id: string;
label: string;
role: string;
shipClass: string;
systemId: string;
localPosition: Vector3Dto;
localVelocity: Vector3Dto;
targetLocalPosition: Vector3Dto;
state: string;
orderKind: string | null;
defaultBehaviorKind: string;
controllerTaskKind: string;
nodeId?: string | null;
bubbleId?: string | null;
dockedStationId?: string | null;
commanderId?: string | null;
policySetId?: string | null;
cargoCapacity: number;
workerPopulation: number;
energyStored: number;
inventory: InventoryEntry[];
factionId: string;
health: number;
history: string[];
spatialState: ShipSpatialStateSnapshot;
}
export interface ShipDelta extends ShipSnapshot {}
export interface ShipSpatialStateSnapshot {
spaceLayer: string;
currentSystemId: string;
currentNodeId?: string | null;
currentBubbleId?: string | null;
localPosition?: Vector3Dto | null;
systemPosition?: Vector3Dto | null;
movementRegime: string;
destinationNodeId?: string | null;
transit?: ShipTransitSnapshot | null;
}
export interface ShipTransitSnapshot {
regime: string;
originNodeId?: string | null;
destinationNodeId?: string | null;
startedAtUtc?: string | null;
arrivalDueAtUtc?: string | null;
progress: number;
}

View File

@@ -0,0 +1,85 @@
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
} from "./contractsInfrastructure";
import type {
FactionDelta,
FactionSnapshot,
} from "./contractsFactions";
import type {
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
SystemSnapshot,
} from "./contractsCelestial";
import type {
MarketOrderDelta,
PolicySetDelta,
PolicySetSnapshot,
MarketOrderSnapshot,
} from "./contractsEconomy";
import type {
ShipDelta,
ShipSnapshot,
} from "./contractsShips";
export interface WorldSnapshot {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: SystemSnapshot[];
spatialNodes: SpatialNodeSnapshot[];
localBubbles: LocalBubbleSnapshot[];
nodes: ResourceNodeSnapshot[];
stations: import("./contractsInfrastructure").StationSnapshot[];
claims: ClaimSnapshot[];
constructionSites: ConstructionSiteSnapshot[];
marketOrders: MarketOrderSnapshot[];
policies: PolicySetSnapshot[];
ships: ShipSnapshot[];
factions: FactionSnapshot[];
}
export interface WorldDelta {
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
requiresSnapshotRefresh: boolean;
events: SimulationEventRecord[];
spatialNodes: SpatialNodeDelta[];
localBubbles: LocalBubbleDelta[];
nodes: ResourceNodeDelta[];
stations: import("./contractsInfrastructure").StationDelta[];
claims: ClaimDelta[];
constructionSites: ConstructionSiteDelta[];
marketOrders: MarketOrderDelta[];
policies: PolicySetDelta[];
ships: ShipDelta[];
factions: FactionDelta[];
scope?: ObserverScope | null;
}
export interface SimulationEventRecord {
entityKind: string;
entityId: string;
kind: string;
message: string;
occurredAtUtc: string;
family?: string;
scopeKind?: string;
scopeEntityId?: string | null;
visibility?: string;
}
export interface ObserverScope {
scopeKind: string;
systemId?: string | null;
bubbleId?: string | null;
}

View File

@@ -0,0 +1,348 @@
import * as THREE from "three";
import { ACTIVE_SYSTEM_CAPTURE_RADIUS, ACTIVE_SYSTEM_DETAIL_SCALE, GALAXY_PARALLAX_FACTOR } from "./viewerConstants";
import { computePlanetLocalPosition, currentWorldTimeSeconds, toThreeVector } from "./viewerMath";
import { resolveSelectableSystemId } from "./viewerSelection";
import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
WorldState,
} from "./viewerTypes";
interface ResolveSelectionPositionParams {
world: WorldState | undefined;
selection: Selectable;
worldTimeSyncMs: number;
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
}
interface FocusOnSelectionParams extends ResolveSelectionPositionParams {
activeSystemId?: string;
galaxyFocus: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
}
interface DetermineActiveSystemParams {
world: WorldState | undefined;
cameraMode: "tactical" | "follow";
cameraTargetShipId?: string;
currentDistance: number;
selectedItems: Selectable[];
galaxyFocus: THREE.Vector3;
}
interface SeedSystemFocusParams {
world: WorldState | undefined;
systemId: string;
cameraMode: "tactical" | "follow";
cameraTargetShipId?: string;
selectedItems: Selectable[];
systemFocusLocal: THREE.Vector3;
worldTimeSyncMs: number;
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
}
interface CameraFocusParams {
world: WorldState | undefined;
activeSystemId?: string;
galaxyFocus: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
}
interface DisplayLocalPositionParams {
world: WorldState | undefined;
systemId?: string;
activeSystemId?: string;
localPosition: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
}
export function updatePanFromKeyboard(
keyState: Set<string>,
orbitYaw: number,
currentDistance: number,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
galaxyFocus: THREE.Vector3,
delta: number,
minimumDistance: number,
maximumDistance: number,
) {
const move = new THREE.Vector3();
if (keyState.has("w")) {
move.z -= 1;
}
if (keyState.has("s")) {
move.z += 1;
}
if (keyState.has("a")) {
move.x += 1;
}
if (keyState.has("d")) {
move.x -= 1;
}
if (move.lengthSq() === 0) {
return;
}
move.normalize();
const forward = new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
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(currentDistance, minimumDistance, maximumDistance, 320, 6800);
if (activeSystemId) {
systemFocusLocal.addScaledVector(pan, speed * delta);
return;
}
galaxyFocus.addScaledVector(pan, speed * delta);
}
export function determineActiveSystemId(params: DetermineActiveSystemParams): string | undefined {
const {
world,
cameraMode,
cameraTargetShipId,
currentDistance,
selectedItems,
galaxyFocus,
} = params;
if (!world) {
return undefined;
}
if (cameraMode === "follow" && cameraTargetShipId) {
return world.ships.get(cameraTargetShipId)?.systemId;
}
if (currentDistance >= 12000) {
return undefined;
}
const selected = selectedItems[0];
if (selected && selectedItems.length === 1) {
if (selected.kind === "system") {
return selected.id;
}
if (selected.kind === "planet") {
return selected.systemId;
}
const selectedSystemId = resolveSelectableSystemId(world, selected);
if (selectedSystemId) {
return selectedSystemId;
}
}
let nearestSystemId: string | undefined;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const system of world.systems.values()) {
const center = toThreeVector(system.galaxyPosition);
const distance = center.distanceTo(galaxyFocus);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestSystemId = system.id;
}
}
return nearestDistance <= Math.max(ACTIVE_SYSTEM_CAPTURE_RADIUS, currentDistance * 2.2)
? nearestSystemId
: undefined;
}
export function resolveSelectionPosition(params: ResolveSelectionPositionParams): THREE.Vector3 | undefined {
const {
world,
selection,
worldTimeSyncMs,
nodeVisuals,
planetVisuals,
computeNodeLocalPosition,
resolveBubblePosition,
resolvePointPosition,
} = params;
if (!world) {
return undefined;
}
if (selection.kind === "ship") {
const ship = world.ships.get(selection.id);
return ship ? toThreeVector(ship.localPosition) : undefined;
}
if (selection.kind === "station") {
const station = world.stations.get(selection.id);
return station ? toThreeVector(station.localPosition) : undefined;
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);
const visual = node ? nodeVisuals.get(node.id) : undefined;
return visual
? computeNodeLocalPosition(visual, currentWorldTimeSeconds(world, worldTimeSyncMs))
: (node ? toThreeVector(node.localPosition) : undefined);
}
if (selection.kind === "spatial-node") {
const node = world.spatialNodes.get(selection.id);
return node ? toThreeVector(node.localPosition) : undefined;
}
if (selection.kind === "bubble") {
return resolveBubblePosition(selection.id);
}
if (selection.kind === "claim") {
const claim = world.claims.get(selection.id);
return claim ? resolvePointPosition(claim.systemId, claim.nodeId) : undefined;
}
if (selection.kind === "construction-site") {
const site = world.constructionSites.get(selection.id);
return site ? resolvePointPosition(site.systemId, site.nodeId) : undefined;
}
if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId);
const planet = system?.planets[selection.planetIndex];
if (!system || !planet) {
return undefined;
}
const visual = planetVisuals.find((candidate) =>
candidate.systemId === selection.systemId && candidate.planet === planet);
return visual?.mesh.position.clone() ?? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
}
const system = world.systems.get(selection.id);
return system ? toThreeVector(system.galaxyPosition) : undefined;
}
export function focusOnSelection(params: FocusOnSelectionParams) {
const {
world,
selection,
activeSystemId,
galaxyFocus,
systemFocusLocal,
} = params;
const nextFocus = resolveSelectionPosition(params);
if (!nextFocus) {
return;
}
const selectionSystemId = resolveSelectableSystemId(world, selection);
if (selectionSystemId && selection.kind !== "system" && world) {
const system = world.systems.get(selectionSystemId);
if (system) {
galaxyFocus.copy(toThreeVector(system.galaxyPosition));
systemFocusLocal.copy(nextFocus);
return;
}
}
if (activeSystemId && resolveSelectableSystemId(world, selection) === activeSystemId) {
systemFocusLocal.copy(nextFocus);
return;
}
galaxyFocus.copy(nextFocus);
}
export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
const {
world,
systemId,
cameraMode,
cameraTargetShipId,
selectedItems,
systemFocusLocal,
} = params;
if (!world) {
return;
}
if (cameraMode === "follow" && cameraTargetShipId) {
const followedShip = world.ships.get(cameraTargetShipId);
if (followedShip?.systemId === systemId) {
systemFocusLocal.copy(toThreeVector(followedShip.localPosition));
return;
}
}
const selected = selectedItems[0];
if (selected && resolveSelectableSystemId(world, selected) === systemId) {
const selectedPosition = resolveSelectionPosition({
world,
selection: selected,
worldTimeSyncMs: params.worldTimeSyncMs,
nodeVisuals: params.nodeVisuals,
planetVisuals: params.planetVisuals,
computeNodeLocalPosition: params.computeNodeLocalPosition,
resolveBubblePosition: params.resolveBubblePosition,
resolvePointPosition: params.resolvePointPosition,
});
if (selectedPosition) {
systemFocusLocal.copy(selectedPosition);
return;
}
}
systemFocusLocal.set(0, 0, 0);
}
export function getCameraFocusWorldPosition(params: CameraFocusParams): THREE.Vector3 {
const {
world,
activeSystemId,
galaxyFocus,
systemFocusLocal,
} = params;
if (!activeSystemId || !world) {
return galaxyFocus;
}
const system = world.systems.get(activeSystemId);
return system
? toThreeVector(system.galaxyPosition).add(
systemFocusLocal.clone().multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE * GALAXY_PARALLAX_FACTOR),
)
: galaxyFocus;
}
export function toDisplayLocalPosition(params: DisplayLocalPositionParams): THREE.Vector3 {
const {
world,
systemId,
activeSystemId,
localPosition,
systemFocusLocal,
} = params;
if (!world || !systemId) {
return localPosition.clone();
}
const system = world.systems.get(systemId);
if (!system) {
return localPosition.clone();
}
const center = toThreeVector(system.galaxyPosition);
if (systemId !== activeSystemId) {
return center.clone().add(localPosition);
}
return center.clone().add(localPosition.clone().sub(systemFocusLocal).multiplyScalar(ACTIVE_SYSTEM_DETAIL_SCALE));
}

View File

@@ -0,0 +1,23 @@
import type { ZoomLevel } from "./viewerTypes";
export const ZOOM_DISTANCE: Record<ZoomLevel, number> = {
local: 900,
system: 3200,
universe: 26000,
};
export const ACTIVE_SYSTEM_DETAIL_SCALE = 10;
export const GALAXY_PARALLAX_FACTOR = 0.025;
export const ACTIVE_SYSTEM_CAPTURE_RADIUS = 9000;
export const PROJECTED_GALAXY_RADIUS = 65000;
export const STAR_RENDER_SCALE = 0.18;
export const PLANET_RENDER_SCALE = 0.95;
export const MOON_RENDER_SCALE = 1.1;
export const MIN_CAMERA_DISTANCE = 450;
export const MAX_CAMERA_DISTANCE = 42000;
export interface ZoomBlend {
localWeight: number;
systemWeight: number;
universeWeight: number;
}

View File

@@ -0,0 +1,272 @@
import * as THREE from "three";
import { ViewerInteractionController } from "./viewerInteractionController";
import { ViewerNavigationController } from "./viewerNavigationController";
import { ViewerPresentationController } from "./viewerPresentationController";
import { ViewerSceneDataController } from "./viewerSceneDataController";
import { ViewerWorldLifecycle } from "./viewerWorldLifecycle";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
export function createViewerControllers(host: any) {
const sceneDataController = new ViewerSceneDataController({
documentRef: document,
getWorldGeneratedAtUtc: () => host.world?.generatedAtUtc,
getWorldSeed: () => host.world?.seed ?? 1,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getWorldPresentationContext: () => host.createWorldPresentationContext(),
systemGroup: host.systemGroup,
spatialNodeGroup: host.spatialNodeGroup,
bubbleGroup: host.bubbleGroup,
nodeGroup: host.nodeGroup,
stationGroup: host.stationGroup,
claimGroup: host.claimGroup,
constructionSiteGroup: host.constructionSiteGroup,
shipGroup: host.shipGroup,
selectableTargets: host.selectableTargets,
presentationEntries: host.presentationEntries,
systemVisuals: host.systemVisuals,
systemSummaryVisuals: host.systemSummaryVisuals,
planetVisuals: host.planetVisuals,
orbitLines: host.orbitLines,
spatialNodeVisuals: host.spatialNodeVisuals,
bubbleVisuals: host.bubbleVisuals,
nodeVisuals: host.nodeVisuals,
stationVisuals: host.stationVisuals,
claimVisuals: host.claimVisuals,
constructionSiteVisuals: host.constructionSiteVisuals,
shipVisuals: host.shipVisuals,
registerPresentation: host.registerPresentation.bind(host),
});
const navigationController = new ViewerNavigationController({
getWorld: () => host.world,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getActiveSystemId: () => host.activeSystemId,
setActiveSystemId: (value) => {
host.activeSystemId = value;
},
getCameraMode: () => host.cameraMode,
setCameraMode: (value) => {
host.cameraMode = value;
},
getCameraTargetShipId: () => host.cameraTargetShipId,
setCameraTargetShipId: (value) => {
host.cameraTargetShipId = value;
},
getCurrentDistance: () => host.currentDistance,
getSelectedItems: () => host.selectedItems,
getOrbitYaw: () => host.orbitYaw,
galaxyFocus: host.galaxyFocus,
systemFocusLocal: host.systemFocusLocal,
camera: host.camera,
shipVisuals: host.shipVisuals,
nodeVisuals: host.nodeVisuals,
planetVisuals: host.planetVisuals,
systemVisuals: host.systemVisuals,
followCameraPosition: host.followCameraPosition,
followCameraFocus: host.followCameraFocus,
followCameraDirection: host.followCameraDirection,
followCameraDesiredDirection: host.followCameraDesiredDirection,
followCameraOffset: host.followCameraOffset,
createWorldPresentationContext: () => host.createWorldPresentationContext(),
updatePanels: () => host.updatePanels(),
updateGamePanel: (mode: string) => host.updateGamePanel(mode),
});
const presentationController = new ViewerPresentationController({
renderer: host.renderer,
scene: host.scene,
camera: host.camera,
ambienceGroup: host.ambienceGroup,
statusEl: host.statusEl,
networkPanelEl: host.networkPanelEl,
performancePanelEl: host.performancePanelEl,
systemPanelEl: host.systemPanelEl,
systemTitleEl: host.systemTitleEl,
systemBodyEl: host.systemBodyEl,
networkStats: host.networkStats,
performanceStats: host.performanceStats,
getWorld: () => host.world,
getActiveSystemId: () => host.activeSystemId,
getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId,
getZoomLevel: () => host.zoomLevel,
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
getCurrentDistance: () => host.currentDistance,
systemFocusLocal: host.systemFocusLocal,
planetVisuals: host.planetVisuals,
systemSummaryVisuals: host.systemSummaryVisuals,
presentationEntries: host.presentationEntries,
orbitLines: host.orbitLines,
systemVisuals: host.systemVisuals,
createWorldPresentationContext: () => host.createWorldPresentationContext(),
});
const worldLifecycle = new ViewerWorldLifecycle({
getWorld: () => host.world,
setWorld: (world) => {
host.world = world;
},
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
setWorldTimeSyncMs: (value) => {
host.worldTimeSyncMs = value;
},
getWorldSignature: () => host.worldSignature,
setWorldSignature: (value) => {
host.worldSignature = value;
},
getStream: () => host.stream,
setStream: (stream) => {
host.stream = stream;
},
getCurrentStreamScopeKey: () => host.currentStreamScopeKey,
setCurrentStreamScopeKey: (value) => {
host.currentStreamScopeKey = value;
},
getZoomLevel: () => host.zoomLevel,
getActiveSystemId: () => host.activeSystemId,
getSelectedItems: () => host.selectedItems,
getCameraMode: () => host.cameraMode,
getCameraTargetShipId: () => host.cameraTargetShipId,
getNetworkStats: () => host.networkStats,
getSystemSummaryVisuals: () => host.systemSummaryVisuals,
errorEl: host.errorEl,
factionStripEl: host.factionStripEl,
detailTitleEl: host.detailTitleEl,
detailBodyEl: host.detailBodyEl,
worldLabel: () => host.world?.label ?? "",
rebuildSystems: (systems) => sceneDataController.rebuildSystems(systems),
syncSpatialNodes: (nodes) => sceneDataController.syncSpatialNodes(nodes),
syncLocalBubbles: (bubbles) => sceneDataController.syncLocalBubbles(bubbles),
syncNodes: (nodes) => sceneDataController.syncNodes(nodes),
syncStations: (stations) => sceneDataController.syncStations(stations),
syncClaims: (claims) => sceneDataController.syncClaims(claims),
syncConstructionSites: (sites) => sceneDataController.syncConstructionSites(sites),
syncShips: (ships, tickIntervalMs) => sceneDataController.syncShips(ships, tickIntervalMs),
applySpatialNodeDeltas: (nodes) => sceneDataController.applySpatialNodeDeltas(nodes),
applyLocalBubbleDeltas: (bubbles) => sceneDataController.applyLocalBubbleDeltas(bubbles),
applyNodeDeltas: (nodes) => sceneDataController.applyNodeDeltas(nodes),
applyStationDeltas: (stations) => sceneDataController.applyStationDeltas(stations),
applyClaimDeltas: (claims) => sceneDataController.applyClaimDeltas(claims),
applyConstructionSiteDeltas: (sites) => sceneDataController.applyConstructionSiteDeltas(sites),
applyShipDeltas: (ships, tickIntervalMs) => sceneDataController.applyShipDeltas(ships, tickIntervalMs),
refreshHistoryWindows: () => host.refreshHistoryWindows(),
resolveFocusedBubbleId: () => host.resolveFocusedBubbleId(),
updateSystemSummaries: () => host.updateSystemSummaries(),
applyZoomPresentation: () => presentationController.applyZoomPresentation(),
updateNetworkPanel: () => presentationController.updateNetworkPanel(),
updateSystemPanel: () => host.updateSystemPanel(),
updateGamePanel: (mode) => host.updateGamePanel(mode),
describeSelectionParent: (selection) => host.describeSelectionParent(selection),
});
const historyController = new ViewerHistoryWindowController({
historyLayerEl: host.historyLayerEl,
historyWindows: host.historyWindows,
getWorld: () => host.world,
getHistoryWindowCounter: () => host.historyWindowCounter,
setHistoryWindowCounter: (value) => {
host.historyWindowCounter = value;
},
getHistoryWindowZCounter: () => host.historyWindowZCounter,
setHistoryWindowZCounter: (value) => {
host.historyWindowZCounter = value;
},
getHistoryWindowDragId: () => host.historyWindowDragId,
setHistoryWindowDragId: (value) => {
host.historyWindowDragId = value;
},
getHistoryWindowDragPointerId: () => host.historyWindowDragPointerId,
setHistoryWindowDragPointerId: (value) => {
host.historyWindowDragPointerId = value;
},
historyWindowDragOffset: host.historyWindowDragOffset,
renderRecentEvents: (entityKind, entityId) => presentationController.renderRecentEvents(entityKind, entityId),
});
const interactionController = new ViewerInteractionController({
renderer: host.renderer,
raycaster: host.raycaster,
mouse: host.mouse,
camera: host.camera,
selectableTargets: host.selectableTargets,
hoverLabelEl: host.hoverLabelEl,
marqueeEl: host.marqueeEl,
keyState: host.keyState,
getWorld: () => host.world,
getActiveSystemId: () => host.activeSystemId,
getSelectedItems: () => host.selectedItems,
setSelectedItems: (items) => {
host.selectedItems = items;
},
getDragMode: () => host.dragMode,
setDragMode: (mode) => {
host.dragMode = mode;
},
getDragPointerId: () => host.dragPointerId,
setDragPointerId: (pointerId) => {
host.dragPointerId = pointerId;
},
dragStart: host.dragStart,
dragLast: host.dragLast,
getMarqueeActive: () => host.marqueeActive,
setMarqueeActive: (value) => {
host.marqueeActive = value;
},
getSuppressClickSelection: () => host.suppressClickSelection,
setSuppressClickSelection: (value) => {
host.suppressClickSelection = value;
},
getDesiredDistance: () => host.desiredDistance,
setDesiredDistance: (value) => {
host.desiredDistance = value;
},
getCameraMode: () => host.cameraMode,
setCameraMode: (value) => {
host.cameraMode = value;
},
getCameraTargetShipId: () => host.cameraTargetShipId,
setCameraTargetShipId: (value) => {
host.cameraTargetShipId = value;
},
getFollowCameraPosition: () => host.followCameraPosition,
getFollowCameraFocus: () => host.followCameraFocus,
screenPointFromClient: (x, y) => presentationController.screenPointFromClient(x, y),
applyOrbitDelta: (delta: THREE.Vector2) => {
host.orbitYaw += delta.x * 0.008;
host.orbitPitch = THREE.MathUtils.clamp(host.orbitPitch + delta.y * 0.004, 0.18, 1.3);
},
syncFollowStateFromSelection: () => navigationController.syncFollowStateFromSelection(),
updatePanels: () => host.updatePanels(),
focusOnSelection: (selection) => navigationController.focusOnSelection(selection),
updateGamePanel: (mode) => host.updateGamePanel(mode),
historyController,
});
return {
historyController,
sceneDataController,
navigationController,
presentationController,
worldLifecycle,
interactionController,
};
}
export function wireViewerEvents(host: any) {
host.renderer.domElement.addEventListener("pointerdown", host.interactionController.onPointerDown);
host.renderer.domElement.addEventListener("pointermove", host.interactionController.onPointerMove);
host.renderer.domElement.addEventListener("pointerup", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("pointerleave", host.interactionController.onPointerUp);
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
host.factionStripEl.addEventListener("click", host.interactionController.onShipStripClick);
host.factionStripEl.addEventListener("dblclick", host.interactionController.onShipStripDoubleClick);
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
window.addEventListener("pointerup", host.interactionController.onHistoryWindowPointerUp);
window.addEventListener("keydown", host.interactionController.onKeyDown);
window.addEventListener("keyup", host.interactionController.onKeyUp);
window.addEventListener("resize", host.onResize);
}

View File

@@ -0,0 +1,210 @@
import * as THREE from "three";
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants";
import type {
CameraMode,
Selectable,
ShipVisual,
SystemVisual,
WorldState,
} from "./viewerTypes";
export function syncFollowStateFromSelection(
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
) {
if (selectedItems.length === 1 && selectedItems[0].kind === "ship") {
return {
cameraMode,
cameraTargetShipId: selectedItems[0].id,
};
}
return {
cameraMode: cameraMode === "follow" ? "tactical" : cameraMode,
cameraTargetShipId: undefined,
};
}
export function toggleCameraMode(params: {
cameraMode: CameraMode;
cameraTargetShipId?: string;
selectedItems: Selectable[];
desiredDistance: number;
followCameraPosition: THREE.Vector3;
followCameraFocus: THREE.Vector3;
forceMode?: CameraMode;
}) {
const {
cameraMode,
cameraTargetShipId,
selectedItems,
desiredDistance,
followCameraPosition,
followCameraFocus,
forceMode,
} = params;
const nextMode = forceMode ?? (cameraMode === "follow" ? "tactical" : "follow");
if (nextMode === "tactical") {
return {
cameraMode: "tactical" as const,
cameraTargetShipId,
desiredDistance,
};
}
const nextTargetShipId = cameraTargetShipId
?? (selectedItems.length === 1 && selectedItems[0].kind === "ship" ? selectedItems[0].id : undefined);
if (!nextTargetShipId) {
return {
cameraMode,
cameraTargetShipId,
desiredDistance,
};
}
followCameraPosition.set(0, 0, 0);
followCameraFocus.set(0, 0, 0);
return {
cameraMode: "follow" as const,
cameraTargetShipId: nextTargetShipId,
desiredDistance: Math.min(desiredDistance, 1800),
};
}
export function updateFollowCamera(params: {
world: WorldState | undefined;
cameraMode: CameraMode;
cameraTargetShipId?: string;
shipVisuals: Map<string, ShipVisual>;
currentDistance: number;
camera: THREE.PerspectiveCamera;
followCameraPosition: THREE.Vector3;
followCameraFocus: THREE.Vector3;
followCameraDirection: THREE.Vector3;
followCameraDesiredDirection: THREE.Vector3;
followCameraOffset: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
delta: number;
getAnimatedShipLocalPosition: (visual: ShipVisual) => THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
resolveShipHeading: (visual: ShipVisual, worldPosition: THREE.Vector3) => THREE.Vector3;
}) {
const {
world,
cameraTargetShipId,
shipVisuals,
currentDistance,
camera,
followCameraPosition,
followCameraFocus,
followCameraDirection,
followCameraDesiredDirection,
followCameraOffset,
systemFocusLocal,
delta,
getAnimatedShipLocalPosition,
toDisplayLocalPosition,
resolveShipHeading,
} = params;
if (!cameraTargetShipId || !world) {
return {
handled: false,
cameraMode: "tactical" as const,
cameraTargetShipId,
};
}
const ship = world.ships.get(cameraTargetShipId);
const visual = shipVisuals.get(cameraTargetShipId);
if (!ship || !visual) {
return {
handled: false,
cameraMode: "tactical" as const,
cameraTargetShipId: undefined,
};
}
const shipLocalPosition = getAnimatedShipLocalPosition(visual);
const shipWorldPosition = toDisplayLocalPosition(shipLocalPosition, ship.systemId);
systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
followCameraDesiredDirection.copy(resolveShipHeading(visual, shipLocalPosition)).normalize();
followCameraDirection.lerp(followCameraDesiredDirection, 1 - Math.exp(-delta * 5));
followCameraDirection.normalize();
const distance = THREE.MathUtils.clamp(currentDistance * 0.72, 320, 6800);
const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100);
const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400);
followCameraOffset.copy(followCameraDirection).multiplyScalar(-distance);
followCameraOffset.y += height;
const desiredPosition = shipWorldPosition.clone().add(followCameraOffset);
const desiredFocus = shipWorldPosition.clone().addScaledVector(followCameraDirection, lookAhead);
desiredFocus.y += height * 0.28;
const positionLerp = 1 - Math.exp(-delta * 6);
const focusLerp = 1 - Math.exp(-delta * 8);
if (followCameraPosition.lengthSq() === 0) {
followCameraPosition.copy(desiredPosition);
followCameraFocus.copy(desiredFocus);
} else {
followCameraPosition.lerp(desiredPosition, positionLerp);
followCameraFocus.lerp(desiredFocus, focusLerp);
}
camera.position.copy(followCameraPosition);
camera.lookAt(followCameraFocus);
return {
handled: true,
cameraMode: "follow" as const,
cameraTargetShipId,
};
}
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string) {
for (const [systemId, visual] of systemVisuals.entries()) {
visual.detailGroup.visible = systemId === activeSystemId;
}
}
export function setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) {
sprite.visible = opacity > 0.02;
sprite.material.opacity = opacity;
sprite.material.needsUpdate = true;
}
export function zoomFromWheel(desiredDistance: number, deltaY: number) {
const clampedDelta = THREE.MathUtils.clamp(deltaY, -180, 180);
const zoomFactor = Math.exp(clampedDelta * 0.00135);
return THREE.MathUtils.clamp(desiredDistance * zoomFactor, MIN_CAMERA_DISTANCE, MAX_CAMERA_DISTANCE);
}
export function applyKeyboardControl(params: {
keyState: Set<string>;
cameraMode: CameraMode;
desiredDistance: number;
key: string;
}) {
const { keyState, key } = params;
keyState.add(key);
let cameraMode = params.cameraMode;
let desiredDistance = params.desiredDistance;
if (["w", "a", "s", "d"].includes(key)) {
cameraMode = "tactical";
}
if (key === "1") {
desiredDistance = ZOOM_DISTANCE.local;
} else if (key === "2") {
desiredDistance = ZOOM_DISTANCE.system;
} else if (key === "3") {
desiredDistance = ZOOM_DISTANCE.universe;
}
return { cameraMode, desiredDistance };
}

View File

@@ -0,0 +1,52 @@
import { inventoryAmount } from "./viewerMath";
import type { CameraMode, Selectable, WorldState } from "./viewerTypes";
export function renderFactionStrip(
world: WorldState | undefined,
selectedItems: Selectable[],
cameraMode: CameraMode,
cameraTargetShipId?: string,
) {
if (!world) {
return "";
}
const ships = [...world.ships.values()]
.sort((left, right) => left.label.localeCompare(right.label));
return ships
.map((ship) => {
const fuel = inventoryAmount(ship.inventory, "gas");
const isSelected = selectedItems.length === 1
&& selectedItems[0].kind === "ship"
&& selectedItems[0].id === ship.id;
const isFollowed = cameraMode === "follow" && cameraTargetShipId === ship.id;
return `
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
<div class="ship-card-header">
<h3>${ship.label}</h3>
<div class="ship-card-meta">
<span class="ship-card-badge">${ship.shipClass}</span>
<button
type="button"
class="ship-card-history-button"
data-history-ship-id="${ship.id}"
aria-label="Open history for ${ship.label}"
title="Open history"
>&#128340;</button>
</div>
</div>
<p>${ship.systemId}</p>
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
<p>State ${ship.state}</p>
<div class="ship-card-ai">
<p>Order ${ship.orderKind ?? "none"}</p>
<p>Behavior ${ship.defaultBehaviorKind}</p>
<p>Task ${ship.controllerTaskKind}</p>
</div>
</article>
`;
})
.join("");
}

View File

@@ -0,0 +1,70 @@
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
export function createHistoryWindowState(
documentRef: Document,
target: Selectable,
historyWindowsCount: number,
historyWindowCounter: number,
): HistoryWindowState {
const id = `history-${historyWindowCounter}`;
const root = documentRef.createElement("aside");
root.className = "history-window";
root.dataset.historyWindowId = id;
root.innerHTML = `
<div class="history-window-header">
<h2 class="history-window-title">History</h2>
<div class="history-window-actions">
<button type="button" class="history-window-copy">Copy</button>
<button type="button" class="history-window-close">Close</button>
</div>
</div>
<div class="history-window-body">No history selected.</div>
`;
root.style.width = `${Math.min(520, window.innerWidth - 40)}px`;
root.style.height = `${Math.min(360, Math.max(240, window.innerHeight * 0.42))}px`;
root.style.left = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerWidth - 580)))}px`;
root.style.top = `${Math.max(20, 20 + ((historyWindowsCount * 28) % Math.max(40, window.innerHeight - 420)))}px`;
return {
id,
target,
root,
titleEl: root.querySelector(".history-window-title") as HTMLHeadingElement,
bodyEl: root.querySelector(".history-window-body") as HTMLDivElement,
copyButtonEl: root.querySelector(".history-window-copy") as HTMLButtonElement,
text: "",
};
}
export function refreshHistoryWindow(
world: WorldState,
windowState: HistoryWindowState,
renderRecentEvents: (entityKind: string, entityId: string) => string,
): boolean {
if (windowState.target.kind === "ship") {
const ship = world.ships.get(windowState.target.id);
if (!ship) {
return false;
}
windowState.titleEl.textContent = `${ship.label} History`;
windowState.text = ship.history.length > 0 ? ship.history.join("\n") : "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
return true;
}
if (windowState.target.kind === "station") {
const station = world.stations.get(windowState.target.id);
if (!station) {
return false;
}
windowState.titleEl.textContent = `${station.label} History`;
windowState.text = renderRecentEvents("station", station.id).replaceAll("<br>", "\n") || "No history yet.";
windowState.bodyEl.innerHTML = windowState.text.replaceAll("\n", "<br>");
return true;
}
return false;
}

View File

@@ -0,0 +1,180 @@
import * as THREE from "three";
import { createHistoryWindowState, refreshHistoryWindow } from "./viewerHistory";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
export function openHistoryWindow(
historyWindows: HistoryWindowState[],
historyLayerEl: HTMLDivElement,
target: Selectable,
nextCounter: number,
bringToFront: (windowState: HistoryWindowState) => void,
refreshWindows: () => void,
) {
const existing = historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target));
if (existing) {
bringToFront(existing);
refreshWindows();
return nextCounter;
}
const windowState = createHistoryWindowState(document, target, historyWindows.length, nextCounter);
historyWindows.push(windowState);
historyLayerEl.append(windowState.root);
bringToFront(windowState);
refreshWindows();
return nextCounter;
}
export function refreshHistoryWindows(
world: WorldState | undefined,
historyWindows: HistoryWindowState[],
renderRecentEvents: (entityKind: string, entityId: string) => string,
destroyWindow: (id: string) => void,
) {
if (!world) {
return;
}
for (const windowState of [...historyWindows]) {
if (!refreshHistoryWindow(world, windowState, renderRecentEvents)) {
destroyWindow(windowState.id);
}
}
}
export function destroyHistoryWindow(
historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
id: string,
) {
const index = historyWindows.findIndex((windowState) => windowState.id === id);
if (index < 0) {
return {
historyWindowDragId,
historyWindowDragPointerId,
};
}
const [removed] = historyWindows.splice(index, 1);
removed.root.remove();
if (historyWindowDragId === id) {
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,
};
}
return {
historyWindowDragId,
historyWindowDragPointerId,
};
}
export function bringHistoryWindowToFront(windowState: HistoryWindowState, nextZIndex: number) {
windowState.root.style.zIndex = `${nextZIndex}`;
}
export function beginHistoryWindowDrag(
historyWindows: HistoryWindowState[],
historyWindowDragOffset: THREE.Vector2,
pointerId: number,
windowId: string,
clientX: number,
clientY: number,
) {
const windowState = historyWindows.find((candidate) => candidate.id === windowId);
if (!windowState) {
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,
};
}
const bounds = windowState.root.getBoundingClientRect();
historyWindowDragOffset.set(clientX - bounds.left, clientY - bounds.top);
windowState.root.setPointerCapture?.(pointerId);
return {
historyWindowDragId: windowId,
historyWindowDragPointerId: pointerId,
};
}
export function updateHistoryWindowDrag(
historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
historyWindowDragOffset: THREE.Vector2,
pointerId: number,
clientX: number,
clientY: number,
) {
if (historyWindowDragPointerId !== pointerId || !historyWindowDragId) {
return;
}
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
if (!windowState) {
return;
}
const width = windowState.root.offsetWidth;
const height = windowState.root.offsetHeight;
const left = THREE.MathUtils.clamp(clientX - historyWindowDragOffset.x, 20, window.innerWidth - width - 20);
const top = THREE.MathUtils.clamp(clientY - historyWindowDragOffset.y, 20, window.innerHeight - height - 20);
windowState.root.style.left = `${left}px`;
windowState.root.style.top = `${top}px`;
}
export function endHistoryWindowDrag(
historyWindows: HistoryWindowState[],
historyWindowDragId: string | undefined,
historyWindowDragPointerId: number | undefined,
pointerId: number,
) {
if (historyWindowDragPointerId !== pointerId || !historyWindowDragId) {
return {
historyWindowDragId,
historyWindowDragPointerId,
};
}
const windowState = historyWindows.find((candidate) => candidate.id === historyWindowDragId);
windowState?.root.releasePointerCapture?.(pointerId);
return {
historyWindowDragId: undefined,
historyWindowDragPointerId: undefined,
};
}
export async function copyTextToClipboard(text: string) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
}
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.width = "1px";
textarea.style.height = "1px";
textarea.style.opacity = "0";
document.body.append(textarea);
textarea.focus();
textarea.select();
try {
const copied = document.execCommand("copy");
if (!copied) {
throw new Error("execCommand copy failed");
}
} finally {
textarea.remove();
}
}

View File

@@ -0,0 +1,175 @@
import * as THREE from "three";
import {
beginHistoryWindowDrag,
bringHistoryWindowToFront,
copyTextToClipboard,
destroyHistoryWindow,
endHistoryWindowDrag,
openHistoryWindow,
refreshHistoryWindows,
updateHistoryWindowDrag,
} from "./viewerHistoryManager";
import type { HistoryWindowState, Selectable, WorldState } from "./viewerTypes";
export interface ViewerHistoryWindowContext {
historyLayerEl: HTMLDivElement;
historyWindows: HistoryWindowState[];
getWorld: () => WorldState | undefined;
getHistoryWindowCounter: () => number;
setHistoryWindowCounter: (value: number) => void;
getHistoryWindowZCounter: () => number;
setHistoryWindowZCounter: (value: number) => void;
getHistoryWindowDragId: () => string | undefined;
setHistoryWindowDragId: (value: string | undefined) => void;
getHistoryWindowDragPointerId: () => number | undefined;
setHistoryWindowDragPointerId: (value: number | undefined) => void;
historyWindowDragOffset: THREE.Vector2;
renderRecentEvents: (entityKind: string, entityId: string) => string;
}
export class ViewerHistoryWindowController {
constructor(private readonly context: ViewerHistoryWindowContext) {}
openHistoryWindow(target: Selectable) {
const nextCounter = openHistoryWindow(
this.context.historyWindows,
this.context.historyLayerEl,
target,
this.context.getHistoryWindowCounter() + 1,
(windowState) => this.bringHistoryWindowToFront(windowState),
() => this.refreshHistoryWindows(),
);
this.context.setHistoryWindowCounter(nextCounter);
}
refreshHistoryWindows() {
refreshHistoryWindows(
this.context.getWorld(),
this.context.historyWindows,
this.context.renderRecentEvents,
(id) => this.destroyHistoryWindow(id),
);
}
readonly onHistoryLayerClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
const windowId = windowEl?.dataset.historyWindowId;
if (!windowId) {
return;
}
if (target.closest(".history-window-copy")) {
void this.copyHistoryWindowContent(windowId);
return;
}
if (target.closest(".history-window-close")) {
this.destroyHistoryWindow(windowId);
return;
}
const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId);
if (windowState) {
this.bringHistoryWindowToFront(windowState);
}
};
readonly onHistoryLayerPointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const windowEl = target.closest<HTMLElement>("[data-history-window-id]");
const windowId = windowEl?.dataset.historyWindowId;
if (!windowEl || !windowId) {
return;
}
const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId);
if (!windowState) {
return;
}
this.bringHistoryWindowToFront(windowState);
if (!target.closest(".history-window-header") || target.closest("button")) {
return;
}
const nextState = beginHistoryWindowDrag(
this.context.historyWindows,
this.context.historyWindowDragOffset,
event.pointerId,
windowId,
event.clientX,
event.clientY,
);
this.context.setHistoryWindowDragId(nextState.historyWindowDragId);
this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId);
};
readonly onHistoryWindowPointerMove = (event: PointerEvent) => {
updateHistoryWindowDrag(
this.context.historyWindows,
this.context.getHistoryWindowDragId(),
this.context.getHistoryWindowDragPointerId(),
this.context.historyWindowDragOffset,
event.pointerId,
event.clientX,
event.clientY,
);
};
readonly onHistoryWindowPointerUp = (event: PointerEvent) => {
const nextState = endHistoryWindowDrag(
this.context.historyWindows,
this.context.getHistoryWindowDragId(),
this.context.getHistoryWindowDragPointerId(),
event.pointerId,
);
this.context.setHistoryWindowDragId(nextState.historyWindowDragId);
this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId);
};
private destroyHistoryWindow(id: string) {
const nextState = destroyHistoryWindow(
this.context.historyWindows,
this.context.getHistoryWindowDragId(),
this.context.getHistoryWindowDragPointerId(),
id,
);
this.context.setHistoryWindowDragId(nextState.historyWindowDragId);
this.context.setHistoryWindowDragPointerId(nextState.historyWindowDragPointerId);
}
private async copyHistoryWindowContent(windowId: string) {
const windowState = this.context.historyWindows.find((candidate) => candidate.id === windowId);
if (!windowState?.text) {
return;
}
try {
await copyTextToClipboard(windowState.text);
windowState.copyButtonEl.textContent = "Copied";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
}, 1200);
} catch {
windowState.copyButtonEl.textContent = "Failed";
window.setTimeout(() => {
windowState.copyButtonEl.textContent = "Copy";
}, 1200);
}
}
private bringHistoryWindowToFront(windowState: HistoryWindowState) {
const nextZIndex = this.context.getHistoryWindowZCounter() + 1;
this.context.setHistoryWindowZCounter(nextZIndex);
bringHistoryWindowToFront(windowState, nextZIndex);
}
}

View File

@@ -0,0 +1,71 @@
export interface ViewerHudElements {
root: HTMLDivElement;
statusEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
factionStripEl: HTMLDivElement;
networkPanelEl: HTMLDivElement;
performancePanelEl: HTMLDivElement;
errorEl: HTMLDivElement;
historyLayerEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
hoverLabelEl: HTMLDivElement;
}
export function createViewerHud(documentRef: Document): ViewerHudElements {
const root = documentRef.createElement("div");
root.className = "viewer-shell";
root.innerHTML = `
<div class="left-panel-stack">
<header class="topbar">
<h2>Game</h2>
<div class="topbar-body">Bootstrapping</div>
</header>
<aside class="network-panel">
<h2>Network</h2>
<div class="network-body">Waiting for snapshot.</div>
</aside>
<aside class="performance-panel">
<h2>Performance</h2>
<div class="performance-body">Waiting for frame samples.</div>
</aside>
</div>
<div class="right-panel-stack">
<aside class="info-panel system-panel-section">
<h2>System</h2>
<h3 class="system-title">Deep Space</h3>
<div class="system-body">Waiting for the authoritative snapshot.</div>
</aside>
<aside class="info-panel detail-panel-section">
<h2>Focus</h2>
<h3 class="detail-title">Nothing selected</h3>
<div class="detail-body">Waiting for the authoritative snapshot.</div>
</aside>
<div class="error-strip" hidden></div>
</div>
<div class="history-layer"></div>
<section class="ship-strip"></section>
<div class="marquee-box"></div>
<div class="hover-label" hidden></div>
`;
return {
root,
statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
systemPanelEl: root.querySelector(".system-panel-section") as HTMLDivElement,
systemTitleEl: root.querySelector(".system-title") as HTMLHeadingElement,
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement,
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
errorEl: root.querySelector(".error-strip") as HTMLDivElement,
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
marqueeEl: root.querySelector(".marquee-box") as HTMLDivElement,
hoverLabelEl: root.querySelector(".hover-label") as HTMLDivElement,
};
}

View File

@@ -0,0 +1,131 @@
import * as THREE from "three";
import { getSelectionGroup } from "./viewerSelection";
import type { Selectable, SelectionGroup, WorldState } from "./viewerTypes";
export function pickSelectableAtClientPosition(
renderer: THREE.WebGLRenderer,
raycaster: THREE.Raycaster,
mouse: THREE.Vector2,
camera: THREE.Camera,
selectableTargets: Map<THREE.Object3D, Selectable>,
clientX: number,
clientY: number,
) {
const bounds = renderer.domElement.getBoundingClientRect();
mouse.x = ((clientX - bounds.left) / bounds.width) * 2 - 1;
mouse.y = -(((clientY - bounds.top) / bounds.height) * 2 - 1);
raycaster.setFromCamera(mouse, camera);
const hit = raycaster.intersectObjects([...selectableTargets.keys()], false)[0];
return hit ? selectableTargets.get(hit.object) : undefined;
}
export function updateHoverLabel(params: {
dragMode?: string;
hoverLabelEl: HTMLDivElement;
selection: Selectable | undefined;
activeSystemId?: string;
world?: WorldState;
point: THREE.Vector2;
}) {
const {
dragMode,
hoverLabelEl,
selection,
activeSystemId,
world,
point,
} = params;
if (dragMode) {
hoverLabelEl.hidden = true;
return;
}
if (!selection || selection.kind !== "system" || selection.id === activeSystemId) {
hoverLabelEl.hidden = true;
return;
}
const system = world?.systems.get(selection.id);
if (!system) {
hoverLabelEl.hidden = true;
return;
}
hoverLabelEl.hidden = false;
hoverLabelEl.textContent = system.label;
hoverLabelEl.style.left = `${point.x + 14}px`;
hoverLabelEl.style.top = `${point.y + 14}px`;
}
export function updateMarqueeBox(
marqueeEl: HTMLDivElement,
dragStart: THREE.Vector2,
dragLast: THREE.Vector2,
) {
const minX = Math.min(dragStart.x, dragLast.x);
const minY = Math.min(dragStart.y, dragLast.y);
const maxX = Math.max(dragStart.x, dragLast.x);
const maxY = Math.max(dragStart.y, dragLast.y);
marqueeEl.style.left = `${minX}px`;
marqueeEl.style.top = `${minY}px`;
marqueeEl.style.width = `${maxX - minX}px`;
marqueeEl.style.height = `${maxY - minY}px`;
}
export function hideMarqueeBox(marqueeEl: HTMLDivElement) {
marqueeEl.style.display = "none";
marqueeEl.style.width = "0";
marqueeEl.style.height = "0";
}
export function completeMarqueeSelection(params: {
renderer: THREE.WebGLRenderer;
camera: THREE.Camera;
dragStart: THREE.Vector2;
dragLast: THREE.Vector2;
selectableTargets: Map<THREE.Object3D, Selectable>;
}) {
const {
renderer,
camera,
dragStart,
dragLast,
selectableTargets,
} = params;
const bounds = renderer.domElement.getBoundingClientRect();
const minX = Math.min(dragStart.x, dragLast.x);
const minY = Math.min(dragStart.y, dragLast.y);
const maxX = Math.max(dragStart.x, dragLast.x);
const maxY = Math.max(dragStart.y, dragLast.y);
const grouped = new Map<SelectionGroup, Selectable[]>();
for (const [object, selectable] of selectableTargets.entries()) {
if (object instanceof THREE.Sprite && !object.visible) {
continue;
}
if (!object.visible) {
continue;
}
const worldPosition = new THREE.Vector3();
object.getWorldPosition(worldPosition);
worldPosition.project(camera);
const screenX = ((worldPosition.x + 1) * 0.5) * bounds.width;
const screenY = ((1 - worldPosition.y) * 0.5) * bounds.height;
if (screenX < minX || screenX > maxX || screenY < minY || screenY > maxY) {
continue;
}
const group = getSelectionGroup(selectable);
const list = grouped.get(group) ?? [];
if (!list.some((entry) => JSON.stringify(entry) === JSON.stringify(selectable))) {
list.push(selectable);
}
grouped.set(group, list);
}
return [...grouped.entries()]
.sort((left, right) => right[1].length - left[1].length)[0]?.[1] ?? [];
}

View File

@@ -0,0 +1,297 @@
import * as THREE from "three";
import {
completeMarqueeSelection,
hideMarqueeBox,
pickSelectableAtClientPosition,
updateHoverLabel,
updateMarqueeBox,
} from "./viewerInteraction";
import {
applyKeyboardControl,
toggleCameraMode,
zoomFromWheel,
} from "./viewerControls";
import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
import type {
CameraMode,
DragMode,
Selectable,
WorldState,
} from "./viewerTypes";
export interface ViewerInteractionContext {
renderer: THREE.WebGLRenderer;
raycaster: THREE.Raycaster;
mouse: THREE.Vector2;
camera: THREE.PerspectiveCamera;
selectableTargets: Map<THREE.Object3D, Selectable>;
hoverLabelEl: HTMLDivElement;
marqueeEl: HTMLDivElement;
keyState: Set<string>;
getWorld: () => WorldState | undefined;
getActiveSystemId: () => string | undefined;
getSelectedItems: () => Selectable[];
setSelectedItems: (items: Selectable[]) => void;
getDragMode: () => DragMode | undefined;
setDragMode: (mode: DragMode | undefined) => void;
getDragPointerId: () => number | undefined;
setDragPointerId: (pointerId: number | undefined) => void;
dragStart: THREE.Vector2;
dragLast: THREE.Vector2;
getMarqueeActive: () => boolean;
setMarqueeActive: (value: boolean) => void;
getSuppressClickSelection: () => boolean;
setSuppressClickSelection: (value: boolean) => void;
getDesiredDistance: () => number;
setDesiredDistance: (value: number) => void;
getCameraMode: () => CameraMode;
setCameraMode: (value: CameraMode) => void;
getCameraTargetShipId: () => string | undefined;
setCameraTargetShipId: (value: string | undefined) => void;
getFollowCameraPosition: () => THREE.Vector3;
getFollowCameraFocus: () => THREE.Vector3;
screenPointFromClient: (clientX: number, clientY: number) => THREE.Vector2;
applyOrbitDelta: (delta: THREE.Vector2) => void;
syncFollowStateFromSelection: () => void;
updatePanels: () => void;
focusOnSelection: (selection: Selectable) => void;
updateGamePanel: (mode: string) => void;
historyController: ViewerHistoryWindowController;
}
export class ViewerInteractionController {
constructor(private readonly context: ViewerInteractionContext) {}
readonly onPointerDown = (event: PointerEvent) => {
if (event.button === 1) {
this.context.setDragMode("orbit");
this.context.setDragPointerId(event.pointerId);
this.context.dragLast.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.renderer.domElement.setPointerCapture(event.pointerId);
return;
}
if (event.button !== 0) {
return;
}
this.context.setDragMode("marquee");
this.context.setDragPointerId(event.pointerId);
this.context.dragStart.copy(this.context.screenPointFromClient(event.clientX, event.clientY));
this.context.dragLast.copy(this.context.dragStart);
this.context.setMarqueeActive(false);
this.context.renderer.domElement.setPointerCapture(event.pointerId);
};
readonly onPointerMove = (event: PointerEvent) => {
this.updateHoverLabel(event);
if (this.context.getDragPointerId() !== event.pointerId || !this.context.getDragMode()) {
return;
}
const point = this.context.screenPointFromClient(event.clientX, event.clientY);
if (this.context.getDragMode() === "orbit") {
const delta = point.clone().sub(this.context.dragLast);
this.context.dragLast.copy(point);
this.context.applyOrbitDelta(delta);
return;
}
const dragDistance = point.distanceTo(this.context.dragStart);
if (!this.context.getMarqueeActive() && dragDistance > 8) {
this.context.setMarqueeActive(true);
this.context.setSuppressClickSelection(true);
this.context.marqueeEl.style.display = "block";
}
if (!this.context.getMarqueeActive()) {
return;
}
this.context.dragLast.copy(point);
updateMarqueeBox(this.context.marqueeEl, this.context.dragStart, this.context.dragLast);
};
readonly onPointerUp = (event: PointerEvent) => {
if (this.context.getDragPointerId() !== event.pointerId) {
return;
}
if (this.context.renderer.domElement.hasPointerCapture(event.pointerId)) {
this.context.renderer.domElement.releasePointerCapture(event.pointerId);
}
if (this.context.getDragMode() === "marquee" && this.context.getMarqueeActive()) {
this.completeMarqueeSelection();
hideMarqueeBox(this.context.marqueeEl);
}
this.context.setDragMode(undefined);
this.context.setDragPointerId(undefined);
this.context.setMarqueeActive(false);
};
readonly onClick = (event: MouseEvent) => {
if (this.context.getSuppressClickSelection()) {
this.context.setSuppressClickSelection(false);
return;
}
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
this.context.setSelectedItems(picked ? [picked] : []);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
};
readonly onShipStripClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
const historyButton = target.closest<HTMLElement>("[data-history-ship-id]");
const historyShipId = historyButton?.dataset.historyShipId;
if (historyShipId) {
this.context.historyController.openHistoryWindow({ kind: "ship", id: historyShipId });
return;
}
const card = target.closest<HTMLElement>("[data-ship-id]");
const shipId = card?.dataset.shipId;
if (!shipId) {
return;
}
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
};
readonly onShipStripDoubleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.closest("[data-history-ship-id]")) {
return;
}
const card = target.closest<HTMLElement>("[data-ship-id]");
const shipId = card?.dataset.shipId;
if (!shipId) {
return;
}
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
this.context.syncFollowStateFromSelection();
this.context.focusOnSelection({ kind: "ship", id: shipId });
this.toggleCameraMode("follow");
this.context.updatePanels();
this.context.updateGamePanel("Live");
};
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
readonly onHistoryLayerPointerDown = (event: PointerEvent) => this.context.historyController.onHistoryLayerPointerDown(event);
readonly onHistoryWindowPointerMove = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerMove(event);
readonly onHistoryWindowPointerUp = (event: PointerEvent) => this.context.historyController.onHistoryWindowPointerUp(event);
readonly onDoubleClick = () => {
const selectedItems = this.context.getSelectedItems();
if (selectedItems.length !== 1) {
return;
}
this.context.focusOnSelection(selectedItems[0]);
this.context.syncFollowStateFromSelection();
};
readonly onWheel = (event: WheelEvent) => {
event.preventDefault();
this.context.setDesiredDistance(zoomFromWheel(this.context.getDesiredDistance(), event.deltaY));
this.context.updateGamePanel("Live");
};
readonly onKeyDown = (event: KeyboardEvent) => {
if (event.repeat) {
return;
}
const key = event.key.toLowerCase();
const controlState = applyKeyboardControl({
keyState: this.context.keyState,
cameraMode: this.context.getCameraMode(),
desiredDistance: this.context.getDesiredDistance(),
key,
});
this.context.setCameraMode(controlState.cameraMode);
this.context.setDesiredDistance(controlState.desiredDistance);
if (key === "c") {
this.toggleCameraMode();
}
this.context.updateGamePanel("Live");
};
readonly onKeyUp = (event: KeyboardEvent) => {
this.context.keyState.delete(event.key.toLowerCase());
};
updateHoverLabel(event: PointerEvent) {
updateHoverLabel({
dragMode: this.context.getDragMode(),
hoverLabelEl: this.context.hoverLabelEl,
selection: this.pickSelectableAtClientPosition(event.clientX, event.clientY),
activeSystemId: this.context.getActiveSystemId(),
world: this.context.getWorld(),
point: this.context.screenPointFromClient(event.clientX, event.clientY),
});
}
refreshHistoryWindows() {
this.context.historyController.refreshHistoryWindows();
}
toggleCameraMode(forceMode?: CameraMode) {
const nextState = toggleCameraMode({
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
selectedItems: this.context.getSelectedItems(),
desiredDistance: this.context.getDesiredDistance(),
followCameraPosition: this.context.getFollowCameraPosition(),
followCameraFocus: this.context.getFollowCameraFocus(),
forceMode,
});
this.context.setCameraMode(nextState.cameraMode);
this.context.setCameraTargetShipId(nextState.cameraTargetShipId);
this.context.setDesiredDistance(nextState.desiredDistance);
}
private pickSelectableAtClientPosition(clientX: number, clientY: number) {
return pickSelectableAtClientPosition(
this.context.renderer,
this.context.raycaster,
this.context.mouse,
this.context.camera,
this.context.selectableTargets,
clientX,
clientY,
);
}
private completeMarqueeSelection() {
const selection = completeMarqueeSelection({
renderer: this.context.renderer,
camera: this.context.camera,
dragStart: this.context.dragStart,
dragLast: this.context.dragLast,
selectableTargets: this.context.selectableTargets,
});
this.context.setSelectedItems(selection);
this.context.syncFollowStateFromSelection();
this.context.updatePanels();
}
}

View File

@@ -0,0 +1,193 @@
import * as THREE from "three";
import { MOON_RENDER_SCALE } from "./viewerConstants";
import type {
PlanetSnapshot,
Vector3Dto,
WorldSnapshot,
} from "./contracts";
import type {
OrbitalAnchor,
WorldState,
ZoomLevel,
} from "./viewerTypes";
import type { ZoomBlend } from "./viewerConstants";
export function formatInventory(entries: { itemId: string; amount: number }[]): string {
if (entries.length === 0) {
return "empty";
}
return entries
.map((entry) => `${entry.itemId} ${entry.amount.toFixed(0)}`)
.join("<br>");
}
export function inventoryAmount(entries: { itemId: string; amount: number }[], itemId: string): number {
return entries.find((entry) => entry.itemId === itemId)?.amount ?? 0;
}
export function formatVector(vector: Vector3Dto): string {
return `${vector.x.toFixed(1)}, ${vector.y.toFixed(1)}, ${vector.z.toFixed(1)}`;
}
export function formatBytes(bytes: number): string {
if (bytes >= 1024 * 1024) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
if (bytes >= 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${Math.round(bytes)} B`;
}
export function smoothBand(value: number, start: number, end: number): number {
const t = THREE.MathUtils.clamp((value - start) / Math.max(end - start, 1), 0, 1);
return t * t * (3 - (2 * t));
}
export function computeZoomBlend(distance: number): ZoomBlend {
const localToSystem = smoothBand(distance, 1200, 5200);
const systemToUniverse = smoothBand(distance, 9000, 22000);
return {
localWeight: 1 - localToSystem,
systemWeight: Math.min(localToSystem, 1 - systemToUniverse),
universeWeight: systemToUniverse,
};
}
export function classifyZoomLevel(distance: number): ZoomLevel {
const blend = computeZoomBlend(distance);
if (blend.localWeight >= blend.systemWeight && blend.localWeight >= blend.universeWeight) {
return "local";
}
if (blend.systemWeight >= blend.universeWeight) {
return "system";
}
return "universe";
}
export function toThreeVector(vector: Vector3Dto): THREE.Vector3 {
return new THREE.Vector3(vector.x, vector.y, vector.z);
}
export function currentWorldTimeSeconds(world: WorldState | undefined, worldTimeSyncMs: number): number {
if (!world) {
return 0;
}
const baseUtcMs = Date.parse(world.generatedAtUtc);
const elapsedMs = performance.now() - worldTimeSyncMs;
return ((baseUtcMs + elapsedMs) / 1000) + (world.seed * 97);
}
export function hashUnit(seed: number, value: string): number {
let hash = seed;
for (let index = 0; index < value.length; index += 1) {
hash = ((hash << 5) - hash) + value.charCodeAt(index);
hash |= 0;
}
return (hash >>> 0) / 0xffffffff;
}
export function computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds: number, phaseOverrideDegrees?: number): THREE.Vector3 {
const eccentricity = THREE.MathUtils.clamp(planet.orbitEccentricity, 0, 0.85);
const meanAnomaly = THREE.MathUtils.degToRad(phaseOverrideDegrees ?? planet.orbitPhaseAtEpoch) + (timeSeconds * planet.orbitSpeed);
const eccentricAnomaly = meanAnomaly
+ (eccentricity * Math.sin(meanAnomaly))
+ (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly));
const semiMajorAxis = planet.orbitRadius;
const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05));
const local = new THREE.Vector3(
semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity),
0,
semiMinorAxis * Math.sin(eccentricAnomaly),
);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitArgumentOfPeriapsis));
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(planet.orbitInclination));
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitLongitudeOfAscendingNode));
return local;
}
export function computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const spacing = planet.size * 1.4;
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:radius`) * planet.size * 0.9;
return (planet.size * 1.8) + (moonIndex * spacing) + variance;
}
export function computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const radius = computeMoonOrbitRadius(planet, moonIndex, seed);
return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003);
}
export function computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number, seed: number): THREE.Vector3 {
const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed);
const speed = computeMoonOrbitSpeed(planet, moonIndex, seed);
const phase = hashUnit(seed, `${planet.label}:${moonIndex}:phase`) * Math.PI * 2;
const inclination = THREE.MathUtils.degToRad((hashUnit(seed, `${planet.label}:${moonIndex}:inclination`) - 0.5) * 28);
const node = THREE.MathUtils.degToRad(hashUnit(seed, `${planet.label}:${moonIndex}:node`) * 360);
const angle = phase + (timeSeconds * speed);
const local = new THREE.Vector3(
Math.cos(angle) * orbitRadius,
0,
Math.sin(angle) * orbitRadius,
);
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination);
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node);
return local;
}
export function computeMoonSize(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
const base = Math.max(2.2, planet.size * 0.11);
const variance = hashUnit(seed, `${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5);
return Math.min(base + variance, planet.size * 0.42);
}
export function celestialRenderRadius(size: number, scale: number, minRadius: number, exponent = 1): number {
return Math.max(minRadius, Math.pow(Math.max(size, 0.1), exponent) * scale);
}
export function computeMoonRenderRadius(planet: PlanetSnapshot, moonIndex: number, seed: number): number {
return celestialRenderRadius(computeMoonSize(planet, moonIndex, seed), MOON_RENDER_SCALE, 2.5, 1.04);
}
export function starHaloOpacity(starKind: string): number {
if (starKind.includes("neutron")) {
return 0.22;
}
if (starKind.includes("white-dwarf")) {
return 0.18;
}
if (starKind.includes("brown-dwarf")) {
return 0.1;
}
return 0.14;
}
export function resolveOrbitalAnchorPosition(
world: WorldState | undefined,
systemId: string,
anchor: OrbitalAnchor,
timeSeconds: number,
seed: number,
): THREE.Vector3 {
if (!world || anchor.kind === "star") {
return new THREE.Vector3();
}
const system = world.systems.get(systemId);
const planet = system?.planets[anchor.planetIndex];
if (!system || !planet) {
return new THREE.Vector3();
}
const planetPosition = computePlanetLocalPosition(planet, timeSeconds);
if (anchor.kind === "planet") {
return planetPosition;
}
return planetPosition.add(computeMoonLocalPosition(planet, anchor.moonIndex, timeSeconds, seed));
}

View File

@@ -0,0 +1,193 @@
import * as THREE from "three";
import {
determineActiveSystemId,
focusOnSelection,
getCameraFocusWorldPosition,
resolveSelectionPosition,
seedSystemFocusLocal,
toDisplayLocalPosition,
} from "./viewerCamera";
import {
syncFollowStateFromSelection,
updateFollowCamera,
updateSystemDetailVisibility,
} from "./viewerControls";
import { computeNodeLocalPosition, resolveBubblePosition, resolvePointPosition } from "./viewerWorldPresentation";
import { getAnimatedShipLocalPosition, resolveShipHeading } from "./viewerPresentation";
import type {
CameraMode,
NodeVisual,
PlanetVisual,
Selectable,
ShipVisual,
SystemVisual,
WorldState,
} from "./viewerTypes";
export interface ViewerNavigationContext {
getWorld: () => WorldState | undefined;
getWorldTimeSyncMs: () => number;
getActiveSystemId: () => string | undefined;
setActiveSystemId: (value: string | undefined) => void;
getCameraMode: () => CameraMode;
setCameraMode: (value: CameraMode) => void;
getCameraTargetShipId: () => string | undefined;
setCameraTargetShipId: (value: string | undefined) => void;
getCurrentDistance: () => number;
getSelectedItems: () => Selectable[];
getOrbitYaw: () => number;
galaxyFocus: THREE.Vector3;
systemFocusLocal: THREE.Vector3;
camera: THREE.PerspectiveCamera;
shipVisuals: Map<string, ShipVisual>;
nodeVisuals: Map<string, NodeVisual>;
planetVisuals: PlanetVisual[];
systemVisuals: Map<string, SystemVisual>;
followCameraPosition: THREE.Vector3;
followCameraFocus: THREE.Vector3;
followCameraDirection: THREE.Vector3;
followCameraDesiredDirection: THREE.Vector3;
followCameraOffset: THREE.Vector3;
createWorldPresentationContext: () => any;
updatePanels: () => void;
updateGamePanel: (mode: string) => void;
}
export class ViewerNavigationController {
constructor(private readonly context: ViewerNavigationContext) {}
focusOnSelection(selection: Selectable) {
focusOnSelection({
world: this.context.getWorld(),
selection,
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolveBubblePosition: (bubbleId) => {
const bubble = this.context.getWorld()?.localBubbles.get(bubbleId);
return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined;
},
resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId),
activeSystemId: this.context.getActiveSystemId(),
galaxyFocus: this.context.galaxyFocus,
systemFocusLocal: this.context.systemFocusLocal,
});
}
resolveSelectionPosition(selection: Selectable) {
return resolveSelectionPosition({
world: this.context.getWorld(),
selection,
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolveBubblePosition: (bubbleId) => {
const bubble = this.context.getWorld()?.localBubbles.get(bubbleId);
return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined;
},
resolvePointPosition: (systemId, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemId, nodeId),
});
}
updateActiveSystem() {
const nextActiveSystemId = determineActiveSystemId({
world: this.context.getWorld(),
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
currentDistance: this.context.getCurrentDistance(),
selectedItems: this.context.getSelectedItems(),
galaxyFocus: this.context.galaxyFocus,
});
if (nextActiveSystemId === this.context.getActiveSystemId()) {
return;
}
if (nextActiveSystemId) {
this.seedSystemFocusLocal(nextActiveSystemId);
}
this.context.setActiveSystemId(nextActiveSystemId);
this.updateSystemDetailVisibility();
this.context.updatePanels();
this.context.updateGamePanel("Live");
}
updateFollowCamera(delta: number) {
const nextState = updateFollowCamera({
world: this.context.getWorld(),
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
shipVisuals: this.context.shipVisuals,
currentDistance: this.context.getCurrentDistance(),
camera: this.context.camera,
followCameraPosition: this.context.followCameraPosition,
followCameraFocus: this.context.followCameraFocus,
followCameraDirection: this.context.followCameraDirection,
followCameraDesiredDirection: this.context.followCameraDesiredDirection,
followCameraOffset: this.context.followCameraOffset,
systemFocusLocal: this.context.systemFocusLocal,
delta,
getAnimatedShipLocalPosition,
toDisplayLocalPosition: (localPosition, systemId) => this.toDisplayLocalPosition(localPosition, systemId),
resolveShipHeading: (visual, worldPosition) => resolveShipHeading(visual, worldPosition, this.context.getOrbitYaw()),
});
this.context.setCameraMode(nextState.cameraMode);
this.context.setCameraTargetShipId(nextState.cameraTargetShipId);
return nextState.handled;
}
syncFollowStateFromSelection() {
const nextState = syncFollowStateFromSelection(
this.context.getSelectedItems(),
this.context.getCameraMode(),
this.context.getCameraTargetShipId(),
);
this.context.setCameraMode(nextState.cameraMode);
this.context.setCameraTargetShipId(nextState.cameraTargetShipId);
}
updateSystemDetailVisibility() {
updateSystemDetailVisibility(this.context.systemVisuals, this.context.getActiveSystemId());
}
getCameraFocusWorldPosition() {
return getCameraFocusWorldPosition({
world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(),
galaxyFocus: this.context.galaxyFocus,
systemFocusLocal: this.context.systemFocusLocal,
});
}
seedSystemFocusLocal(systemId: string) {
seedSystemFocusLocal({
world: this.context.getWorld(),
systemId,
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
selectedItems: this.context.getSelectedItems(),
systemFocusLocal: this.context.systemFocusLocal,
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
nodeVisuals: this.context.nodeVisuals,
planetVisuals: this.context.planetVisuals,
computeNodeLocalPosition: (node, timeSeconds) => computeNodeLocalPosition(this.context.createWorldPresentationContext(), node, timeSeconds),
resolveBubblePosition: (bubbleId) => {
const bubble = this.context.getWorld()?.localBubbles.get(bubbleId);
return bubble ? resolveBubblePosition(this.context.createWorldPresentationContext(), bubble) : undefined;
},
resolvePointPosition: (systemIdValue, nodeId) => resolvePointPosition(this.context.createWorldPresentationContext(), systemIdValue, nodeId),
});
}
toDisplayLocalPosition(localPosition: THREE.Vector3, systemId?: string) {
return toDisplayLocalPosition({
world: this.context.getWorld(),
systemId,
activeSystemId: this.context.getActiveSystemId(),
localPosition,
systemFocusLocal: this.context.systemFocusLocal,
});
}
}

View File

@@ -0,0 +1,296 @@
import { formatInventory, formatVector } from "./viewerMath";
import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
HistoryWindowState,
NodeVisual,
OrbitalAnchor,
Selectable,
ShipVisual,
StructureVisual,
WorldState,
} from "./viewerTypes";
interface DetailPanelParams {
world: WorldState;
selectedItems: Selectable[];
zoomLevel: string;
cameraMode: CameraMode;
cameraTargetShipId?: string;
worldLabel: string;
describeSelectionParent: (selection: Selectable) => string;
}
interface SystemPanelParams {
world: WorldState;
activeSystemId?: string;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
cameraMode: CameraMode;
cameraTargetShipId?: string;
}
export function updateDetailPanel(
detailTitleEl: HTMLHeadingElement,
detailBodyEl: HTMLDivElement,
params: DetailPanelParams,
) {
const {
world,
selectedItems,
zoomLevel,
cameraMode,
cameraTargetShipId,
worldLabel,
describeSelectionParent,
} = params;
if (selectedItems.length === 0) {
detailTitleEl.textContent = worldLabel;
detailBodyEl.innerHTML = `
Zoom ${zoomLevel}<br>
Systems ${world.systems.size}<br>
Spatial nodes ${world.spatialNodes.size}<br>
Bubbles ${world.localBubbles.size}<br>
Stations ${world.stations.size}<br>
Claims ${world.claims.size}<br>
Construction ${world.constructionSites.size}<br>
Ships ${world.ships.size}<br>
Recent events ${world.recentEvents.length}
`;
return;
}
if (selectedItems.length > 1) {
const group = getSelectionGroup(selectedItems[0]);
detailTitleEl.textContent = `${selectedItems.length} selected`;
detailBodyEl.innerHTML = `
Type ${group}<br>
${selectedItems.slice(0, 8).map((item) => describeSelectable(world, item)).join("<br>")}
`;
return;
}
const selected = selectedItems[0];
if (selected.kind === "ship") {
const ship = world.ships.get(selected.id);
if (!ship) {
return;
}
const parent = describeSelectionParent(selected);
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
detailTitleEl.textContent = ship.label;
detailBodyEl.innerHTML = `
<p>Parent ${parent}</p>
<p>State ${ship.state}</p>
<p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
<p>Inventory ${formatInventory(ship.inventory)}</p>
<p>Velocity ${formatVector(ship.localVelocity)}</p>
<p>Camera ${cameraMode === "follow" && cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
`;
return;
}
if (selected.kind === "station") {
const station = world.stations.get(selected.id);
if (!station) {
return;
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = station.label;
detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
<p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips} / ${station.dockingPads}</p>
<p>Inventory ${formatInventory(station.inventory)}</p>
<p>History available in the separate history window.</p>
`;
return;
}
if (selected.kind === "node") {
const node = world.nodes.get(selected.id);
if (!node) {
return;
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = `Node ${node.id}`;
detailBodyEl.innerHTML = `
<p>${node.systemId}</p>
<p>Parent ${parent}</p>
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
return;
}
if (selected.kind === "spatial-node") {
const node = world.spatialNodes.get(selected.id);
if (!node) {
return;
}
const bubble = world.localBubbles.get(node.bubbleId);
detailTitleEl.textContent = `${node.kind} node`;
detailBodyEl.innerHTML = `
<p>${node.systemId}</p>
<p>Bubble ${node.bubbleId}</p>
<p>Parent ${node.parentNodeId ?? "none"}<br>Orbit ref ${node.orbitReferenceId ?? "none"}</p>
<p>Occupying structure ${node.occupyingStructureId ?? "none"}</p>
<p>Bubble occupants ${bubble ? bubble.occupantShipIds.length + bubble.occupantStationIds.length : 0}</p>
`;
return;
}
if (selected.kind === "bubble") {
const bubble = world.localBubbles.get(selected.id);
if (!bubble) {
return;
}
detailTitleEl.textContent = `Bubble ${bubble.id}`;
detailBodyEl.innerHTML = `
<p>${bubble.systemId}</p>
<p>Anchor node ${bubble.nodeId}<br>Radius ${bubble.radius.toFixed(0)}</p>
<p>Ships ${bubble.occupantShipIds.length}<br>Stations ${bubble.occupantStationIds.length}</p>
<p>Claims ${bubble.occupantClaimIds.length}<br>Construction sites ${bubble.occupantConstructionSiteIds.length}</p>
`;
return;
}
if (selected.kind === "claim") {
const claim = world.claims.get(selected.id);
if (!claim) {
return;
}
detailTitleEl.textContent = `Claim ${claim.id}`;
detailBodyEl.innerHTML = `
<p>${claim.systemId}</p>
<p>Node ${claim.nodeId}<br>Bubble ${claim.bubbleId}</p>
<p>State ${claim.state}<br>Health ${claim.health.toFixed(0)}</p>
<p>Activates ${new Date(claim.activatesAtUtc).toLocaleTimeString()}</p>
`;
return;
}
if (selected.kind === "construction-site") {
const site = world.constructionSites.get(selected.id);
if (!site) {
return;
}
const orderCount = [...world.marketOrders.values()].filter((order) => order.constructionSiteId === site.id).length;
detailTitleEl.textContent = `Construction ${site.id}`;
detailBodyEl.innerHTML = `
<p>${site.systemId}</p>
<p>Node ${site.nodeId}<br>Bubble ${site.bubbleId}</p>
<p>${site.targetKind} ${site.targetDefinitionId}</p>
<p>State ${site.state}<br>Progress ${(site.progress * 100).toFixed(0)}%</p>
<p>Orders ${orderCount}<br>Assigned constructors ${site.assignedConstructorShipIds.length}</p>
`;
return;
}
if (selected.kind === "planet") {
const system = world.systems.get(selected.systemId);
const planet = system?.planets[selected.planetIndex];
if (!system || !planet) {
return;
}
const parent = describeSelectionParent(selected);
detailTitleEl.textContent = planet.label;
detailBodyEl.innerHTML = `
<p>${system.label}</p>
<p>Parent ${parent}</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>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
`;
return;
}
const system = world.systems.get(selected.id);
if (!system) {
return;
}
detailTitleEl.textContent = system.label;
detailBodyEl.innerHTML = `
<p>Parent galaxy</p>
${renderSystemDetails(world, system, false, cameraMode, cameraTargetShipId)}
`;
}
export function updateSystemPanel(params: SystemPanelParams) {
const {
world,
activeSystemId,
systemTitleEl,
systemBodyEl,
systemPanelEl,
cameraMode,
cameraTargetShipId,
} = params;
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
systemPanelEl.hidden = !activeSystem;
if (!activeSystem) {
systemTitleEl.textContent = "Deep Space";
systemBodyEl.innerHTML = "";
return;
}
systemTitleEl.textContent = activeSystem.label;
systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId);
}
export function describeSelectionParent(
world: WorldState | undefined,
selection: Selectable,
stationVisuals: Map<string, StructureVisual>,
nodeVisuals: Map<string, NodeVisual>,
) {
if (!world) {
return "unknown";
}
if (selection.kind === "system") {
return "galaxy";
}
if (selection.kind === "planet") {
const system = world.systems.get(selection.systemId);
return system ? `${system.label} star` : selection.systemId;
}
if (selection.kind === "ship") {
const ship = world.ships.get(selection.id);
if (!ship) {
return "unknown";
}
const system = world.systems.get(ship.systemId);
return system ? `${system.label} system` : ship.systemId;
}
if (selection.kind === "station") {
const station = world.stations.get(selection.id);
const visual = station ? stationVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, station?.systemId, visual?.anchor);
}
if (selection.kind === "node") {
const node = world.nodes.get(selection.id);
const visual = node ? nodeVisuals.get(selection.id) : undefined;
return describeOrbitalParent(world, node?.systemId, visual?.anchor);
}
if (selection.kind === "spatial-node") {
const node = world.spatialNodes.get(selection.id);
return node?.parentNodeId ?? `${node?.systemId ?? "unknown"} network`;
}
if (selection.kind === "bubble") {
return `${world.localBubbles.get(selection.id)?.nodeId ?? "unknown"} node`;
}
if (selection.kind === "claim") {
return world.claims.get(selection.id)?.nodeId ?? "unknown";
}
if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.nodeId ?? "unknown";
}
return "unknown";
}

View File

@@ -0,0 +1,126 @@
import * as THREE from "three";
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath";
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
const elapsedMs = now - visual.receivedAtMs;
const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1);
return new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT);
}
export function resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vector3, orbitYaw: number) {
const desiredHeading = visual.targetPosition.clone().sub(worldPosition);
if (desiredHeading.lengthSq() > 0.01) {
return desiredHeading;
}
if (visual.velocity.lengthSq() > 0.01) {
return visual.velocity.clone();
}
return new THREE.Vector3(Math.cos(orbitYaw), 0, Math.sin(orbitYaw));
}
export function updatePlanetPresentation(
world: WorldState | undefined,
worldTimeSyncMs: number,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
planetVisuals: PlanetVisual[],
) {
const nowSeconds = currentWorldTimeSeconds(world, worldTimeSyncMs);
for (const visual of planetVisuals) {
const scale = visual.systemId === activeSystemId ? ACTIVE_SYSTEM_DETAIL_SCALE : 1;
const localPosition = computePlanetLocalPosition(visual.planet, nowSeconds);
const orbitOffset = visual.systemId === activeSystemId
? systemFocusLocal.clone().multiplyScalar(-scale)
: new THREE.Vector3();
const position = visual.systemId === activeSystemId
? localPosition.clone().sub(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) {
visual.ring.position.copy(position);
}
for (const [moonIndex, moon] of visual.moons.entries()) {
moon.orbit.position.copy(position);
moon.orbit.scale.setScalar(scale);
moon.mesh.position.copy(position).add(
computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale),
);
}
}
}
export function updateSystemSummaryPresentation(
systemSummaryVisuals: Map<string, SystemSummaryVisual>,
camera: THREE.PerspectiveCamera,
activeSystemId?: string,
) {
const distanceScale = activeSystemId ? 0.05 : 0.085;
for (const [systemId, visual] of systemSummaryVisuals.entries()) {
const worldPosition = visual.sprite.getWorldPosition(new THREE.Vector3());
const distance = camera.position.distanceTo(worldPosition);
const minimumScale = activeSystemId && systemId !== activeSystemId ? 1200 : 1400;
const scale = Math.max(minimumScale, distance * distanceScale);
visual.sprite.scale.set(scale, scale * 0.3125, 1);
}
}
export function updateSystemStarPresentation(
systemVisuals: Map<string, SystemVisual>,
activeSystemId: string | undefined,
systemFocusLocal: THREE.Vector3,
camera: THREE.PerspectiveCamera,
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void,
) {
const activeSystem = activeSystemId ? systemVisuals.get(activeSystemId) : undefined;
for (const [systemId, visual] of 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;
setShellReticleOpacity(visual.shellReticle, 0);
continue;
}
if (systemId !== 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;
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 = camera.position.distanceTo(reticleWorldPosition);
const reticleScale = Math.max(900, reticleDistance * 0.032);
visual.shellReticle.scale.setScalar(reticleScale);
continue;
}
const offset = 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;
setShellReticleOpacity(visual.shellReticle, 0);
}
}

View File

@@ -0,0 +1,182 @@
import * as THREE from "three";
import { computeZoomBlend } from "./viewerMath";
import {
updateNetworkPanel as renderNetworkPanel,
recordPerformanceStats,
updatePerformancePanel as renderPerformancePanel,
} from "./viewerTelemetry";
import { updatePlanetPresentation } from "./viewerPresentation";
import { renderRecentEvents, updateGameStatus, updateSystemSummaries, updateWorldPresentation } from "./viewerWorldPresentation";
import { updateSystemPanel } from "./viewerPanels";
import { createBackdropStars, createNebulaClouds, createNebulaTexture } from "./viewerSceneFactory";
export interface ViewerPresentationContext {
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
ambienceGroup: THREE.Group;
statusEl: HTMLDivElement;
networkPanelEl: HTMLDivElement;
performancePanelEl: HTMLDivElement;
systemPanelEl: HTMLDivElement;
systemTitleEl: HTMLHeadingElement;
systemBodyEl: HTMLDivElement;
networkStats: any;
performanceStats: any;
getWorld: () => any;
getActiveSystemId: () => string | undefined;
getCameraMode: () => any;
getCameraTargetShipId: () => string | undefined;
getZoomLevel: () => any;
getWorldTimeSyncMs: () => number;
getCurrentDistance: () => number;
systemFocusLocal: THREE.Vector3;
planetVisuals: any[];
systemSummaryVisuals: Map<any, any>;
presentationEntries: any[];
orbitLines: THREE.Object3D[];
systemVisuals: Map<any, any>;
createWorldPresentationContext: () => any;
}
export class ViewerPresentationController {
constructor(private readonly context: ViewerPresentationContext) {}
initializeAmbience() {
this.context.ambienceGroup.renderOrder = -10;
this.context.ambienceGroup.add(createBackdropStars());
this.context.ambienceGroup.add(...createNebulaClouds(createNebulaTexture(document)));
}
updateAmbience(delta: number) {
this.context.ambienceGroup.position.copy(this.context.camera.position);
this.context.ambienceGroup.rotation.y += delta * 0.005;
this.context.ambienceGroup.rotation.x = Math.sin(performance.now() * 0.00003) * 0.015;
}
applyZoomPresentation() {
const activeSystemId = this.context.getActiveSystemId();
const blend = computeZoomBlend(this.context.getCurrentDistance());
for (const entry of this.context.presentationEntries) {
const systemId = entry.systemId;
const isActiveDetail = !systemId || systemId === activeSystemId;
const isProjectedSystemIcon = !!activeSystemId
&& !!systemId
&& systemId !== activeSystemId
&& this.context.systemVisuals.get(systemId)?.icon === entry.icon;
const detailAlpha = entry.hideDetailInUniverse
? Math.max(blend.localWeight, blend.systemWeight) * (isActiveDetail ? 1 : 0)
: 1;
const iconAlpha = isProjectedSystemIcon
? 0
: entry.hideIconInUniverse
? blend.systemWeight * (isActiveDetail ? 1 : 0)
: Math.max(blend.systemWeight, blend.universeWeight);
this.setObjectOpacity(entry.detail, detailAlpha);
this.setObjectOpacity(entry.icon, iconAlpha);
}
for (const orbitLine of this.context.orbitLines) {
const alpha = Math.max(blend.localWeight * 0.55, blend.systemWeight) * (activeSystemId ? 1 : 0);
this.setObjectOpacity(orbitLine, alpha);
}
for (const [systemId, summaryVisual] of this.context.systemSummaryVisuals.entries()) {
const summaryOpacity = systemId === activeSystemId
? 0
: (activeSystemId ? 0.72 : 0.96);
this.setObjectOpacity(summaryVisual.sprite, summaryOpacity);
}
this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
}
updateNetworkPanel() {
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
}
recordPerformanceStats(frameMs: number) {
recordPerformanceStats(this.context.performanceStats, frameMs);
}
updatePerformancePanel() {
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
}
updateShipPresentation() {
updateWorldPresentation(this.context.createWorldPresentationContext());
}
updatePlanetPresentation() {
const world = this.context.getWorld();
updatePlanetPresentation(
world,
this.context.getWorldTimeSyncMs(),
this.context.getActiveSystemId(),
this.context.systemFocusLocal,
this.context.planetVisuals,
);
}
updateSystemSummaries() {
updateSystemSummaries(this.context.getWorld(), this.context.systemSummaryVisuals);
}
renderRecentEvents(entityKind: string, entityId: string) {
return renderRecentEvents(this.context.getWorld(), entityKind, entityId);
}
updateGamePanel(mode: string) {
updateGameStatus({
statusEl: this.context.statusEl,
world: this.context.getWorld(),
activeSystemId: this.context.getActiveSystemId(),
cameraMode: this.context.getCameraMode(),
zoomLevel: this.context.getZoomLevel(),
mode,
});
}
updateSystemPanel() {
const world = this.context.getWorld();
if (!world) {
return;
}
updateSystemPanel({
world,
activeSystemId: this.context.getActiveSystemId(),
systemTitleEl: this.context.systemTitleEl,
systemBodyEl: this.context.systemBodyEl,
systemPanelEl: this.context.systemPanelEl,
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
});
}
screenPointFromClient(clientX: number, clientY: number) {
const bounds = this.context.renderer.domElement.getBoundingClientRect();
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
}
private setObjectOpacity(object: THREE.Object3D, opacity: number) {
const visible = opacity > 0.02;
object.visible = visible;
object.traverse((child) => {
if (!("material" in child)) {
return;
}
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
if (!("opacity" in material)) {
continue;
}
material.transparent = true;
material.opacity = opacity;
material.needsUpdate = true;
}
});
}
}

View File

@@ -0,0 +1,59 @@
import * as THREE from "three";
import { classifyZoomLevel } from "./viewerMath";
import type { PerformanceStats } from "./viewerTypes";
export interface RenderFrameParams {
clock: THREE.Clock;
renderer: THREE.WebGLRenderer;
scene: THREE.Scene;
camera: THREE.PerspectiveCamera;
updateCamera: (delta: number) => void;
updateAmbience: (delta: number) => void;
updatePlanetPresentation: () => void;
updateShipPresentation: () => void;
updateNetworkPanel: () => void;
applyZoomPresentation: () => void;
recordPerformanceStats: (frameMs: number) => void;
updatePerformancePanel: () => void;
}
export interface ResizeParams {
renderer: THREE.WebGLRenderer;
camera: THREE.PerspectiveCamera;
}
export interface CameraStepParams {
currentDistance: number;
desiredDistance: number;
orbitPitch: number;
delta: number;
}
export function renderFrame(params: RenderFrameParams) {
const frameStartedAtMs = performance.now();
const delta = Math.min(params.clock.getDelta(), 0.033);
params.updateCamera(delta);
params.updateAmbience(delta);
params.updatePlanetPresentation();
params.updateShipPresentation();
params.updateNetworkPanel();
params.applyZoomPresentation();
params.renderer.render(params.scene, params.camera);
params.recordPerformanceStats(performance.now() - frameStartedAtMs);
params.updatePerformancePanel();
}
export function resizeViewer(params: ResizeParams) {
const width = window.innerWidth;
const height = window.innerHeight;
params.camera.aspect = width / height;
params.camera.updateProjectionMatrix();
params.renderer.setSize(width, height);
}
export function stepCamera(params: CameraStepParams) {
const currentDistance = THREE.MathUtils.damp(params.currentDistance, params.desiredDistance, 7.5, params.delta);
const zoomLevel = classifyZoomLevel(currentDistance);
const orbitPitch = THREE.MathUtils.clamp(params.orbitPitch, 0.18, 1.3);
return { currentDistance, zoomLevel, orbitPitch };
}

View File

@@ -0,0 +1,67 @@
import * as THREE from "three";
import type { ShipSnapshot } from "./contracts";
export function shipSize(ship: ShipSnapshot) {
switch (ship.shipClass) {
case "capital":
return 18;
case "cruiser":
return 13;
case "destroyer":
return 10;
case "industrial":
return 11;
default:
return 8;
}
}
export function shipLength(ship: ShipSnapshot) {
return shipSize(ship) * 2.6;
}
export function shipColor(role: ShipSnapshot["role"]) {
if (role === "mining") {
return "#ffcf6e";
}
if (role === "transport") {
return "#9ff0aa";
}
return "#8bc0ff";
}
export function shipPresentationColor(ship: ShipSnapshot) {
if (ship.spatialState.spaceLayer !== "local-space") {
return "#c77dff";
}
if (ship.spatialState.movementRegime === "warp") {
return "#ffd166";
}
if (ship.spatialState.movementRegime === "ftl-transit") {
return "#ff6ad5";
}
return shipColor(ship.role);
}
export function spatialNodeColor(kind: string) {
if (kind.includes("lagrange")) {
return "#7fe8ff";
}
if (kind.includes("station")) {
return "#ffc36e";
}
if (kind.includes("planet")) {
return "#8bc0ff";
}
if (kind.includes("moon")) {
return "#c7d7e8";
}
return "#ffe082";
}
export function createCirclePoints(radius: number, segments: number) {
return Array.from({ length: segments }, (_, index) => {
const theta = (index / segments) * Math.PI * 2;
return new THREE.Vector3(Math.cos(theta) * radius, 0, Math.sin(theta) * radius);
});
}

View File

@@ -0,0 +1,222 @@
import * as THREE from "three";
import {
applyClaimDeltas as applyClaimDeltaUpdates,
applyConstructionSiteDeltas as applyConstructionSiteDeltaUpdates,
applyLocalBubbleDeltas as applyLocalBubbleDeltaUpdates,
applyNodeDeltas as applyNodeDeltaUpdates,
applyShipDeltas as applyShipDeltaUpdates,
applySpatialNodeDeltas as applySpatialNodeDeltaUpdates,
applyStationDeltas as applyStationDeltaUpdates,
rebuildSystems as rebuildSystemScene,
syncClaims as syncClaimScene,
syncConstructionSites as syncConstructionSiteScene,
syncLocalBubbles as syncBubbleScene,
syncNodes as syncNodeScene,
syncShips as syncShipScene,
syncSpatialNodes as syncSpatialNodeScene,
syncStations as syncStationScene,
} from "./viewerSceneSync";
import {
deriveNodeOrbital,
deriveOrbitalFromLocalPosition,
resolveBubblePosition,
resolveOrbitalAnchor,
resolvePointPosition,
setBubbleVisualState,
} from "./viewerWorldPresentation";
import {
createCirclePoints,
shipLength,
shipPresentationColor,
shipSize,
spatialNodeColor,
} from "./viewerSceneAppearance";
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type {
OrbitalAnchor,
} from "./viewerTypes";
export interface ViewerSceneDataContext {
documentRef: Document;
getWorldGeneratedAtUtc: () => string | undefined;
getWorldSeed: () => number;
getWorldTimeSyncMs: () => number;
getWorldPresentationContext: () => any;
systemGroup: THREE.Group;
spatialNodeGroup: THREE.Group;
bubbleGroup: THREE.Group;
nodeGroup: THREE.Group;
stationGroup: THREE.Group;
claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group;
selectableTargets: Map<any, any>;
presentationEntries: any[];
systemVisuals: Map<any, any>;
systemSummaryVisuals: Map<any, any>;
planetVisuals: any[];
orbitLines: THREE.Object3D[];
spatialNodeVisuals: Map<any, any>;
bubbleVisuals: Map<any, any>;
nodeVisuals: Map<any, any>;
stationVisuals: Map<any, any>;
claimVisuals: Map<any, any>;
constructionSiteVisuals: Map<any, any>;
shipVisuals: Map<any, any>;
registerPresentation: (detail: THREE.Object3D, icon: THREE.Sprite, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void;
}
export class ViewerSceneDataController {
constructor(private readonly context: ViewerSceneDataContext) {}
rebuildSystems(systems: SystemSnapshot[]) {
rebuildSystemScene(this.createSceneSyncContext(), systems);
}
syncSpatialNodes(nodes: SpatialNodeSnapshot[]) {
syncSpatialNodeScene(this.createSceneSyncContext(), nodes);
}
syncLocalBubbles(bubbles: LocalBubbleSnapshot[]) {
syncBubbleScene(this.createSceneSyncContext(), bubbles);
}
syncNodes(nodes: ResourceNodeSnapshot[]) {
syncNodeScene(this.createSceneSyncContext(), nodes);
}
syncStations(stations: StationSnapshot[]) {
syncStationScene(this.createSceneSyncContext(), stations);
}
syncClaims(claims: ClaimSnapshot[]) {
syncClaimScene(this.createSceneSyncContext(), claims);
}
syncConstructionSites(sites: ConstructionSiteSnapshot[]) {
syncConstructionSiteScene(this.createSceneSyncContext(), sites);
}
syncShips(ships: ShipSnapshot[], tickIntervalMs: number) {
syncShipScene(this.createSceneSyncContext(), ships, tickIntervalMs);
}
applySpatialNodeDeltas(nodes: SpatialNodeDelta[]) {
applySpatialNodeDeltaUpdates(this.createSceneSyncContext(), nodes);
}
applyLocalBubbleDeltas(bubbles: LocalBubbleDelta[]) {
applyLocalBubbleDeltaUpdates(this.createSceneSyncContext(), bubbles);
}
applyNodeDeltas(nodes: ResourceNodeDelta[]) {
applyNodeDeltaUpdates(this.createSceneSyncContext(), nodes);
}
applyStationDeltas(stations: StationDelta[]) {
applyStationDeltaUpdates(this.createSceneSyncContext(), stations);
}
applyClaimDeltas(claims: ClaimDelta[]) {
applyClaimDeltaUpdates(this.createSceneSyncContext(), claims);
}
applyConstructionSiteDeltas(sites: ConstructionSiteDelta[]) {
applyConstructionSiteDeltaUpdates(this.createSceneSyncContext(), sites);
}
applyShipDeltas(ships: ShipDelta[], tickIntervalMs: number) {
applyShipDeltaUpdates(this.createSceneSyncContext(), ships, tickIntervalMs);
}
createWorldPresentationContext(overrides: {
world: any;
activeSystemId?: string;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
}) {
return {
world: overrides.world,
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
worldSeed: this.context.getWorldSeed(),
activeSystemId: overrides.activeSystemId,
orbitYaw: overrides.orbitYaw,
camera: overrides.camera,
systemFocusLocal: overrides.systemFocusLocal,
shipVisuals: this.context.shipVisuals,
nodeVisuals: this.context.nodeVisuals,
spatialNodeVisuals: this.context.spatialNodeVisuals,
bubbleVisuals: this.context.bubbleVisuals,
stationVisuals: this.context.stationVisuals,
claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals,
toDisplayLocalPosition: overrides.toDisplayLocalPosition,
updateSystemDetailVisibility: overrides.updateSystemDetailVisibility,
setShellReticleOpacity: overrides.setShellReticleOpacity,
};
}
private createSceneSyncContext() {
return {
documentRef: this.context.documentRef,
worldGeneratedAtUtc: this.context.getWorldGeneratedAtUtc(),
worldSeed: this.context.getWorldSeed(),
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
systemGroup: this.context.systemGroup,
spatialNodeGroup: this.context.spatialNodeGroup,
bubbleGroup: this.context.bubbleGroup,
nodeGroup: this.context.nodeGroup,
stationGroup: this.context.stationGroup,
claimGroup: this.context.claimGroup,
constructionSiteGroup: this.context.constructionSiteGroup,
shipGroup: this.context.shipGroup,
selectableTargets: this.context.selectableTargets,
presentationEntries: this.context.presentationEntries,
systemVisuals: this.context.systemVisuals,
systemSummaryVisuals: this.context.systemSummaryVisuals,
planetVisuals: this.context.planetVisuals,
orbitLines: this.context.orbitLines,
spatialNodeVisuals: this.context.spatialNodeVisuals,
bubbleVisuals: this.context.bubbleVisuals,
nodeVisuals: this.context.nodeVisuals,
stationVisuals: this.context.stationVisuals,
claimVisuals: this.context.claimVisuals,
constructionSiteVisuals: this.context.constructionSiteVisuals,
shipVisuals: this.context.shipVisuals,
registerPresentation: this.context.registerPresentation,
shipSize,
shipLength,
shipPresentationColor,
spatialNodeColor,
createCirclePoints,
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => resolveBubblePosition(this.context.getWorldPresentationContext(), bubble),
resolvePointPosition: (systemId: string, nodeId?: string | null) => resolvePointPosition(this.context.getWorldPresentationContext(), systemId, nodeId),
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => resolveOrbitalAnchor(this.context.getWorldPresentationContext(), systemId, localPosition),
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: OrbitalAnchor) => deriveNodeOrbital(this.context.getWorldPresentationContext(), node, anchor),
deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: OrbitalAnchor) => deriveOrbitalFromLocalPosition(this.context.getWorldPresentationContext(), localPosition, systemId, anchor),
setBubbleVisualState,
};
}
}

View File

@@ -0,0 +1,420 @@
import * as THREE from "three";
import {
MOON_RENDER_SCALE,
PLANET_RENDER_SCALE,
STAR_RENDER_SCALE,
} from "./viewerConstants";
import type {
ClaimSnapshot,
ConstructionSiteSnapshot,
LocalBubbleSnapshot,
PlanetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
SpatialNodeSnapshot,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import type { MoonVisual, SystemSummaryVisual } from "./viewerTypes";
import {
celestialRenderRadius,
computeMoonOrbitRadius,
computeMoonRenderRadius,
computePlanetLocalPosition,
starHaloOpacity,
toThreeVector,
} from "./viewerMath";
export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
const mesh = new THREE.Mesh(
isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0),
new THREE.MeshStandardMaterial({
color: isGas ? 0x7fd6ff : 0xd2b07a,
flatShading: !isGas,
transparent: isGas,
opacity: isGas ? 0.68 : 1,
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05),
}),
);
mesh.position.copy(toThreeVector(node.localPosition));
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
return mesh;
}
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh {
const color = spatialNodeColor(node.kind);
return new THREE.Mesh(
new THREE.OctahedronGeometry(10, 0),
new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.16),
roughness: 0.35,
metalness: 0.45,
}),
);
}
export function createBubbleRing(
bubble: LocalBubbleSnapshot,
localPosition: THREE.Vector3,
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[],
): THREE.LineLoop {
const ring = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)),
new THREE.LineBasicMaterial({
color: 0x6ed6ff,
transparent: true,
opacity: 0.32,
}),
);
ring.position.copy(localPosition);
return ring;
}
export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
return new THREE.Mesh(
new THREE.ConeGeometry(9, 20, 4),
new THREE.MeshStandardMaterial({
color: claim.state === "active" ? 0xff7f50 : 0xff5b5b,
emissive: new THREE.Color(claim.state === "active" ? 0xff7f50 : 0xff5b5b).multiplyScalar(0.16),
roughness: 0.4,
metalness: 0.28,
}),
);
}
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh {
return new THREE.Mesh(
new THREE.TorusKnotGeometry(7, 2.2, 54, 8),
new THREE.MeshStandardMaterial({
color: site.state === "completed" ? 0x46d37f : 0x9df29c,
emissive: new THREE.Color(site.state === "completed" ? 0x46d37f : 0x9df29c).multiplyScalar(0.15),
roughness: 0.34,
metalness: 0.48,
}),
);
}
export function createStarCluster(system: SystemSnapshot): THREE.Group {
const root = new THREE.Group();
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const offsets = system.starCount > 1
? [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(renderedStarSize * sizeScale, 24, 24),
new THREE.MeshBasicMaterial({ color: system.starColor }),
);
const halo = new THREE.Mesh(
new THREE.SphereGeometry(renderedStarSize * sizeScale * 1.45, 20, 20),
new THREE.MeshBasicMaterial({
color: system.starColor,
transparent: true,
opacity: starHaloOpacity(system.starKind),
side: THREE.BackSide,
}),
);
star.position.copy(offset);
halo.position.copy(offset);
root.add(star, halo);
}
return root;
}
export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop {
const points = Array.from({ length: 120 }, (_, index) => {
const phaseDegrees = (index / 120) * 360;
return computePlanetLocalPosition(planet, 0, phaseDegrees);
});
return new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }),
);
}
export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh {
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
const ring = new THREE.Mesh(
new THREE.RingGeometry(renderedPlanetRadius * 1.35, renderedPlanetRadius * 2.15, 48),
new THREE.MeshBasicMaterial({
color: 0xdac89a,
transparent: true,
opacity: 0.42,
side: THREE.DoubleSide,
}),
);
ring.rotation.x = Math.PI / 2;
ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
return ring;
}
export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] {
const moonCount = Math.min(planet.moonCount, 12);
const moons: MoonVisual[] = [];
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
const orbitRadius = computeMoonOrbitRadius(planet, moonIndex, seed);
const orbit = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
Array.from({ length: 48 }, (_, index) => {
const angle = (index / 48) * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * orbitRadius,
0,
Math.sin(angle) * orbitRadius,
);
}),
),
new THREE.LineBasicMaterial({ color: 0x3b5065, transparent: true, opacity: 0.1 }),
);
orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35);
const moonSize = computeMoonRenderRadius(planet, moonIndex, seed);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(moonSize, 12, 12),
new THREE.MeshStandardMaterial({
color: new THREE.Color(planet.color).lerp(new THREE.Color("#d9dee7"), 0.55),
roughness: 0.96,
metalness: 0.02,
}),
);
moons.push({ mesh, orbit });
}
return moons;
}
export function createStationMesh(station: StationSnapshot): THREE.Mesh {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10),
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
);
mesh.rotation.x = Math.PI / 2;
mesh.position.copy(toThreeVector(station.localPosition));
return mesh;
}
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh {
const geometry = new THREE.ConeGeometry(size, length, 7);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({
color,
emissive: new THREE.Color(color).multiplyScalar(0.18),
}),
);
mesh.position.copy(toThreeVector(ship.localPosition));
return mesh;
}
export function createBackdropStars(): THREE.Points {
const starCount = 1800;
const radius = 36000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const color = new THREE.Color();
for (let index = 0; index < starCount; index += 1) {
const direction = new THREE.Vector3(
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
THREE.MathUtils.randFloatSpread(2),
).normalize().multiplyScalar(radius * THREE.MathUtils.randFloat(0.82, 1));
positions[index * 3] = direction.x;
positions[index * 3 + 1] = direction.y;
positions[index * 3 + 2] = direction.z;
const tint = THREE.MathUtils.randFloat(0, 1);
color.setRGB(
THREE.MathUtils.lerp(0.68, 1, tint),
THREE.MathUtils.lerp(0.76, 0.94, tint),
THREE.MathUtils.lerp(0.9, 1, tint),
);
if (Math.random() < 0.08) {
color.lerp(new THREE.Color(0xffd6a0), 0.45);
}
colors[index * 3] = color.r;
colors[index * 3 + 1] = color.g;
colors[index * 3 + 2] = color.b;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
return new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 2.2,
sizeAttenuation: false,
vertexColors: true,
transparent: true,
opacity: 0.9,
depthWrite: false,
blending: THREE.AdditiveBlending,
}),
);
}
export function createNebulaTexture(documentRef: Document): THREE.CanvasTexture {
const canvas = documentRef.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create nebula texture");
}
const gradient = context.createRadialGradient(128, 128, 18, 128, 128, 118);
gradient.addColorStop(0, "rgba(255,255,255,0.95)");
gradient.addColorStop(0.2, "rgba(255,255,255,0.48)");
gradient.addColorStop(0.55, "rgba(140,180,255,0.14)");
gradient.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = gradient;
context.fillRect(0, 0, 256, 256);
for (let index = 0; index < 10; index += 1) {
const x = THREE.MathUtils.randFloat(30, 226);
const y = THREE.MathUtils.randFloat(30, 226);
const radius = THREE.MathUtils.randFloat(24, 72);
const puff = context.createRadialGradient(x, y, 0, x, y, radius);
puff.addColorStop(0, "rgba(255,255,255,0.16)");
puff.addColorStop(0.45, "rgba(255,255,255,0.08)");
puff.addColorStop(1, "rgba(0,0,0,0)");
context.fillStyle = puff;
context.beginPath();
context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
}
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] {
const directions = [
new THREE.Vector3(0.74, 0.34, -0.58),
new THREE.Vector3(-0.62, 0.18, -0.77),
new THREE.Vector3(0.22, -0.44, -0.87),
new THREE.Vector3(-0.38, 0.56, 0.73),
];
return directions.map((direction, index) => {
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
opacity: 0.14,
depthWrite: false,
color: ["#6dc7ff", "#ff9ec8", "#8e7dff", "#7ce0c3"][index] ?? "#6dc7ff",
blending: THREE.AdditiveBlending,
}));
sprite.position.copy(direction.normalize().multiplyScalar(25000 + index * 2600));
const scale = 15000 + index * 2400;
sprite.scale.set(scale, scale * 0.62, 1);
return sprite;
});
}
export function createTacticalIcon(documentRef: Document, color: string, size: number): THREE.Sprite {
const canvas = documentRef.createElement("canvas");
canvas.width = 64;
canvas.height = 64;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create tactical icon");
}
context.clearRect(0, 0, 64, 64);
context.strokeStyle = color;
context.lineWidth = 5;
context.beginPath();
context.arc(32, 32, 18, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(32, 8);
context.lineTo(32, 56);
context.moveTo(8, 32);
context.lineTo(56, 32);
context.stroke();
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
color: "#ffffff",
}));
sprite.scale.setScalar(size);
sprite.visible = false;
return sprite;
}
export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual {
const canvas = documentRef.createElement("canvas");
canvas.width = 512;
canvas.height = 160;
const texture = new THREE.CanvasTexture(canvas);
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
}));
sprite.scale.set(520, 160, 1);
sprite.visible = false;
return { sprite, texture, anchor };
}
export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite {
const canvas = documentRef.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;
}

View File

@@ -0,0 +1,508 @@
import * as THREE from "three";
import {
PLANET_RENDER_SCALE,
STAR_RENDER_SCALE,
} from "./viewerConstants";
import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
PlanetVisual,
PresentationEntry,
Selectable,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
} from "./viewerTypes";
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
import {
celestialRenderRadius,
computePlanetLocalPosition,
toThreeVector,
} from "./viewerMath";
import {
createBubbleRing,
createClaimMesh,
createConstructionSiteMesh,
createMoonVisuals,
createNodeMesh,
createPlanetOrbit,
createPlanetRing,
createShellReticle,
createShipMesh,
createSpatialNodeMesh,
createStarCluster,
createStationMesh,
createSystemSummaryVisual,
createTacticalIcon,
} from "./viewerSceneFactory";
interface SceneSyncContext {
documentRef: Document;
worldGeneratedAtUtc?: string;
worldSeed: number;
worldTimeSyncMs: number;
systemGroup: THREE.Group;
spatialNodeGroup: THREE.Group;
bubbleGroup: THREE.Group;
nodeGroup: THREE.Group;
stationGroup: THREE.Group;
claimGroup: THREE.Group;
constructionSiteGroup: THREE.Group;
shipGroup: THREE.Group;
selectableTargets: Map<THREE.Object3D, Selectable>;
presentationEntries: PresentationEntry[];
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
planetVisuals: PlanetVisual[];
orbitLines: THREE.Object3D[];
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
nodeVisuals: Map<string, NodeVisual>;
stationVisuals: Map<string, StructureVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
shipVisuals: Map<string, ShipVisual>;
registerPresentation: (
detail: THREE.Object3D,
icon: THREE.Sprite,
hideDetailInUniverse: boolean,
hideIconInUniverse?: boolean,
systemId?: string,
) => void;
shipSize: (ship: ShipSnapshot) => number;
shipLength: (ship: ShipSnapshot) => number;
shipPresentationColor: (ship: ShipSnapshot) => string;
spatialNodeColor: (kind: string) => string;
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[];
resolveBubblePosition: (bubble: LocalBubbleSnapshot | LocalBubbleDelta) => THREE.Vector3;
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
resolveOrbitalAnchor: (systemId: string, localPosition: THREE.Vector3) => NodeVisual["anchor"];
deriveNodeOrbital: (node: ResourceNodeSnapshot | ResourceNodeDelta, anchor: NodeVisual["anchor"]) => {
radius: number;
phase: number;
inclination: number;
};
deriveOrbitalFromLocalPosition: (localPosition: THREE.Vector3, systemId: string, anchor: StructureVisual["anchor"]) => {
radius: number;
phase: number;
inclination: number;
};
setBubbleVisualState: (visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) => void;
}
export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) {
const worldTimeSeconds = context.worldGeneratedAtUtc
? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97)
: 0;
context.systemGroup.clear();
context.selectableTargets.clear();
context.presentationEntries.length = 0;
context.planetVisuals.length = 0;
context.orbitLines.length = 0;
context.systemVisuals.clear();
context.systemSummaryVisuals.clear();
for (const system of systems) {
const root = new THREE.Group();
root.position.set(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z);
const detailGroup = new THREE.Group();
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
const starCluster = createStarCluster(system);
const systemIcon = createTacticalIcon(context.documentRef, system.starColor, 96);
const shellReticle = createShellReticle(context.documentRef, "#ff3b30", 400);
const summaryVisual = createSystemSummaryVisual(
context.documentRef,
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);
context.registerPresentation(starCluster, systemIcon, true);
context.systemVisuals.set(system.id, {
root,
starCluster,
icon: systemIcon,
shellReticle,
shellReticleBaseScale: 400,
detailGroup,
summary: summaryVisual,
galaxyPosition: toThreeVector(system.galaxyPosition),
});
context.systemSummaryVisuals.set(system.id, summaryVisual);
starCluster.traverse((child) => {
if (child instanceof THREE.Mesh) {
context.selectableTargets.set(child, { kind: "system", id: system.id });
}
});
context.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
context.selectableTargets.set(shellReticle, { kind: "system", id: system.id });
for (const [planetIndex, planet] of system.planets.entries()) {
const orbit = createPlanetOrbit(planet);
const renderedPlanetRadius = celestialRenderRadius(planet.size, PLANET_RENDER_SCALE, 7, 1.06);
const planetMesh = new THREE.Mesh(
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
new THREE.MeshStandardMaterial({
color: planet.color,
roughness: 0.92,
metalness: 0.08,
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
}),
);
planetMesh.position.copy(computePlanetLocalPosition(planet, worldTimeSeconds));
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
planetIcon.position.copy(planetMesh.position);
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
if (ring) {
ring.position.copy(planetMesh.position);
}
const moons = createMoonVisuals(planet, context.worldSeed);
detailGroup.add(orbit, planetMesh, planetIcon);
if (ring) {
detailGroup.add(ring);
}
for (const moon of moons) {
moon.orbit.position.copy(planetMesh.position);
moon.mesh.position.copy(planetMesh.position);
detailGroup.add(moon.orbit, moon.mesh);
context.orbitLines.push(moon.orbit);
context.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
}
context.orbitLines.push(orbit);
context.registerPresentation(planetMesh, planetIcon, true, true, system.id);
if (ring) {
context.registerPresentation(ring, planetIcon, true, true, system.id);
}
context.planetVisuals.push({ systemId: system.id, planet, orbit, mesh: planetMesh, icon: planetIcon, ring, moons });
context.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
context.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
}
context.systemGroup.add(root);
}
}
export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSnapshot[]) {
context.spatialNodeGroup.clear();
context.spatialNodeVisuals.clear();
for (const node of nodes) {
const mesh = createSpatialNodeMesh(node, context.spatialNodeColor);
const icon = createTacticalIcon(context.documentRef, context.spatialNodeColor(node.kind), 18);
const localPosition = toThreeVector(node.localPosition);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
context.spatialNodeVisuals.set(node.id, {
id: node.id,
systemId: node.systemId,
mesh,
icon,
kind: node.kind,
localPosition,
});
context.spatialNodeGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "spatial-node", id: node.id });
context.selectableTargets.set(icon, { kind: "spatial-node", id: node.id });
}
}
export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubbleSnapshot[]) {
context.bubbleGroup.clear();
context.bubbleVisuals.clear();
for (const bubble of bubbles) {
const localPosition = context.resolveBubblePosition(bubble);
const mesh = createBubbleRing(bubble, localPosition, context.createCirclePoints);
const visual = { id: bubble.id, systemId: bubble.systemId, mesh, localPosition, radius: bubble.radius };
context.setBubbleVisualState(visual, bubble);
context.bubbleVisuals.set(bubble.id, visual);
context.bubbleGroup.add(mesh);
context.selectableTargets.set(mesh, { kind: "bubble", id: bubble.id });
}
}
export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot[]) {
context.nodeGroup.clear();
context.nodeVisuals.clear();
for (const node of nodes) {
const mesh = createNodeMesh(node);
const icon = createTacticalIcon(context.documentRef, node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
icon.position.copy(mesh.position);
const localPosition = toThreeVector(node.localPosition);
const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition);
const orbital = context.deriveNodeOrbital(node, anchor);
context.nodeVisuals.set(node.id, {
systemId: node.systemId,
mesh,
icon,
sourceKind: node.sourceKind,
anchor,
localPosition,
orbitRadius: orbital.radius,
orbitPhase: orbital.phase,
orbitInclination: orbital.inclination,
});
context.nodeGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, node.systemId);
context.selectableTargets.set(mesh, { kind: "node", id: node.id });
context.selectableTargets.set(icon, { kind: "node", id: node.id });
}
}
export function syncStations(context: SceneSyncContext, stations: StationSnapshot[]) {
context.stationGroup.clear();
context.stationVisuals.clear();
for (const station of stations) {
const mesh = createStationMesh(station);
const icon = createTacticalIcon(context.documentRef, station.color, 26);
icon.position.copy(mesh.position);
const localPosition = toThreeVector(station.localPosition);
const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition);
const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
context.stationVisuals.set(station.id, {
id: station.id,
systemId: station.systemId,
mesh,
icon,
anchor,
orbitRadius: orbital.radius,
orbitPhase: orbital.phase,
orbitInclination: orbital.inclination,
localPosition,
});
context.stationGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, station.systemId);
context.selectableTargets.set(mesh, { kind: "station", id: station.id });
context.selectableTargets.set(icon, { kind: "station", id: station.id });
}
}
export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
context.claimGroup.clear();
context.claimVisuals.clear();
for (const claim of claims) {
const localPosition = context.resolvePointPosition(claim.systemId, claim.nodeId);
const mesh = createClaimMesh(claim);
const icon = createTacticalIcon(context.documentRef, "#ff5b5b", 18);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
context.claimVisuals.set(claim.id, {
id: claim.id,
nodeId: claim.nodeId,
systemId: claim.systemId,
mesh,
icon,
localPosition,
});
context.claimGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, claim.systemId);
context.selectableTargets.set(mesh, { kind: "claim", id: claim.id });
context.selectableTargets.set(icon, { kind: "claim", id: claim.id });
}
}
export function syncConstructionSites(context: SceneSyncContext, sites: ConstructionSiteSnapshot[]) {
context.constructionSiteGroup.clear();
context.constructionSiteVisuals.clear();
for (const site of sites) {
const localPosition = context.resolvePointPosition(site.systemId, site.nodeId);
const mesh = createConstructionSiteMesh(site);
const icon = createTacticalIcon(context.documentRef, "#9df29c", 18);
mesh.position.copy(localPosition);
icon.position.copy(localPosition);
context.constructionSiteVisuals.set(site.id, {
id: site.id,
nodeId: site.nodeId,
systemId: site.systemId,
mesh,
icon,
localPosition,
});
context.constructionSiteGroup.add(mesh, icon);
context.registerPresentation(mesh, icon, true, true, site.systemId);
context.selectableTargets.set(mesh, { kind: "construction-site", id: site.id });
context.selectableTargets.set(icon, { kind: "construction-site", id: site.id });
}
}
export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tickIntervalMs: number) {
context.shipGroup.clear();
context.shipVisuals.clear();
for (const ship of ships) {
const mesh = createShipMesh(ship, context.shipSize(ship), context.shipLength(ship), context.shipPresentationColor(ship));
const shipColor = context.shipPresentationColor(ship);
const icon = createTacticalIcon(context.documentRef, shipColor, 18);
const position = toThreeVector(ship.localPosition);
icon.position.copy(position);
icon.material.color.set(shipColor);
context.shipGroup.add(mesh, icon);
context.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
context.selectableTargets.set(icon, { kind: "ship", id: ship.id });
context.registerPresentation(mesh, icon, true, true, ship.systemId);
context.shipVisuals.set(ship.id, {
systemId: ship.systemId,
mesh,
icon,
startPosition: position.clone(),
authoritativePosition: position.clone(),
targetPosition: toThreeVector(ship.targetLocalPosition),
velocity: toThreeVector(ship.localVelocity),
receivedAtMs: performance.now(),
blendDurationMs: Math.max(tickIntervalMs, 80),
});
}
}
export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: SpatialNodeDelta[]) {
for (const node of nodes) {
const visual = context.spatialNodeVisuals.get(node.id);
if (!visual) {
continue;
}
visual.systemId = node.systemId;
visual.kind = node.kind;
visual.localPosition.copy(toThreeVector(node.localPosition));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
(visual.mesh.material as THREE.MeshStandardMaterial).color.set(context.spatialNodeColor(node.kind));
}
}
export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: LocalBubbleDelta[]) {
for (const bubble of bubbles) {
const visual = context.bubbleVisuals.get(bubble.id);
if (!visual) {
continue;
}
visual.systemId = bubble.systemId;
visual.radius = bubble.radius;
visual.localPosition.copy(context.resolveBubblePosition(bubble));
visual.mesh.position.copy(visual.localPosition);
visual.mesh.scale.setScalar(Math.max(bubble.radius, 60));
context.setBubbleVisualState(visual, bubble);
}
}
export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDelta[]) {
for (const node of nodes) {
const visual = context.nodeVisuals.get(node.id);
if (!visual) {
continue;
}
visual.systemId = node.systemId;
visual.sourceKind = node.sourceKind;
visual.localPosition.copy(toThreeVector(node.localPosition));
visual.anchor = context.resolveOrbitalAnchor(node.systemId, visual.localPosition);
const orbital = context.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);
}
}
export function applyStationDeltas(context: SceneSyncContext, stations: StationDelta[]) {
for (const station of stations) {
const visual = context.stationVisuals.get(station.id);
if (!visual) {
continue;
}
visual.systemId = station.systemId;
visual.localPosition.copy(toThreeVector(station.localPosition));
visual.anchor = context.resolveOrbitalAnchor(station.systemId, visual.localPosition);
const orbital = context.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);
}
}
export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]) {
for (const claim of claims) {
const visual = context.claimVisuals.get(claim.id);
if (!visual) {
continue;
}
visual.systemId = claim.systemId;
visual.localPosition.copy(context.resolvePointPosition(claim.systemId, claim.nodeId));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
material.emissive.set(claim.state === "active" ? "#ffb27d" : "#7a2020");
}
}
export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: ConstructionSiteDelta[]) {
for (const site of sites) {
const visual = context.constructionSiteVisuals.get(site.id);
if (!visual) {
continue;
}
visual.systemId = site.systemId;
visual.localPosition.copy(context.resolvePointPosition(site.systemId, site.nodeId));
visual.mesh.position.copy(visual.localPosition);
visual.icon.position.copy(visual.localPosition);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(site.state === "completed" ? "#46d37f" : "#9df29c");
visual.mesh.scale.setScalar(0.75 + site.progress * 0.35);
}
}
export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], tickIntervalMs: number) {
for (const ship of ships) {
const visual = context.shipVisuals.get(ship.id);
if (!visual) {
continue;
}
visual.systemId = ship.systemId;
visual.startPosition.copy(visual.authoritativePosition);
visual.authoritativePosition.copy(toThreeVector(ship.localPosition));
visual.targetPosition.copy(toThreeVector(ship.targetLocalPosition));
visual.velocity.copy(toThreeVector(ship.localVelocity));
visual.receivedAtMs = performance.now();
visual.blendDurationMs = Math.max(tickIntervalMs * 1.35, 100);
const shipColor = context.shipPresentationColor(ship);
const material = visual.mesh.material as THREE.MeshStandardMaterial;
material.color.set(shipColor);
material.emissive.set(new THREE.Color(shipColor).multiplyScalar(0.18));
visual.icon.material.color.set(shipColor);
}
}

View File

@@ -0,0 +1,216 @@
import type { SystemSnapshot } from "./contracts";
import type {
CameraMode,
OrbitalAnchor,
Selectable,
SelectionGroup,
WorldState,
} from "./viewerTypes";
export function describeSelectable(world: WorldState | undefined, item: Selectable): string {
if (!world) {
return item.kind;
}
if (item.kind === "ship") {
return world.ships.get(item.id)?.label ?? item.id;
}
if (item.kind === "station") {
return world.stations.get(item.id)?.label ?? item.id;
}
if (item.kind === "node") {
return item.id;
}
if (item.kind === "spatial-node") {
return `${world.spatialNodes.get(item.id)?.kind ?? "node"} ${item.id}`;
}
if (item.kind === "bubble") {
return `bubble ${item.id}`;
}
if (item.kind === "claim") {
return `claim ${item.id}`;
}
if (item.kind === "construction-site") {
return `construction ${item.id}`;
}
if (item.kind === "planet") {
return world.systems.get(item.systemId)?.planets[item.planetIndex]?.label ?? `${item.systemId}:${item.planetIndex}`;
}
return world.systems.get(item.id)?.label ?? item.id;
}
export function getSelectionGroup(item: Selectable): SelectionGroup {
if (item.kind === "ship") {
return "ships";
}
if (
item.kind === "station"
|| item.kind === "node"
|| item.kind === "spatial-node"
|| item.kind === "bubble"
|| item.kind === "claim"
|| item.kind === "construction-site"
) {
return "structures";
}
return "celestials";
}
export function resolveSelectableSystemId(world: WorldState | undefined, selection: Selectable): string | undefined {
if (!world) {
return undefined;
}
if (selection.kind === "ship") {
return world.ships.get(selection.id)?.systemId;
}
if (selection.kind === "station") {
return world.stations.get(selection.id)?.systemId;
}
if (selection.kind === "node") {
return world.nodes.get(selection.id)?.systemId;
}
if (selection.kind === "spatial-node") {
return world.spatialNodes.get(selection.id)?.systemId;
}
if (selection.kind === "bubble") {
return world.localBubbles.get(selection.id)?.systemId;
}
if (selection.kind === "claim") {
return world.claims.get(selection.id)?.systemId;
}
if (selection.kind === "construction-site") {
return world.constructionSites.get(selection.id)?.systemId;
}
if (selection.kind === "planet") {
return selection.systemId;
}
return selection.id;
}
export function resolveFocusedBubbleId(world: WorldState | undefined, selectedItems: Selectable[]): string | undefined {
if (!world || selectedItems.length !== 1) {
return undefined;
}
const selected = selectedItems[0];
if (selected.kind === "bubble") {
return selected.id;
}
if (selected.kind === "ship") {
return world.ships.get(selected.id)?.bubbleId ?? world.ships.get(selected.id)?.spatialState.currentBubbleId ?? undefined;
}
if (selected.kind === "station") {
return world.stations.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "spatial-node") {
return world.spatialNodes.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "claim") {
return world.claims.get(selected.id)?.bubbleId ?? undefined;
}
if (selected.kind === "construction-site") {
return world.constructionSites.get(selected.id)?.bubbleId ?? undefined;
}
return undefined;
}
export function describeOrbitalParent(world: WorldState | undefined, systemId?: string, anchor?: OrbitalAnchor): string {
if (!world || !systemId) {
return "unknown";
}
const system = world.systems.get(systemId);
if (!system) {
return systemId;
}
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}`;
}
export function renderSystemDetails(
world: WorldState | undefined,
system: SystemSnapshot,
activeContext: boolean,
cameraMode: CameraMode,
cameraTargetShipId?: string,
): string {
if (!world) {
return "";
}
let shipCount = 0;
let stationCount = 0;
let nodeCount = 0;
let spatialNodeCount = 0;
let bubbleCount = 0;
let claimCount = 0;
let constructionCount = 0;
let moonCount = 0;
for (const ship of world.ships.values()) {
if (ship.systemId === system.id) {
shipCount += 1;
}
}
for (const station of world.stations.values()) {
if (station.systemId === system.id) {
stationCount += 1;
}
}
for (const node of world.nodes.values()) {
if (node.systemId === system.id) {
nodeCount += 1;
}
}
for (const node of world.spatialNodes.values()) {
if (node.systemId === system.id) {
spatialNodeCount += 1;
}
}
for (const bubble of world.localBubbles.values()) {
if (bubble.systemId === system.id) {
bubbleCount += 1;
}
}
for (const claim of world.claims.values()) {
if (claim.systemId === system.id) {
claimCount += 1;
}
}
for (const site of world.constructionSites.values()) {
if (site.systemId === system.id) {
constructionCount += 1;
}
}
for (const planet of system.planets) {
moonCount += planet.moonCount;
}
const followText = activeContext && cameraMode === "follow" && cameraTargetShipId
? `<p>Camera locked to ${world.ships.get(cameraTargetShipId)?.label ?? cameraTargetShipId}</p>`
: "";
return `
<p>${system.id}${activeContext ? " · active system" : ""}</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}</p>
<p>Spatial nodes ${spatialNodeCount}<br>Resource nodes ${nodeCount}<br>Bubbles ${bubbleCount}</p>
<p>Claims ${claimCount}<br>Construction sites ${constructionCount}</p>
<p>Height ${system.galaxyPosition.y.toFixed(0)}</p>
<p>${system.planets.slice(0, 8).map((planet) => `${planet.label} (${planet.planetType})`).join("<br>")}</p>
${followText}
`;
}

View File

@@ -0,0 +1,119 @@
import type {
FactionSnapshot,
WorldDelta,
WorldSnapshot,
} from "./contracts";
import type {
NetworkStats,
PerformanceStats,
WorldState,
} from "./viewerTypes";
export function createInitialNetworkStats(): NetworkStats {
return {
snapshotBytes: 0,
deltasReceived: 0,
deltaBytes: 0,
lastDeltaBytes: 0,
lastEntityChanges: 0,
eventsReceived: 0,
streamConnected: false,
throughputSamples: [],
};
}
export function createInitialPerformanceStats(): PerformanceStats {
return {
frameSamples: [],
lastFrameMs: 0,
lastPanelUpdateAtMs: 0,
};
}
export function createWorldState(snapshot: WorldSnapshot): WorldState {
return {
label: snapshot.label,
seed: snapshot.seed,
sequence: snapshot.sequence,
tickIntervalMs: snapshot.tickIntervalMs,
generatedAtUtc: snapshot.generatedAtUtc,
systems: new Map(snapshot.systems.map((system) => [system.id, system])),
spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])),
localBubbles: new Map(snapshot.localBubbles.map((bubble) => [bubble.id, bubble])),
nodes: new Map(snapshot.nodes.map((node) => [node.id, node])),
stations: new Map(snapshot.stations.map((station) => [station.id, station])),
claims: new Map(snapshot.claims.map((claim) => [claim.id, claim])),
constructionSites: new Map(snapshot.constructionSites.map((site) => [site.id, site])),
marketOrders: new Map(snapshot.marketOrders.map((order) => [order.id, order])),
policies: new Map(snapshot.policies.map((policy) => [policy.id, policy])),
ships: new Map(snapshot.ships.map((ship) => [ship.id, ship])),
factions: new Map(snapshot.factions.map((faction) => [faction.id, faction])),
recentEvents: [],
};
}
export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean {
world.sequence = delta.sequence;
world.tickIntervalMs = delta.tickIntervalMs;
world.generatedAtUtc = delta.generatedAtUtc;
world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18);
for (const node of delta.spatialNodes) {
world.spatialNodes.set(node.id, node);
}
for (const bubble of delta.localBubbles) {
world.localBubbles.set(bubble.id, bubble);
}
for (const node of delta.nodes) {
world.nodes.set(node.id, node);
}
for (const station of delta.stations) {
world.stations.set(station.id, station);
}
for (const claim of delta.claims) {
world.claims.set(claim.id, claim);
}
for (const site of delta.constructionSites) {
world.constructionSites.set(site.id, site);
}
for (const order of delta.marketOrders) {
world.marketOrders.set(order.id, order);
}
for (const policy of delta.policies) {
world.policies.set(policy.id, policy);
}
for (const ship of delta.ships) {
world.ships.set(ship.id, ship);
}
for (const faction of delta.factions) {
world.factions.set(faction.id, faction);
}
return delta.factions.length > 0;
}
export function recordDeltaStats(networkStats: NetworkStats, delta: WorldDelta, rawBytes: number): void {
const changedEntities = delta.ships.length
+ delta.stations.length
+ delta.nodes.length
+ delta.spatialNodes.length
+ delta.localBubbles.length
+ delta.claims.length
+ delta.constructionSites.length
+ delta.marketOrders.length
+ delta.policies.length
+ delta.factions.length;
networkStats.deltasReceived += 1;
networkStats.deltaBytes += rawBytes;
networkStats.lastDeltaBytes = rawBytes;
networkStats.lastEntityChanges = changedEntities;
networkStats.eventsReceived += delta.events.length;
networkStats.lastDeltaAtMs = performance.now();
networkStats.throughputSamples.push({ atMs: performance.now(), bytes: rawBytes });
const cutoff = performance.now() - 4000;
networkStats.throughputSamples = networkStats.throughputSamples.filter((sample) => sample.atMs >= cutoff);
}
export function cloneFactions(world: WorldState): FactionSnapshot[] {
return [...world.factions.values()];
}

View File

@@ -0,0 +1,91 @@
import * as THREE from "three";
import { formatBytes } from "./viewerMath";
import type {
NetworkStats,
PerformanceStats,
} from "./viewerTypes";
export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats: NetworkStats) {
const now = performance.now();
const uptimeSeconds = networkStats.streamOpenedAtMs
? (now - networkStats.streamOpenedAtMs) / 1000
: 0;
const recentBytes = networkStats.throughputSamples.reduce((sum, sample) => sum + sample.bytes, 0);
const recentWindowSeconds = networkStats.throughputSamples.length > 1
? Math.max((now - networkStats.throughputSamples[0].atMs) / 1000, 1)
: 1;
const kbPerSecond = recentBytes / 1024 / recentWindowSeconds;
const averageDeltaBytes = networkStats.deltasReceived > 0
? networkStats.deltaBytes / networkStats.deltasReceived
: 0;
const secondsSinceLastDelta = networkStats.lastDeltaAtMs
? ((now - networkStats.lastDeltaAtMs) / 1000).toFixed(1)
: "n/a";
networkPanelEl.textContent = [
`snapshot: ${formatBytes(networkStats.snapshotBytes)}`,
`stream: ${networkStats.streamConnected ? "live" : "offline"}`,
`deltas: ${networkStats.deltasReceived}`,
`events: ${networkStats.eventsReceived}`,
`avg delta: ${formatBytes(averageDeltaBytes)}`,
`last delta: ${formatBytes(networkStats.lastDeltaBytes)}`,
`recent rate: ${kbPerSecond.toFixed(1)} KB/s`,
`changed: ${networkStats.lastEntityChanges}`,
`uptime: ${uptimeSeconds.toFixed(1)}s`,
`last packet: ${secondsSinceLastDelta}s`,
].join("\n");
}
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
const now = performance.now();
performanceStats.lastFrameMs = frameMs;
performanceStats.frameSamples.push({ atMs: now, frameMs });
const cutoff = now - 4000;
performanceStats.frameSamples = performanceStats.frameSamples.filter((sample) => sample.atMs >= cutoff);
}
export function updatePerformancePanel(
performancePanelEl: HTMLDivElement,
performanceStats: PerformanceStats,
renderer: THREE.WebGLRenderer,
) {
const now = performance.now();
if (
performanceStats.lastPanelUpdateAtMs > 0 &&
now - performanceStats.lastPanelUpdateAtMs < 250
) {
return;
}
const samples = performanceStats.frameSamples;
const elapsedWindowSeconds = samples.length > 1
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
: 1;
const averageFrameMs = samples.length > 0
? samples.reduce((sum, sample) => sum + sample.frameMs, 0) / samples.length
: 0;
const worstFrameMs = samples.length > 0
? samples.reduce((max, sample) => Math.max(max, sample.frameMs), 0)
: 0;
const fps = samples.length > 1
? (samples.length - 1) / elapsedWindowSeconds
: 0;
const recentLowFps = averageFrameMs > 0 ? 1000 / Math.max(worstFrameMs, averageFrameMs) : 0;
const renderInfo = renderer.info;
performancePanelEl.textContent = [
`fps: ${fps.toFixed(1)}`,
`frame avg: ${averageFrameMs.toFixed(2)} ms`,
`frame last: ${performanceStats.lastFrameMs.toFixed(2)} ms`,
`frame worst: ${worstFrameMs.toFixed(2)} ms`,
`recent low: ${recentLowFps.toFixed(1)}`,
`draw calls: ${renderInfo.render.calls}`,
`triangles: ${renderInfo.render.triangles}`,
`points: ${renderInfo.render.points}`,
`lines: ${renderInfo.render.lines}`,
`geometries: ${renderInfo.memory.geometries}`,
`textures: ${renderInfo.memory.textures}`,
`pixel ratio: ${renderer.getPixelRatio().toFixed(2)}`,
].join("\n");
performanceStats.lastPanelUpdateAtMs = now;
}

View File

@@ -0,0 +1,207 @@
import * as THREE from "three";
import type {
ClaimSnapshot,
ConstructionSiteSnapshot,
FactionSnapshot,
LocalBubbleSnapshot,
MarketOrderSnapshot,
PlanetSnapshot,
PolicySetSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
SimulationEventRecord,
SpatialNodeSnapshot,
StationSnapshot,
SystemSnapshot,
} from "./contracts";
export type ZoomLevel = "local" | "system" | "universe";
export type SelectionGroup = "ships" | "structures" | "celestials";
export type DragMode = "orbit" | "marquee";
export type CameraMode = "tactical" | "follow";
export type Selectable =
| { kind: "ship"; id: string }
| { kind: "station"; id: string }
| { kind: "node"; id: string }
| { kind: "spatial-node"; id: string }
| { kind: "bubble"; id: string }
| { kind: "claim"; id: string }
| { kind: "construction-site"; id: string }
| { kind: "system"; id: string }
| { kind: "planet"; systemId: string; planetIndex: number };
export interface ShipVisual {
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
startPosition: THREE.Vector3;
authoritativePosition: THREE.Vector3;
targetPosition: THREE.Vector3;
velocity: THREE.Vector3;
receivedAtMs: number;
blendDurationMs: number;
}
export interface PlanetVisual {
systemId: string;
planet: PlanetSnapshot;
orbit: THREE.LineLoop;
mesh: THREE.Mesh;
icon: THREE.Sprite;
ring?: THREE.Mesh;
moons: MoonVisual[];
}
export interface MoonVisual {
mesh: THREE.Mesh;
orbit: THREE.LineLoop;
}
export type OrbitalAnchor =
| { kind: "star" }
| { kind: "planet"; planetIndex: number }
| { kind: "moon"; planetIndex: number; moonIndex: number };
export interface NodeVisual {
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
sourceKind: string;
anchor: OrbitalAnchor;
localPosition: THREE.Vector3;
orbitRadius: number;
orbitPhase: number;
orbitInclination: number;
}
export interface SpatialNodeVisual {
id: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
kind: string;
localPosition: THREE.Vector3;
}
export interface BubbleVisual {
id: string;
systemId: string;
mesh: THREE.LineLoop;
localPosition: THREE.Vector3;
radius: number;
}
export interface ClaimVisual {
id: string;
nodeId: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
localPosition: THREE.Vector3;
}
export interface ConstructionSiteVisual {
id: string;
nodeId: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
localPosition: THREE.Vector3;
}
export interface StructureVisual {
id: string;
systemId: string;
mesh: THREE.Mesh;
icon: THREE.Sprite;
anchor: OrbitalAnchor;
orbitRadius: number;
orbitPhase: number;
orbitInclination: number;
localPosition: THREE.Vector3;
}
export interface SystemVisual {
root: THREE.Group;
starCluster: THREE.Group;
icon: THREE.Sprite;
shellReticle: THREE.Sprite;
shellReticleBaseScale: number;
detailGroup: THREE.Group;
summary: SystemSummaryVisual;
galaxyPosition: THREE.Vector3;
}
export interface WorldState {
label: string;
seed: number;
sequence: number;
tickIntervalMs: number;
generatedAtUtc: string;
systems: Map<string, SystemSnapshot>;
spatialNodes: Map<string, SpatialNodeSnapshot>;
localBubbles: Map<string, LocalBubbleSnapshot>;
nodes: Map<string, ResourceNodeSnapshot>;
stations: Map<string, StationSnapshot>;
claims: Map<string, ClaimSnapshot>;
constructionSites: Map<string, ConstructionSiteSnapshot>;
marketOrders: Map<string, MarketOrderSnapshot>;
policies: Map<string, PolicySetSnapshot>;
ships: Map<string, ShipSnapshot>;
factions: Map<string, FactionSnapshot>;
recentEvents: SimulationEventRecord[];
}
export interface NetworkSample {
atMs: number;
bytes: number;
}
export interface NetworkStats {
snapshotBytes: number;
deltasReceived: number;
deltaBytes: number;
lastDeltaBytes: number;
lastEntityChanges: number;
eventsReceived: number;
streamConnected: boolean;
streamOpenedAtMs?: number;
lastDeltaAtMs?: number;
throughputSamples: NetworkSample[];
}
export interface PerformanceSample {
atMs: number;
frameMs: number;
}
export interface PerformanceStats {
frameSamples: PerformanceSample[];
lastFrameMs: number;
lastPanelUpdateAtMs: number;
}
export interface PresentationEntry {
detail: THREE.Object3D;
icon: THREE.Sprite;
systemId?: string;
hideDetailInUniverse?: boolean;
hideIconInUniverse?: boolean;
}
export interface SystemSummaryVisual {
sprite: THREE.Sprite;
texture: THREE.CanvasTexture;
anchor: THREE.Vector3;
}
export interface HistoryWindowState {
id: string;
target: Selectable;
root: HTMLElement;
titleEl: HTMLHeadingElement;
bodyEl: HTMLDivElement;
copyButtonEl: HTMLButtonElement;
text: string;
}

View File

@@ -0,0 +1,247 @@
import { fetchWorldSnapshot, openWorldStream } from "./api";
import { renderFactionStrip } from "./viewerFactionStrip";
import { updateDetailPanel } from "./viewerPanels";
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
import type {
ClaimDelta,
ClaimSnapshot,
ConstructionSiteDelta,
ConstructionSiteSnapshot,
FactionSnapshot,
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
ShipDelta,
ShipSnapshot,
SpatialNodeDelta,
SpatialNodeSnapshot,
StationDelta,
StationSnapshot,
SystemSnapshot,
WorldDelta,
WorldSnapshot,
} from "./contracts";
import type {
CameraMode,
NetworkStats,
Selectable,
WorldState,
ZoomLevel,
} from "./viewerTypes";
export interface ViewerWorldLifecycleContext {
getWorld: () => WorldState | undefined;
setWorld: (world: WorldState | undefined) => void;
getWorldTimeSyncMs: () => number;
setWorldTimeSyncMs: (value: number) => void;
getWorldSignature: () => string;
setWorldSignature: (value: string) => void;
getStream: () => EventSource | undefined;
setStream: (stream: EventSource | undefined) => void;
getCurrentStreamScopeKey: () => string;
setCurrentStreamScopeKey: (value: string) => void;
getZoomLevel: () => ZoomLevel;
getActiveSystemId: () => string | undefined;
getSelectedItems: () => Selectable[];
getCameraMode: () => CameraMode;
getCameraTargetShipId: () => string | undefined;
getNetworkStats: () => NetworkStats;
getSystemSummaryVisuals: () => Map<string, unknown>;
errorEl: HTMLDivElement;
factionStripEl: HTMLDivElement;
detailTitleEl: HTMLHeadingElement;
detailBodyEl: HTMLDivElement;
worldLabel: () => string;
rebuildSystems: (systems: SystemSnapshot[]) => void;
syncSpatialNodes: (nodes: SpatialNodeSnapshot[]) => void;
syncLocalBubbles: (bubbles: LocalBubbleSnapshot[]) => void;
syncNodes: (nodes: ResourceNodeSnapshot[]) => void;
syncStations: (stations: StationSnapshot[]) => void;
syncClaims: (claims: ClaimSnapshot[]) => void;
syncConstructionSites: (sites: ConstructionSiteSnapshot[]) => void;
syncShips: (ships: ShipSnapshot[], tickIntervalMs: number) => void;
applySpatialNodeDeltas: (nodes: SpatialNodeDelta[]) => void;
applyLocalBubbleDeltas: (bubbles: LocalBubbleDelta[]) => void;
applyNodeDeltas: (nodes: ResourceNodeDelta[]) => void;
applyStationDeltas: (stations: StationDelta[]) => void;
applyClaimDeltas: (claims: ClaimDelta[]) => void;
applyConstructionSiteDeltas: (sites: ConstructionSiteDelta[]) => void;
applyShipDeltas: (ships: ShipDelta[], tickIntervalMs: number) => void;
refreshHistoryWindows: () => void;
resolveFocusedBubbleId: () => string | undefined;
updateSystemSummaries: () => void;
applyZoomPresentation: () => void;
updateNetworkPanel: () => void;
updateSystemPanel: () => void;
updateGamePanel: (mode: string) => void;
describeSelectionParent: (selection: Selectable) => string;
}
export class ViewerWorldLifecycle {
constructor(private readonly context: ViewerWorldLifecycleContext) {}
async bootstrapWorld() {
try {
const snapshot = await fetchWorldSnapshot();
this.context.setWorld(createWorldState(snapshot));
this.context.getNetworkStats().snapshotBytes = new Blob([JSON.stringify(snapshot)]).size;
this.context.updateGamePanel("Bootstrapped");
this.context.errorEl.hidden = true;
this.applySnapshot(snapshot);
this.openDeltaStream(snapshot.sequence);
this.updatePanels();
} catch (error) {
this.context.updateGamePanel("Backend offline");
this.context.errorEl.hidden = false;
this.context.errorEl.textContent = error instanceof Error ? error.message : "Unable to bootstrap the backend snapshot.";
}
}
openDeltaStream(afterSequence: number) {
this.context.getStream()?.close();
const scope = this.getPreferredStreamScope();
this.context.setCurrentStreamScopeKey(JSON.stringify(scope));
this.context.setStream(openWorldStream(afterSequence, {
onOpen: () => {
const networkStats = this.context.getNetworkStats();
networkStats.streamConnected = true;
networkStats.streamOpenedAtMs = performance.now();
this.context.updateGamePanel("Stream live");
this.context.updateNetworkPanel();
},
onError: () => {
this.context.getNetworkStats().streamConnected = false;
this.context.updateGamePanel("Stream reconnecting");
this.context.updateNetworkPanel();
},
onDelta: (delta, rawBytes) => {
void this.handleDelta(delta, rawBytes);
},
}, scope));
}
async handleDelta(delta: WorldDelta, rawBytes: number) {
if (!this.context.getWorld()) {
return;
}
if (delta.requiresSnapshotRefresh) {
await this.bootstrapWorld();
return;
}
this.applyDelta(delta);
recordDeltaStats(this.context.getNetworkStats(), delta, rawBytes);
this.context.updateGamePanel("Live");
this.updatePanels();
this.context.updateNetworkPanel();
this.refreshStreamScopeIfNeeded();
}
refreshStreamScopeIfNeeded() {
const world = this.context.getWorld();
if (!world) {
return;
}
const nextScopeKey = JSON.stringify(this.getPreferredStreamScope());
if (nextScopeKey === this.context.getCurrentStreamScopeKey()) {
return;
}
this.openDeltaStream(world.sequence);
}
applySnapshot(snapshot: WorldSnapshot) {
this.context.setWorldTimeSyncMs(performance.now());
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.context.getWorldSignature()) {
this.context.setWorldSignature(signature);
this.context.rebuildSystems(snapshot.systems);
}
this.context.syncSpatialNodes(snapshot.spatialNodes);
this.context.syncLocalBubbles(snapshot.localBubbles);
this.context.syncNodes(snapshot.nodes);
this.context.syncStations(snapshot.stations);
this.context.syncClaims(snapshot.claims);
this.context.syncConstructionSites(snapshot.constructionSites);
this.context.syncShips(snapshot.ships, snapshot.tickIntervalMs);
this.rebuildFactions(snapshot.factions);
this.context.updateSystemSummaries();
this.context.applyZoomPresentation();
this.context.updateNetworkPanel();
}
applyDelta(delta: WorldDelta) {
const world = this.context.getWorld();
if (!world) {
return;
}
this.context.setWorldTimeSyncMs(performance.now());
const factionsChanged = applyDeltaToWorld(world, delta);
this.context.applySpatialNodeDeltas(delta.spatialNodes);
this.context.applyLocalBubbleDeltas(delta.localBubbles);
this.context.applyNodeDeltas(delta.nodes);
this.context.applyStationDeltas(delta.stations);
this.context.applyClaimDeltas(delta.claims);
this.context.applyConstructionSiteDeltas(delta.constructionSites);
this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs);
if (factionsChanged) {
this.rebuildFactions(cloneFactions(world));
}
this.context.updateSystemSummaries();
}
rebuildFactions(_factions: FactionSnapshot[]) {
this.context.factionStripEl.innerHTML = renderFactionStrip(
this.context.getWorld(),
this.context.getSelectedItems(),
this.context.getCameraMode(),
this.context.getCameraTargetShipId(),
);
}
updatePanels() {
const world = this.context.getWorld();
if (!world) {
return;
}
this.context.refreshHistoryWindows();
this.context.updateSystemPanel();
this.refreshStreamScopeIfNeeded();
updateDetailPanel(this.context.detailTitleEl, this.context.detailBodyEl, {
world,
selectedItems: this.context.getSelectedItems(),
zoomLevel: this.context.getZoomLevel(),
cameraMode: this.context.getCameraMode(),
cameraTargetShipId: this.context.getCameraTargetShipId(),
worldLabel: this.context.worldLabel(),
describeSelectionParent: this.context.describeSelectionParent,
});
}
private getPreferredStreamScope() {
const activeSystemId = this.context.getActiveSystemId();
if (this.context.getZoomLevel() === "universe" || !activeSystemId) {
return { scopeKind: "universe" as const };
}
const bubbleId = this.context.resolveFocusedBubbleId();
if (this.context.getZoomLevel() === "local" && bubbleId) {
return {
scopeKind: "local-bubble" as const,
systemId: activeSystemId,
bubbleId,
};
}
return {
scopeKind: "system" as const,
systemId: activeSystemId,
};
}
}

View File

@@ -0,0 +1,465 @@
import * as THREE from "three";
import {
computeMoonLocalPosition,
computeMoonSize,
computePlanetLocalPosition,
currentWorldTimeSeconds,
resolveOrbitalAnchorPosition,
toThreeVector,
} from "./viewerMath";
import {
resolveShipHeading,
updateSystemStarPresentation,
updateSystemSummaryPresentation,
getAnimatedShipLocalPosition,
} from "./viewerPresentation";
import type {
LocalBubbleDelta,
LocalBubbleSnapshot,
ResourceNodeDelta,
ResourceNodeSnapshot,
} from "./contracts";
import type {
BubbleVisual,
ClaimVisual,
ConstructionSiteVisual,
NodeVisual,
OrbitalAnchor,
ShipVisual,
SpatialNodeVisual,
StructureVisual,
SystemSummaryVisual,
SystemVisual,
WorldState,
ZoomLevel,
CameraMode,
} from "./viewerTypes";
type SummaryIconKind = "ship" | "station" | "structure";
export interface WorldOrbitalContext {
world?: WorldState;
worldTimeSyncMs: number;
worldSeed: number;
nodeVisuals: Map<string, NodeVisual>;
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
bubbleVisuals: Map<string, BubbleVisual>;
stationVisuals: Map<string, StructureVisual>;
}
export interface WorldPresentationContext extends WorldOrbitalContext {
activeSystemId?: string;
orbitYaw: number;
camera: THREE.PerspectiveCamera;
systemFocusLocal: THREE.Vector3;
shipVisuals: Map<string, ShipVisual>;
claimVisuals: Map<string, ClaimVisual>;
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
systemVisuals: Map<string, SystemVisual>;
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
updateSystemDetailVisibility: () => void;
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
}
export interface GameStatusParams {
statusEl: HTMLDivElement;
world?: WorldState;
activeSystemId?: string;
cameraMode: CameraMode;
zoomLevel: ZoomLevel;
mode: string;
}
export function updateWorldPresentation(context: WorldPresentationContext) {
const now = performance.now();
const worldTimeSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs);
for (const visual of context.shipVisuals.values()) {
const worldPosition = getAnimatedShipLocalPosition(visual, now);
visual.mesh.position.copy(context.toDisplayLocalPosition(worldPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
const shipVisible = visual.systemId === context.activeSystemId;
visual.mesh.visible = shipVisible;
visual.icon.visible = shipVisible && visual.icon.visible;
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
if (desiredHeading.lengthSq() > 0.01) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
}
}
for (const visual of context.nodeVisuals.values()) {
const animatedLocalPosition = computeNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.spatialNodeVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.bubbleVisuals.values()) {
const animatedLocalPosition = resolveBubbleAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.stationVisuals.values()) {
const animatedLocalPosition = resolveStructureAnimatedLocalPosition(context, visual, worldTimeSeconds);
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.claimVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
for (const visual of context.constructionSiteVisuals.values()) {
const animatedLocalPosition = computeSpatialNodeLocalPositionById(context, visual.nodeId, worldTimeSeconds) ?? visual.localPosition.clone();
visual.mesh.position.copy(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === context.activeSystemId;
visual.icon.visible = visual.systemId === context.activeSystemId;
}
updateSystemStarPresentation(
context.systemVisuals,
context.activeSystemId,
context.systemFocusLocal,
context.camera,
context.setShellReticleOpacity,
);
context.updateSystemDetailVisibility();
updateSystemSummaryPresentation(context.systemSummaryVisuals, context.camera, context.activeSystemId);
}
export function updateSystemSummaries(world: WorldState | undefined, systemSummaryVisuals: Map<string, SystemSummaryVisual>) {
if (!world) {
return;
}
const shipCounts = new Map<string, number>();
const stationCounts = new Map<string, number>();
const structureCounts = new Map<string, number>();
for (const ship of world.ships.values()) {
shipCounts.set(ship.systemId, (shipCounts.get(ship.systemId) ?? 0) + 1);
}
for (const station of world.stations.values()) {
stationCounts.set(station.systemId, (stationCounts.get(station.systemId) ?? 0) + 1);
structureCounts.set(station.systemId, (structureCounts.get(station.systemId) ?? 0) + 1);
}
for (const node of world.nodes.values()) {
structureCounts.set(node.systemId, (structureCounts.get(node.systemId) ?? 0) + 1);
}
for (const [systemId, system] of world.systems.entries()) {
const visual = systemSummaryVisuals.get(systemId);
if (!visual) {
continue;
}
const canvas = visual.texture.image as HTMLCanvasElement;
const context = canvas.getContext("2d");
if (!context) {
continue;
}
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#eaf4ff";
context.font = "600 34px Space Grotesk, sans-serif";
context.textAlign = "center";
context.fillText(system.label, canvas.width / 2, 40);
const ships = shipCounts.get(systemId) ?? 0;
const stations = stationCounts.get(systemId) ?? 0;
const structures = structureCounts.get(systemId) ?? 0;
const gasClouds = [...world.nodes.values()]
.filter((node) => node.systemId === systemId && node.sourceKind === "gas-cloud")
.length;
const total = ships + stations + structures;
if (total > 0) {
context.fillStyle = "rgba(3, 8, 18, 0.72)";
context.fillRect(56, 64, canvas.width - 112, 68);
context.strokeStyle = "rgba(132, 196, 255, 0.22)";
context.strokeRect(56, 64, canvas.width - 112, 68);
drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff");
drawCountIcon(context, "station", 256, 98, stations, "#ffbf69");
drawCountIcon(context, "structure", 386, 98, structures, gasClouds > 0 ? "#7fd6ff" : "#98adc4");
}
visual.texture.needsUpdate = true;
}
}
export function renderRecentEvents(world: WorldState | undefined, entityKind: string, entityId: string) {
if (!world) {
return "";
}
return world.recentEvents
.filter((event) => event.entityKind === entityKind && (!entityId || event.entityId === entityId))
.slice(0, 8)
.map((event) => `${new Date(event.occurredAtUtc).toLocaleTimeString()} ${event.message}`)
.join("<br>");
}
export function updateGameStatus(params: GameStatusParams) {
const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params;
const sequence = world?.sequence ?? 0;
const generatedAt = world?.generatedAtUtc
? new Date(world.generatedAtUtc).toLocaleTimeString()
: "n/a";
const activeSystem = activeSystemId ?? "deep-space";
const cameraModeLabel = cameraMode === "follow" ? "camera-follow" : "tactical";
statusEl.textContent = [
`mode: ${mode}`,
`camera: ${cameraModeLabel}`,
`zoom: ${zoomLevel}`,
`system: ${activeSystem}`,
`sequence: ${sequence}`,
`snapshot: ${generatedAt}`,
].join("\n");
}
export function deriveNodeOrbital(
context: WorldOrbitalContext,
node: ResourceNodeSnapshot | ResourceNodeDelta,
anchor: OrbitalAnchor,
) {
return deriveOrbitalFromLocalPosition(context, toThreeVector(node.localPosition), node.systemId, anchor);
}
export function deriveOrbitalFromLocalPosition(
context: WorldOrbitalContext,
localPosition: THREE.Vector3,
systemId: string,
anchor: OrbitalAnchor,
) {
const anchorPosition = getOrbitalAnchorPosition(context, systemId, anchor, currentWorldTimeSeconds(context.world, context.worldTimeSyncMs));
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 };
}
export function computeNodeLocalPosition(context: WorldOrbitalContext, node: NodeVisual, timeSeconds: number) {
const speed = 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(getOrbitalAnchorPosition(context, node.systemId, node.anchor, timeSeconds));
}
export function resolveOrbitalAnchor(context: WorldOrbitalContext, systemId: string, localPosition: THREE.Vector3): OrbitalAnchor {
if (!context.world) {
return { kind: "star" };
}
const system = context.world.systems.get(systemId);
if (!system) {
return { kind: "star" };
}
const nowSeconds = currentWorldTimeSeconds(context.world, context.worldTimeSyncMs);
let bestAnchor: OrbitalAnchor = { kind: "star" };
let bestDistance = Number.POSITIVE_INFINITY;
for (const [planetIndex, planet] of system.planets.entries()) {
const planetPosition = 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(computeMoonLocalPosition(planet, moonIndex, nowSeconds, context.world.seed));
const moonDistance = localPosition.distanceTo(moonPosition);
const moonThreshold = Math.max(computeMoonSize(planet, moonIndex, context.world.seed) * 14, 80);
if (moonDistance < moonThreshold && moonDistance < bestDistance) {
bestDistance = moonDistance;
bestAnchor = { kind: "moon", planetIndex, moonIndex };
}
}
}
return bestAnchor;
}
export function resolvePointPosition(context: WorldOrbitalContext, _systemId: string, nodeId?: string | null) {
if (nodeId) {
const spatialNode = context.world?.spatialNodes.get(nodeId);
if (spatialNode) {
return toThreeVector(spatialNode.localPosition);
}
}
return new THREE.Vector3(0, 0, 0);
}
export function resolveBubblePosition(context: WorldOrbitalContext, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
return resolvePointPosition(context, bubble.systemId, bubble.nodeId);
}
export function computeSpatialNodeLocalPosition(context: WorldOrbitalContext, visual: SpatialNodeVisual, timeSeconds: number) {
return computeSpatialNodeLocalPositionById(context, visual.id, timeSeconds) ?? visual.localPosition.clone();
}
export function computeSpatialNodeLocalPositionById(
context: WorldOrbitalContext,
nodeId: string,
timeSeconds: number,
visiting = new Set<string>(),
): THREE.Vector3 | undefined {
if (!context.world || visiting.has(nodeId)) {
return undefined;
}
const node = context.world.spatialNodes.get(nodeId);
if (!node) {
return undefined;
}
const basePosition = toThreeVector(node.localPosition);
if (!node.parentNodeId) {
return basePosition;
}
const parentNode = context.world.spatialNodes.get(node.parentNodeId);
if (!parentNode) {
return basePosition;
}
visiting.add(nodeId);
const parentCurrentPosition = computeSpatialNodeLocalPositionById(context, node.parentNodeId, timeSeconds, visiting);
visiting.delete(nodeId);
if (!parentCurrentPosition) {
return basePosition;
}
const parentInitialPosition = toThreeVector(parentNode.localPosition);
const relativeOffset = basePosition.clone().sub(parentInitialPosition);
const initialAngle = Math.atan2(parentInitialPosition.z, parentInitialPosition.x);
const currentAngle = Math.atan2(parentCurrentPosition.z, parentCurrentPosition.x);
const rotatedOffset = relativeOffset.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentAngle - initialAngle);
return parentCurrentPosition.clone().add(rotatedOffset);
}
export function setBubbleVisualState(visual: BubbleVisual, bubble: LocalBubbleSnapshot | LocalBubbleDelta) {
const intensity = bubble.occupantShipIds.length + bubble.occupantStationIds.length + bubble.occupantConstructionSiteIds.length;
const material = visual.mesh.material as THREE.LineBasicMaterial;
material.opacity = THREE.MathUtils.clamp(0.18 + intensity * 0.05, 0.18, 0.72);
material.color.set(intensity > 0 ? "#7fffd4" : "#6ed6ff");
}
function drawCountIcon(
context: CanvasRenderingContext2D,
kind: SummaryIconKind,
x: number,
y: number,
value: number,
color: string,
) {
context.save();
context.strokeStyle = color;
context.fillStyle = color;
context.lineWidth = 3;
if (kind === "ship") {
context.beginPath();
context.moveTo(x - 14, y + 10);
context.lineTo(x, y - 14);
context.lineTo(x + 14, y + 10);
context.closePath();
context.stroke();
} else if (kind === "station") {
context.strokeRect(x - 14, y - 14, 28, 28);
} else {
context.beginPath();
context.arc(x, y, 14, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(x - 8, y);
context.lineTo(x + 8, y);
context.moveTo(x, y - 8);
context.lineTo(x, y + 8);
context.stroke();
}
context.fillStyle = "#eaf4ff";
context.font = "600 26px IBM Plex Mono, monospace";
context.textAlign = "left";
context.fillText(String(value), x + 24, y + 9);
context.restore();
}
function computeNodeOrbitSpeed(node: NodeVisual) {
const base = node.sourceKind === "gas-cloud" ? 0.16 : 0.24;
return base / Math.sqrt(Math.max(node.orbitRadius / 140, 0.4));
}
function computeStructureLocalPosition(
context: WorldOrbitalContext,
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(getOrbitalAnchorPosition(context, structure.systemId, structure.anchor, timeSeconds));
}
function getOrbitalAnchorPosition(context: WorldOrbitalContext, systemId: string, anchor: OrbitalAnchor, timeSeconds: number) {
return resolveOrbitalAnchorPosition(context.world, systemId, anchor, timeSeconds, context.worldSeed);
}
function resolveBubbleAnimatedLocalPosition(context: WorldOrbitalContext, visual: BubbleVisual, timeSeconds: number) {
const bubble = context.world?.localBubbles.get(visual.id);
if (!bubble) {
return visual.localPosition.clone();
}
return computeSpatialNodeLocalPositionById(context, bubble.nodeId, timeSeconds) ?? visual.localPosition.clone();
}
function resolveStructureAnimatedLocalPosition(context: WorldOrbitalContext, visual: StructureVisual, timeSeconds: number) {
if (!context.world) {
return visual.localPosition.clone();
}
const station = context.world.stations.get(visual.id);
if (!station?.nodeId) {
return computeStructureLocalPosition(context, visual, timeSeconds, 0.14);
}
return computeSpatialNodeLocalPositionById(context, station.nodeId, timeSeconds) ?? visual.localPosition.clone();
}