Refactor simulation and viewer architecture
This commit is contained in:
85
apps/backend/Contracts/WorldContracts.Celestial.cs
Normal file
85
apps/backend/Contracts/WorldContracts.Celestial.cs
Normal 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);
|
||||
4
apps/backend/Contracts/WorldContracts.Common.cs
Normal file
4
apps/backend/Contracts/WorldContracts.Common.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace SpaceGame.Simulation.Api.Contracts;
|
||||
|
||||
public sealed record Vector3Dto(float X, float Y, float Z);
|
||||
|
||||
47
apps/backend/Contracts/WorldContracts.Economy.cs
Normal file
47
apps/backend/Contracts/WorldContracts.Economy.cs
Normal 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);
|
||||
25
apps/backend/Contracts/WorldContracts.Factions.cs
Normal file
25
apps/backend/Contracts/WorldContracts.Factions.cs
Normal 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);
|
||||
113
apps/backend/Contracts/WorldContracts.Infrastructure.cs
Normal file
113
apps/backend/Contracts/WorldContracts.Infrastructure.cs
Normal 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);
|
||||
74
apps/backend/Contracts/WorldContracts.Ships.cs
Normal file
74
apps/backend/Contracts/WorldContracts.Ships.cs
Normal 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);
|
||||
53
apps/backend/Contracts/WorldContracts.World.cs
Normal file
53
apps/backend/Contracts/WorldContracts.World.cs
Normal 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);
|
||||
@@ -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);
|
||||
10
apps/backend/Simulation/AI/IShipBehaviorState.cs
Normal file
10
apps/backend/Simulation/AI/IShipBehaviorState.cs
Normal 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);
|
||||
}
|
||||
39
apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs
Normal file
39
apps/backend/Simulation/AI/ShipBehaviorStateMachine.cs
Normal 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;
|
||||
}
|
||||
135
apps/backend/Simulation/AI/ShipBehaviorStates.cs
Normal file
135
apps/backend/Simulation/AI/ShipBehaviorStates.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
30
apps/backend/Simulation/Model/CommerceRuntimeModels.cs
Normal file
30
apps/backend/Simulation/Model/CommerceRuntimeModels.cs
Normal 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;
|
||||
}
|
||||
38
apps/backend/Simulation/Model/ConstructionRuntimeModels.cs
Normal file
38
apps/backend/Simulation/Model/ConstructionRuntimeModels.cs
Normal 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;
|
||||
}
|
||||
66
apps/backend/Simulation/Model/FactionRuntimeModels.cs
Normal file
66
apps/backend/Simulation/Model/FactionRuntimeModels.cs
Normal 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; }
|
||||
}
|
||||
64
apps/backend/Simulation/Model/ShipRuntimeModels.cs
Normal file
64
apps/backend/Simulation/Model/ShipRuntimeModels.cs
Normal 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; }
|
||||
}
|
||||
148
apps/backend/Simulation/Model/SimulationKinds.cs
Normal file
148
apps/backend/Simulation/Model/SimulationKinds.cs
Normal 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),
|
||||
};
|
||||
}
|
||||
28
apps/backend/Simulation/Model/SimulationWorld.cs
Normal file
28
apps/backend/Simulation/Model/SimulationWorld.cs
Normal 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; }
|
||||
}
|
||||
70
apps/backend/Simulation/Model/SpatialRuntimeModels.cs
Normal file
70
apps/backend/Simulation/Model/SpatialRuntimeModels.cs
Normal 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; }
|
||||
}
|
||||
39
apps/backend/Simulation/Model/StationRuntimeModels.cs
Normal file
39
apps/backend/Simulation/Model/StationRuntimeModels.cs
Normal 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;
|
||||
}
|
||||
36
apps/backend/Simulation/Model/Vector3.cs
Normal file
36
apps/backend/Simulation/Model/Vector3.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
509
apps/backend/Simulation/ScenarioLoader.Generation.cs
Normal file
509
apps/backend/Simulation/ScenarioLoader.Generation.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
413
apps/backend/Simulation/ScenarioLoader.Seeding.cs
Normal file
413
apps/backend/Simulation/ScenarioLoader.Seeding.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
217
apps/backend/Simulation/ScenarioLoader.Spatial.cs
Normal file
217
apps/backend/Simulation/ScenarioLoader.Spatial.cs
Normal 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
276
apps/backend/Simulation/SimulationEngine.MovementSystem.cs
Normal file
276
apps/backend/Simulation/SimulationEngine.MovementSystem.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
256
apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs
Normal file
256
apps/backend/Simulation/SimulationEngine.OrbitalSystem.cs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
680
apps/backend/Simulation/SimulationEngine.Replication.cs
Normal file
680
apps/backend/Simulation/SimulationEngine.Replication.cs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
519
apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs
Normal file
519
apps/backend/Simulation/SimulationEngine.ShipActionSystem.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
483
apps/backend/Simulation/SimulationEngine.ShipControl.cs
Normal file
483
apps/backend/Simulation/SimulationEngine.ShipControl.cs
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
263
apps/backend/Simulation/SimulationEngine.StationSystems.cs
Normal file
263
apps/backend/Simulation/SimulationEngine.StationSystems.cs
Normal 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
362
apps/viewer/src/ViewerAppController.ts
Normal file
362
apps/viewer/src/ViewerAppController.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
67
apps/viewer/src/contractsCelestial.ts
Normal file
67
apps/viewer/src/contractsCelestial.ts
Normal 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 {}
|
||||
10
apps/viewer/src/contractsCommon.ts
Normal file
10
apps/viewer/src/contractsCommon.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Vector3Dto {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface InventoryEntry {
|
||||
itemId: string;
|
||||
amount: number;
|
||||
}
|
||||
28
apps/viewer/src/contractsEconomy.ts
Normal file
28
apps/viewer/src/contractsEconomy.ts
Normal 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 {}
|
||||
14
apps/viewer/src/contractsFactions.ts
Normal file
14
apps/viewer/src/contractsFactions.ts
Normal 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 {}
|
||||
64
apps/viewer/src/contractsInfrastructure.ts
Normal file
64
apps/viewer/src/contractsInfrastructure.ts
Normal 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 {}
|
||||
52
apps/viewer/src/contractsShips.ts
Normal file
52
apps/viewer/src/contractsShips.ts
Normal 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;
|
||||
}
|
||||
85
apps/viewer/src/contractsWorld.ts
Normal file
85
apps/viewer/src/contractsWorld.ts
Normal 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;
|
||||
}
|
||||
348
apps/viewer/src/viewerCamera.ts
Normal file
348
apps/viewer/src/viewerCamera.ts
Normal 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));
|
||||
}
|
||||
23
apps/viewer/src/viewerConstants.ts
Normal file
23
apps/viewer/src/viewerConstants.ts
Normal 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;
|
||||
}
|
||||
272
apps/viewer/src/viewerControllerFactory.ts
Normal file
272
apps/viewer/src/viewerControllerFactory.ts
Normal 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);
|
||||
}
|
||||
210
apps/viewer/src/viewerControls.ts
Normal file
210
apps/viewer/src/viewerControls.ts
Normal 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 };
|
||||
}
|
||||
|
||||
52
apps/viewer/src/viewerFactionStrip.ts
Normal file
52
apps/viewer/src/viewerFactionStrip.ts
Normal 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"
|
||||
>🕔</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("");
|
||||
}
|
||||
70
apps/viewer/src/viewerHistory.ts
Normal file
70
apps/viewer/src/viewerHistory.ts
Normal 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;
|
||||
}
|
||||
180
apps/viewer/src/viewerHistoryManager.ts
Normal file
180
apps/viewer/src/viewerHistoryManager.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
175
apps/viewer/src/viewerHistoryWindowController.ts
Normal file
175
apps/viewer/src/viewerHistoryWindowController.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
71
apps/viewer/src/viewerHud.ts
Normal file
71
apps/viewer/src/viewerHud.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
131
apps/viewer/src/viewerInteraction.ts
Normal file
131
apps/viewer/src/viewerInteraction.ts
Normal 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] ?? [];
|
||||
}
|
||||
297
apps/viewer/src/viewerInteractionController.ts
Normal file
297
apps/viewer/src/viewerInteractionController.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
193
apps/viewer/src/viewerMath.ts
Normal file
193
apps/viewer/src/viewerMath.ts
Normal 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));
|
||||
}
|
||||
193
apps/viewer/src/viewerNavigationController.ts
Normal file
193
apps/viewer/src/viewerNavigationController.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
296
apps/viewer/src/viewerPanels.ts
Normal file
296
apps/viewer/src/viewerPanels.ts
Normal 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";
|
||||
}
|
||||
126
apps/viewer/src/viewerPresentation.ts
Normal file
126
apps/viewer/src/viewerPresentation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
182
apps/viewer/src/viewerPresentationController.ts
Normal file
182
apps/viewer/src/viewerPresentationController.ts
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
59
apps/viewer/src/viewerRenderLoop.ts
Normal file
59
apps/viewer/src/viewerRenderLoop.ts
Normal 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 };
|
||||
}
|
||||
67
apps/viewer/src/viewerSceneAppearance.ts
Normal file
67
apps/viewer/src/viewerSceneAppearance.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
222
apps/viewer/src/viewerSceneDataController.ts
Normal file
222
apps/viewer/src/viewerSceneDataController.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
420
apps/viewer/src/viewerSceneFactory.ts
Normal file
420
apps/viewer/src/viewerSceneFactory.ts
Normal 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;
|
||||
}
|
||||
508
apps/viewer/src/viewerSceneSync.ts
Normal file
508
apps/viewer/src/viewerSceneSync.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
216
apps/viewer/src/viewerSelection.ts
Normal file
216
apps/viewer/src/viewerSelection.ts
Normal 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}
|
||||
`;
|
||||
}
|
||||
119
apps/viewer/src/viewerState.ts
Normal file
119
apps/viewer/src/viewerState.ts
Normal 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()];
|
||||
}
|
||||
91
apps/viewer/src/viewerTelemetry.ts
Normal file
91
apps/viewer/src/viewerTelemetry.ts
Normal 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;
|
||||
}
|
||||
207
apps/viewer/src/viewerTypes.ts
Normal file
207
apps/viewer/src/viewerTypes.ts
Normal 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;
|
||||
}
|
||||
247
apps/viewer/src/viewerWorldLifecycle.ts
Normal file
247
apps/viewer/src/viewerWorldLifecycle.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
465
apps/viewer/src/viewerWorldPresentation.ts
Normal file
465
apps/viewer/src/viewerWorldPresentation.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user