feat: production chain
This commit is contained in:
@@ -30,6 +30,7 @@ public sealed record ResourceNodeSnapshot(
|
||||
string Id,
|
||||
string SystemId,
|
||||
Vector3Dto LocalPosition,
|
||||
string? AnchorNodeId,
|
||||
string SourceKind,
|
||||
float OreRemaining,
|
||||
float MaxOre,
|
||||
@@ -39,6 +40,7 @@ public sealed record ResourceNodeDelta(
|
||||
string Id,
|
||||
string SystemId,
|
||||
Vector3Dto LocalPosition,
|
||||
string? AnchorNodeId,
|
||||
string SourceKind,
|
||||
float OreRemaining,
|
||||
float MaxOre,
|
||||
|
||||
@@ -15,8 +15,13 @@ public sealed record StationSnapshot(
|
||||
string? AnchorNodeId,
|
||||
string Color,
|
||||
int DockedShips,
|
||||
IReadOnlyList<string> DockedShipIds,
|
||||
int DockingPads,
|
||||
float FuelStored,
|
||||
float FuelCapacity,
|
||||
float EnergyStored,
|
||||
float EnergyCapacity,
|
||||
IReadOnlyList<StationActionProgressSnapshot> CurrentProcesses,
|
||||
IReadOnlyList<InventoryEntry> Inventory,
|
||||
string FactionId,
|
||||
string? CommanderId,
|
||||
@@ -39,8 +44,13 @@ public sealed record StationDelta(
|
||||
string? AnchorNodeId,
|
||||
string Color,
|
||||
int DockedShips,
|
||||
IReadOnlyList<string> DockedShipIds,
|
||||
int DockingPads,
|
||||
float FuelStored,
|
||||
float FuelCapacity,
|
||||
float EnergyStored,
|
||||
float EnergyCapacity,
|
||||
IReadOnlyList<StationActionProgressSnapshot> CurrentProcesses,
|
||||
IReadOnlyList<InventoryEntry> Inventory,
|
||||
string FactionId,
|
||||
string? CommanderId,
|
||||
@@ -52,6 +62,11 @@ public sealed record StationDelta(
|
||||
IReadOnlyList<string> InstalledModules,
|
||||
IReadOnlyList<string> MarketOrderIds);
|
||||
|
||||
public sealed record StationActionProgressSnapshot(
|
||||
string Lane,
|
||||
string Label,
|
||||
float Progress);
|
||||
|
||||
public sealed record ClaimSnapshot(
|
||||
string Id,
|
||||
string FactionId,
|
||||
|
||||
@@ -19,12 +19,14 @@ public sealed record ShipSnapshot(
|
||||
string? CommanderId,
|
||||
string? PolicySetId,
|
||||
float CargoCapacity,
|
||||
string? CargoItemId,
|
||||
float WorkerPopulation,
|
||||
float EnergyStored,
|
||||
IReadOnlyList<InventoryEntry> Inventory,
|
||||
string FactionId,
|
||||
float Health,
|
||||
IReadOnlyList<string> History,
|
||||
ShipActionProgressSnapshot? CurrentAction,
|
||||
ShipSpatialStateSnapshot SpatialState);
|
||||
|
||||
public sealed record ShipDelta(
|
||||
@@ -46,14 +48,20 @@ public sealed record ShipDelta(
|
||||
string? CommanderId,
|
||||
string? PolicySetId,
|
||||
float CargoCapacity,
|
||||
string? CargoItemId,
|
||||
float WorkerPopulation,
|
||||
float EnergyStored,
|
||||
IReadOnlyList<InventoryEntry> Inventory,
|
||||
string FactionId,
|
||||
float Health,
|
||||
IReadOnlyList<string> History,
|
||||
ShipActionProgressSnapshot? CurrentAction,
|
||||
ShipSpatialStateSnapshot SpatialState);
|
||||
|
||||
public sealed record ShipActionProgressSnapshot(
|
||||
string Label,
|
||||
float Progress);
|
||||
|
||||
public sealed record ShipSpatialStateSnapshot(
|
||||
string SpaceLayer,
|
||||
string CurrentSystemId,
|
||||
|
||||
@@ -5,6 +5,8 @@ public sealed record WorldSnapshot(
|
||||
int Seed,
|
||||
long Sequence,
|
||||
int TickIntervalMs,
|
||||
double OrbitalTimeSeconds,
|
||||
OrbitalSimulationSnapshot OrbitalSimulation,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
IReadOnlyList<SystemSnapshot> Systems,
|
||||
IReadOnlyList<SpatialNodeSnapshot> SpatialNodes,
|
||||
@@ -21,6 +23,8 @@ public sealed record WorldSnapshot(
|
||||
public sealed record WorldDelta(
|
||||
long Sequence,
|
||||
int TickIntervalMs,
|
||||
double OrbitalTimeSeconds,
|
||||
OrbitalSimulationSnapshot OrbitalSimulation,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
bool RequiresSnapshotRefresh,
|
||||
IReadOnlyList<SimulationEventRecord> Events,
|
||||
@@ -51,3 +55,6 @@ public sealed record ObserverScope(
|
||||
string ScopeKind,
|
||||
string? SystemId = null,
|
||||
string? BubbleId = null);
|
||||
|
||||
public sealed record OrbitalSimulationSnapshot(
|
||||
double SimulatedSecondsPerRealSecond);
|
||||
|
||||
@@ -11,7 +11,6 @@ public sealed class BalanceDefinition
|
||||
public float UndockingDuration { get; set; }
|
||||
public float UndockDistance { get; set; }
|
||||
public EnergyBalanceDefinition Energy { get; set; } = new();
|
||||
public FuelBalanceDefinition Fuel { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class EnergyBalanceDefinition
|
||||
@@ -23,11 +22,6 @@ public sealed class EnergyBalanceDefinition
|
||||
public float StationSolarCharge { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FuelBalanceDefinition
|
||||
{
|
||||
public float WarpDrain { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SolarSystemDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
@@ -57,6 +51,9 @@ public sealed class ResourceNodeDefinition
|
||||
public string SourceKind { get; set; } = "asteroid-belt";
|
||||
public float Angle { get; set; }
|
||||
public float RadiusOffset { get; set; }
|
||||
public float InclinationDegrees { get; set; }
|
||||
public int? AnchorPlanetIndex { get; set; }
|
||||
public int? AnchorMoonIndex { get; set; }
|
||||
public float OreAmount { get; set; }
|
||||
public required string ItemId { get; set; }
|
||||
public int ShardCount { get; set; }
|
||||
@@ -99,6 +96,7 @@ public sealed class RecipeDefinition
|
||||
public List<string> RequiredModules { get; set; } = [];
|
||||
public List<RecipeInputDefinition> Inputs { get; set; } = [];
|
||||
public List<RecipeOutputDefinition> Outputs { get; set; } = [];
|
||||
public string? ShipOutputId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlanetDefinition
|
||||
|
||||
@@ -16,6 +16,8 @@ builder.Services.AddCors((options) =>
|
||||
.AllowAnyOrigin();
|
||||
});
|
||||
});
|
||||
builder.Services.Configure<WorldGenerationOptions>(builder.Configuration.GetSection("WorldGeneration"));
|
||||
builder.Services.Configure<OrbitalSimulationOptions>(builder.Configuration.GetSection("OrbitalSimulation"));
|
||||
builder.Services.AddSingleton<WorldService>();
|
||||
builder.Services.AddHostedService<SimulationHostedService>();
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ internal sealed class ShipBehaviorStateMachine
|
||||
new PatrolShipBehaviorState(),
|
||||
new ResourceHarvestShipBehaviorState("auto-mine", "ore", "mining-turret"),
|
||||
new ResourceHarvestShipBehaviorState("auto-harvest-gas", "gas", "gas-extractor"),
|
||||
new EnergySupplyShipBehaviorState(),
|
||||
new ConstructStationShipBehaviorState(),
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ internal sealed class IdleShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
Status = WorkStatus.Pending,
|
||||
};
|
||||
@@ -30,7 +30,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
|
||||
ship.DefaultBehavior.Kind = "idle";
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
Status = WorkStatus.Pending,
|
||||
};
|
||||
@@ -39,7 +39,7 @@ internal sealed class PatrolShipBehaviorState : IShipBehaviorState
|
||||
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
|
||||
TargetSystemId = ship.SystemId,
|
||||
Threshold = 18f,
|
||||
@@ -82,6 +82,10 @@ internal sealed class ResourceHarvestShipBehaviorState : IShipBehaviorState
|
||||
case ("extract", "cargo-full"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-station";
|
||||
break;
|
||||
case ("extract", "node-depleted"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-node";
|
||||
ship.DefaultBehavior.NodeId = null;
|
||||
break;
|
||||
case ("travel-to-station", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock";
|
||||
break;
|
||||
@@ -114,10 +118,7 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
|
||||
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";
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "deliver-to-site";
|
||||
break;
|
||||
case ("refuel", "refueled"):
|
||||
ship.DefaultBehavior.Phase = "deliver-to-site";
|
||||
@@ -133,3 +134,37 @@ internal sealed class ConstructStationShipBehaviorState : IShipBehaviorState
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class EnergySupplyShipBehaviorState : IShipBehaviorState
|
||||
{
|
||||
public string Kind => "auto-supply-energy";
|
||||
|
||||
public void Plan(SimulationEngine engine, ShipRuntime ship, SimulationWorld world) =>
|
||||
engine.PlanEnergySupply(ship, world);
|
||||
|
||||
public void ApplyEvent(SimulationEngine engine, ShipRuntime ship, SimulationWorld world, string controllerEvent)
|
||||
{
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-source", "arrived"):
|
||||
case ("travel-to-destination", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock";
|
||||
break;
|
||||
case ("dock", "docked"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "unload" : "load";
|
||||
break;
|
||||
case ("load", "loaded"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock";
|
||||
break;
|
||||
case ("unload", "unloaded"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.NeedsRefuel(ship, world) ? "refuel" : "undock";
|
||||
break;
|
||||
case ("refuel", "refueled"):
|
||||
ship.DefaultBehavior.Phase = "undock";
|
||||
break;
|
||||
case ("undock", "undocked"):
|
||||
ship.DefaultBehavior.Phase = SimulationEngine.GetShipCargoAmount(ship) > 0.01f ? "travel-to-destination" : "travel-to-source";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ public sealed class ShipRuntime
|
||||
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 ShipState State { get; set; } = ShipState.Idle;
|
||||
public ShipOrderRuntime? Order { get; set; }
|
||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||
public required ControllerTaskRuntime ControllerTask { get; set; }
|
||||
@@ -25,6 +25,8 @@ public sealed class ShipRuntime
|
||||
public string? CommanderId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public float Health { get; set; }
|
||||
public string? TrackedActionKey { get; set; }
|
||||
public float TrackedActionTotal { get; set; }
|
||||
public List<string> History { get; } = [];
|
||||
public string LastSignature { get; set; } = string.Empty;
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
@@ -53,7 +55,7 @@ public sealed class DefaultBehaviorRuntime
|
||||
|
||||
public sealed class ControllerTaskRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public required ControllerTaskKind Kind { get; set; }
|
||||
public WorkStatus Status { get; set; } = WorkStatus.Pending;
|
||||
public string? CommanderId { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
|
||||
@@ -24,6 +24,53 @@ public enum OrderStatus
|
||||
Completed,
|
||||
}
|
||||
|
||||
public enum ShipState
|
||||
{
|
||||
Idle,
|
||||
Arriving,
|
||||
CapacitorStarved,
|
||||
LocalFlight,
|
||||
SpoolingWarp,
|
||||
Warping,
|
||||
SpoolingFtl,
|
||||
Ftl,
|
||||
CargoFull,
|
||||
MiningApproach,
|
||||
Mining,
|
||||
NodeDepleted,
|
||||
AwaitingDock,
|
||||
DockingApproach,
|
||||
Docking,
|
||||
Docked,
|
||||
Transferring,
|
||||
Loading,
|
||||
Unloading,
|
||||
Refueling,
|
||||
WaitingMaterials,
|
||||
ConstructionBlocked,
|
||||
Constructing,
|
||||
DeliveringConstruction,
|
||||
Blocked,
|
||||
Undocking,
|
||||
}
|
||||
|
||||
public enum ControllerTaskKind
|
||||
{
|
||||
Idle,
|
||||
Travel,
|
||||
Extract,
|
||||
Dock,
|
||||
Load,
|
||||
Unload,
|
||||
Refuel,
|
||||
DeliverConstruction,
|
||||
BuildConstructionSite,
|
||||
LoadWorkers,
|
||||
UnloadWorkers,
|
||||
ConstructModule,
|
||||
Undock,
|
||||
}
|
||||
|
||||
public static class SpaceLayerKinds
|
||||
{
|
||||
public const string UniverseSpace = "universe-space";
|
||||
@@ -145,4 +192,53 @@ public static class SimulationEnumMappings
|
||||
OrderStatus.Completed => "completed",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(status), status, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ShipState state) => state switch
|
||||
{
|
||||
ShipState.Idle => "idle",
|
||||
ShipState.Arriving => "arriving",
|
||||
ShipState.CapacitorStarved => "capacitor-starved",
|
||||
ShipState.LocalFlight => "local-flight",
|
||||
ShipState.SpoolingWarp => "spooling-warp",
|
||||
ShipState.Warping => "warping",
|
||||
ShipState.SpoolingFtl => "spooling-ftl",
|
||||
ShipState.Ftl => "ftl",
|
||||
ShipState.CargoFull => "cargo-full",
|
||||
ShipState.MiningApproach => "mining-approach",
|
||||
ShipState.Mining => "mining",
|
||||
ShipState.NodeDepleted => "node-depleted",
|
||||
ShipState.AwaitingDock => "awaiting-dock",
|
||||
ShipState.DockingApproach => "docking-approach",
|
||||
ShipState.Docking => "docking",
|
||||
ShipState.Docked => "docked",
|
||||
ShipState.Transferring => "transferring",
|
||||
ShipState.Loading => "loading",
|
||||
ShipState.Unloading => "unloading",
|
||||
ShipState.Refueling => "refueling",
|
||||
ShipState.WaitingMaterials => "waiting-materials",
|
||||
ShipState.ConstructionBlocked => "construction-blocked",
|
||||
ShipState.Constructing => "constructing",
|
||||
ShipState.DeliveringConstruction => "delivering-construction",
|
||||
ShipState.Blocked => "blocked",
|
||||
ShipState.Undocking => "undocking",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(state), state, null),
|
||||
};
|
||||
|
||||
public static string ToContractValue(this ControllerTaskKind kind) => kind switch
|
||||
{
|
||||
ControllerTaskKind.Idle => "idle",
|
||||
ControllerTaskKind.Travel => "travel",
|
||||
ControllerTaskKind.Extract => "extract",
|
||||
ControllerTaskKind.Dock => "dock",
|
||||
ControllerTaskKind.Load => "load",
|
||||
ControllerTaskKind.Unload => "unload",
|
||||
ControllerTaskKind.Refuel => "refuel",
|
||||
ControllerTaskKind.DeliverConstruction => "deliver-construction",
|
||||
ControllerTaskKind.BuildConstructionSite => "build-construction-site",
|
||||
ControllerTaskKind.LoadWorkers => "load-workers",
|
||||
ControllerTaskKind.UnloadWorkers => "unload-workers",
|
||||
ControllerTaskKind.ConstructModule => "construct-module",
|
||||
ControllerTaskKind.Undock => "undock",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,5 +24,6 @@ public sealed class SimulationWorld
|
||||
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
|
||||
public required Dictionary<string, RecipeDefinition> Recipes { get; init; }
|
||||
public int TickIntervalMs { get; init; } = 200;
|
||||
public double OrbitalTimeSeconds { get; set; }
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -12,9 +12,13 @@ public sealed class ResourceNodeRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; init; }
|
||||
public required Vector3 Position { get; init; }
|
||||
public required Vector3 Position { get; set; }
|
||||
public required string SourceKind { get; init; }
|
||||
public required string ItemId { get; init; }
|
||||
public string? AnchorNodeId { get; set; }
|
||||
public float OrbitRadius { get; init; }
|
||||
public float OrbitPhase { get; init; }
|
||||
public float OrbitInclination { get; init; }
|
||||
public float OreRemaining { get; set; }
|
||||
public float MaxOre { get; init; }
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
|
||||
@@ -14,17 +14,18 @@ public sealed class StationRuntime
|
||||
public string? AnchorNodeId { get; set; }
|
||||
public string? CommanderId { get; set; }
|
||||
public string? PolicySetId { get; set; }
|
||||
public HashSet<string> InstalledModules { get; } = new(StringComparer.Ordinal);
|
||||
public List<string> InstalledModules { get; } = [];
|
||||
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
|
||||
public Dictionary<string, float> ProductionLaneTimers { 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 float ShipProductionProgressSeconds { get; set; }
|
||||
public HashSet<string> DockedShipIds { get; } = [];
|
||||
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
|
||||
public string LastDeltaSignature { get; set; } = string.Empty;
|
||||
|
||||
6
apps/backend/Simulation/OrbitalSimulationOptions.cs
Normal file
6
apps/backend/Simulation/OrbitalSimulationOptions.cs
Normal file
@@ -0,0 +1,6 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class OrbitalSimulationOptions
|
||||
{
|
||||
public double SimulatedSecondsPerRealSecond { get; init; } = 0d;
|
||||
}
|
||||
@@ -4,13 +4,18 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class ScenarioLoader
|
||||
{
|
||||
private static List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
|
||||
private const string SolSystemId = "sol";
|
||||
private const string DevelopmentCompanionSystemId = "helios";
|
||||
|
||||
private static List<SolarSystemDefinition> InjectSpecialSystems(
|
||||
IReadOnlyList<SolarSystemDefinition> authoredSystems,
|
||||
bool includeSolSystem)
|
||||
{
|
||||
var systems = authoredSystems
|
||||
.Select(CloneSystemDefinition)
|
||||
.ToList();
|
||||
|
||||
if (systems.All((system) => system.Id != "sol"))
|
||||
if (includeSolSystem && systems.All((system) => system.Id != "sol"))
|
||||
{
|
||||
systems.Add(CreateSolSystem());
|
||||
}
|
||||
@@ -18,13 +23,25 @@ public sealed partial class ScenarioLoader
|
||||
return systems;
|
||||
}
|
||||
|
||||
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
|
||||
private static List<SolarSystemDefinition> ExpandSystems(
|
||||
IReadOnlyList<SolarSystemDefinition> authoredSystems,
|
||||
int targetSystemCount)
|
||||
{
|
||||
var systems = authoredSystems
|
||||
.Select(CloneSystemDefinition)
|
||||
.ToList();
|
||||
|
||||
if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0)
|
||||
if (targetSystemCount <= 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (systems.Count > targetSystemCount)
|
||||
{
|
||||
return TrimSystemsToTarget(systems, targetSystemCount);
|
||||
}
|
||||
|
||||
if (systems.Count >= targetSystemCount || authoredSystems.Count == 0)
|
||||
{
|
||||
return systems;
|
||||
}
|
||||
@@ -32,9 +49,11 @@ public sealed partial class ScenarioLoader
|
||||
var existingIds = systems
|
||||
.Select((system) => system.Id)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count);
|
||||
var generatedPositions = BuildGalaxyPositions(
|
||||
authoredSystems.Select((system) => ToVector(system.Position)).ToList(),
|
||||
targetSystemCount - systems.Count);
|
||||
|
||||
for (var index = systems.Count; index < TargetSystemCount; index += 1)
|
||||
for (var index = systems.Count; index < targetSystemCount; index += 1)
|
||||
{
|
||||
var template = authoredSystems[index % authoredSystems.Count];
|
||||
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
|
||||
@@ -50,6 +69,63 @@ public sealed partial class ScenarioLoader
|
||||
return systems;
|
||||
}
|
||||
|
||||
private static List<SolarSystemDefinition> TrimSystemsToTarget(
|
||||
IReadOnlyList<SolarSystemDefinition> systems,
|
||||
int targetSystemCount)
|
||||
{
|
||||
var selected = new List<SolarSystemDefinition>(targetSystemCount);
|
||||
|
||||
void AddById(string systemId)
|
||||
{
|
||||
var system = systems.FirstOrDefault((candidate) => string.Equals(candidate.Id, systemId, StringComparison.Ordinal));
|
||||
if (system is not null && selected.All((candidate) => !string.Equals(candidate.Id, systemId, StringComparison.Ordinal)))
|
||||
{
|
||||
selected.Add(system);
|
||||
}
|
||||
}
|
||||
|
||||
AddById(SolSystemId);
|
||||
AddById(DevelopmentCompanionSystemId);
|
||||
|
||||
foreach (var system in systems)
|
||||
{
|
||||
if (selected.Count >= targetSystemCount)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (selected.Any((candidate) => string.Equals(candidate.Id, system.Id, StringComparison.Ordinal)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
selected.Add(system);
|
||||
}
|
||||
|
||||
if (selected.Count > 0 && selected.Count <= 4)
|
||||
{
|
||||
ApplyCompactGalaxyLayout(selected);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private static void ApplyCompactGalaxyLayout(IReadOnlyList<SolarSystemDefinition> systems)
|
||||
{
|
||||
var compactPositions = new[]
|
||||
{
|
||||
new[] { 0f, 0f, 0f },
|
||||
new[] { 2600f, 24f, -420f },
|
||||
new[] { -2400f, -36f, 560f },
|
||||
new[] { 520f, 42f, 2480f },
|
||||
};
|
||||
|
||||
for (var index = 0; index < systems.Count && index < compactPositions.Length; index += 1)
|
||||
{
|
||||
systems[index].Position = compactPositions[index];
|
||||
}
|
||||
}
|
||||
|
||||
private static SolarSystemDefinition CreateGeneratedSystem(
|
||||
SolarSystemDefinition template,
|
||||
string label,
|
||||
@@ -66,6 +142,9 @@ public sealed partial class ScenarioLoader
|
||||
SourceKind = node.SourceKind,
|
||||
Angle = node.Angle,
|
||||
RadiusOffset = node.RadiusOffset,
|
||||
InclinationDegrees = node.InclinationDegrees,
|
||||
AnchorPlanetIndex = node.AnchorPlanetIndex,
|
||||
AnchorMoonIndex = node.AnchorMoonIndex,
|
||||
OreAmount = node.OreAmount,
|
||||
ItemId = node.ItemId,
|
||||
ShardCount = node.ShardCount,
|
||||
@@ -118,8 +197,12 @@ public sealed partial class ScenarioLoader
|
||||
ResourceNodes = definition.ResourceNodes
|
||||
.Select((node) => new ResourceNodeDefinition
|
||||
{
|
||||
SourceKind = node.SourceKind,
|
||||
Angle = node.Angle,
|
||||
RadiusOffset = node.RadiusOffset,
|
||||
InclinationDegrees = node.InclinationDegrees,
|
||||
AnchorPlanetIndex = node.AnchorPlanetIndex,
|
||||
AnchorMoonIndex = node.AnchorMoonIndex,
|
||||
OreAmount = node.OreAmount,
|
||||
ItemId = node.ItemId,
|
||||
ShardCount = node.ShardCount,
|
||||
@@ -161,6 +244,9 @@ public sealed partial class ScenarioLoader
|
||||
SourceKind = node.SourceKind,
|
||||
Angle = node.Angle,
|
||||
RadiusOffset = node.RadiusOffset,
|
||||
InclinationDegrees = node.InclinationDegrees,
|
||||
AnchorPlanetIndex = node.AnchorPlanetIndex,
|
||||
AnchorMoonIndex = node.AnchorMoonIndex,
|
||||
OreAmount = node.OreAmount,
|
||||
ItemId = node.ItemId,
|
||||
ShardCount = node.ShardCount,
|
||||
@@ -239,9 +325,8 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
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);
|
||||
var oreAmount = 1000f;
|
||||
|
||||
for (var index = 0; index < nodeCount; index += 1)
|
||||
{
|
||||
@@ -249,7 +334,9 @@ public sealed partial class ScenarioLoader
|
||||
{
|
||||
SourceKind = "asteroid-belt",
|
||||
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
|
||||
RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f),
|
||||
RadiusOffset = 120f + Jitter(generatedIndex, 200 + index, 36f),
|
||||
InclinationDegrees = Jitter(generatedIndex, 280 + index, 12f),
|
||||
AnchorPlanetIndex = ResolveAsteroidAnchorPlanetIndex(planets),
|
||||
OreAmount = oreAmount,
|
||||
ItemId = "ore",
|
||||
ShardCount = 6 + (index % 4),
|
||||
@@ -269,15 +356,27 @@ public sealed partial class ScenarioLoader
|
||||
yield break;
|
||||
}
|
||||
|
||||
var gasAnchorIndex = 0;
|
||||
for (var index = 0; index < planets.Count; index += 1)
|
||||
{
|
||||
if (ReferenceEquals(planets[index], gasAnchor))
|
||||
{
|
||||
gasAnchorIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var nodeCount = 2 + (generatedIndex % 3);
|
||||
var gasAmount = 2200f + ((generatedIndex % 4) * 260f);
|
||||
var gasAmount = 1000f;
|
||||
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),
|
||||
RadiusOffset = 170f + Jitter(generatedIndex, 260 + index, 44f),
|
||||
InclinationDegrees = Jitter(generatedIndex, 320 + index, 10f),
|
||||
AnchorPlanetIndex = gasAnchorIndex,
|
||||
OreAmount = gasAmount,
|
||||
ItemId = "gas",
|
||||
ShardCount = 10 + index,
|
||||
@@ -285,19 +384,29 @@ public sealed partial class ScenarioLoader
|
||||
}
|
||||
}
|
||||
|
||||
private static float ResolveAsteroidBeltRadius(IReadOnlyList<PlanetDefinition> planets, int generatedIndex)
|
||||
private static int ResolveAsteroidAnchorPlanetIndex(IReadOnlyList<PlanetDefinition> planets)
|
||||
{
|
||||
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)
|
||||
if (planets.Count == 0)
|
||||
{
|
||||
return gap.LeftOrbitRadius + (gap.Gap * 0.52f);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 420f + ((generatedIndex % 5) * 60f);
|
||||
var gasGiantIndex = -1;
|
||||
for (var index = 0; index < planets.Count; index += 1)
|
||||
{
|
||||
if (planets[index].PlanetType is "gas-giant" or "ice-giant")
|
||||
{
|
||||
gasGiantIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (gasGiantIndex > 0)
|
||||
{
|
||||
return gasGiantIndex - 1;
|
||||
}
|
||||
|
||||
return Math.Clamp((planets.Count / 2) - 1, 0, planets.Count - 1);
|
||||
}
|
||||
|
||||
private static List<PlanetDefinition> BuildGeneratedPlanets(
|
||||
@@ -424,6 +533,15 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
private static SolarSystemDefinition CreateSolSystem()
|
||||
{
|
||||
var mercuryOrbitAu = 0.3871f;
|
||||
var venusOrbitAu = 0.7233f;
|
||||
var earthOrbitAu = 1.000f;
|
||||
var marsOrbitAu = 1.5237f;
|
||||
var jupiterOrbitAu = 5.203f;
|
||||
var saturnOrbitAu = 9.582f;
|
||||
var uranusOrbitAu = 19.201f;
|
||||
var neptuneOrbitAu = 30.047f;
|
||||
|
||||
return new SolarSystemDefinition
|
||||
{
|
||||
Id = "sol",
|
||||
@@ -438,30 +556,30 @@ public sealed partial class ScenarioLoader
|
||||
AsteroidField = new AsteroidFieldDefinition
|
||||
{
|
||||
DecorationCount = 240,
|
||||
RadiusOffset = 780f,
|
||||
RadiusOffset = ScaleSolOrbitRadiusFromAu(2.82f),
|
||||
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 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 126f, InclinationDegrees = 4f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 148f, InclinationDegrees = -6f, AnchorPlanetIndex = 3, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 138f, InclinationDegrees = 8f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 164f, InclinationDegrees = -5f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "ore", ShardCount = 9 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 210f, InclinationDegrees = 3f, AnchorPlanetIndex = 4, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 228f, InclinationDegrees = -4f, AnchorPlanetIndex = 5, OreAmount = 1000f, ItemId = "gas", ShardCount = 12 },
|
||||
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 186f, InclinationDegrees = 6f, AnchorPlanetIndex = 6, OreAmount = 1000f, 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)
|
||||
CreateSolPlanet("Mercury", "barren", "sphere", 0, mercuryOrbitAu, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
|
||||
CreateSolPlanet("Venus", "desert", "sphere", 0, venusOrbitAu, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
|
||||
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, earthOrbitAu, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
|
||||
CreateSolPlanet("Mars", "desert", "sphere", 2, marsOrbitAu, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
|
||||
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, jupiterOrbitAu, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
|
||||
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, saturnOrbitAu, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
|
||||
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, uranusOrbitAu, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
|
||||
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, neptuneOrbitAu, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -471,8 +589,7 @@ public sealed partial class ScenarioLoader
|
||||
string planetType,
|
||||
string shape,
|
||||
int moonCount,
|
||||
float orbitRadius,
|
||||
float orbitSpeed,
|
||||
float orbitRadiusAu,
|
||||
float orbitEccentricity,
|
||||
float orbitInclination,
|
||||
float ascendingNode,
|
||||
@@ -488,8 +605,8 @@ public sealed partial class ScenarioLoader
|
||||
PlanetType = planetType,
|
||||
Shape = shape,
|
||||
MoonCount = moonCount,
|
||||
OrbitRadius = orbitRadius,
|
||||
OrbitSpeed = orbitSpeed,
|
||||
OrbitRadius = ScaleSolOrbitRadiusFromAu(orbitRadiusAu),
|
||||
OrbitSpeed = ComputeSolOrbitSpeed(orbitRadiusAu),
|
||||
OrbitEccentricity = orbitEccentricity,
|
||||
OrbitInclination = orbitInclination,
|
||||
OrbitLongitudeOfAscendingNode = ascendingNode,
|
||||
@@ -506,4 +623,13 @@ public sealed partial class ScenarioLoader
|
||||
HasRing = hasRing,
|
||||
};
|
||||
}
|
||||
|
||||
private static float ScaleSolOrbitRadiusFromAu(float orbitRadiusAu) =>
|
||||
MathF.Round(500f * MathF.Pow(orbitRadiusAu, 0.70f));
|
||||
|
||||
private static float ComputeSolOrbitSpeed(float orbitRadiusAu)
|
||||
{
|
||||
const float earthAngularSpeed = 0.11f;
|
||||
return earthAngularSpeed / MathF.Sqrt(orbitRadiusAu * orbitRadiusAu * orbitRadiusAu);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,12 @@ public sealed partial class ScenarioLoader
|
||||
factionIds.Add(DefaultFactionId);
|
||||
}
|
||||
|
||||
return factionIds.Select(CreateFaction).ToList();
|
||||
factionIds.Add(UnclaimedFactionId);
|
||||
|
||||
return factionIds
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Select(CreateFaction)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static FactionRuntime CreateFaction(string factionId)
|
||||
@@ -35,6 +40,13 @@ public sealed partial class ScenarioLoader
|
||||
Color = "#7ed4ff",
|
||||
Credits = MinimumFactionCredits,
|
||||
},
|
||||
UnclaimedFactionId => new FactionRuntime
|
||||
{
|
||||
Id = factionId,
|
||||
Label = "Unclaimed",
|
||||
Color = "#7f8794",
|
||||
Credits = 0f,
|
||||
},
|
||||
_ => new FactionRuntime
|
||||
{
|
||||
Id = factionId,
|
||||
@@ -89,30 +101,32 @@ public sealed partial class ScenarioLoader
|
||||
IReadOnlyCollection<NodeRuntime> nodes,
|
||||
DateTimeOffset nowUtc)
|
||||
{
|
||||
var claims = new List<ClaimRuntime>(stations.Count);
|
||||
foreach (var station in stations)
|
||||
var stationsByAnchorNodeId = stations
|
||||
.Where((station) => station.AnchorNodeId is not null)
|
||||
.ToDictionary((station) => station.AnchorNodeId!, StringComparer.Ordinal);
|
||||
var claims = new List<ClaimRuntime>();
|
||||
foreach (var node in nodes.Where((candidate) => candidate.Kind == SpatialNodeKind.LagrangePoint))
|
||||
{
|
||||
if (station.AnchorNodeId is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var anchorNode = nodes.FirstOrDefault((node) => node.Id == station.AnchorNodeId);
|
||||
if (anchorNode is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var owningFactionId = stationsByAnchorNodeId.TryGetValue(node.Id, out var station)
|
||||
? station.FactionId
|
||||
: UnclaimedFactionId;
|
||||
var activatesAtUtc = owningFactionId == UnclaimedFactionId
|
||||
? nowUtc
|
||||
: nowUtc.AddSeconds(8);
|
||||
var state = owningFactionId == UnclaimedFactionId
|
||||
? ClaimStateKinds.Active
|
||||
: ClaimStateKinds.Activating;
|
||||
|
||||
claims.Add(new ClaimRuntime
|
||||
{
|
||||
Id = $"claim-{station.Id}",
|
||||
FactionId = station.FactionId,
|
||||
SystemId = station.SystemId,
|
||||
NodeId = anchorNode.Id,
|
||||
BubbleId = anchorNode.BubbleId,
|
||||
Id = $"claim-{node.Id}",
|
||||
FactionId = owningFactionId,
|
||||
SystemId = node.SystemId,
|
||||
NodeId = node.Id,
|
||||
BubbleId = node.BubbleId,
|
||||
PlacedAtUtc = nowUtc,
|
||||
ActivatesAtUtc = nowUtc.AddSeconds(8),
|
||||
State = ClaimStateKinds.Activating,
|
||||
ActivatesAtUtc = activatesAtUtc,
|
||||
State = state,
|
||||
Health = 100f,
|
||||
});
|
||||
}
|
||||
@@ -138,8 +152,13 @@ public sealed partial class ScenarioLoader
|
||||
}
|
||||
|
||||
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))
|
||||
if (anchorNode is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var claim = claims.FirstOrDefault((candidate) => candidate.NodeId == anchorNode.Id);
|
||||
if (claim is null || !moduleRecipes.TryGetValue(moduleId, out var recipe))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -192,9 +211,20 @@ public sealed partial class ScenarioLoader
|
||||
StationRuntime station,
|
||||
IReadOnlyDictionary<string, ModuleRecipeDefinition> moduleRecipes)
|
||||
{
|
||||
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
|
||||
foreach (var (moduleId, targetCount) in new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
|
||||
("gas-tank", 1),
|
||||
("fuel-processor", 1),
|
||||
("refinery-stack", 1),
|
||||
("container-bay", 1),
|
||||
("fabricator-array", 2),
|
||||
("component-factory", 1),
|
||||
("ship-factory", 1),
|
||||
("solar-array", 2),
|
||||
("dock-bay-small", 2),
|
||||
})
|
||||
{
|
||||
if (CountModules(station.InstalledModules, moduleId) < targetCount
|
||||
&& moduleRecipes.ContainsKey(moduleId))
|
||||
{
|
||||
return moduleId;
|
||||
@@ -220,6 +250,11 @@ public sealed partial class ScenarioLoader
|
||||
var policies = new List<PolicySetRuntime>(factions.Count);
|
||||
foreach (var faction in factions)
|
||||
{
|
||||
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var policyId = $"policy-{faction.Id}";
|
||||
faction.DefaultPolicySetId = policyId;
|
||||
policies.Add(new PolicySetRuntime
|
||||
@@ -244,6 +279,11 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
foreach (var faction in factions)
|
||||
{
|
||||
if (string.Equals(faction.Id, UnclaimedFactionId, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var commander = new CommanderRuntime
|
||||
{
|
||||
Id = $"commander-faction-{faction.Id}",
|
||||
@@ -251,9 +291,16 @@ public sealed partial class ScenarioLoader
|
||||
FactionId = faction.Id,
|
||||
ControlledEntityId = faction.Id,
|
||||
PolicySetId = faction.DefaultPolicySetId,
|
||||
Doctrine = "strategic-default",
|
||||
Doctrine = "strategic-expansionist",
|
||||
};
|
||||
|
||||
commander.Goals.Add("control-all-systems");
|
||||
commander.Goals.Add("control-five-systems-fast");
|
||||
commander.Goals.Add("expand-industrial-base");
|
||||
commander.Goals.Add("grow-war-fleet");
|
||||
commander.Goals.Add("deter-pirate-harassment");
|
||||
commander.Goals.Add("contest-rival-expansion");
|
||||
|
||||
commanders.Add(commander);
|
||||
factionCommanders[faction.Id] = commander;
|
||||
faction.CommanderIds.Add(commander.Id);
|
||||
@@ -334,7 +381,7 @@ public sealed partial class ScenarioLoader
|
||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||
StationRuntime? refinery)
|
||||
{
|
||||
if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null)
|
||||
if (string.Equals(definition.Role, "construction", StringComparison.Ordinal) && refinery is not null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
@@ -345,24 +392,23 @@ public sealed partial class ScenarioLoader
|
||||
}
|
||||
|
||||
if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null)
|
||||
{
|
||||
return CreateResourceHarvestBehavior("auto-harvest-gas", scenario.MiningDefaults.NodeSystemId, refinery.Id);
|
||||
}
|
||||
|
||||
if (string.Equals(definition.Role, "transport", StringComparison.Ordinal) && refinery is not null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "auto-harvest-gas",
|
||||
Kind = "auto-supply-energy",
|
||||
StationId = refinery.Id,
|
||||
Phase = "travel-to-node",
|
||||
Phase = "travel-to-source",
|
||||
};
|
||||
}
|
||||
|
||||
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",
|
||||
};
|
||||
return CreateResourceHarvestBehavior("auto-mine", scenario.MiningDefaults.NodeSystemId, refinery.Id);
|
||||
}
|
||||
|
||||
if (HasModules(definition, "reactor-core", "capacitor-bank", "gun-turret") && patrolRoutes.TryGetValue(systemId, out var route))
|
||||
@@ -381,6 +427,14 @@ public sealed partial class ScenarioLoader
|
||||
};
|
||||
}
|
||||
|
||||
private static DefaultBehaviorRuntime CreateResourceHarvestBehavior(string kind, string areaSystemId, string stationId) => new()
|
||||
{
|
||||
Kind = kind,
|
||||
AreaSystemId = areaSystemId,
|
||||
StationId = stationId,
|
||||
Phase = "travel-to-node",
|
||||
};
|
||||
|
||||
private static CommanderBehaviorRuntime CopyBehavior(DefaultBehaviorRuntime behavior) => new()
|
||||
{
|
||||
Kind = behavior.Kind,
|
||||
@@ -402,7 +456,7 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
private static CommanderTaskRuntime CopyTask(ControllerTaskRuntime task, string? targetNodeId) => new()
|
||||
{
|
||||
Kind = task.Kind,
|
||||
Kind = task.Kind.ToContractValue(),
|
||||
Status = task.Status,
|
||||
TargetEntityId = task.TargetEntityId,
|
||||
TargetNodeId = targetNodeId ?? task.TargetNodeId,
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed partial class ScenarioLoader
|
||||
parentNodeId: starNode.Id);
|
||||
|
||||
var lagrangeNodes = new Dictionary<string, NodeRuntime>(StringComparer.Ordinal);
|
||||
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
|
||||
foreach (var point in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
|
||||
{
|
||||
var lagrangeNode = AddSpatialNode(
|
||||
nodes,
|
||||
@@ -111,22 +111,41 @@ public sealed partial class ScenarioLoader
|
||||
return node;
|
||||
}
|
||||
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
|
||||
Vector3 planetPosition,
|
||||
float orbitRadius,
|
||||
float planetSize,
|
||||
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 offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
|
||||
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("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L4",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L5",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
|
||||
}
|
||||
|
||||
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
|
||||
{
|
||||
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
|
||||
var sizeScale = (planetSize * 1.9f) + 10f;
|
||||
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
|
||||
}
|
||||
|
||||
private static StationPlacement ResolveStationPlacement(
|
||||
@@ -172,6 +191,39 @@ public sealed partial class ScenarioLoader
|
||||
_ => "L1",
|
||||
};
|
||||
|
||||
private static NodeRuntime? ResolveResourceNodeAnchor(SystemSpatialGraph graph, ResourceNodeDefinition definition)
|
||||
{
|
||||
if (definition.AnchorPlanetIndex is not int planetIndex || planetIndex < 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (definition.AnchorMoonIndex is int moonIndex && moonIndex >= 0)
|
||||
{
|
||||
var moonNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}-moon-{moonIndex + 1}";
|
||||
return graph.Nodes.FirstOrDefault((node) => node.Id == moonNodeId);
|
||||
}
|
||||
|
||||
var planetNodeId = $"node-{graph.SystemId}-planet-{planetIndex + 1}";
|
||||
return graph.Nodes.FirstOrDefault((node) => node.Id == planetNodeId);
|
||||
}
|
||||
|
||||
private static Vector3 ComputeResourceNodePosition(NodeRuntime? anchorNode, ResourceNodeDefinition definition, float yPlane)
|
||||
{
|
||||
var verticalOffset = MathF.Sin(DegreesToRadians(definition.InclinationDegrees)) * MathF.Min(definition.RadiusOffset * 0.18f, 28f);
|
||||
var offset = new Vector3(
|
||||
MathF.Cos(definition.Angle) * definition.RadiusOffset,
|
||||
verticalOffset,
|
||||
MathF.Sin(definition.Angle) * definition.RadiusOffset);
|
||||
|
||||
if (anchorNode is null)
|
||||
{
|
||||
return new Vector3(offset.X, yPlane + offset.Y, offset.Z);
|
||||
}
|
||||
|
||||
return Add(anchorNode.Position, offset);
|
||||
}
|
||||
|
||||
private static Vector3 ComputePlanetPosition(PlanetDefinition planet)
|
||||
{
|
||||
var angle = DegreesToRadians(planet.OrbitPhaseAtEpoch);
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
public sealed partial class ScenarioLoader
|
||||
{
|
||||
private const string DefaultFactionId = "sol-dominion";
|
||||
private const int TargetSystemCount = 160;
|
||||
private const string UnclaimedFactionId = "unclaimed";
|
||||
private const int WorldSeed = 1;
|
||||
private const float MinimumFactionCredits = 0f;
|
||||
private const float MinimumRefineryOre = 0f;
|
||||
@@ -76,20 +76,27 @@ public sealed partial class ScenarioLoader
|
||||
];
|
||||
|
||||
private readonly string _dataRoot;
|
||||
private readonly WorldGenerationOptions _worldGeneration;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public ScenarioLoader(string contentRootPath)
|
||||
public ScenarioLoader(string contentRootPath, WorldGenerationOptions? worldGeneration = null)
|
||||
{
|
||||
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
|
||||
_worldGeneration = worldGeneration ?? new WorldGenerationOptions();
|
||||
}
|
||||
|
||||
public SimulationWorld Load()
|
||||
{
|
||||
var systems = ExpandSystems(InjectSpecialSystems(Read<List<SolarSystemDefinition>>("systems.json")));
|
||||
var scenario = Read<ScenarioDefinition>("scenario.json");
|
||||
var authoredSystems = Read<List<SolarSystemDefinition>>("systems.json");
|
||||
var systems = ExpandSystems(
|
||||
InjectSpecialSystems(authoredSystems, _worldGeneration.IncludeSolSystem),
|
||||
_worldGeneration.TargetSystemCount);
|
||||
var scenario = NormalizeScenarioToAvailableSystems(
|
||||
Read<ScenarioDefinition>("scenario.json"),
|
||||
systems.Select((system) => system.Id).ToList());
|
||||
var ships = Read<List<ShipDefinition>>("ships.json");
|
||||
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
|
||||
var items = Read<List<ItemDefinition>>("items.json");
|
||||
@@ -127,18 +134,21 @@ public sealed partial class ScenarioLoader
|
||||
|
||||
foreach (var system in systemRuntimes)
|
||||
{
|
||||
var systemGraph = systemGraphs[system.Definition.Id];
|
||||
foreach (var node in system.Definition.ResourceNodes)
|
||||
{
|
||||
var anchorNode = ResolveResourceNodeAnchor(systemGraph, node);
|
||||
var resourceNode = new ResourceNodeRuntime
|
||||
{
|
||||
Id = $"node-{++nodeIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Position = new Vector3(
|
||||
MathF.Cos(node.Angle) * node.RadiusOffset,
|
||||
balance.YPlane,
|
||||
MathF.Sin(node.Angle) * node.RadiusOffset),
|
||||
Position = ComputeResourceNodePosition(anchorNode, node, balance.YPlane),
|
||||
SourceKind = node.SourceKind,
|
||||
ItemId = node.ItemId,
|
||||
AnchorNodeId = anchorNode?.Id,
|
||||
OrbitRadius = node.RadiusOffset,
|
||||
OrbitPhase = node.Angle,
|
||||
OrbitInclination = DegreesToRadians(node.InclinationDegrees),
|
||||
OreRemaining = node.OreAmount,
|
||||
MaxOre = node.OreAmount,
|
||||
};
|
||||
@@ -152,6 +162,7 @@ public sealed partial class ScenarioLoader
|
||||
Kind = SpatialNodeKind.ResourceSite,
|
||||
Position = resourceNode.Position,
|
||||
BubbleId = bubbleId,
|
||||
ParentNodeId = anchorNode?.Id,
|
||||
});
|
||||
localBubbles.Add(new LocalBubbleRuntime
|
||||
{
|
||||
@@ -230,10 +241,15 @@ public sealed partial class ScenarioLoader
|
||||
station.SystemId == scenario.MiningDefaults.RefinerySystemId)
|
||||
?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank"));
|
||||
|
||||
var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
|
||||
(route) => route.SystemId,
|
||||
(route) => route.Points.Select((point) => NormalizeScenarioPoint(systemsById[route.SystemId], point)).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
var patrolRoutes = scenario.PatrolRoutes
|
||||
.GroupBy((route) => route.SystemId, StringComparer.Ordinal)
|
||||
.ToDictionary(
|
||||
(group) => group.Key,
|
||||
(group) => group
|
||||
.SelectMany((route) => route.Points)
|
||||
.Select((point) => NormalizeScenarioPoint(systemsById[group.Key], point))
|
||||
.ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var shipsRuntime = new List<ShipRuntime>();
|
||||
var shipIdCounter = 0;
|
||||
@@ -258,7 +274,7 @@ public sealed partial class ScenarioLoader
|
||||
TargetPosition = position,
|
||||
SpatialState = CreateInitialShipSpatialState(formation.SystemId, position, spatialNodes),
|
||||
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
|
||||
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
|
||||
ControllerTask = new ControllerTaskRuntime { Kind = ControllerTaskKind.Idle, Threshold = balance.ArrivalThreshold, Status = WorkStatus.Pending },
|
||||
Health = definition.MaxHealth,
|
||||
});
|
||||
|
||||
@@ -306,6 +322,7 @@ public sealed partial class ScenarioLoader
|
||||
ItemDefinitions = itemDefinitions,
|
||||
ModuleRecipes = moduleRecipeDefinitions,
|
||||
Recipes = recipeDefinitions,
|
||||
OrbitalTimeSeconds = WorldSeed * 97d,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
@@ -318,6 +335,60 @@ public sealed partial class ScenarioLoader
|
||||
?? throw new InvalidOperationException($"Unable to read {fileName}.");
|
||||
}
|
||||
|
||||
private static ScenarioDefinition NormalizeScenarioToAvailableSystems(
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyList<string> availableSystemIds)
|
||||
{
|
||||
if (availableSystemIds.Count == 0)
|
||||
{
|
||||
return scenario;
|
||||
}
|
||||
|
||||
var fallbackSystemId = availableSystemIds.Contains("sol", StringComparer.Ordinal)
|
||||
? "sol"
|
||||
: availableSystemIds[0];
|
||||
|
||||
string ResolveSystemId(string systemId) =>
|
||||
availableSystemIds.Contains(systemId, StringComparer.Ordinal) ? systemId : fallbackSystemId;
|
||||
|
||||
return new ScenarioDefinition
|
||||
{
|
||||
InitialStations = scenario.InitialStations
|
||||
.Select((station) => new InitialStationDefinition
|
||||
{
|
||||
ConstructibleId = station.ConstructibleId,
|
||||
SystemId = ResolveSystemId(station.SystemId),
|
||||
FactionId = station.FactionId,
|
||||
PlanetIndex = station.PlanetIndex,
|
||||
LagrangeSide = station.LagrangeSide,
|
||||
Position = station.Position?.ToArray(),
|
||||
})
|
||||
.ToList(),
|
||||
ShipFormations = scenario.ShipFormations
|
||||
.Select((formation) => new ShipFormationDefinition
|
||||
{
|
||||
ShipId = formation.ShipId,
|
||||
Count = formation.Count,
|
||||
Center = formation.Center.ToArray(),
|
||||
SystemId = ResolveSystemId(formation.SystemId),
|
||||
FactionId = formation.FactionId,
|
||||
})
|
||||
.ToList(),
|
||||
PatrolRoutes = scenario.PatrolRoutes
|
||||
.Select((route) => new PatrolRouteDefinition
|
||||
{
|
||||
SystemId = ResolveSystemId(route.SystemId),
|
||||
Points = route.Points.Select((point) => point.ToArray()).ToList(),
|
||||
})
|
||||
.ToList(),
|
||||
MiningDefaults = new MiningDefaultsDefinition
|
||||
{
|
||||
NodeSystemId = ResolveSystemId(scenario.MiningDefaults.NodeSystemId),
|
||||
RefinerySystemId = ResolveSystemId(scenario.MiningDefaults.RefinerySystemId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
|
||||
|
||||
private static Vector3 NormalizeScenarioPoint(SystemRuntime system, float[] values)
|
||||
|
||||
@@ -7,18 +7,19 @@ public sealed partial class SimulationEngine
|
||||
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),
|
||||
ControllerTaskKind.Idle => UpdateIdle(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Travel => UpdateTravel(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Extract => UpdateExtract(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Dock => UpdateDock(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Load => UpdateLoadCargo(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Unload => UpdateUnload(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Refuel => UpdateRefuel(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.DeliverConstruction => UpdateDeliverConstruction(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.BuildConstructionSite => UpdateBuildConstructionSite(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.LoadWorkers => UpdateLoadWorkers(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.UnloadWorkers => UpdateUnloadWorkers(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.ConstructModule => UpdateConstructModule(ship, world, deltaSeconds),
|
||||
ControllerTaskKind.Undock => UpdateUndock(ship, world, deltaSeconds),
|
||||
_ => UpdateIdle(ship, world, deltaSeconds),
|
||||
};
|
||||
}
|
||||
@@ -26,7 +27,7 @@ public sealed partial class SimulationEngine
|
||||
private static string UpdateIdle(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds);
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -36,7 +37,7 @@ public sealed partial class SimulationEngine
|
||||
var task = ship.ControllerTask;
|
||||
if (task.TargetPosition is null || task.TargetSystemId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -47,7 +48,9 @@ public sealed partial class SimulationEngine
|
||||
|
||||
if (ship.SystemId != task.TargetSystemId)
|
||||
{
|
||||
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, targetPosition, targetNode);
|
||||
var destinationEntryNode = ResolveSystemEntryNode(world, task.TargetSystemId);
|
||||
var destinationEntryPosition = destinationEntryNode?.Position ?? Vector3.Zero;
|
||||
return UpdateFtlTransit(ship, world, deltaSeconds, task.TargetSystemId, destinationEntryPosition, destinationEntryNode);
|
||||
}
|
||||
|
||||
var currentNode = ResolveCurrentNode(world, ship);
|
||||
@@ -95,6 +98,11 @@ public sealed partial class SimulationEngine
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static NodeRuntime? ResolveSystemEntryNode(SimulationWorld world, string systemId) =>
|
||||
world.SpatialNodes.FirstOrDefault(candidate =>
|
||||
candidate.SystemId == systemId &&
|
||||
candidate.Kind == SpatialNodeKind.Star);
|
||||
|
||||
private string UpdateLocalTravel(
|
||||
ShipRuntime ship,
|
||||
SimulationWorld world,
|
||||
@@ -119,19 +127,19 @@ public sealed partial class SimulationEngine
|
||||
ship.SystemId = targetSystemId;
|
||||
ship.SpatialState.CurrentNodeId = targetNode?.Id;
|
||||
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
|
||||
ship.State = "arriving";
|
||||
ship.State = ShipState.Arriving;
|
||||
return "arrived";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "local-flight";
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.Position = ship.Position.MoveToward(targetPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
@@ -158,32 +166,32 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.DestinationNodeId = targetNode.Id;
|
||||
|
||||
var spoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
||||
if (ship.State != "warping")
|
||||
if (ship.State != ShipState.Warping)
|
||||
{
|
||||
if (ship.State != "spooling-warp")
|
||||
if (ship.State != ShipState.SpoolingWarp)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "spooling-warp";
|
||||
ship.State = ShipState.SpoolingWarp;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, spoolDuration))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "warping";
|
||||
ship.State = ShipState.Warping;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -220,32 +228,32 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.CurrentBubbleId = null;
|
||||
ship.SpatialState.DestinationNodeId = destinationNodeId;
|
||||
|
||||
if (ship.State != "ftl")
|
||||
if (ship.State != ShipState.Ftl)
|
||||
{
|
||||
if (ship.State != "spooling-ftl")
|
||||
if (ship.State != ShipState.SpoolingFtl)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "spooling-ftl";
|
||||
ship.State = ShipState.SpoolingFtl;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, ship.Definition.SpoolTime))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "ftl";
|
||||
ship.State = ShipState.Ftl;
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.WarpDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -254,7 +262,7 @@ public sealed partial class SimulationEngine
|
||||
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)
|
||||
? CompleteSystemEntryArrival(ship, targetSystemId, targetPosition, targetNode)
|
||||
: "none";
|
||||
}
|
||||
|
||||
@@ -270,7 +278,23 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.CurrentNodeId = targetNode?.Id;
|
||||
ship.SpatialState.CurrentBubbleId = targetNode?.BubbleId;
|
||||
ship.SpatialState.DestinationNodeId = targetNode?.Id;
|
||||
ship.State = "arriving";
|
||||
ship.State = ShipState.Arriving;
|
||||
return "arrived";
|
||||
}
|
||||
|
||||
private static string CompleteSystemEntryArrival(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 = ShipState.Arriving;
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,22 +55,57 @@ public sealed partial class SimulationEngine
|
||||
return 0.9f / MathF.Sqrt(MathF.Max(radius, 1f)) + (moonIndex * 0.003f);
|
||||
}
|
||||
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(Vector3 planetPosition, float orbitRadius, int planetIndex)
|
||||
private static float ComputeResourceNodeOrbitSpeed(ResourceNodeRuntime node)
|
||||
{
|
||||
var baseSpeed = node.SourceKind == "gas-cloud" ? 0.16f : 0.24f;
|
||||
return baseSpeed / MathF.Sqrt(MathF.Max(node.OrbitRadius / 180f, 0.45f));
|
||||
}
|
||||
|
||||
private static Vector3 ComputeResourceNodeOffset(ResourceNodeRuntime node, float timeSeconds)
|
||||
{
|
||||
var angle = node.OrbitPhase + (timeSeconds * ComputeResourceNodeOrbitSpeed(node));
|
||||
var orbit = new Vector3(
|
||||
MathF.Cos(angle) * node.OrbitRadius,
|
||||
0f,
|
||||
MathF.Sin(angle) * node.OrbitRadius);
|
||||
return RotateAroundX(orbit, node.OrbitInclination);
|
||||
}
|
||||
|
||||
private static IEnumerable<LagrangePointPlacement> EnumeratePlanetLagrangePoints(
|
||||
Vector3 planetPosition,
|
||||
float orbitRadius,
|
||||
float planetSize,
|
||||
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 offset = ComputePlanetLocalLagrangeOffset(orbitRadius, planetSize, planetIndex);
|
||||
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("L3", Add(planetPosition, Scale(radial, -(offset * 1.2f))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L4",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, offset * MathF.Sin(triangularAngle)))));
|
||||
yield return new LagrangePointPlacement(
|
||||
"L5",
|
||||
Add(Scale(radial, orbitRadius * MathF.Cos(triangularAngle)), Scale(tangential, -orbitRadius * MathF.Sin(triangularAngle))));
|
||||
Add(
|
||||
planetPosition,
|
||||
Add(
|
||||
Scale(radial, offset * MathF.Cos(triangularAngle)),
|
||||
Scale(tangential, -offset * MathF.Sin(triangularAngle)))));
|
||||
}
|
||||
|
||||
private static float ComputePlanetLocalLagrangeOffset(float orbitRadius, float planetSize, int planetIndex)
|
||||
{
|
||||
var orbitalScale = MathF.Min(orbitRadius * 0.016f, 96f + (planetIndex * 4f));
|
||||
var sizeScale = (planetSize * 1.9f) + 10f;
|
||||
return MathF.Max(22f + (planetIndex * 2f), MathF.Max(orbitalScale, sizeScale));
|
||||
}
|
||||
|
||||
private static Vector3 NormalizeOrFallback(Vector3 value, Vector3 fallback)
|
||||
@@ -125,9 +160,9 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
private static void UpdateOrbitalState(SimulationWorld world, DateTimeOffset nowUtc)
|
||||
private void UpdateOrbitalState(SimulationWorld world)
|
||||
{
|
||||
var worldTimeSeconds = (float)(nowUtc.ToUnixTimeMilliseconds() / 1000d) + (world.Seed * 97f);
|
||||
var worldTimeSeconds = (float)world.OrbitalTimeSeconds;
|
||||
var spatialNodesById = world.SpatialNodes.ToDictionary(node => node.Id, StringComparer.Ordinal);
|
||||
|
||||
foreach (var system in world.Systems)
|
||||
@@ -150,7 +185,7 @@ public sealed partial class SimulationEngine
|
||||
var planetPosition = ComputePlanetPosition(planet, worldTimeSeconds);
|
||||
planetNode.Position = planetPosition;
|
||||
|
||||
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planetIndex))
|
||||
foreach (var lagrange in EnumeratePlanetLagrangePoints(planetPosition, planet.OrbitRadius, planet.Size, planetIndex))
|
||||
{
|
||||
var lagrangeId = $"node-{system.Definition.Id}-planet-{planetIndex + 1}-{lagrange.Designation.ToLowerInvariant()}";
|
||||
if (spatialNodesById.TryGetValue(lagrangeId, out var lagrangeNode))
|
||||
@@ -186,6 +221,20 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var node in world.Nodes)
|
||||
{
|
||||
if (node.AnchorNodeId is null || !spatialNodesById.TryGetValue(node.AnchorNodeId, out var anchorNode))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
node.Position = Add(anchorNode.Position, ComputeResourceNodeOffset(node, worldTimeSeconds));
|
||||
if (spatialNodesById.TryGetValue(node.Id, out var resourceNode))
|
||||
{
|
||||
resourceNode.Position = node.Position;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var ship in world.Ships.Where(ship => ship.DockedStationId is not null))
|
||||
{
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class SimulationEngine
|
||||
{
|
||||
private const float StationEnergyCellToEnergyRatio = 1f;
|
||||
|
||||
private static bool HasShipModules(ShipDefinition definition, params string[] modules) =>
|
||||
modules.All(moduleId => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
|
||||
|
||||
@@ -19,7 +21,7 @@ public sealed partial class SimulationEngine
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
var previousEnergy = station.EnergyStored;
|
||||
GenerateStationEnergy(station, deltaSeconds);
|
||||
GenerateStationEnergy(station, world, deltaSeconds);
|
||||
|
||||
if (previousEnergy > 0.01f && station.EnergyStored <= 0.01f && GetInventoryAmount(station.Inventory, "fuel") <= 0.01f)
|
||||
{
|
||||
@@ -39,7 +41,7 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
private static void GenerateStationEnergy(StationRuntime station, float deltaSeconds)
|
||||
private static void GenerateStationEnergy(StationRuntime station, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var powerCores = CountModules(station.InstalledModules, "power-core");
|
||||
var tanks = CountModules(station.InstalledModules, "liquid-tank");
|
||||
@@ -53,6 +55,32 @@ public sealed partial class SimulationEngine
|
||||
var energyCapacity = powerCores * StationEnergyPerPowerCore;
|
||||
var fuelStored = GetInventoryAmount(station.Inventory, "fuel");
|
||||
var desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
||||
if (desiredEnergy <= 0.01f)
|
||||
{
|
||||
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
|
||||
station.Inventory["fuel"] = MathF.Min(fuelStored, tanks * StationFuelPerTank);
|
||||
return;
|
||||
}
|
||||
|
||||
var solarGenerated = MathF.Min(desiredEnergy, GetStationSolarGeneration(station, world) * deltaSeconds);
|
||||
if (solarGenerated > 0.01f)
|
||||
{
|
||||
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + solarGenerated);
|
||||
desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
||||
}
|
||||
|
||||
if (desiredEnergy > 0.01f && fuelStored <= 0.01f)
|
||||
{
|
||||
var energyCells = GetInventoryAmount(station.Inventory, "energy-cell");
|
||||
if (energyCells > 0.01f)
|
||||
{
|
||||
var consumedCells = MathF.Min(energyCells, desiredEnergy / StationEnergyCellToEnergyRatio);
|
||||
RemoveInventory(station.Inventory, "energy-cell", consumedCells);
|
||||
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + (consumedCells * StationEnergyCellToEnergyRatio));
|
||||
desiredEnergy = MathF.Max(0f, energyCapacity - station.EnergyStored);
|
||||
}
|
||||
}
|
||||
|
||||
if (desiredEnergy <= 0.01f || fuelStored <= 0.01f)
|
||||
{
|
||||
station.EnergyStored = MathF.Min(station.EnergyStored, energyCapacity);
|
||||
@@ -69,6 +97,37 @@ public sealed partial class SimulationEngine
|
||||
station.EnergyStored = MathF.Min(energyCapacity, station.EnergyStored + actualGenerated);
|
||||
}
|
||||
|
||||
private static float GetStationFuelCapacity(StationRuntime station) =>
|
||||
CountModules(station.InstalledModules, "liquid-tank") * StationFuelPerTank;
|
||||
|
||||
private static float GetStationEnergyCapacity(StationRuntime station) =>
|
||||
CountModules(station.InstalledModules, "power-core") * StationEnergyPerPowerCore;
|
||||
|
||||
private static float GetStationSolarGeneration(StationRuntime station, SimulationWorld world) =>
|
||||
world.Balance.Energy.StationSolarCharge * (1f + CountModules(station.InstalledModules, "solar-array"));
|
||||
|
||||
private static float GetStationStorageCapacity(StationRuntime station, string storageClass)
|
||||
{
|
||||
var baseCapacity = station.Definition.Storage.TryGetValue(storageClass, out var capacity)
|
||||
? capacity
|
||||
: 0f;
|
||||
|
||||
var extraBulkBays = Math.Max(0, CountModules(station.InstalledModules, "bulk-bay") - CountModules(station.Definition.Modules, "bulk-bay"));
|
||||
var extraLiquidTanks = Math.Max(0, CountModules(station.InstalledModules, "liquid-tank") - CountModules(station.Definition.Modules, "liquid-tank"));
|
||||
var extraGasTanks = Math.Max(0, CountModules(station.InstalledModules, "gas-tank") - CountModules(station.Definition.Modules, "gas-tank"));
|
||||
var extraContainerBays = Math.Max(0, CountModules(station.InstalledModules, "container-bay") - CountModules(station.Definition.Modules, "container-bay"));
|
||||
var moduleBonus = storageClass switch
|
||||
{
|
||||
"bulk-solid" => extraBulkBays * 1000f,
|
||||
"bulk-liquid" => extraLiquidTanks * 500f,
|
||||
"bulk-gas" => extraGasTanks * 500f,
|
||||
"container" => extraContainerBays * 800f,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return baseCapacity + moduleBonus;
|
||||
}
|
||||
|
||||
private static void GenerateShipEnergy(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var reactors = CountModules(ship.Definition.Modules, "reactor-core");
|
||||
@@ -166,11 +225,265 @@ public sealed partial class SimulationEngine
|
||||
_ => false,
|
||||
};
|
||||
|
||||
private static bool CanBuildClaimBeacon(ShipRuntime ship) =>
|
||||
string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal);
|
||||
|
||||
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 GetShipAvailableEnergyBudget(ShipRuntime ship) =>
|
||||
ship.EnergyStored + (GetInventoryAmount(ship.Inventory, "fuel") * ShipFuelToEnergyRatio);
|
||||
|
||||
private static float GetShipFuelReserve(ShipRuntime ship, float plannedFuel)
|
||||
{
|
||||
var capacity = GetShipFuelCapacity(ship);
|
||||
var reserveRatio = ship.Definition.CargoItemId == "gas" ? 0.4f : 0.3f;
|
||||
var reserve = MathF.Max(16f, MathF.Max(capacity * 0.18f, plannedFuel * reserveRatio));
|
||||
return MathF.Min(capacity, reserve);
|
||||
}
|
||||
|
||||
private static float EstimateFuelForEnergyDemand(ShipRuntime ship, float energyDemand) =>
|
||||
MathF.Max(0f, energyDemand - ship.EnergyStored) / ShipFuelToEnergyRatio;
|
||||
|
||||
private static float EstimateTimedEnergyUse(SimulationWorld world, float durationSeconds, float drainPerSecond) =>
|
||||
MathF.Max(0f, durationSeconds) * drainPerSecond;
|
||||
|
||||
private static float EstimateTravelEnergy(
|
||||
ShipRuntime ship,
|
||||
SimulationWorld world,
|
||||
Vector3 fromPosition,
|
||||
string fromSystemId,
|
||||
Vector3 toPosition,
|
||||
string toSystemId)
|
||||
{
|
||||
if (!string.Equals(fromSystemId, toSystemId, StringComparison.Ordinal))
|
||||
{
|
||||
var destinationEntryNode = ResolveSystemEntryNode(world, toSystemId);
|
||||
var destinationEntryPosition = destinationEntryNode?.Position ?? toPosition;
|
||||
var ftlDistance = fromPosition.DistanceTo(destinationEntryPosition);
|
||||
var ftlDuration = ftlDistance / MathF.Max(ship.Definition.FtlSpeed, 0.01f);
|
||||
return EstimateTimedEnergyUse(world, ship.Definition.SpoolTime, world.Balance.Energy.IdleDrain)
|
||||
+ EstimateTimedEnergyUse(world, ftlDuration, world.Balance.Energy.WarpDrain)
|
||||
+ EstimateInSystemTravelEnergy(ship, world, destinationEntryPosition, toPosition);
|
||||
}
|
||||
|
||||
return EstimateInSystemTravelEnergy(ship, world, fromPosition, toPosition);
|
||||
}
|
||||
|
||||
private static float EstimateInSystemTravelEnergy(ShipRuntime ship, SimulationWorld world, Vector3 fromPosition, Vector3 toPosition)
|
||||
{
|
||||
var distance = fromPosition.DistanceTo(toPosition);
|
||||
if (distance <= world.Balance.ArrivalThreshold)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (distance <= 120f)
|
||||
{
|
||||
var localDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
|
||||
return EstimateTimedEnergyUse(world, localDuration, world.Balance.Energy.MoveDrain);
|
||||
}
|
||||
|
||||
var warpSpoolDuration = MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f);
|
||||
var warpDuration = distance / MathF.Max(ship.Definition.Speed, 0.01f);
|
||||
return EstimateTimedEnergyUse(world, warpSpoolDuration, world.Balance.Energy.IdleDrain)
|
||||
+ EstimateTimedEnergyUse(world, warpDuration, world.Balance.Energy.WarpDrain);
|
||||
}
|
||||
|
||||
private static float EstimateDockingEnergy(SimulationWorld world) =>
|
||||
EstimateTimedEnergyUse(world, world.Balance.DockingDuration, world.Balance.Energy.MoveDrain)
|
||||
+ EstimateTimedEnergyUse(world, 6f, world.Balance.Energy.IdleDrain);
|
||||
|
||||
private static float EstimateUndockingEnergy(SimulationWorld world) =>
|
||||
EstimateTimedEnergyUse(world, world.Balance.UndockingDuration, world.Balance.Energy.MoveDrain)
|
||||
+ EstimateTimedEnergyUse(world, 4f, world.Balance.Energy.IdleDrain);
|
||||
|
||||
private static float EstimateExtractionEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var remainingCargo = MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
|
||||
if (remainingCargo <= 0.01f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var cycles = MathF.Ceiling(remainingCargo / MathF.Max(world.Balance.MiningRate, 0.01f));
|
||||
return EstimateTimedEnergyUse(world, cycles * world.Balance.MiningCycleSeconds, world.Balance.Energy.MoveDrain)
|
||||
+ EstimateTimedEnergyUse(world, cycles * 1.5f, world.Balance.Energy.IdleDrain);
|
||||
}
|
||||
|
||||
private static float EstimateConstructionEnergy(ShipRuntime ship, SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
var holdPosition = GetConstructionHoldPosition(station, ship.Id);
|
||||
var travelEnergy = EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, holdPosition, station.SystemId);
|
||||
var site = GetConstructionSiteForStation(world, station.Id);
|
||||
if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
|
||||
{
|
||||
if (world.ModuleRecipes.TryGetValue(site.BlueprintId ?? string.Empty, out var siteRecipe))
|
||||
{
|
||||
return travelEnergy + EstimateTimedEnergyUse(world, siteRecipe.Duration, world.Balance.Energy.IdleDrain);
|
||||
}
|
||||
|
||||
return travelEnergy;
|
||||
}
|
||||
|
||||
var moduleId = site?.BlueprintId ?? GetNextStationModuleToBuild(station, world);
|
||||
if (moduleId is not null
|
||||
&& world.ModuleRecipes.TryGetValue(moduleId, out var recipe)
|
||||
&& CanStartModuleConstruction(station, recipe))
|
||||
{
|
||||
return travelEnergy + EstimateTimedEnergyUse(world, recipe.Duration, world.Balance.Energy.IdleDrain);
|
||||
}
|
||||
|
||||
return travelEnergy;
|
||||
}
|
||||
|
||||
private static float EstimateResourceHarvestEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var requiredModule = cargoItemId == "gas" ? "gas-extractor" : "mining-turret";
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var refinery = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
|
||||
var node = behavior.NodeId is null
|
||||
? world.Nodes
|
||||
.Where(candidate =>
|
||||
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
|
||||
candidate.ItemId == cargoItemId &&
|
||||
candidate.OreRemaining > 0.01f)
|
||||
.OrderByDescending(candidate => candidate.OreRemaining)
|
||||
.FirstOrDefault()
|
||||
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
|
||||
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var currentPosition = ship.Position;
|
||||
var currentSystemId = ship.SystemId;
|
||||
var energy = 0f;
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (ship.DockedStationId == refinery.Id)
|
||||
{
|
||||
currentPosition = GetUndockTargetPosition(refinery, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
|
||||
currentSystemId = refinery.SystemId;
|
||||
energy += EstimateUndockingEnergy(world);
|
||||
}
|
||||
|
||||
if (cargoAmount > 0.01f)
|
||||
{
|
||||
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId);
|
||||
return energy + EstimateDockingEnergy(world);
|
||||
}
|
||||
|
||||
var holdPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
|
||||
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, holdPosition, node.SystemId);
|
||||
energy += EstimateExtractionEnergy(ship, world);
|
||||
energy += EstimateTravelEnergy(ship, world, holdPosition, node.SystemId, refinery.Position, refinery.SystemId);
|
||||
energy += EstimateDockingEnergy(world);
|
||||
return energy;
|
||||
}
|
||||
|
||||
private static float EstimateResourceReturnEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var refinery = SelectBestBuyStation(world, ship, cargoItemId, ship.DefaultBehavior.StationId);
|
||||
if (refinery is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var currentPosition = ship.Position;
|
||||
var currentSystemId = ship.SystemId;
|
||||
return EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, refinery.Position, refinery.SystemId)
|
||||
+ EstimateDockingEnergy(world);
|
||||
}
|
||||
|
||||
private static float EstimateTransportEnergy(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId);
|
||||
var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
|
||||
if (source is null && destination is null)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
var currentPosition = ship.Position;
|
||||
var currentSystemId = ship.SystemId;
|
||||
if (ship.DockedStationId is not null)
|
||||
{
|
||||
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (dockedStation is not null)
|
||||
{
|
||||
currentPosition = GetUndockTargetPosition(dockedStation, ship.AssignedDockingPadIndex, world.Balance.UndockDistance);
|
||||
currentSystemId = dockedStation.SystemId;
|
||||
}
|
||||
}
|
||||
|
||||
var targetStation = cargoAmount > 0.01f ? destination : source;
|
||||
if (targetStation is null)
|
||||
{
|
||||
return ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f;
|
||||
}
|
||||
|
||||
var energy = ship.DockedStationId is not null ? EstimateUndockingEnergy(world) : 0f;
|
||||
energy += EstimateTravelEnergy(ship, world, currentPosition, currentSystemId, targetStation.Position, targetStation.SystemId);
|
||||
return energy + EstimateDockingEnergy(world);
|
||||
}
|
||||
|
||||
private static float EstimateShipMissionEnergyDemand(ShipRuntime ship, SimulationWorld world) =>
|
||||
ship.DefaultBehavior.Kind switch
|
||||
{
|
||||
"auto-mine" or "auto-harvest-gas" => EstimateResourceHarvestEnergy(ship, world),
|
||||
"auto-supply-energy" => EstimateTransportEnergy(ship, world),
|
||||
"construct-station" when ship.DefaultBehavior.StationId is not null
|
||||
=> world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId) is { } station
|
||||
? EstimateConstructionEnergy(ship, world, station)
|
||||
: 0f,
|
||||
_ when ship.ControllerTask.TargetPosition is { } targetPosition && ship.ControllerTask.TargetSystemId is { } targetSystemId
|
||||
=> EstimateTravelEnergy(ship, world, ship.Position, ship.SystemId, targetPosition, targetSystemId),
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
private static float GetShipRefuelTarget(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var capacity = GetShipFuelCapacity(ship);
|
||||
var missionFuel = EstimateFuelForEnergyDemand(ship, EstimateShipMissionEnergyDemand(ship, world));
|
||||
var reserveFuel = GetShipFuelReserve(ship, missionFuel);
|
||||
return MathF.Min(capacity, missionFuel + reserveFuel);
|
||||
}
|
||||
|
||||
internal static bool NeedsRefuel(ShipRuntime ship, SimulationWorld world) =>
|
||||
GetInventoryAmount(ship.Inventory, "fuel") + 0.01f < GetShipRefuelTarget(ship, world);
|
||||
|
||||
internal static bool NeedsEmergencyReturn(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
if (ship.DefaultBehavior.Kind is not "auto-mine" and not "auto-harvest-gas")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var returnEnergy = EstimateResourceReturnEnergy(ship, world);
|
||||
var reserveFuel = GetShipFuelReserve(ship, EstimateFuelForEnergyDemand(ship, returnEnergy));
|
||||
var requiredBudget = returnEnergy + (reserveFuel * ShipFuelToEnergyRatio);
|
||||
return GetShipAvailableEnergyBudget(ship) + 0.01f < requiredBudget;
|
||||
}
|
||||
|
||||
private static float ComputeWorkforceRatio(float population, float workforceRequired)
|
||||
{
|
||||
@@ -206,7 +519,8 @@ public sealed partial class SimulationEngine
|
||||
return 0f;
|
||||
}
|
||||
|
||||
if (!station.Definition.Storage.TryGetValue(storageClass, out var capacity))
|
||||
var capacity = GetStationStorageCapacity(station, storageClass);
|
||||
if (capacity <= 0.01f)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
@@ -232,6 +546,17 @@ public sealed partial class SimulationEngine
|
||||
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);
|
||||
private static float GetConstructionDeliveredAmount(SimulationWorld world, ConstructionSiteRuntime site, string itemId)
|
||||
{
|
||||
if (site.StationId is not null
|
||||
&& world.Stations.FirstOrDefault(candidate => candidate.Id == site.StationId) is { } station)
|
||||
{
|
||||
return GetInventoryAmount(station.Inventory, itemId);
|
||||
}
|
||||
|
||||
return GetInventoryAmount(site.DeliveredItems, itemId);
|
||||
}
|
||||
|
||||
private static bool IsConstructionSiteReady(SimulationWorld world, ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.All(entry => GetConstructionDeliveredAmount(world, site, entry.Key) + 0.001f >= entry.Value);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ public sealed partial class SimulationEngine
|
||||
world.Seed,
|
||||
sequence,
|
||||
world.TickIntervalMs,
|
||||
world.OrbitalTimeSeconds,
|
||||
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
|
||||
world.GeneratedAtUtc,
|
||||
world.Systems.Select(system => new SystemSnapshot(
|
||||
system.Definition.Id,
|
||||
@@ -59,11 +61,12 @@ public sealed partial class SimulationEngine
|
||||
node.Id,
|
||||
node.SystemId,
|
||||
node.LocalPosition,
|
||||
node.AnchorNodeId,
|
||||
node.SourceKind,
|
||||
node.OreRemaining,
|
||||
node.MaxOre,
|
||||
node.ItemId)).ToList(),
|
||||
world.Stations.Select(ToStationDelta).Select(station => new StationSnapshot(
|
||||
world.Stations.Select(station => ToStationDelta(world, station)).Select(station => new StationSnapshot(
|
||||
station.Id,
|
||||
station.Label,
|
||||
station.Category,
|
||||
@@ -74,8 +77,13 @@ public sealed partial class SimulationEngine
|
||||
station.AnchorNodeId,
|
||||
station.Color,
|
||||
station.DockedShips,
|
||||
station.DockedShipIds,
|
||||
station.DockingPads,
|
||||
station.FuelStored,
|
||||
station.FuelCapacity,
|
||||
station.EnergyStored,
|
||||
station.EnergyCapacity,
|
||||
station.CurrentProcesses,
|
||||
station.Inventory,
|
||||
station.FactionId,
|
||||
station.CommanderId,
|
||||
@@ -135,7 +143,7 @@ public sealed partial class SimulationEngine
|
||||
policy.DockingAccessPolicy,
|
||||
policy.ConstructionAccessPolicy,
|
||||
policy.OperationalRangePolicy)).ToList(),
|
||||
world.Ships.Select(ToShipDelta).Select(ship => new ShipSnapshot(
|
||||
world.Ships.Select(ship => ToShipDelta(world, ship)).Select(ship => new ShipSnapshot(
|
||||
ship.Id,
|
||||
ship.Label,
|
||||
ship.Role,
|
||||
@@ -154,12 +162,14 @@ public sealed partial class SimulationEngine
|
||||
ship.CommanderId,
|
||||
ship.PolicySetId,
|
||||
ship.CargoCapacity,
|
||||
ship.CargoItemId,
|
||||
ship.WorkerPopulation,
|
||||
ship.EnergyStored,
|
||||
ship.Inventory,
|
||||
ship.FactionId,
|
||||
ship.Health,
|
||||
ship.History,
|
||||
ship.CurrentAction,
|
||||
ship.SpatialState)).ToList(),
|
||||
world.Factions.Select(ToFactionDelta).Select(faction => new FactionSnapshot(
|
||||
faction.Id,
|
||||
@@ -193,7 +203,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
station.LastDeltaSignature = BuildStationSignature(station);
|
||||
station.LastDeltaSignature = BuildStationSignature(world, station);
|
||||
}
|
||||
|
||||
foreach (var claim in world.Claims)
|
||||
@@ -218,7 +228,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
foreach (var ship in world.Ships)
|
||||
{
|
||||
ship.LastDeltaSignature = BuildShipSignature(ship);
|
||||
ship.LastDeltaSignature = BuildShipSignature(world, ship);
|
||||
}
|
||||
|
||||
foreach (var faction in world.Factions)
|
||||
@@ -286,14 +296,14 @@ public sealed partial class SimulationEngine
|
||||
var deltas = new List<StationDelta>();
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
var signature = BuildStationSignature(station);
|
||||
var signature = BuildStationSignature(world, station);
|
||||
if (signature == station.LastDeltaSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
station.LastDeltaSignature = signature;
|
||||
deltas.Add(ToStationDelta(station));
|
||||
deltas.Add(ToStationDelta(world, station));
|
||||
}
|
||||
|
||||
return deltas;
|
||||
@@ -371,19 +381,19 @@ public sealed partial class SimulationEngine
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
|
||||
private IReadOnlyList<ShipDelta> BuildShipDeltas(SimulationWorld world)
|
||||
{
|
||||
var deltas = new List<ShipDelta>();
|
||||
foreach (var ship in world.Ships)
|
||||
{
|
||||
var signature = BuildShipSignature(ship);
|
||||
var signature = BuildShipSignature(world, ship);
|
||||
if (signature == ship.LastDeltaSignature)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ship.LastDeltaSignature = signature;
|
||||
deltas.Add(ToShipDelta(ship));
|
||||
deltas.Add(ToShipDelta(world, ship));
|
||||
}
|
||||
|
||||
return deltas;
|
||||
@@ -407,7 +417,8 @@ public sealed partial class SimulationEngine
|
||||
return deltas;
|
||||
}
|
||||
|
||||
private static string BuildNodeSignature(ResourceNodeRuntime node) => $"{node.SystemId}|{node.OreRemaining:0.###}";
|
||||
private static string BuildNodeSignature(ResourceNodeRuntime node) =>
|
||||
$"{node.SystemId}|{node.Position.X:0.###}|{node.Position.Y:0.###}|{node.Position.Z:0.###}|{node.AnchorNodeId}|{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}";
|
||||
@@ -415,8 +426,34 @@ public sealed partial class SimulationEngine
|
||||
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 BuildStationSignature(SimulationWorld world, StationRuntime station)
|
||||
{
|
||||
var processes = ToStationActionProgressSnapshots(world, station);
|
||||
return string.Join("|",
|
||||
station.SystemId,
|
||||
station.NodeId ?? "none",
|
||||
station.BubbleId ?? "none",
|
||||
station.AnchorNodeId ?? "none",
|
||||
station.CommanderId ?? "none",
|
||||
station.PolicySetId ?? "none",
|
||||
BuildInventorySignature(station.Inventory),
|
||||
GetInventoryAmount(station.Inventory, "fuel").ToString("0.###"),
|
||||
GetStationFuelCapacity(station).ToString("0.###"),
|
||||
station.EnergyStored.ToString("0.###"),
|
||||
GetStationEnergyCapacity(station).ToString("0.###"),
|
||||
string.Join(",", processes.Select(process => $"{process.Lane}:{process.Label}:{process.Progress:0.###}")),
|
||||
string.Join(",", station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal)),
|
||||
station.DockingPadAssignments.Count.ToString(),
|
||||
station.Population.ToString("0.###"),
|
||||
station.PopulationCapacity.ToString("0.###"),
|
||||
station.WorkforceRequired.ToString("0.###"),
|
||||
station.WorkforceEffectiveRatio.ToString("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",
|
||||
string.Join(",", station.ProductionLaneTimers.OrderBy(entry => entry.Key, StringComparer.Ordinal).Select(entry => $"{entry.Key}:{entry.Value:0.###}")));
|
||||
}
|
||||
|
||||
private static string BuildClaimSignature(ClaimRuntime claim) =>
|
||||
$"{claim.FactionId}|{claim.SystemId}|{claim.NodeId}|{claim.BubbleId}|{claim.State}|{claim.Health:0.###}|{claim.ActivatesAtUtc:O}";
|
||||
@@ -430,7 +467,7 @@ public sealed partial class SimulationEngine
|
||||
private static string BuildPolicySignature(PolicySetRuntime policy) =>
|
||||
$"{policy.OwnerKind}|{policy.OwnerId}|{policy.TradeAccessPolicy}|{policy.DockingAccessPolicy}|{policy.ConstructionAccessPolicy}|{policy.OperationalRangePolicy}";
|
||||
|
||||
private static string BuildShipSignature(ShipRuntime ship) =>
|
||||
private static string BuildShipSignature(SimulationWorld world, ShipRuntime ship) =>
|
||||
string.Join("|",
|
||||
ship.SystemId,
|
||||
ship.Position.X.ToString("0.###"),
|
||||
@@ -442,10 +479,10 @@ public sealed partial class SimulationEngine
|
||||
ship.TargetPosition.X.ToString("0.###"),
|
||||
ship.TargetPosition.Y.ToString("0.###"),
|
||||
ship.TargetPosition.Z.ToString("0.###"),
|
||||
ship.State,
|
||||
ship.State.ToContractValue(),
|
||||
ship.Order?.Kind ?? "none",
|
||||
ship.DefaultBehavior.Kind,
|
||||
ship.ControllerTask.Kind,
|
||||
ship.ControllerTask.Kind.ToContractValue(),
|
||||
ship.SpatialState.CurrentNodeId ?? "none",
|
||||
ship.SpatialState.CurrentBubbleId ?? "none",
|
||||
ship.DockedStationId ?? "none",
|
||||
@@ -463,8 +500,14 @@ public sealed partial class SimulationEngine
|
||||
ship.SpatialState.Transit?.Progress.ToString("0.###") ?? "0",
|
||||
GetShipCargoAmount(ship).ToString("0.###"),
|
||||
GetInventoryAmount(ship.Inventory, "fuel").ToString("0.###"),
|
||||
ship.TrackedActionKey ?? "none",
|
||||
ship.TrackedActionTotal.ToString("0.###"),
|
||||
ship.ControllerTask.TargetEntityId is not null && world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is { } site
|
||||
? GetRemainingConstructionDelivery(world, site).ToString("0.###")
|
||||
: "0",
|
||||
ship.EnergyStored.ToString("0.###"),
|
||||
ship.Health.ToString("0.###"));
|
||||
ship.Health.ToString("0.###"),
|
||||
ship.ActionTimer.ToString("0.###"));
|
||||
|
||||
private static string BuildInventorySignature(IReadOnlyDictionary<string, float> inventory) =>
|
||||
string.Join(",",
|
||||
@@ -480,6 +523,7 @@ public sealed partial class SimulationEngine
|
||||
node.Id,
|
||||
node.SystemId,
|
||||
ToDto(node.Position),
|
||||
node.AnchorNodeId,
|
||||
node.SourceKind,
|
||||
node.OreRemaining,
|
||||
node.MaxOre,
|
||||
@@ -505,7 +549,7 @@ public sealed partial class SimulationEngine
|
||||
bubble.OccupantClaimIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
bubble.OccupantConstructionSiteIds.OrderBy(id => id, StringComparer.Ordinal).ToList());
|
||||
|
||||
private static StationDelta ToStationDelta(StationRuntime station) => new(
|
||||
private static StationDelta ToStationDelta(SimulationWorld world, StationRuntime station) => new(
|
||||
station.Id,
|
||||
station.Definition.Label,
|
||||
station.Definition.Category,
|
||||
@@ -516,8 +560,13 @@ public sealed partial class SimulationEngine
|
||||
station.AnchorNodeId,
|
||||
station.Definition.Color,
|
||||
station.DockedShipIds.Count,
|
||||
station.DockedShipIds.OrderBy(id => id, StringComparer.Ordinal).ToList(),
|
||||
GetDockingPadCount(station),
|
||||
GetInventoryAmount(station.Inventory, "fuel"),
|
||||
GetStationFuelCapacity(station),
|
||||
station.EnergyStored,
|
||||
GetStationEnergyCapacity(station),
|
||||
ToStationActionProgressSnapshots(world, station),
|
||||
ToInventoryEntries(station.Inventory),
|
||||
station.FactionId,
|
||||
station.CommanderId,
|
||||
@@ -529,6 +578,23 @@ public sealed partial class SimulationEngine
|
||||
station.InstalledModules.OrderBy(moduleId => moduleId, StringComparer.Ordinal).ToList(),
|
||||
station.MarketOrderIds.OrderBy(orderId => orderId, StringComparer.Ordinal).ToList());
|
||||
|
||||
private static IReadOnlyList<StationActionProgressSnapshot> ToStationActionProgressSnapshots(SimulationWorld world, StationRuntime station) =>
|
||||
GetStationProductionLanes(station)
|
||||
.Select(laneKey =>
|
||||
{
|
||||
var recipe = SelectProductionRecipe(world, station, laneKey);
|
||||
var timer = GetStationProductionTimer(station, laneKey);
|
||||
return recipe is null || station.EnergyStored <= 0.01f || timer <= 0.01f
|
||||
? null
|
||||
: new StationActionProgressSnapshot(
|
||||
laneKey,
|
||||
recipe.Label,
|
||||
Math.Clamp(timer / MathF.Max(recipe.Duration, 0.1f), 0f, 1f));
|
||||
})
|
||||
.Where(snapshot => snapshot is not null)
|
||||
.Cast<StationActionProgressSnapshot>()
|
||||
.ToList();
|
||||
|
||||
private static ClaimDelta ToClaimDelta(ClaimRuntime claim) => new(
|
||||
claim.Id,
|
||||
claim.FactionId,
|
||||
@@ -582,7 +648,7 @@ public sealed partial class SimulationEngine
|
||||
policy.ConstructionAccessPolicy,
|
||||
policy.OperationalRangePolicy);
|
||||
|
||||
private static ShipDelta ToShipDelta(ShipRuntime ship) => new(
|
||||
private ShipDelta ToShipDelta(SimulationWorld world, ShipRuntime ship) => new(
|
||||
ship.Id,
|
||||
ship.Definition.Label,
|
||||
ship.Definition.Role,
|
||||
@@ -591,24 +657,75 @@ public sealed partial class SimulationEngine
|
||||
ToDto(ship.Position),
|
||||
ToDto(ship.Velocity),
|
||||
ToDto(ship.TargetPosition),
|
||||
ship.State,
|
||||
ship.State.ToContractValue(),
|
||||
ship.Order?.Kind,
|
||||
ship.DefaultBehavior.Kind,
|
||||
ship.ControllerTask.Kind,
|
||||
ship.ControllerTask.Kind.ToContractValue(),
|
||||
ship.SpatialState.CurrentNodeId,
|
||||
ship.SpatialState.CurrentBubbleId,
|
||||
ship.DockedStationId,
|
||||
ship.CommanderId,
|
||||
ship.PolicySetId,
|
||||
ship.Definition.CargoCapacity,
|
||||
ship.Definition.CargoItemId,
|
||||
ship.WorkerPopulation,
|
||||
ship.EnergyStored,
|
||||
ToInventoryEntries(ship.Inventory),
|
||||
ship.FactionId,
|
||||
ship.Health,
|
||||
ship.History.ToList(),
|
||||
ToShipActionProgressSnapshot(world, ship),
|
||||
ToShipSpatialStateSnapshot(ship.SpatialState));
|
||||
|
||||
private static ShipActionProgressSnapshot? ToShipActionProgressSnapshot(SimulationWorld world, ShipRuntime ship)
|
||||
{
|
||||
var progress = ship.State switch
|
||||
{
|
||||
ShipState.SpoolingFtl => CreateShipActionProgress("FTL spool", ship.ActionTimer, MathF.Max(ship.Definition.SpoolTime, 0.1f)),
|
||||
ShipState.Ftl => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("FTL", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
|
||||
ShipState.SpoolingWarp => CreateShipActionProgress("Warp spool", ship.ActionTimer, MathF.Max(0.4f, ship.Definition.SpoolTime * 0.5f)),
|
||||
ShipState.Warping => ship.SpatialState.Transit is null ? null : new ShipActionProgressSnapshot("Warp", Math.Clamp(ship.SpatialState.Transit.Progress, 0f, 1f)),
|
||||
ShipState.Mining => CreateShipActionProgress("Mining", ship.ActionTimer, MathF.Max(world.Balance.MiningCycleSeconds, 0.1f)),
|
||||
ShipState.Docking => CreateShipActionProgress("Docking", ship.ActionTimer, MathF.Max(world.Balance.DockingDuration, 0.1f)),
|
||||
ShipState.Undocking => CreateShipActionProgress("Undocking", ship.ActionTimer, MathF.Max(world.Balance.UndockingDuration, 0.1f)),
|
||||
ShipState.Transferring => CreateShipRemainingActionProgress("Transfer", ship.TrackedActionTotal, GetShipCargoAmount(ship)),
|
||||
ShipState.Refueling => CreateShipRemainingActionProgress(
|
||||
"Refuel",
|
||||
ship.TrackedActionTotal,
|
||||
MathF.Max(0f, GetShipRefuelTarget(ship, world) - GetInventoryAmount(ship.Inventory, "fuel"))),
|
||||
ShipState.Loading => CreateShipRemainingActionProgress(
|
||||
"Load workers",
|
||||
ship.TrackedActionTotal,
|
||||
MathF.Max(0f, ship.TrackedActionTotal - ship.WorkerPopulation)),
|
||||
ShipState.Unloading => CreateShipRemainingActionProgress(
|
||||
"Unload workers",
|
||||
ship.TrackedActionTotal,
|
||||
ship.WorkerPopulation),
|
||||
ShipState.DeliveringConstruction => ship.ControllerTask.TargetEntityId is null
|
||||
? null
|
||||
: world.ConstructionSites.FirstOrDefault(site => site.Id == ship.ControllerTask.TargetEntityId) is not { } site
|
||||
? null
|
||||
: CreateShipRemainingActionProgress("Deliver materials", ship.TrackedActionTotal, GetRemainingConstructionDelivery(world, site)),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
return progress;
|
||||
}
|
||||
|
||||
private static ShipActionProgressSnapshot CreateShipActionProgress(string label, float elapsedSeconds, float requiredSeconds) =>
|
||||
new(label, Math.Clamp(elapsedSeconds / requiredSeconds, 0f, 1f));
|
||||
|
||||
private static ShipActionProgressSnapshot? CreateShipRemainingActionProgress(string label, float totalAmount, float remainingAmount)
|
||||
{
|
||||
if (totalAmount <= 0.01f)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var progress = 1f - Math.Clamp(remainingAmount / totalAmount, 0f, 1f);
|
||||
return new ShipActionProgressSnapshot(label, progress);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<InventoryEntry> ToInventoryEntries(IReadOnlyDictionary<string, float> inventory) =>
|
||||
inventory
|
||||
.Where(entry => entry.Value > 0.001f)
|
||||
@@ -647,9 +764,9 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private static void EmitShipStateEvents(
|
||||
ShipRuntime ship,
|
||||
string previousState,
|
||||
ShipState previousState,
|
||||
string previousBehavior,
|
||||
string previousTask,
|
||||
ControllerTaskKind previousTask,
|
||||
string controllerEvent,
|
||||
ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
@@ -657,7 +774,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
if (previousState != ship.State)
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState} -> {ship.State}", occurredAtUtc));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "state-changed", $"{ship.Definition.Label} {previousState.ToContractValue()} -> {ship.State.ToContractValue()}", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (previousBehavior != ship.DefaultBehavior.Kind)
|
||||
@@ -667,7 +784,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
if (previousTask != ship.ControllerTask.Kind)
|
||||
{
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask} -> {ship.ControllerTask.Kind}", occurredAtUtc));
|
||||
events.Add(new SimulationEventRecord("ship", ship.Id, "task-changed", $"{ship.Definition.Label} task {previousTask.ToContractValue()} -> {ship.ControllerTask.Kind.ToContractValue()}", occurredAtUtc));
|
||||
}
|
||||
|
||||
if (controllerEvent != "none")
|
||||
|
||||
@@ -65,7 +65,7 @@ public sealed partial class SimulationEngine
|
||||
continue;
|
||||
}
|
||||
|
||||
var remaining = MathF.Max(0f, required - GetInventoryAmount(site.DeliveredItems, order.ItemId));
|
||||
var remaining = MathF.Max(0f, required - GetConstructionDeliveredAmount(world, site, order.ItemId));
|
||||
order.RemainingAmount = remaining;
|
||||
order.State = remaining <= 0.01f
|
||||
? MarketOrderStateKinds.Filled
|
||||
@@ -78,11 +78,6 @@ public sealed partial class SimulationEngine
|
||||
|
||||
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)
|
||||
@@ -111,9 +106,35 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private static string? GetNextStationModuleToBuild(StationRuntime station, SimulationWorld world)
|
||||
{
|
||||
foreach (var moduleId in new[] { "gas-tank", "fuel-processor", "refinery-stack", "dock-bay-small" })
|
||||
var priorities = GetFactionExpansionPressure(world, station.FactionId) > 0f
|
||||
? new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
("gas-tank", 1),
|
||||
("fuel-processor", 1),
|
||||
("refinery-stack", 1),
|
||||
("container-bay", 1),
|
||||
("fabricator-array", 2),
|
||||
("component-factory", 1),
|
||||
("ship-factory", 1),
|
||||
("dock-bay-small", 2),
|
||||
("solar-array", 2),
|
||||
}
|
||||
: new (string ModuleId, int TargetCount)[]
|
||||
{
|
||||
("gas-tank", 1),
|
||||
("fuel-processor", 1),
|
||||
("refinery-stack", 1),
|
||||
("container-bay", 1),
|
||||
("fabricator-array", 2),
|
||||
("component-factory", 1),
|
||||
("ship-factory", 1),
|
||||
("solar-array", 2),
|
||||
("dock-bay-small", 2),
|
||||
};
|
||||
|
||||
foreach (var (moduleId, targetCount) in priorities)
|
||||
{
|
||||
if (!station.InstalledModules.Contains(moduleId, StringComparer.Ordinal)
|
||||
if (CountModules(station.InstalledModules, moduleId) < targetCount
|
||||
&& world.ModuleRecipes.ContainsKey(moduleId))
|
||||
{
|
||||
return moduleId;
|
||||
@@ -133,7 +154,10 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
order.State = MarketOrderStateKinds.Cancelled;
|
||||
order.RemainingAmount = 0f;
|
||||
world.MarketOrders.Remove(order);
|
||||
}
|
||||
|
||||
station.MarketOrderIds.Remove(orderId);
|
||||
}
|
||||
|
||||
site.MarketOrderIds.Clear();
|
||||
@@ -214,7 +238,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
var padCount = Math.Max(1, GetDockingPadCount(station));
|
||||
var angle = ((MathF.PI * 2f) / padCount) * padIndex;
|
||||
var radius = station.Definition.Radius + 14f;
|
||||
var radius = station.Definition.Radius + 18f;
|
||||
return new Vector3(
|
||||
station.Position.X + (MathF.Cos(angle) * radius),
|
||||
station.Position.Y,
|
||||
@@ -225,7 +249,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
|
||||
var angle = (hash % 360) * (MathF.PI / 180f);
|
||||
var radius = station.Definition.Radius + 34f;
|
||||
var radius = station.Definition.Radius + 24f;
|
||||
return new Vector3(
|
||||
station.Position.X + (MathF.Cos(angle) * radius),
|
||||
station.Position.Y,
|
||||
@@ -259,4 +283,25 @@ public sealed partial class SimulationEngine
|
||||
ship.AssignedDockingPadIndex is int padIndex
|
||||
? GetDockingPadPosition(station, padIndex)
|
||||
: station.Position;
|
||||
|
||||
private static Vector3 GetConstructionHoldPosition(StationRuntime station, string shipId)
|
||||
{
|
||||
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
|
||||
var angle = (hash % 360) * (MathF.PI / 180f);
|
||||
var radius = station.Definition.Radius + 78f;
|
||||
return new Vector3(
|
||||
station.Position.X + (MathF.Cos(angle) * radius),
|
||||
station.Position.Y,
|
||||
station.Position.Z + (MathF.Sin(angle) * radius));
|
||||
}
|
||||
|
||||
private static Vector3 GetResourceHoldPosition(Vector3 nodePosition, string shipId, float radius)
|
||||
{
|
||||
var hash = Math.Abs(shipId.GetHashCode(StringComparison.Ordinal));
|
||||
var angle = (hash % 360) * (MathF.PI / 180f);
|
||||
return new Vector3(
|
||||
nodePosition.X + (MathF.Cos(angle) * radius),
|
||||
nodePosition.Y,
|
||||
nodePosition.Z + (MathF.Sin(angle) * radius));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ public sealed partial class SimulationEngine
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void BeginTrackedAction(ShipRuntime ship, string actionKey, float total)
|
||||
{
|
||||
if (ship.TrackedActionKey == actionKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.TrackedActionKey = actionKey;
|
||||
ship.TrackedActionTotal = MathF.Max(total, 0.01f);
|
||||
}
|
||||
|
||||
internal static float GetShipCargoAmount(ShipRuntime ship)
|
||||
{
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
@@ -26,11 +37,20 @@ public sealed partial class SimulationEngine
|
||||
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.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (cargoAmount >= ship.Definition.CargoCapacity - 0.01f)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = ShipState.CargoFull;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "cargo-full";
|
||||
}
|
||||
|
||||
ship.TargetPosition = task.TargetPosition.Value;
|
||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||
if (distance > task.Threshold)
|
||||
@@ -38,42 +58,47 @@ public sealed partial class SimulationEngine
|
||||
ship.ActionTimer = 0f;
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "mining-approach";
|
||||
ship.State = ShipState.MiningApproach;
|
||||
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.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "mining";
|
||||
ship.State = ShipState.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);
|
||||
var remainingCapacity = MathF.Max(0f, ship.Definition.CargoCapacity - cargoAmount);
|
||||
var mined = MathF.Min(world.Balance.MiningRate, remainingCapacity);
|
||||
mined = MathF.Min(mined, node.OreRemaining);
|
||||
if (mined <= 0.01f)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = node.OreRemaining <= 0.01f ? ShipState.NodeDepleted : ShipState.CargoFull;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return node.OreRemaining <= 0.01f ? "node-depleted" : "cargo-full";
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
node.OreRemaining = MathF.Max(0f, node.OreRemaining);
|
||||
|
||||
return GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
|
||||
}
|
||||
@@ -84,7 +109,7 @@ public sealed partial class SimulationEngine
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == task.TargetEntityId);
|
||||
if (station is null || task.TargetPosition is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -93,7 +118,7 @@ public sealed partial class SimulationEngine
|
||||
if (padIndex is null)
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "awaiting-dock";
|
||||
ship.State = ShipState.AwaitingDock;
|
||||
ship.TargetPosition = GetDockingHoldPosition(station, ship.Id);
|
||||
var waitDistance = ship.Position.DistanceTo(ship.TargetPosition);
|
||||
if (waitDistance > 4f && TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
@@ -113,37 +138,37 @@ public sealed partial class SimulationEngine
|
||||
ship.ActionTimer = 0f;
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "docking-approach";
|
||||
ship.State = ShipState.DockingApproach;
|
||||
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.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "docking";
|
||||
ship.State = ShipState.Docking;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.DockingDuration))
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "docked";
|
||||
ship.State = ShipState.Docked;
|
||||
ship.DockedStationId = station.Id;
|
||||
station.DockedShipIds.Add(ship.Id);
|
||||
ship.Position = padPosition;
|
||||
@@ -155,7 +180,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -165,15 +190,14 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -181,7 +205,8 @@ public sealed partial class SimulationEngine
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "transferring";
|
||||
ship.State = ShipState.Transferring;
|
||||
BeginTrackedAction(ship, "transferring", GetShipCargoAmount(ship));
|
||||
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)
|
||||
@@ -201,11 +226,11 @@ public sealed partial class SimulationEngine
|
||||
return cargoItemId is null || GetInventoryAmount(ship.Inventory, cargoItemId) <= 0.01f ? "unloaded" : "none";
|
||||
}
|
||||
|
||||
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
private string UpdateLoadCargo(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -215,15 +240,14 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -231,8 +255,58 @@ public sealed partial class SimulationEngine
|
||||
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"));
|
||||
ship.State = ShipState.Loading;
|
||||
BeginTrackedAction(ship, "loading", MathF.Max(0f, ship.Definition.CargoCapacity - GetShipCargoAmount(ship)));
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, ship.Definition.CargoCapacity - GetShipCargoAmount(ship));
|
||||
var moved = cargoItemId is null ? 0f : MathF.Min(transfer, GetInventoryAmount(station.Inventory, cargoItemId));
|
||||
if (cargoItemId is not null && moved > 0.01f)
|
||||
{
|
||||
RemoveInventory(station.Inventory, cargoItemId, moved);
|
||||
AddInventory(ship.Inventory, cargoItemId, moved);
|
||||
}
|
||||
|
||||
return cargoItemId is null
|
||||
|| GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|
||||
|| GetInventoryAmount(station.Inventory, cargoItemId) <= 0.01f
|
||||
? "loaded"
|
||||
: "none";
|
||||
}
|
||||
|
||||
private string UpdateRefuel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null)
|
||||
{
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = ShipState.Refueling;
|
||||
var refuelTarget = GetShipRefuelTarget(ship, world);
|
||||
BeginTrackedAction(ship, "refueling", MathF.Max(0f, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel")));
|
||||
var transfer = MathF.Min(world.Balance.TransferRate * deltaSeconds, refuelTarget - GetInventoryAmount(ship.Inventory, "fuel"));
|
||||
var moved = MathF.Min(transfer, GetInventoryAmount(station.Inventory, "fuel"));
|
||||
if (moved > 0.01f)
|
||||
{
|
||||
@@ -240,31 +314,40 @@ public sealed partial class SimulationEngine
|
||||
AddInventory(ship.Inventory, "fuel", moved);
|
||||
}
|
||||
|
||||
return !NeedsRefuel(ship) ? "refueled" : "none";
|
||||
return !NeedsRefuel(ship, world) ? "refueled" : "none";
|
||||
}
|
||||
|
||||
private string UpdateConstructModule(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null || ship.DefaultBehavior.ModuleId is null)
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null || ship.DefaultBehavior.ModuleId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.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))
|
||||
if (!world.ModuleRecipes.TryGetValue(ship.DefaultBehavior.ModuleId, out var recipe))
|
||||
{
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -272,22 +355,22 @@ public sealed partial class SimulationEngine
|
||||
if (!TryEnsureModuleConstructionStarted(station, recipe, ship.Id))
|
||||
{
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "waiting-materials";
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.State = ShipState.WaitingMaterials;
|
||||
ship.TargetPosition = supportPosition;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (station.ActiveConstruction?.AssignedConstructorShipId != ship.Id)
|
||||
{
|
||||
ship.State = "construction-blocked";
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.State = ShipState.ConstructionBlocked;
|
||||
ship.TargetPosition = supportPosition;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "constructing";
|
||||
ship.State = ShipState.Constructing;
|
||||
station.ActiveConstruction.ProgressSeconds += deltaSeconds;
|
||||
if (station.ActiveConstruction.ProgressSeconds < station.ActiveConstruction.RequiredSeconds)
|
||||
{
|
||||
@@ -301,34 +384,49 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private string UpdateDeliverConstruction(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.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.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "delivering-construction";
|
||||
ship.State = ShipState.DeliveringConstruction;
|
||||
BeginTrackedAction(ship, "delivering-construction", GetRemainingConstructionDelivery(world, site));
|
||||
|
||||
if (site.StationId is not null)
|
||||
{
|
||||
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
foreach (var required in site.RequiredItems)
|
||||
{
|
||||
@@ -349,49 +447,58 @@ public sealed partial class SimulationEngine
|
||||
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(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
return IsConstructionSiteReady(site) ? "construction-delivered" : "none";
|
||||
return IsConstructionSiteReady(world, site) ? "construction-delivered" : "none";
|
||||
}
|
||||
|
||||
private string UpdateBuildConstructionSite(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
var station = ResolveShipSupportStation(ship, world);
|
||||
if (station is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.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.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!IsConstructionSiteReady(site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
var supportPosition = ResolveShipSupportPosition(ship, station);
|
||||
if (!IsShipWithinSupportRange(ship, supportPosition, ship.ControllerTask.Threshold))
|
||||
{
|
||||
ship.State = "waiting-materials";
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.State = ShipState.LocalFlight;
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.Position.MoveToward(supportPosition, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!IsConstructionSiteReady(world, site) || !world.ModuleRecipes.TryGetValue(site.BlueprintId, out var recipe))
|
||||
{
|
||||
ship.State = ShipState.WaitingMaterials;
|
||||
ship.TargetPosition = supportPosition;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.IdleDrain * deltaSeconds)
|
||||
|| !TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.TargetPosition = GetShipDockedPosition(ship, station);
|
||||
ship.TargetPosition = supportPosition;
|
||||
ship.Position = ship.TargetPosition;
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "constructing";
|
||||
ship.State = ShipState.Constructing;
|
||||
site.AssignedConstructorShipIds.Add(ship.Id);
|
||||
site.Progress += deltaSeconds;
|
||||
if (site.Progress < recipe.Duration)
|
||||
@@ -404,22 +511,38 @@ public sealed partial class SimulationEngine
|
||||
return "site-constructed";
|
||||
}
|
||||
|
||||
private StationRuntime? ResolveShipSupportStation(ShipRuntime ship, SimulationWorld world) =>
|
||||
ship.DockedStationId is not null
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId)
|
||||
: ship.DefaultBehavior.Kind == "construct-station" && ship.DefaultBehavior.StationId is not null
|
||||
? world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DefaultBehavior.StationId)
|
||||
: null;
|
||||
|
||||
private static Vector3 ResolveShipSupportPosition(ShipRuntime ship, StationRuntime station) =>
|
||||
ship.DockedStationId is not null
|
||||
? GetShipDockedPosition(ship, station)
|
||||
: GetConstructionHoldPosition(station, ship.Id);
|
||||
|
||||
private static bool IsShipWithinSupportRange(ShipRuntime ship, Vector3 supportPosition, float threshold) =>
|
||||
ship.Position.DistanceTo(supportPosition) <= MathF.Max(threshold, 6f);
|
||||
|
||||
private string UpdateLoadWorkers(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
|
||||
{
|
||||
ship.State = "blocked";
|
||||
ship.State = ShipState.Blocked;
|
||||
return "failed";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (station is null || station.Population <= 0.01f)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var transfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
|
||||
var totalTransfer = MathF.Min(station.Population, GetWorkerTransportCapacity(ship) - ship.WorkerPopulation);
|
||||
transfer = MathF.Min(transfer, 4f * deltaSeconds);
|
||||
if (transfer <= 0.01f)
|
||||
{
|
||||
@@ -428,7 +551,8 @@ public sealed partial class SimulationEngine
|
||||
|
||||
station.Population = MathF.Max(0f, station.Population - transfer);
|
||||
ship.WorkerPopulation += transfer;
|
||||
ship.State = "loading";
|
||||
ship.State = ShipState.Loading;
|
||||
BeginTrackedAction(ship, "loading", totalTransfer);
|
||||
return ship.WorkerPopulation >= GetWorkerTransportCapacity(ship) - 0.01f ? "workers-loaded" : "none";
|
||||
}
|
||||
|
||||
@@ -436,18 +560,19 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
if (ship.DockedStationId is null || !CanTransportWorkers(ship))
|
||||
{
|
||||
ship.State = "blocked";
|
||||
ship.State = ShipState.Blocked;
|
||||
return "failed";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (station is null || ship.WorkerPopulation <= 0.01f)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
return "none";
|
||||
}
|
||||
|
||||
var transfer = MathF.Min(ship.WorkerPopulation, MathF.Max(0f, station.PopulationCapacity - station.Population));
|
||||
var totalTransfer = transfer;
|
||||
transfer = MathF.Min(transfer, 4f * deltaSeconds);
|
||||
if (transfer <= 0.01f)
|
||||
{
|
||||
@@ -456,7 +581,8 @@ public sealed partial class SimulationEngine
|
||||
|
||||
ship.WorkerPopulation = MathF.Max(0f, ship.WorkerPopulation - transfer);
|
||||
station.Population = MathF.Min(station.PopulationCapacity, station.Population + transfer);
|
||||
ship.State = "unloading";
|
||||
ship.State = ShipState.Unloading;
|
||||
BeginTrackedAction(ship, "unloading", totalTransfer);
|
||||
return ship.WorkerPopulation <= 0.01f ? "workers-unloaded" : "none";
|
||||
}
|
||||
|
||||
@@ -465,7 +591,7 @@ public sealed partial class SimulationEngine
|
||||
var task = ship.ControllerTask;
|
||||
if (ship.DockedStationId is null || task.TargetPosition is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
ship.State = ShipState.Idle;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
@@ -477,19 +603,19 @@ public sealed partial class SimulationEngine
|
||||
ship.TargetPosition = undockTarget;
|
||||
if (!TryConsumeShipEnergy(ship, world.Balance.Energy.MoveDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
if (station is not null && !TryConsumeStationEnergy(station, world.Balance.Energy.IdleDrain * deltaSeconds))
|
||||
{
|
||||
ship.State = "power-starved";
|
||||
ship.State = ShipState.CapacitorStarved;
|
||||
ship.TargetPosition = ship.Position;
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "undocking";
|
||||
ship.State = ShipState.Undocking;
|
||||
if (!AdvanceTimedAction(ship, deltaSeconds, world.Balance.UndockingDuration))
|
||||
{
|
||||
if (station is not null)
|
||||
@@ -516,4 +642,7 @@ public sealed partial class SimulationEngine
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
return "undocked";
|
||||
}
|
||||
|
||||
private static float GetRemainingConstructionDelivery(SimulationWorld world, ConstructionSiteRuntime site) =>
|
||||
site.RequiredItems.Sum(required => MathF.Max(0f, required.Value - GetConstructionDeliveredAmount(world, site, required.Key)));
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = commander.ActiveTask.Kind,
|
||||
Kind = ParseControllerTaskKind(commander.ActiveTask.Kind),
|
||||
Status = commander.ActiveTask.Status,
|
||||
CommanderId = commander.Id,
|
||||
TargetEntityId = commander.ActiveTask.TargetEntityId,
|
||||
@@ -81,8 +81,8 @@ public sealed partial class SimulationEngine
|
||||
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 ??= new CommanderTaskRuntime { Kind = ship.ControllerTask.Kind.ToContractValue() };
|
||||
commander.ActiveTask.Kind = ship.ControllerTask.Kind.ToContractValue();
|
||||
commander.ActiveTask.Status = ship.ControllerTask.Status;
|
||||
commander.ActiveTask.TargetEntityId = ship.ControllerTask.TargetEntityId;
|
||||
commander.ActiveTask.TargetNodeId = ship.ControllerTask.TargetNodeId;
|
||||
@@ -121,7 +121,7 @@ public sealed partial class SimulationEngine
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
Status = WorkStatus.Active,
|
||||
CommanderId = commander?.Id,
|
||||
TargetSystemId = ship.Order.DestinationSystemId,
|
||||
@@ -144,10 +144,13 @@ public sealed partial class SimulationEngine
|
||||
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)
|
||||
.Where(candidate =>
|
||||
(behavior.AreaSystemId is null || candidate.SystemId == behavior.AreaSystemId) &&
|
||||
candidate.ItemId == resourceItemId &&
|
||||
candidate.OreRemaining > 0.01f)
|
||||
.OrderByDescending(candidate => candidate.OreRemaining)
|
||||
.FirstOrDefault()
|
||||
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId);
|
||||
: world.Nodes.FirstOrDefault(candidate => candidate.Id == behavior.NodeId && candidate.OreRemaining > 0.01f);
|
||||
|
||||
if (refinery is null || node is null || !HasShipModules(ship.Definition, "reactor-core", "capacitor-bank", requiredModule))
|
||||
{
|
||||
@@ -157,13 +160,29 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
|
||||
behavior.NodeId ??= node.Id;
|
||||
if (NeedsEmergencyReturn(ship, world) && behavior.Phase is "travel-to-node" or "extract")
|
||||
{
|
||||
behavior.Phase = "travel-to-station";
|
||||
}
|
||||
|
||||
if (GetShipCargoAmount(ship) >= ship.Definition.CargoCapacity - 0.01f
|
||||
&& behavior.Phase is "travel-to-node" or "extract")
|
||||
{
|
||||
behavior.Phase = "travel-to-station";
|
||||
}
|
||||
|
||||
if (ship.DockedStationId == refinery.Id)
|
||||
{
|
||||
if (GetShipCargoAmount(ship) > 0.01f)
|
||||
{
|
||||
behavior.Phase = "unload";
|
||||
}
|
||||
else if (NeedsRefuel(ship))
|
||||
else if (behavior.Phase == "undock")
|
||||
{
|
||||
// Keep the post-refuel departure decision stable for the current dock cycle.
|
||||
behavior.Phase = "undock";
|
||||
}
|
||||
else if (NeedsRefuel(ship, world))
|
||||
{
|
||||
behavior.Phase = "refuel";
|
||||
}
|
||||
@@ -172,7 +191,7 @@ public sealed partial class SimulationEngine
|
||||
behavior.Phase = "undock";
|
||||
}
|
||||
}
|
||||
else if (NeedsRefuel(ship) && behavior.Phase is not "travel-to-station" and not "dock")
|
||||
else if (NeedsRefuel(ship, world) && behavior.Phase is not "travel-to-station" and not "dock" and not "travel-to-node" and not "extract")
|
||||
{
|
||||
behavior.Phase = "travel-to-station";
|
||||
}
|
||||
@@ -180,19 +199,20 @@ public sealed partial class SimulationEngine
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "extract":
|
||||
var extractionPosition = GetResourceHoldPosition(node.Position, ship.Id, 20f);
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "extract",
|
||||
Kind = ControllerTaskKind.Extract,
|
||||
TargetEntityId = node.Id,
|
||||
TargetSystemId = node.SystemId,
|
||||
TargetPosition = node.Position,
|
||||
Threshold = 14f,
|
||||
TargetPosition = extractionPosition,
|
||||
Threshold = 5f,
|
||||
};
|
||||
break;
|
||||
case "travel-to-station":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -202,7 +222,7 @@ public sealed partial class SimulationEngine
|
||||
case "dock":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "dock",
|
||||
Kind = ControllerTaskKind.Dock,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -212,7 +232,7 @@ public sealed partial class SimulationEngine
|
||||
case "unload":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "unload",
|
||||
Kind = ControllerTaskKind.Unload,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -222,7 +242,7 @@ public sealed partial class SimulationEngine
|
||||
case "refuel":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "refuel",
|
||||
Kind = ControllerTaskKind.Refuel,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
@@ -232,7 +252,7 @@ public sealed partial class SimulationEngine
|
||||
case "undock":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "undock",
|
||||
Kind = ControllerTaskKind.Undock,
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
|
||||
@@ -242,7 +262,7 @@ public sealed partial class SimulationEngine
|
||||
default:
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = node.Id,
|
||||
TargetSystemId = node.SystemId,
|
||||
TargetPosition = node.Position,
|
||||
@@ -278,6 +298,153 @@ public sealed partial class SimulationEngine
|
||||
return bestOrder.Station ?? preferred;
|
||||
}
|
||||
|
||||
internal static StationRuntime? SelectBestSellStation(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.Sell &&
|
||||
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 && GetInventoryAmount(entry.Station!.Inventory, itemId) > 0.01f)
|
||||
.OrderByDescending(entry =>
|
||||
{
|
||||
var distancePenalty = entry.Station!.SystemId == ship.SystemId ? 0f : 0.2f;
|
||||
return entry.Order.Valuation - distancePenalty;
|
||||
})
|
||||
.FirstOrDefault();
|
||||
|
||||
return bestOrder.Station ?? preferred;
|
||||
}
|
||||
|
||||
internal void PlanEnergySupply(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var cargoItemId = ship.Definition.CargoItemId;
|
||||
if (cargoItemId is null)
|
||||
{
|
||||
behavior.Kind = "idle";
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
var cargoAmount = GetShipCargoAmount(ship);
|
||||
if (cargoAmount > 0.01f)
|
||||
{
|
||||
var destination = SelectBestBuyStation(world, ship, cargoItemId, behavior.StationId);
|
||||
if (destination is null)
|
||||
{
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
behavior.StationId = destination.Id;
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "dock":
|
||||
case "unload":
|
||||
case "refuel":
|
||||
case "undock":
|
||||
ship.ControllerTask = CreateStationSupportTask(world, ship, destination, behavior.Phase);
|
||||
break;
|
||||
default:
|
||||
behavior.Phase = "travel-to-destination";
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = destination.Id,
|
||||
TargetSystemId = destination.SystemId,
|
||||
TargetPosition = destination.Position,
|
||||
Threshold = 18f,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var source = SelectBestSellStation(world, ship, cargoItemId, behavior.StationId);
|
||||
if (source is null)
|
||||
{
|
||||
ship.ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold);
|
||||
return;
|
||||
}
|
||||
|
||||
behavior.StationId = source.Id;
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "dock":
|
||||
case "load":
|
||||
case "refuel":
|
||||
case "undock":
|
||||
ship.ControllerTask = CreateStationSupportTask(world, ship, source, behavior.Phase);
|
||||
break;
|
||||
default:
|
||||
behavior.Phase = "travel-to-source";
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = source.Id,
|
||||
TargetSystemId = source.SystemId,
|
||||
TargetPosition = source.Position,
|
||||
Threshold = 18f,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static ControllerTaskRuntime CreateStationSupportTask(SimulationWorld world, ShipRuntime ship, StationRuntime station, string? phase) =>
|
||||
phase switch
|
||||
{
|
||||
"dock" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Dock,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 8f,
|
||||
},
|
||||
"load" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Load,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 8f,
|
||||
},
|
||||
"unload" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Unload,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 8f,
|
||||
},
|
||||
"refuel" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Refuel,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 12f,
|
||||
},
|
||||
"undock" => new ControllerTaskRuntime
|
||||
{
|
||||
Kind = ControllerTaskKind.Undock,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = new Vector3(station.Position.X + world.Balance.UndockDistance, station.Position.Y, station.Position.Z),
|
||||
Threshold = 8f,
|
||||
},
|
||||
_ => CreateIdleTask(world.Balance.ArrivalThreshold),
|
||||
};
|
||||
|
||||
internal void PlanStationConstruction(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
@@ -298,17 +465,36 @@ public sealed partial class SimulationEngine
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.DockedStationId == station.Id)
|
||||
if (ship.DockedStationId is not null)
|
||||
{
|
||||
if (NeedsRefuel(ship))
|
||||
var dockedStation = world.Stations.FirstOrDefault(candidate => candidate.Id == ship.DockedStationId);
|
||||
if (dockedStation is not null)
|
||||
{
|
||||
dockedStation.DockedShipIds.Remove(ship.Id);
|
||||
ReleaseDockingPad(dockedStation, ship.Id);
|
||||
}
|
||||
|
||||
ship.DockedStationId = null;
|
||||
ship.AssignedDockingPadIndex = null;
|
||||
ship.Position = GetConstructionHoldPosition(station, ship.Id);
|
||||
ship.TargetPosition = ship.Position;
|
||||
}
|
||||
|
||||
var constructionHoldPosition = GetConstructionHoldPosition(station, ship.Id);
|
||||
var isAtConstructionHold = ship.SystemId == station.SystemId
|
||||
&& ship.Position.DistanceTo(constructionHoldPosition) <= 10f;
|
||||
|
||||
if (isAtConstructionHold)
|
||||
{
|
||||
if (NeedsRefuel(ship, world))
|
||||
{
|
||||
behavior.Phase = "refuel";
|
||||
}
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(site))
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && !IsConstructionSiteReady(world, site))
|
||||
{
|
||||
behavior.Phase = "deliver-to-site";
|
||||
}
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(site))
|
||||
else if (site is not null && site.State == ConstructionSiteStateKinds.Active && IsConstructionSiteReady(world, site))
|
||||
{
|
||||
behavior.Phase = "build-site";
|
||||
}
|
||||
@@ -325,81 +511,71 @@ public sealed partial class SimulationEngine
|
||||
behavior.Phase = "wait-for-materials";
|
||||
}
|
||||
}
|
||||
else if (behavior.Phase is not "travel-to-station" and not "dock")
|
||||
else if (behavior.Phase != "travel-to-station")
|
||||
{
|
||||
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",
|
||||
Kind = ControllerTaskKind.Refuel,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "construct-module":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "construct-module",
|
||||
Kind = ControllerTaskKind.ConstructModule,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "deliver-to-site":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "deliver-construction",
|
||||
Kind = ControllerTaskKind.DeliverConstruction,
|
||||
TargetEntityId = site?.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "build-site":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "build-construction-site",
|
||||
Kind = ControllerTaskKind.BuildConstructionSite,
|
||||
TargetEntityId = site?.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = 0f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
break;
|
||||
case "wait-for-materials":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 0f,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
Kind = ControllerTaskKind.Travel,
|
||||
TargetEntityId = station.Id,
|
||||
TargetSystemId = station.SystemId,
|
||||
TargetPosition = station.Position,
|
||||
Threshold = station.Definition.Radius + 8f,
|
||||
TargetPosition = constructionHoldPosition,
|
||||
Threshold = 10f,
|
||||
};
|
||||
behavior.Phase = "travel-to-station";
|
||||
break;
|
||||
@@ -412,7 +588,7 @@ public sealed partial class SimulationEngine
|
||||
if (ship.Order is not null && controllerEvent == "arrived")
|
||||
{
|
||||
ship.Order = null;
|
||||
ship.ControllerTask.Kind = "idle";
|
||||
ship.ControllerTask.Kind = ControllerTaskKind.Idle;
|
||||
if (commander is not null)
|
||||
{
|
||||
commander.ActiveOrder = null;
|
||||
@@ -439,16 +615,20 @@ public sealed partial class SimulationEngine
|
||||
}
|
||||
}
|
||||
|
||||
private static void TrackHistory(ShipRuntime ship)
|
||||
private static void TrackHistory(ShipRuntime ship, string controllerEvent)
|
||||
{
|
||||
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{GetShipCargoAmount(ship):0.0}";
|
||||
var signature = $"{ship.State.ToContractValue()}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind.ToContractValue()}|{ship.ControllerTask.TargetSystemId}|{ship.ControllerTask.TargetEntityId}|{GetShipCargoAmount(ship):0.0}|{controllerEvent}";
|
||||
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.#}");
|
||||
var target = ship.ControllerTask.TargetEntityId
|
||||
?? ship.ControllerTask.TargetSystemId
|
||||
?? "none";
|
||||
var eventSummary = controllerEvent == "none" ? string.Empty : $" event={controllerEvent}";
|
||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State.ToContractValue()} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind.ToContractValue()} target={target} cargo={GetShipCargoAmount(ship):0.#}{eventSummary}");
|
||||
if (ship.History.Count > 18)
|
||||
{
|
||||
ship.History.RemoveAt(0);
|
||||
@@ -458,10 +638,27 @@ public sealed partial class SimulationEngine
|
||||
private static ControllerTaskRuntime CreateIdleTask(float threshold) =>
|
||||
new()
|
||||
{
|
||||
Kind = "idle",
|
||||
Kind = ControllerTaskKind.Idle,
|
||||
Threshold = threshold,
|
||||
};
|
||||
|
||||
private static ControllerTaskKind ParseControllerTaskKind(string kind) => kind switch
|
||||
{
|
||||
"travel" => ControllerTaskKind.Travel,
|
||||
"extract" => ControllerTaskKind.Extract,
|
||||
"dock" => ControllerTaskKind.Dock,
|
||||
"load" => ControllerTaskKind.Load,
|
||||
"unload" => ControllerTaskKind.Unload,
|
||||
"refuel" => ControllerTaskKind.Refuel,
|
||||
"deliver-construction" => ControllerTaskKind.DeliverConstruction,
|
||||
"build-construction-site" => ControllerTaskKind.BuildConstructionSite,
|
||||
"load-workers" => ControllerTaskKind.LoadWorkers,
|
||||
"unload-workers" => ControllerTaskKind.UnloadWorkers,
|
||||
"construct-module" => ControllerTaskKind.ConstructModule,
|
||||
"undock" => ControllerTaskKind.Undock,
|
||||
_ => ControllerTaskKind.Idle,
|
||||
};
|
||||
|
||||
private static void SyncCommanderTask(CommanderRuntime? commander, ControllerTaskRuntime task)
|
||||
{
|
||||
if (commander is null)
|
||||
@@ -471,7 +668,7 @@ public sealed partial class SimulationEngine
|
||||
|
||||
commander.ActiveTask = new CommanderTaskRuntime
|
||||
{
|
||||
Kind = task.Kind,
|
||||
Kind = task.Kind.ToContractValue(),
|
||||
Status = task.Status,
|
||||
TargetEntityId = task.TargetEntityId,
|
||||
TargetNodeId = task.TargetNodeId,
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class SimulationEngine
|
||||
{
|
||||
private const int StrategicControlTargetSystems = 5;
|
||||
|
||||
private void UpdateStations(SimulationWorld world, float deltaSeconds, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
var factionPopulation = new Dictionary<string, float>(StringComparer.Ordinal);
|
||||
@@ -62,18 +64,27 @@ public sealed partial class SimulationEngine
|
||||
|
||||
var desiredOrders = new List<DesiredMarketOrder>();
|
||||
var fuelReserve = MathF.Max(80f, CountModules(station.InstalledModules, "power-core") * 140f);
|
||||
var energyCellReserve = HasStationModules(station, "power-core", "liquid-tank") ? MathF.Max(20f, CountModules(station.InstalledModules, "power-core") * 40f) : 0f;
|
||||
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;
|
||||
var shipPartsReserve = HasStationModules(station, "fabricator-array")
|
||||
&& !HasStationModules(station, "component-factory", "ship-factory")
|
||||
&& FactionNeedsMoreWarships(world, station.FactionId)
|
||||
? 90f
|
||||
: 0f;
|
||||
|
||||
AddDemandOrder(desiredOrders, station, "fuel", fuelReserve, valuationBase: 1.2f);
|
||||
AddDemandOrder(desiredOrders, station, "energy-cell", energyCellReserve, valuationBase: 1.1f);
|
||||
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);
|
||||
AddDemandOrder(desiredOrders, station, "ship-parts", shipPartsReserve, valuationBase: 1.3f);
|
||||
|
||||
AddSupplyOrder(desiredOrders, station, "fuel", fuelReserve * 1.5f, reserveFloor: fuelReserve, valuationBase: 0.8f);
|
||||
AddSupplyOrder(desiredOrders, station, "energy-cell", energyCellReserve * 1.8f, reserveFloor: energyCellReserve, valuationBase: 0.82f);
|
||||
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);
|
||||
@@ -84,56 +95,160 @@ public sealed partial class SimulationEngine
|
||||
|
||||
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)
|
||||
foreach (var laneKey in GetStationProductionLanes(station))
|
||||
{
|
||||
faction.GoodsProduced += produced;
|
||||
var recipe = SelectProductionRecipe(world, station, laneKey);
|
||||
if (recipe is null || station.EnergyStored <= 0.01f)
|
||||
{
|
||||
station.ProductionLaneTimers[laneKey] = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
var throughput = GetStationProductionThroughput(station, recipe);
|
||||
if (!TryConsumeStationEnergy(station, world.Balance.Energy.MoveDrain * deltaSeconds * throughput))
|
||||
{
|
||||
station.ProductionLaneTimers[laneKey] = 0f;
|
||||
continue;
|
||||
}
|
||||
|
||||
var produced = 0f;
|
||||
station.ProductionLaneTimers[laneKey] = GetStationProductionTimer(station, laneKey) + (deltaSeconds * station.WorkforceEffectiveRatio * throughput);
|
||||
while (station.ProductionLaneTimers[laneKey] >= recipe.Duration && CanRunRecipe(world, station, recipe))
|
||||
{
|
||||
station.ProductionLaneTimers[laneKey] -= recipe.Duration;
|
||||
foreach (var input in recipe.Inputs)
|
||||
{
|
||||
RemoveInventory(station.Inventory, input.ItemId, input.Amount);
|
||||
}
|
||||
|
||||
if (recipe.ShipOutputId is not null)
|
||||
{
|
||||
produced += CompleteShipRecipe(world, station, recipe, events);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var output in recipe.Outputs)
|
||||
{
|
||||
produced += TryAddStationInventory(world, station, output.ItemId, output.Amount);
|
||||
}
|
||||
}
|
||||
|
||||
if (produced <= 0.01f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "production-complete", $"{station.Definition.Label} completed {recipe.Label}.", DateTimeOffset.UtcNow));
|
||||
if (faction is not null)
|
||||
{
|
||||
faction.GoodsProduced += produced;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station) =>
|
||||
private static IEnumerable<string> GetStationProductionLanes(StationRuntime station)
|
||||
{
|
||||
if (CountModules(station.InstalledModules, "refinery-stack") > 0)
|
||||
{
|
||||
yield return "refinery";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "fuel-processor") > 0)
|
||||
{
|
||||
yield return "fuel";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "fabricator-array") > 0)
|
||||
{
|
||||
yield return "fabrication";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "component-factory") > 0)
|
||||
{
|
||||
yield return "components";
|
||||
}
|
||||
|
||||
if (CountModules(station.InstalledModules, "ship-factory") > 0)
|
||||
{
|
||||
yield return "shipyard";
|
||||
}
|
||||
}
|
||||
|
||||
private static float GetStationProductionTimer(StationRuntime station, string laneKey) =>
|
||||
station.ProductionLaneTimers.TryGetValue(laneKey, out var timer) ? timer : 0f;
|
||||
|
||||
private static RecipeDefinition? SelectProductionRecipe(SimulationWorld world, StationRuntime station, string laneKey) =>
|
||||
world.Recipes.Values
|
||||
.Where(recipe => RecipeAppliesToStation(station, recipe))
|
||||
.OrderByDescending(recipe => recipe.Priority)
|
||||
.Where(recipe => RecipeAppliesToStation(station, recipe) && string.Equals(GetStationProductionLaneKey(recipe), laneKey, StringComparison.Ordinal))
|
||||
.OrderByDescending(recipe => GetStationRecipePriority(world, station, recipe))
|
||||
.FirstOrDefault(recipe => CanRunRecipe(world, station, recipe));
|
||||
|
||||
private static string? GetStationProductionLaneKey(RecipeDefinition recipe)
|
||||
{
|
||||
if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal))
|
||||
{
|
||||
return "fuel";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal))
|
||||
{
|
||||
return "refinery";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal))
|
||||
{
|
||||
return "fabrication";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return "components";
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return "shipyard";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static float GetStationRecipePriority(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
var priority = (float)recipe.Priority;
|
||||
var producesFuel = recipe.Outputs.Any(output => string.Equals(output.ItemId, "fuel", StringComparison.Ordinal));
|
||||
if (producesFuel)
|
||||
{
|
||||
var fuelCapacity = MathF.Max(GetStationFuelCapacity(station), 1f);
|
||||
var fuelRatio = GetInventoryAmount(station.Inventory, "fuel") / fuelCapacity;
|
||||
priority += (1f - Math.Clamp(fuelRatio, 0f, 1f)) * 200f;
|
||||
}
|
||||
|
||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
||||
var fleetPressure = FactionNeedsMoreWarships(world, station.FactionId) ? 1f : 0f;
|
||||
priority += recipe.Id switch
|
||||
{
|
||||
"ship-parts-integration" => HasStationModules(station, "component-factory", "ship-factory")
|
||||
? -140f * MathF.Max(expansionPressure, fleetPressure)
|
||||
: 280f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"hull-fabrication" => 180f * expansionPressure,
|
||||
"equipment-assembly" => 170f * expansionPressure,
|
||||
"gun-assembly" => 160f * expansionPressure,
|
||||
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
|
||||
=> 220f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"destroyer-construction" => 200f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"cruiser-construction" => 120f * MathF.Max(expansionPressure, fleetPressure),
|
||||
"ammo-fabrication" => -80f * expansionPressure,
|
||||
"trade-hub-assembly" or "refinery-assembly" or "farm-ring-assembly" or "manufactory-assembly" or "shipyard-assembly" or "defense-grid-assembly" or "stargate-assembly"
|
||||
=> -120f * expansionPressure,
|
||||
_ => 0f,
|
||||
};
|
||||
|
||||
return priority;
|
||||
}
|
||||
|
||||
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
var categoryMatch = string.Equals(station.Definition.Category, recipe.FacilityCategory, StringComparison.Ordinal)
|
||||
@@ -144,6 +259,21 @@ public sealed partial class SimulationEngine
|
||||
|
||||
private static bool CanRunRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
if (recipe.ShipOutputId is not null)
|
||||
{
|
||||
if (!world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var shipDefinition)
|
||||
|| !CanLaunchShipFromStation(station))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(shipDefinition.Role, "military", StringComparison.Ordinal)
|
||||
|| !FactionNeedsMoreWarships(world, station.FactionId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (recipe.Inputs.Any(input => GetInventoryAmount(station.Inventory, input.ItemId) + 0.001f < input.Amount))
|
||||
{
|
||||
return false;
|
||||
@@ -165,7 +295,8 @@ public sealed partial class SimulationEngine
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!station.Definition.Storage.TryGetValue(itemDefinition.Storage, out var capacity))
|
||||
var capacity = GetStationStorageCapacity(station, itemDefinition.Storage);
|
||||
if (capacity <= 0.01f)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -259,5 +390,151 @@ public sealed partial class SimulationEngine
|
||||
private static bool CanProcessFuel(StationRuntime station) =>
|
||||
HasStationModules(station, "fuel-processor", "power-core", "gas-tank", "liquid-tank");
|
||||
|
||||
private float CompleteShipRecipe(SimulationWorld world, StationRuntime station, RecipeDefinition recipe, ICollection<SimulationEventRecord> events)
|
||||
{
|
||||
if (recipe.ShipOutputId is null || !world.ShipDefinitions.TryGetValue(recipe.ShipOutputId, out var definition))
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var spawnPosition = new Vector3(station.Position.X + station.Definition.Radius + 32f, station.Position.Y, station.Position.Z);
|
||||
var ship = new ShipRuntime
|
||||
{
|
||||
Id = $"ship-{world.Ships.Count + 1}",
|
||||
SystemId = station.SystemId,
|
||||
Definition = definition,
|
||||
FactionId = station.FactionId,
|
||||
Position = spawnPosition,
|
||||
TargetPosition = spawnPosition,
|
||||
SpatialState = CreateSpawnedShipSpatialState(station, spawnPosition),
|
||||
DefaultBehavior = CreateSpawnedShipBehavior(definition, station),
|
||||
ControllerTask = CreateIdleTask(world.Balance.ArrivalThreshold),
|
||||
Health = definition.MaxHealth,
|
||||
};
|
||||
|
||||
ship.Inventory["fuel"] = 120f;
|
||||
world.Ships.Add(ship);
|
||||
if (world.Factions.FirstOrDefault(candidate => candidate.Id == station.FactionId) is { } faction)
|
||||
{
|
||||
faction.ShipsBuilt += 1;
|
||||
}
|
||||
|
||||
events.Add(new SimulationEventRecord("station", station.Id, "ship-built", $"{station.Definition.Label} launched {definition.Label}.", DateTimeOffset.UtcNow));
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private static bool CanLaunchShipFromStation(StationRuntime station) =>
|
||||
HasStationModules(station, "power-core", "ship-factory", "container-bay", "dock-bay-small");
|
||||
|
||||
private static bool FactionNeedsMoreWarships(SimulationWorld world, string factionId)
|
||||
{
|
||||
var militaryShipCount = world.Ships.Count(ship =>
|
||||
ship.FactionId == factionId
|
||||
&& string.Equals(ship.Definition.Role, "military", StringComparison.Ordinal));
|
||||
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
||||
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
||||
var expansionDeficit = Math.Max(0, targetSystems - controlledSystems);
|
||||
var targetWarships = Math.Max(2, (controlledSystems * 2) + (expansionDeficit * 3));
|
||||
return militaryShipCount < targetWarships;
|
||||
}
|
||||
|
||||
private static int GetFactionControlledSystemsCount(SimulationWorld world, string factionId)
|
||||
{
|
||||
return world.Claims
|
||||
.Where(claim => claim.State != ClaimStateKinds.Destroyed)
|
||||
.Select(claim => claim.SystemId)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Count(systemId => FactionControlsSystem(world, factionId, systemId));
|
||||
}
|
||||
|
||||
private static float GetFactionExpansionPressure(SimulationWorld world, string factionId)
|
||||
{
|
||||
var targetSystems = Math.Max(1, Math.Min(StrategicControlTargetSystems, world.Systems.Count));
|
||||
var controlledSystems = GetFactionControlledSystemsCount(world, factionId);
|
||||
var deficit = Math.Max(0, targetSystems - controlledSystems);
|
||||
return Math.Clamp(deficit / (float)targetSystems, 0f, 1f);
|
||||
}
|
||||
|
||||
private static bool FactionControlsSystem(SimulationWorld world, string factionId, string systemId)
|
||||
{
|
||||
var buildableLocations = world.Claims
|
||||
.Where(claim =>
|
||||
claim.SystemId == systemId &&
|
||||
claim.State is ClaimStateKinds.Activating or ClaimStateKinds.Active)
|
||||
.ToList();
|
||||
if (buildableLocations.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var ownedLocations = buildableLocations.Count(claim => claim.FactionId == factionId);
|
||||
return ownedLocations > (buildableLocations.Count / 2f);
|
||||
}
|
||||
|
||||
private static ShipSpatialStateRuntime CreateSpawnedShipSpatialState(StationRuntime station, Vector3 position) => new()
|
||||
{
|
||||
CurrentSystemId = station.SystemId,
|
||||
SpaceLayer = SpaceLayerKinds.LocalSpace,
|
||||
CurrentNodeId = station.NodeId,
|
||||
CurrentBubbleId = station.BubbleId,
|
||||
LocalPosition = position,
|
||||
SystemPosition = position,
|
||||
MovementRegime = MovementRegimeKinds.LocalFlight,
|
||||
};
|
||||
|
||||
private static DefaultBehaviorRuntime CreateSpawnedShipBehavior(ShipDefinition definition, StationRuntime station)
|
||||
{
|
||||
if (!string.Equals(definition.Role, "military", StringComparison.Ordinal))
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
};
|
||||
}
|
||||
|
||||
var patrolRadius = station.Definition.Radius + 90f;
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "patrol",
|
||||
PatrolPoints =
|
||||
[
|
||||
new Vector3(station.Position.X + patrolRadius, station.Position.Y, station.Position.Z),
|
||||
new Vector3(station.Position.X, station.Position.Y, station.Position.Z + patrolRadius),
|
||||
new Vector3(station.Position.X - patrolRadius, station.Position.Y, station.Position.Z),
|
||||
new Vector3(station.Position.X, station.Position.Y, station.Position.Z - patrolRadius),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private static float GetStationProductionThroughput(StationRuntime station, RecipeDefinition recipe)
|
||||
{
|
||||
if (recipe.RequiredModules.Contains("refinery-stack", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "refinery-stack"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("fuel-processor", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "fuel-processor"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("fabricator-array", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "fabricator-array"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("component-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "component-factory"));
|
||||
}
|
||||
|
||||
if (recipe.RequiredModules.Contains("ship-factory", StringComparer.Ordinal))
|
||||
{
|
||||
return Math.Max(1, CountModules(station.InstalledModules, "ship-factory"));
|
||||
}
|
||||
|
||||
return 1f;
|
||||
}
|
||||
|
||||
private sealed record DesiredMarketOrder(string Kind, string ItemId, float Amount, float Valuation, float? ReserveThreshold);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed partial class SimulationEngine
|
||||
{
|
||||
private readonly OrbitalSimulationOptions _orbitalSimulation;
|
||||
private const float ShipFuelToEnergyRatio = 12f;
|
||||
private const float StationFuelToEnergyRatio = 18f;
|
||||
private const float CapacitorEnergyPerModule = 120f;
|
||||
@@ -16,7 +17,7 @@ public sealed partial class SimulationEngine
|
||||
private static readonly ShipBehaviorStateMachine _shipBehaviorStateMachine = ShipBehaviorStateMachine.CreateDefault();
|
||||
private static readonly IReadOnlyList<WorldUpdateStep> _worldUpdatePipeline =
|
||||
[
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateOrbitalState(world, nowUtc)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => engine.UpdateOrbitalState(world)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateClaims(world, events)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateConstructionSites(world, events)),
|
||||
new((engine, world, deltaSeconds, nowUtc, events) => UpdateStationPower(world, deltaSeconds, events)),
|
||||
@@ -29,10 +30,16 @@ public sealed partial class SimulationEngine
|
||||
new((engine, ship, world, deltaSeconds, events) => engine.PlanControllerTask(ship, world)),
|
||||
];
|
||||
|
||||
public SimulationEngine(OrbitalSimulationOptions? orbitalSimulation = null)
|
||||
{
|
||||
_orbitalSimulation = orbitalSimulation ?? new OrbitalSimulationOptions();
|
||||
}
|
||||
|
||||
public WorldDelta Tick(SimulationWorld world, float deltaSeconds, long sequence)
|
||||
{
|
||||
var events = new List<SimulationEventRecord>();
|
||||
var nowUtc = DateTimeOffset.UtcNow;
|
||||
world.OrbitalTimeSeconds += deltaSeconds * _orbitalSimulation.SimulatedSecondsPerRealSecond;
|
||||
|
||||
foreach (var step in _worldUpdatePipeline)
|
||||
{
|
||||
@@ -54,7 +61,7 @@ public sealed partial class SimulationEngine
|
||||
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
|
||||
AdvanceControlState(ship, world, controllerEvent);
|
||||
ship.Velocity = ship.Position.Subtract(previousPosition).Divide(deltaSeconds);
|
||||
TrackHistory(ship);
|
||||
TrackHistory(ship, controllerEvent);
|
||||
|
||||
EmitShipStateEvents(ship, previousState, previousBehavior, previousTask, controllerEvent, events);
|
||||
}
|
||||
@@ -65,6 +72,8 @@ public sealed partial class SimulationEngine
|
||||
return new WorldDelta(
|
||||
sequence,
|
||||
world.TickIntervalMs,
|
||||
world.OrbitalTimeSeconds,
|
||||
new OrbitalSimulationSnapshot(_orbitalSimulation.SimulatedSecondsPerRealSecond),
|
||||
world.GeneratedAtUtc,
|
||||
false,
|
||||
events,
|
||||
|
||||
8
apps/backend/Simulation/WorldGenerationOptions.cs
Normal file
8
apps/backend/Simulation/WorldGenerationOptions.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class WorldGenerationOptions
|
||||
{
|
||||
public int TargetSystemCount { get; init; } = 160;
|
||||
|
||||
public bool IncludeSolSystem { get; init; } = true;
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SpaceGame.Simulation.Api.Contracts;
|
||||
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class WorldService(IWebHostEnvironment environment)
|
||||
public sealed class WorldService(
|
||||
IWebHostEnvironment environment,
|
||||
IOptions<WorldGenerationOptions> worldGenerationOptions,
|
||||
IOptions<OrbitalSimulationOptions> orbitalSimulationOptions)
|
||||
{
|
||||
private const int DeltaHistoryLimit = 256;
|
||||
|
||||
private readonly object _sync = new();
|
||||
private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
|
||||
private readonly SimulationEngine _engine = new();
|
||||
private readonly OrbitalSimulationSnapshot _orbitalSimulation = new(orbitalSimulationOptions.Value.SimulatedSecondsPerRealSecond);
|
||||
private readonly ScenarioLoader _loader = new(environment.ContentRootPath, worldGenerationOptions.Value);
|
||||
private readonly SimulationEngine _engine = new(orbitalSimulationOptions.Value);
|
||||
private readonly Dictionary<Guid, SubscriptionState> _subscribers = [];
|
||||
private readonly Queue<WorldDelta> _history = [];
|
||||
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load();
|
||||
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath, worldGenerationOptions.Value).Load();
|
||||
private long _sequence;
|
||||
|
||||
public WorldSnapshot GetSnapshot()
|
||||
@@ -98,6 +103,8 @@ public sealed class WorldService(IWebHostEnvironment environment)
|
||||
var resetDelta = new WorldDelta(
|
||||
_sequence,
|
||||
_world.TickIntervalMs,
|
||||
_world.OrbitalTimeSeconds,
|
||||
_orbitalSimulation,
|
||||
DateTimeOffset.UtcNow,
|
||||
true,
|
||||
[new SimulationEventRecord("world", "world", "reset", "World reset requested", DateTimeOffset.UtcNow, "world", "universe", "world")],
|
||||
|
||||
@@ -4,5 +4,12 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"WorldGeneration": {
|
||||
"TargetSystemCount": 1,
|
||||
"IncludeSolSystem": true
|
||||
},
|
||||
"OrbitalSimulation": {
|
||||
"SimulatedSecondsPerRealSecond": 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,12 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"WorldGeneration": {
|
||||
"TargetSystemCount": 160,
|
||||
"IncludeSolSystem": true
|
||||
},
|
||||
"OrbitalSimulation": {
|
||||
"SimulatedSecondsPerRealSecond": 0
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ import { ViewerNavigationController } from "./viewerNavigationController";
|
||||
import { ViewerSceneDataController } from "./viewerSceneDataController";
|
||||
import { ViewerPresentationController } from "./viewerPresentationController";
|
||||
import { createViewerControllers, wireViewerEvents } from "./viewerControllerFactory";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
import type { FactionSnapshot, ShipSnapshot } from "./contracts";
|
||||
import type {
|
||||
BubbleVisual,
|
||||
@@ -66,6 +67,7 @@ import type {
|
||||
MoonVisual,
|
||||
NetworkStats,
|
||||
NodeVisual,
|
||||
OrbitLineVisual,
|
||||
OrbitalAnchor,
|
||||
PerformanceStats,
|
||||
PlanetVisual,
|
||||
@@ -101,6 +103,7 @@ export class ViewerAppController {
|
||||
private readonly constructionSiteGroup = new THREE.Group();
|
||||
private readonly shipGroup = new THREE.Group();
|
||||
private readonly ambienceGroup = new THREE.Group();
|
||||
private readonly gamePanelEl: HTMLDivElement;
|
||||
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
|
||||
private readonly presentationEntries: PresentationEntry[] = [];
|
||||
private readonly nodeVisuals = new Map<string, NodeVisual>();
|
||||
@@ -113,15 +116,20 @@ export class ViewerAppController {
|
||||
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 orbitLines: OrbitLineVisual[] = [];
|
||||
private readonly statusEl: HTMLDivElement;
|
||||
private readonly gameSummaryEl: HTMLSpanElement;
|
||||
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 networkSectionEl: HTMLDivElement;
|
||||
private readonly networkSummaryEl: HTMLSpanElement;
|
||||
private readonly networkPanelEl: HTMLDivElement;
|
||||
private readonly performanceSectionEl: HTMLDivElement;
|
||||
private readonly performanceSummaryEl: HTMLSpanElement;
|
||||
private readonly performancePanelEl: HTMLDivElement;
|
||||
private readonly errorEl: HTMLDivElement;
|
||||
private readonly historyLayerEl: HTMLDivElement;
|
||||
@@ -179,15 +187,32 @@ export class ViewerAppController {
|
||||
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
|
||||
keyLight.position.set(1000, 1200, 800);
|
||||
this.scene.add(keyLight);
|
||||
this.scene.add(
|
||||
this.ambienceGroup,
|
||||
this.systemGroup,
|
||||
this.spatialNodeGroup,
|
||||
this.bubbleGroup,
|
||||
this.nodeGroup,
|
||||
this.stationGroup,
|
||||
this.claimGroup,
|
||||
this.constructionSiteGroup,
|
||||
this.shipGroup,
|
||||
);
|
||||
const hud = createViewerHud(document);
|
||||
this.gamePanelEl = hud.gamePanelEl;
|
||||
this.statusEl = hud.statusEl;
|
||||
this.gameSummaryEl = hud.gameSummaryEl;
|
||||
this.networkSectionEl = hud.networkSectionEl;
|
||||
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.networkSummaryEl = hud.networkSummaryEl;
|
||||
this.networkPanelEl = hud.networkPanelEl;
|
||||
this.performanceSectionEl = hud.performanceSectionEl;
|
||||
this.performanceSummaryEl = hud.performanceSummaryEl;
|
||||
this.performancePanelEl = hud.performancePanelEl;
|
||||
this.errorEl = hud.errorEl;
|
||||
this.historyLayerEl = hud.historyLayerEl;
|
||||
@@ -200,13 +225,31 @@ export class ViewerAppController {
|
||||
worldLifecycle: this.worldLifecycle,
|
||||
interactionController: this.interactionController,
|
||||
} = createViewerControllers(this));
|
||||
this.presentationController.initializeAmbience();
|
||||
|
||||
this.container.append(this.renderer.domElement, hud.root);
|
||||
this.initializePanelToggles();
|
||||
wireViewerEvents(this);
|
||||
this.onResize();
|
||||
this.updateCamera(0);
|
||||
}
|
||||
|
||||
private initializePanelToggles() {
|
||||
for (const panel of [this.gamePanelEl, this.networkSectionEl, this.performanceSectionEl]) {
|
||||
const toggle = panel.querySelector(".panel-toggle");
|
||||
if (!(toggle instanceof HTMLButtonElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", () => {
|
||||
const collapsed = panel.classList.toggle("is-collapsed");
|
||||
toggle.textContent = collapsed ? "+" : "-";
|
||||
toggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
|
||||
toggle.setAttribute("aria-label", `${collapsed ? "Expand" : "Collapse"} ${panel.dataset.panelName ?? "panel"}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.worldLifecycle.bootstrapWorld();
|
||||
this.renderer.setAnimationLoop(() => this.render());
|
||||
@@ -308,8 +351,8 @@ export class ViewerAppController {
|
||||
}
|
||||
|
||||
private registerPresentation(
|
||||
detail: THREE.Object3D,
|
||||
icon: THREE.Sprite,
|
||||
detail: SceneNode,
|
||||
icon: SceneNode,
|
||||
hideDetailInUniverse: boolean,
|
||||
hideIconInUniverse = false,
|
||||
systemId?: string,
|
||||
@@ -344,7 +387,7 @@ export class ViewerAppController {
|
||||
});
|
||||
};
|
||||
|
||||
private setShellReticleOpacity(sprite: THREE.Sprite, opacity: number) {
|
||||
private setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
||||
setShellReticleOpacity(sprite, opacity);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export type {
|
||||
WorldDelta,
|
||||
SimulationEventRecord,
|
||||
ObserverScope,
|
||||
OrbitalSimulationSnapshot,
|
||||
} from "./contractsWorld";
|
||||
export type {
|
||||
SystemSnapshot,
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ResourceNodeSnapshot {
|
||||
id: string;
|
||||
systemId: string;
|
||||
localPosition: Vector3Dto;
|
||||
anchorNodeId?: string | null;
|
||||
sourceKind: string;
|
||||
oreRemaining: number;
|
||||
maxOre: number;
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
|
||||
|
||||
export interface StationActionProgressSnapshot {
|
||||
lane: string;
|
||||
label: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface StationSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -11,8 +17,13 @@ export interface StationSnapshot {
|
||||
anchorNodeId?: string | null;
|
||||
color: string;
|
||||
dockedShips: number;
|
||||
dockedShipIds: string[];
|
||||
dockingPads: number;
|
||||
fuelStored: number;
|
||||
fuelCapacity: number;
|
||||
energyStored: number;
|
||||
energyCapacity: number;
|
||||
currentProcesses: StationActionProgressSnapshot[];
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
commanderId?: string | null;
|
||||
|
||||
@@ -19,17 +19,24 @@ export interface ShipSnapshot {
|
||||
commanderId?: string | null;
|
||||
policySetId?: string | null;
|
||||
cargoCapacity: number;
|
||||
cargoItemId?: string | null;
|
||||
workerPopulation: number;
|
||||
energyStored: number;
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
health: number;
|
||||
history: string[];
|
||||
currentAction?: ShipActionProgressSnapshot | null;
|
||||
spatialState: ShipSpatialStateSnapshot;
|
||||
}
|
||||
|
||||
export interface ShipDelta extends ShipSnapshot {}
|
||||
|
||||
export interface ShipActionProgressSnapshot {
|
||||
label: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export interface ShipSpatialStateSnapshot {
|
||||
spaceLayer: string;
|
||||
currentSystemId: string;
|
||||
|
||||
@@ -33,6 +33,8 @@ export interface WorldSnapshot {
|
||||
seed: number;
|
||||
sequence: number;
|
||||
tickIntervalMs: number;
|
||||
orbitalTimeSeconds: number;
|
||||
orbitalSimulation: OrbitalSimulationSnapshot;
|
||||
generatedAtUtc: string;
|
||||
systems: SystemSnapshot[];
|
||||
spatialNodes: SpatialNodeSnapshot[];
|
||||
@@ -50,6 +52,8 @@ export interface WorldSnapshot {
|
||||
export interface WorldDelta {
|
||||
sequence: number;
|
||||
tickIntervalMs: number;
|
||||
orbitalTimeSeconds: number;
|
||||
orbitalSimulation: OrbitalSimulationSnapshot;
|
||||
generatedAtUtc: string;
|
||||
requiresSnapshotRefresh: boolean;
|
||||
events: SimulationEventRecord[];
|
||||
@@ -83,3 +87,7 @@ export interface ObserverScope {
|
||||
systemId?: string | null;
|
||||
bubbleId?: string | null;
|
||||
}
|
||||
|
||||
export interface OrbitalSimulationSnapshot {
|
||||
simulatedSecondsPerRealSecond: number;
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ canvas {
|
||||
|
||||
.topbar {
|
||||
border-radius: 22px;
|
||||
padding: 18px 20px;
|
||||
padding: 14px 16px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -124,8 +124,48 @@ canvas {
|
||||
.topbar h2 {
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.64rem;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.panel-heading-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-summary {
|
||||
display: none;
|
||||
color: var(--muted);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.panel-toggle {
|
||||
border: 1px solid rgba(127, 214, 255, 0.2);
|
||||
background: rgba(127, 214, 255, 0.08);
|
||||
color: var(--text);
|
||||
border-radius: 999px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.panel-toggle:hover {
|
||||
background: rgba(127, 214, 255, 0.16);
|
||||
}
|
||||
|
||||
.topbar-body {
|
||||
@@ -139,7 +179,7 @@ canvas {
|
||||
|
||||
.info-panel {
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
padding: 16px;
|
||||
color: var(--text);
|
||||
pointer-events: auto;
|
||||
overflow: auto;
|
||||
@@ -147,7 +187,7 @@ canvas {
|
||||
|
||||
.network-panel {
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
padding: 14px 16px;
|
||||
color: var(--text);
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -155,7 +195,7 @@ canvas {
|
||||
.performance-panel {
|
||||
width: min(360px, calc(100vw - 40px));
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
padding: 14px 16px;
|
||||
color: var(--text);
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -172,7 +212,8 @@ canvas {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
font-size: 0.64rem;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -186,6 +227,20 @@ canvas {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.collapsible-panel.is-collapsed .topbar-body,
|
||||
.collapsible-panel.is-collapsed .network-body,
|
||||
.collapsible-panel.is-collapsed .performance-body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapsible-panel.is-collapsed .panel-summary {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.collapsible-panel.is-collapsed {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin-top: 12px;
|
||||
font-size: 1.05rem;
|
||||
@@ -208,6 +263,40 @@ canvas {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.detail-progress,
|
||||
.ship-action-progress {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.detail-progress-label,
|
||||
.ship-action-progress-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.detail-progress-track,
|
||||
.ship-action-progress-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
background: rgba(127, 214, 255, 0.12);
|
||||
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||
}
|
||||
|
||||
.detail-progress-fill,
|
||||
.ship-action-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, rgba(127, 214, 255, 0.72), rgba(255, 191, 105, 0.9));
|
||||
}
|
||||
|
||||
.history {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.78rem;
|
||||
@@ -329,7 +418,7 @@ canvas {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
width: 50vw;
|
||||
min-height: 128px;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
@@ -412,12 +501,16 @@ canvas {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.ship-card-header + p {
|
||||
.ship-card-header+p {
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ship-action-progress {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.ship-card-ai {
|
||||
margin-top: 2px;
|
||||
padding-top: 6px;
|
||||
@@ -495,7 +588,7 @@ canvas {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100vw;
|
||||
width: 50vw;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ClaimVisual,
|
||||
ConstructionSiteVisual,
|
||||
NodeVisual,
|
||||
PlanetVisual,
|
||||
Selectable,
|
||||
ShipVisual,
|
||||
SpatialNodeVisual,
|
||||
@@ -19,7 +20,7 @@ interface ResolveSelectionPositionParams {
|
||||
selection: Selectable;
|
||||
worldTimeSyncMs: number;
|
||||
nodeVisuals: Map<string, NodeVisual>;
|
||||
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
|
||||
planetVisuals: PlanetVisual[];
|
||||
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
|
||||
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
|
||||
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
|
||||
@@ -49,7 +50,7 @@ interface SeedSystemFocusParams {
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
worldTimeSyncMs: number;
|
||||
nodeVisuals: Map<string, NodeVisual>;
|
||||
planetVisuals: { systemId: string; planet: { label: string }; mesh: THREE.Mesh }[];
|
||||
planetVisuals: PlanetVisual[];
|
||||
computeNodeLocalPosition: (visual: NodeVisual, timeSeconds: number) => THREE.Vector3;
|
||||
resolveBubblePosition: (bubbleId: string) => THREE.Vector3 | undefined;
|
||||
resolvePointPosition: (systemId: string, nodeId?: string | null) => THREE.Vector3;
|
||||
@@ -217,9 +218,7 @@ export function resolveSelectionPosition(params: ResolveSelectionPositionParams)
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const visual = planetVisuals.find((candidate) =>
|
||||
candidate.systemId === selection.systemId && candidate.planet === planet);
|
||||
return visual?.mesh.position.clone() ?? computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
|
||||
return computePlanetLocalPosition(planet, currentWorldTimeSeconds(world, worldTimeSyncMs));
|
||||
}
|
||||
|
||||
const system = world.systems.get(selection.id);
|
||||
@@ -240,8 +239,14 @@ export function focusOnSelection(params: FocusOnSelectionParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selection.kind === "system") {
|
||||
galaxyFocus.copy(nextFocus);
|
||||
systemFocusLocal.set(0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectionSystemId = resolveSelectableSystemId(world, selection);
|
||||
if (selectionSystemId && selection.kind !== "system" && world) {
|
||||
if (selectionSystemId && world) {
|
||||
const system = world.systems.get(selectionSystemId);
|
||||
if (system) {
|
||||
galaxyFocus.copy(toThreeVector(system.galaxyPosition));
|
||||
@@ -282,6 +287,11 @@ export function seedSystemFocusLocal(params: SeedSystemFocusParams) {
|
||||
|
||||
const selected = selectedItems[0];
|
||||
if (selected && resolveSelectableSystemId(world, selected) === systemId) {
|
||||
if (selected.kind === "system") {
|
||||
systemFocusLocal.set(0, 0, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedPosition = resolveSelectionPosition({
|
||||
world,
|
||||
selection: selected,
|
||||
|
||||
@@ -9,7 +9,8 @@ import { ViewerHistoryWindowController } from "./viewerHistoryWindowController";
|
||||
export function createViewerControllers(host: any) {
|
||||
const sceneDataController = new ViewerSceneDataController({
|
||||
documentRef: document,
|
||||
getWorldGeneratedAtUtc: () => host.world?.generatedAtUtc,
|
||||
getWorldOrbitalTimeSeconds: () => host.world?.orbitalTimeSeconds,
|
||||
getOrbitalSimulationSpeed: () => host.world?.orbitalSimulation.simulatedSecondsPerRealSecond ?? 0,
|
||||
getWorldSeed: () => host.world?.seed ?? 1,
|
||||
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
|
||||
getWorldPresentationContext: () => host.createWorldPresentationContext(),
|
||||
@@ -77,6 +78,9 @@ export function createViewerControllers(host: any) {
|
||||
scene: host.scene,
|
||||
camera: host.camera,
|
||||
ambienceGroup: host.ambienceGroup,
|
||||
gameSummaryEl: host.gameSummaryEl,
|
||||
networkSummaryEl: host.networkSummaryEl,
|
||||
performanceSummaryEl: host.performanceSummaryEl,
|
||||
statusEl: host.statusEl,
|
||||
networkPanelEl: host.networkPanelEl,
|
||||
performancePanelEl: host.performancePanelEl,
|
||||
@@ -90,6 +94,7 @@ export function createViewerControllers(host: any) {
|
||||
getCameraMode: () => host.cameraMode,
|
||||
getCameraTargetShipId: () => host.cameraTargetShipId,
|
||||
getZoomLevel: () => host.zoomLevel,
|
||||
getSelectedItems: () => host.selectedItems,
|
||||
getWorldTimeSyncMs: () => host.worldTimeSyncMs,
|
||||
getCurrentDistance: () => host.currentDistance,
|
||||
systemFocusLocal: host.systemFocusLocal,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as THREE from "three";
|
||||
import { MAX_CAMERA_DISTANCE, MIN_CAMERA_DISTANCE, ZOOM_DISTANCE } from "./viewerConstants";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type {
|
||||
CameraMode,
|
||||
Selectable,
|
||||
@@ -166,14 +167,15 @@ export function updateFollowCamera(params: {
|
||||
|
||||
export function updateSystemDetailVisibility(systemVisuals: Map<string, SystemVisual>, activeSystemId?: string) {
|
||||
for (const [systemId, visual] of systemVisuals.entries()) {
|
||||
visual.detailGroup.visible = systemId === activeSystemId;
|
||||
visual.detailGroup.setVisible(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 setShellReticleOpacity(sprite: SystemVisual["shellReticle"], opacity: number) {
|
||||
sprite.setVisible(opacity > 0.02);
|
||||
const material = (rawObject(sprite) as THREE.Sprite).material;
|
||||
material.opacity = opacity;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
export function zoomFromWheel(desiredDistance: number, deltaY: number) {
|
||||
@@ -203,8 +205,17 @@ export function applyKeyboardControl(params: {
|
||||
desiredDistance = ZOOM_DISTANCE.system;
|
||||
} else if (key === "3") {
|
||||
desiredDistance = ZOOM_DISTANCE.universe;
|
||||
} else if (key === "=") {
|
||||
desiredDistance = desiredDistance <= ZOOM_DISTANCE.system
|
||||
? ZOOM_DISTANCE.local
|
||||
: ZOOM_DISTANCE.system;
|
||||
} else if (key === "-") {
|
||||
desiredDistance = desiredDistance >= ZOOM_DISTANCE.system
|
||||
? ZOOM_DISTANCE.universe
|
||||
: ZOOM_DISTANCE.system;
|
||||
} else if (key === "/") {
|
||||
desiredDistance = ZOOM_DISTANCE.system;
|
||||
}
|
||||
|
||||
return { cameraMode, desiredDistance };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,22 +1,38 @@
|
||||
import { inventoryAmount } from "./viewerMath";
|
||||
import type { CameraMode, Selectable, WorldState } from "./viewerTypes";
|
||||
import { describeShipCurrentAction, describeShipLocation, describeShipState } from "./viewerSelection";
|
||||
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes";
|
||||
|
||||
export function renderFactionStrip(
|
||||
world: WorldState | undefined,
|
||||
selectedItems: Selectable[],
|
||||
cameraMode: CameraMode,
|
||||
cameraTargetShipId?: string,
|
||||
zoomLevel?: ZoomLevel,
|
||||
activeSystemId?: string,
|
||||
) {
|
||||
if (!world) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const ships = [...world.ships.values()]
|
||||
.filter((ship) => {
|
||||
if (zoomLevel === "universe" || !activeSystemId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ship.systemId === activeSystemId;
|
||||
})
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
|
||||
return ships
|
||||
.map((ship) => {
|
||||
const fuel = inventoryAmount(ship.inventory, "gas");
|
||||
const fuel = inventoryAmount(ship.inventory, "fuel");
|
||||
const cargo = ship.cargoItemId
|
||||
? inventoryAmount(ship.inventory, ship.cargoItemId)
|
||||
: 0;
|
||||
const shipLocation = describeShipLocation(world, ship);
|
||||
const shipState = describeShipState(world, ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
const isSelected = selectedItems.length === 1
|
||||
&& selectedItems[0].kind === "ship"
|
||||
&& selectedItems[0].id === ship.id;
|
||||
@@ -37,9 +53,20 @@ export function renderFactionStrip(
|
||||
>🕔</button>
|
||||
</div>
|
||||
</div>
|
||||
<p>${ship.systemId}</p>
|
||||
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
|
||||
<p>State ${ship.state}</p>
|
||||
<p>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
|
||||
<p>Fuel ${fuel.toFixed(1)} · Cap ${ship.energyStored.toFixed(1)}${ship.cargoCapacity > 0 ? ` · Cargo ${cargo.toFixed(0)}` : ""}</p>
|
||||
<p>State ${shipState}</p>
|
||||
${shipAction ? `
|
||||
<div class="ship-action-progress">
|
||||
<div class="ship-action-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="ship-action-progress-track">
|
||||
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div class="ship-card-ai">
|
||||
<p>Order ${ship.orderKind ?? "none"}</p>
|
||||
<p>Behavior ${ship.defaultBehaviorKind}</p>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
export interface ViewerHudElements {
|
||||
root: HTMLDivElement;
|
||||
gamePanelEl: HTMLDivElement;
|
||||
statusEl: HTMLDivElement;
|
||||
gameSummaryEl: HTMLSpanElement;
|
||||
networkSectionEl: HTMLDivElement;
|
||||
systemPanelEl: HTMLDivElement;
|
||||
systemTitleEl: HTMLHeadingElement;
|
||||
systemBodyEl: HTMLDivElement;
|
||||
detailTitleEl: HTMLHeadingElement;
|
||||
detailBodyEl: HTMLDivElement;
|
||||
factionStripEl: HTMLDivElement;
|
||||
networkSummaryEl: HTMLSpanElement;
|
||||
networkPanelEl: HTMLDivElement;
|
||||
performanceSectionEl: HTMLDivElement;
|
||||
performanceSummaryEl: HTMLSpanElement;
|
||||
performancePanelEl: HTMLDivElement;
|
||||
errorEl: HTMLDivElement;
|
||||
historyLayerEl: HTMLDivElement;
|
||||
@@ -20,16 +26,34 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
|
||||
root.className = "viewer-shell";
|
||||
root.innerHTML = `
|
||||
<div class="left-panel-stack">
|
||||
<header class="topbar">
|
||||
<h2>Game</h2>
|
||||
<header class="topbar collapsible-panel is-collapsed" data-panel-name="game">
|
||||
<div class="panel-heading">
|
||||
<h2>Game</h2>
|
||||
<div class="panel-heading-meta">
|
||||
<span class="panel-summary game-summary">Bootstrapping</span>
|
||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Game panel">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-body">Bootstrapping</div>
|
||||
</header>
|
||||
<aside class="network-panel">
|
||||
<h2>Network</h2>
|
||||
<aside class="network-panel collapsible-panel is-collapsed" data-panel-name="network">
|
||||
<div class="panel-heading">
|
||||
<h2>Network</h2>
|
||||
<div class="panel-heading-meta">
|
||||
<span class="panel-summary network-summary">Waiting</span>
|
||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Network panel">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-body">Waiting for snapshot.</div>
|
||||
</aside>
|
||||
<aside class="performance-panel">
|
||||
<h2>Performance</h2>
|
||||
<aside class="performance-panel collapsible-panel is-collapsed" data-panel-name="performance">
|
||||
<div class="panel-heading">
|
||||
<h2>Performance</h2>
|
||||
<div class="panel-heading-meta">
|
||||
<span class="panel-summary performance-summary">Waiting</span>
|
||||
<button type="button" class="panel-toggle" aria-expanded="false" aria-label="Expand Performance panel">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="performance-body">Waiting for frame samples.</div>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -54,14 +78,20 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
|
||||
|
||||
return {
|
||||
root,
|
||||
gamePanelEl: root.querySelector(".topbar") as HTMLDivElement,
|
||||
statusEl: root.querySelector(".topbar-body") as HTMLDivElement,
|
||||
gameSummaryEl: root.querySelector(".game-summary") as HTMLSpanElement,
|
||||
networkSectionEl: root.querySelector(".network-panel") 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,
|
||||
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
|
||||
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
|
||||
performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement,
|
||||
performanceSummaryEl: root.querySelector(".performance-summary") as HTMLSpanElement,
|
||||
performancePanelEl: root.querySelector(".performance-body") as HTMLDivElement,
|
||||
errorEl: root.querySelector(".error-strip") as HTMLDivElement,
|
||||
historyLayerEl: root.querySelector(".history-layer") as HTMLDivElement,
|
||||
|
||||
@@ -140,6 +140,9 @@ export class ViewerInteractionController {
|
||||
|
||||
const picked = this.pickSelectableAtClientPosition(event.clientX, event.clientY);
|
||||
this.context.setSelectedItems(picked ? [picked] : []);
|
||||
if (picked && this.shouldFocusSelectionOnClick(picked)) {
|
||||
this.context.focusOnSelection(picked);
|
||||
}
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
};
|
||||
@@ -294,4 +297,12 @@ export class ViewerInteractionController {
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
}
|
||||
|
||||
private shouldFocusSelectionOnClick(selection: Selectable) {
|
||||
if (selection.kind === "planet") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return selection.kind === "system" && selection.id !== this.context.getActiveSystemId();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +76,8 @@ export function currentWorldTimeSeconds(world: WorldState | undefined, worldTime
|
||||
return 0;
|
||||
}
|
||||
|
||||
const baseUtcMs = Date.parse(world.generatedAtUtc);
|
||||
const elapsedMs = performance.now() - worldTimeSyncMs;
|
||||
return ((baseUtcMs + elapsedMs) / 1000) + (world.seed * 97);
|
||||
return world.orbitalTimeSeconds + ((elapsedMs / 1000) * world.orbitalSimulation.simulatedSecondsPerRealSecond);
|
||||
}
|
||||
|
||||
export function hashUnit(seed: number, value: string): number {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatInventory, formatVector } from "./viewerMath";
|
||||
import { describeOrbitalParent, describeSelectable, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||
import { formatInventory, formatVector, inventoryAmount } from "./viewerMath";
|
||||
import { describeOrbitalParent, describeSelectable, describeShipCurrentAction, describeShipState, describeSpatialNodePathWithinSystem, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||
import type {
|
||||
CameraMode,
|
||||
HistoryWindowState,
|
||||
@@ -31,6 +31,29 @@ interface SystemPanelParams {
|
||||
cameraTargetShipId?: string;
|
||||
}
|
||||
|
||||
function renderSystemOwnership(world: WorldState, systemId: string): string {
|
||||
const claims = [...world.claims.values()].filter((claim) =>
|
||||
claim.systemId === systemId && claim.state !== "destroyed");
|
||||
if (claims.length === 0) {
|
||||
return "Ownership none";
|
||||
}
|
||||
|
||||
const ownershipByFaction = new Map<string, number>();
|
||||
for (const claim of claims) {
|
||||
ownershipByFaction.set(claim.factionId, (ownershipByFaction.get(claim.factionId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [...ownershipByFaction.entries()]
|
||||
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
||||
.map(([factionId, count]) => {
|
||||
const faction = world.factions.get(factionId);
|
||||
const label = faction?.label ?? factionId;
|
||||
const share = Math.round((count / claims.length) * 100);
|
||||
return `${label} ${count}/${claims.length} (${share}%)`;
|
||||
})
|
||||
.join("<br>");
|
||||
}
|
||||
|
||||
export function updateDetailPanel(
|
||||
detailTitleEl: HTMLHeadingElement,
|
||||
detailBodyEl: HTMLDivElement,
|
||||
@@ -79,12 +102,30 @@ export function updateDetailPanel(
|
||||
return;
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
|
||||
const fuelStored = inventoryAmount(ship.inventory, "fuel");
|
||||
const cargoUsed = ship.cargoItemId
|
||||
? inventoryAmount(ship.inventory, ship.cargoItemId)
|
||||
: 0;
|
||||
const cargoLabel = ship.cargoItemId ?? "none";
|
||||
const shipState = describeShipState(world, ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
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>State ${shipState}</p>
|
||||
${shipAction ? `
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<p>Fuel ${fuelStored.toFixed(1)}<br>Capacitor ${ship.energyStored.toFixed(1)}</p>
|
||||
<p>Cargo ${cargoLabel} ${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>
|
||||
@@ -98,12 +139,43 @@ export function updateDetailPanel(
|
||||
return;
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const installedModules = station.installedModules.length > 0
|
||||
? station.installedModules.join("<br>")
|
||||
: "none";
|
||||
const activeConstruction = [...world.constructionSites.values()]
|
||||
.filter((site) => site.stationId === station.id && site.state !== "completed")
|
||||
.map((site) => `${site.blueprintId ?? site.targetDefinitionId} (${site.state})`)
|
||||
.join("<br>") || "none";
|
||||
const dockedShipLabels = station.dockedShipIds.length > 0
|
||||
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
|
||||
: "none";
|
||||
const stationInventory = station.inventory.filter((entry) => entry.itemId !== "fuel");
|
||||
const stationProcesses = station.currentProcesses;
|
||||
const stationProcessingHtml = stationProcesses.length > 0
|
||||
? stationProcesses.map((process) => `
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>${process.label}</span>
|
||||
<span>${Math.round(process.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(process.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")
|
||||
: "";
|
||||
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>
|
||||
${stationProcessingHtml}
|
||||
<p>Fuel ${station.fuelStored.toFixed(1)} / ${station.fuelCapacity.toFixed(1)}<br>Capacitor ${station.energyStored.toFixed(1)} / ${station.energyCapacity.toFixed(1)}</p>
|
||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
||||
<br>
|
||||
${dockedShipLabels}</p>
|
||||
<p>Modules ${installedModules}</p>
|
||||
<p>Constructing ${activeConstruction}</p>
|
||||
<p>Inventory ${formatInventory(stationInventory)}</p>
|
||||
<p>History available in the separate history window.</p>
|
||||
`;
|
||||
return;
|
||||
@@ -115,11 +187,23 @@ export function updateDetailPanel(
|
||||
return;
|
||||
}
|
||||
const parent = describeSelectionParent(selected);
|
||||
const nodeLevel = node.maxOre > 0
|
||||
? Math.max(0, Math.min(node.oreRemaining / node.maxOre, 1))
|
||||
: 0;
|
||||
detailTitleEl.textContent = `Node ${node.id}`;
|
||||
detailBodyEl.innerHTML = `
|
||||
<p>${node.systemId}</p>
|
||||
<p>Parent ${parent}</p>
|
||||
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
|
||||
<div class="detail-progress">
|
||||
<div class="detail-progress-label">
|
||||
<span>Level</span>
|
||||
<span>${Math.round(nodeLevel * 100)}%</span>
|
||||
</div>
|
||||
<div class="detail-progress-track">
|
||||
<div class="detail-progress-fill" style="width: ${(nodeLevel * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||
`;
|
||||
return;
|
||||
@@ -240,7 +324,9 @@ export function updateSystemPanel(params: SystemPanelParams) {
|
||||
}
|
||||
|
||||
systemTitleEl.textContent = activeSystem.label;
|
||||
systemBodyEl.innerHTML = renderSystemDetails(world, activeSystem, true, cameraMode, cameraTargetShipId);
|
||||
systemBodyEl.innerHTML = `
|
||||
<p>${renderSystemOwnership(world, activeSystem.id)}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
export function describeSelectionParent(
|
||||
@@ -270,8 +356,13 @@ export function describeSelectionParent(
|
||||
}
|
||||
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 (!station) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
return station.anchorNodeId
|
||||
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId) ?? `${station.systemId} network`
|
||||
: "unknown";
|
||||
}
|
||||
if (selection.kind === "node") {
|
||||
const node = world.nodes.get(selection.id);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as THREE from "three";
|
||||
import { ACTIVE_SYSTEM_DETAIL_SCALE, PROJECTED_GALAXY_RADIUS } from "./viewerConstants";
|
||||
import { computeMoonLocalPosition, computePlanetLocalPosition, currentWorldTimeSeconds } from "./viewerMath";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type { PlanetVisual, ShipVisual, SystemSummaryVisual, SystemVisual, WorldState } from "./viewerTypes";
|
||||
|
||||
export function getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
|
||||
@@ -40,19 +41,21 @@ export function updatePlanetPresentation(
|
||||
? 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);
|
||||
visual.orbit.setScaleScalar(scale);
|
||||
visual.orbit.setPosition(orbitOffset);
|
||||
visual.mesh.setPosition(position);
|
||||
visual.icon.setPosition(position);
|
||||
if (visual.ring) {
|
||||
visual.ring.position.copy(position);
|
||||
visual.ring.setPosition(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),
|
||||
moon.orbit.setPosition(position);
|
||||
moon.orbit.setScaleScalar(scale);
|
||||
moon.mesh.setPosition(
|
||||
position.clone().add(
|
||||
computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds, world?.seed ?? 1).multiplyScalar(scale),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +72,7 @@ export function updateSystemSummaryPresentation(
|
||||
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);
|
||||
rawObject(visual.sprite).scale.set(scale, scale * 0.3125, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,49 +81,49 @@ export function updateSystemStarPresentation(
|
||||
activeSystemId: string | undefined,
|
||||
systemFocusLocal: THREE.Vector3,
|
||||
camera: THREE.PerspectiveCamera,
|
||||
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void,
|
||||
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], 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);
|
||||
visual.root.setPosition(visual.galaxyPosition);
|
||||
visual.shellReticle.setScaleScalar(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;
|
||||
visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setVisible(true);
|
||||
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.shellReticle.setVisible(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;
|
||||
visual.starCluster.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.icon.setVisible(false);
|
||||
visual.shellReticle.setPosition(new THREE.Vector3(0, 0, 0));
|
||||
visual.shellReticle.setVisible(true);
|
||||
setShellReticleOpacity(visual.shellReticle, 1);
|
||||
const direction = visual.galaxyPosition.clone().sub(activeSystem.galaxyPosition);
|
||||
if (direction.lengthSq() > 0.0001) {
|
||||
visual.root.position.copy(
|
||||
visual.root.setPosition(
|
||||
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);
|
||||
visual.shellReticle.setScaleScalar(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;
|
||||
visual.starCluster.setPosition(offset);
|
||||
visual.icon.setPosition(offset);
|
||||
visual.icon.setVisible(true);
|
||||
visual.shellReticle.setVisible(false);
|
||||
setShellReticleOpacity(visual.shellReticle, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,18 +3,24 @@ import { computeZoomBlend } from "./viewerMath";
|
||||
import {
|
||||
updateNetworkPanel as renderNetworkPanel,
|
||||
recordPerformanceStats,
|
||||
summarizeNetworkStats,
|
||||
summarizePerformanceStats,
|
||||
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";
|
||||
import type { OrbitLineVisual, Selectable } from "./viewerTypes";
|
||||
|
||||
export interface ViewerPresentationContext {
|
||||
renderer: THREE.WebGLRenderer;
|
||||
scene: THREE.Scene;
|
||||
camera: THREE.PerspectiveCamera;
|
||||
ambienceGroup: THREE.Group;
|
||||
gameSummaryEl: HTMLSpanElement;
|
||||
networkSummaryEl: HTMLSpanElement;
|
||||
performanceSummaryEl: HTMLSpanElement;
|
||||
statusEl: HTMLDivElement;
|
||||
networkPanelEl: HTMLDivElement;
|
||||
performancePanelEl: HTMLDivElement;
|
||||
@@ -28,13 +34,14 @@ export interface ViewerPresentationContext {
|
||||
getCameraMode: () => any;
|
||||
getCameraTargetShipId: () => string | undefined;
|
||||
getZoomLevel: () => any;
|
||||
getSelectedItems: () => Selectable[];
|
||||
getWorldTimeSyncMs: () => number;
|
||||
getCurrentDistance: () => number;
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
planetVisuals: any[];
|
||||
systemSummaryVisuals: Map<any, any>;
|
||||
presentationEntries: any[];
|
||||
orbitLines: THREE.Object3D[];
|
||||
orbitLines: OrbitLineVisual[];
|
||||
systemVisuals: Map<any, any>;
|
||||
createWorldPresentationContext: () => any;
|
||||
}
|
||||
@@ -74,20 +81,20 @@ export class ViewerPresentationController {
|
||||
? blend.systemWeight * (isActiveDetail ? 1 : 0)
|
||||
: Math.max(blend.systemWeight, blend.universeWeight);
|
||||
|
||||
this.setObjectOpacity(entry.detail, detailAlpha);
|
||||
this.setObjectOpacity(entry.icon, iconAlpha);
|
||||
entry.detail.setOpacity(detailAlpha);
|
||||
entry.icon.setOpacity(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);
|
||||
const alpha = this.resolveOrbitLineOpacity(orbitLine, blend, activeSystemId);
|
||||
orbitLine.line.setOpacity(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);
|
||||
summaryVisual.sprite.setOpacity(summaryOpacity);
|
||||
}
|
||||
|
||||
this.context.scene.fog = new THREE.FogExp2(0x040912, 0.000035);
|
||||
@@ -95,6 +102,7 @@ export class ViewerPresentationController {
|
||||
|
||||
updateNetworkPanel() {
|
||||
renderNetworkPanel(this.context.networkPanelEl, this.context.networkStats);
|
||||
this.context.networkSummaryEl.textContent = summarizeNetworkStats(this.context.networkStats);
|
||||
}
|
||||
|
||||
recordPerformanceStats(frameMs: number) {
|
||||
@@ -103,6 +111,7 @@ export class ViewerPresentationController {
|
||||
|
||||
updatePerformancePanel() {
|
||||
renderPerformancePanel(this.context.performancePanelEl, this.context.performanceStats, this.context.renderer);
|
||||
this.context.performanceSummaryEl.textContent = summarizePerformanceStats(this.context.performanceStats);
|
||||
}
|
||||
|
||||
updateShipPresentation() {
|
||||
@@ -131,10 +140,12 @@ export class ViewerPresentationController {
|
||||
updateGamePanel(mode: string) {
|
||||
updateGameStatus({
|
||||
statusEl: this.context.statusEl,
|
||||
summaryEl: this.context.gameSummaryEl,
|
||||
world: this.context.getWorld(),
|
||||
activeSystemId: this.context.getActiveSystemId(),
|
||||
cameraMode: this.context.getCameraMode(),
|
||||
zoomLevel: this.context.getZoomLevel(),
|
||||
selectedItems: this.context.getSelectedItems(),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
@@ -161,22 +172,21 @@ export class ViewerPresentationController {
|
||||
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;
|
||||
}
|
||||
});
|
||||
private resolveOrbitLineOpacity(orbitLine: OrbitLineVisual, blend: ReturnType<typeof computeZoomBlend>, activeSystemId?: string) {
|
||||
if (!activeSystemId || orbitLine.systemId !== activeSystemId) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const selected = this.context.getSelectedItems();
|
||||
const selectedItem = selected.length === 1 ? selected[0] : undefined;
|
||||
const baseAlpha = Math.max(blend.localWeight * 0.55, blend.systemWeight);
|
||||
|
||||
if (selectedItem?.kind === "planet" && selectedItem.systemId === activeSystemId) {
|
||||
return orbitLine.kind === "moon" && orbitLine.planetIndex === selectedItem.planetIndex
|
||||
? baseAlpha
|
||||
: 0;
|
||||
}
|
||||
|
||||
return orbitLine.kind === "planet" ? baseAlpha : 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,13 +48,13 @@ import type {
|
||||
StationSnapshot,
|
||||
SystemSnapshot,
|
||||
} from "./contracts";
|
||||
import type {
|
||||
OrbitalAnchor,
|
||||
} from "./viewerTypes";
|
||||
import type { OrbitLineVisual, OrbitalAnchor } from "./viewerTypes";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
|
||||
export interface ViewerSceneDataContext {
|
||||
documentRef: Document;
|
||||
getWorldGeneratedAtUtc: () => string | undefined;
|
||||
getWorldOrbitalTimeSeconds: () => number | undefined;
|
||||
getOrbitalSimulationSpeed: () => number;
|
||||
getWorldSeed: () => number;
|
||||
getWorldTimeSyncMs: () => number;
|
||||
getWorldPresentationContext: () => any;
|
||||
@@ -71,7 +71,7 @@ export interface ViewerSceneDataContext {
|
||||
systemVisuals: Map<any, any>;
|
||||
systemSummaryVisuals: Map<any, any>;
|
||||
planetVisuals: any[];
|
||||
orbitLines: THREE.Object3D[];
|
||||
orbitLines: OrbitLineVisual[];
|
||||
spatialNodeVisuals: Map<any, any>;
|
||||
bubbleVisuals: Map<any, any>;
|
||||
nodeVisuals: Map<any, any>;
|
||||
@@ -79,7 +79,7 @@ export interface ViewerSceneDataContext {
|
||||
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;
|
||||
registerPresentation: (detail: SceneNode, icon: SceneNode, hideDetailInUniverse: boolean, hideIconInUniverse?: boolean, systemId?: string) => void;
|
||||
}
|
||||
|
||||
export class ViewerSceneDataController {
|
||||
@@ -153,7 +153,7 @@ export class ViewerSceneDataController {
|
||||
systemFocusLocal: THREE.Vector3;
|
||||
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
|
||||
updateSystemDetailVisibility: () => void;
|
||||
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
|
||||
setShellReticleOpacity: (sprite: any, opacity: number) => void;
|
||||
}) {
|
||||
return {
|
||||
world: overrides.world,
|
||||
@@ -181,7 +181,8 @@ export class ViewerSceneDataController {
|
||||
private createSceneSyncContext() {
|
||||
return {
|
||||
documentRef: this.context.documentRef,
|
||||
worldGeneratedAtUtc: this.context.getWorldGeneratedAtUtc(),
|
||||
worldOrbitalTimeSeconds: this.context.getWorldOrbitalTimeSeconds(),
|
||||
orbitalSimulationSpeed: this.context.getOrbitalSimulationSpeed(),
|
||||
worldSeed: this.context.getWorldSeed(),
|
||||
worldTimeSyncMs: this.context.getWorldTimeSyncMs(),
|
||||
systemGroup: this.context.systemGroup,
|
||||
|
||||
@@ -24,8 +24,10 @@ import {
|
||||
starHaloOpacity,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { createSceneNode } from "./viewerScenePrimitives";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
|
||||
export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
|
||||
export function createNodeMesh(node: ResourceNodeSnapshot): SceneNode {
|
||||
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),
|
||||
@@ -39,12 +41,12 @@ export function createNodeMesh(node: ResourceNodeSnapshot): THREE.Mesh {
|
||||
);
|
||||
mesh.position.copy(toThreeVector(node.localPosition));
|
||||
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
return mesh;
|
||||
return createSceneNode(mesh);
|
||||
}
|
||||
|
||||
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): THREE.Mesh {
|
||||
export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColor: (kind: string) => string): SceneNode {
|
||||
const color = spatialNodeColor(node.kind);
|
||||
return new THREE.Mesh(
|
||||
return createSceneNode(new THREE.Mesh(
|
||||
new THREE.OctahedronGeometry(10, 0),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
@@ -52,14 +54,14 @@ export function createSpatialNodeMesh(node: SpatialNodeSnapshot, spatialNodeColo
|
||||
roughness: 0.35,
|
||||
metalness: 0.45,
|
||||
}),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
export function createBubbleRing(
|
||||
bubble: LocalBubbleSnapshot,
|
||||
localPosition: THREE.Vector3,
|
||||
createCirclePoints: (radius: number, segments: number) => THREE.Vector3[],
|
||||
): THREE.LineLoop {
|
||||
): SceneNode {
|
||||
const ring = new THREE.LineLoop(
|
||||
new THREE.BufferGeometry().setFromPoints(createCirclePoints(Math.max(bubble.radius, 60), 64)),
|
||||
new THREE.LineBasicMaterial({
|
||||
@@ -69,11 +71,11 @@ export function createBubbleRing(
|
||||
}),
|
||||
);
|
||||
ring.position.copy(localPosition);
|
||||
return ring;
|
||||
return createSceneNode(ring);
|
||||
}
|
||||
|
||||
export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
|
||||
return new THREE.Mesh(
|
||||
export function createClaimMesh(claim: ClaimSnapshot): SceneNode {
|
||||
return createSceneNode(new THREE.Mesh(
|
||||
new THREE.ConeGeometry(9, 20, 4),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: claim.state === "active" ? 0xff7f50 : 0xff5b5b,
|
||||
@@ -81,11 +83,11 @@ export function createClaimMesh(claim: ClaimSnapshot): THREE.Mesh {
|
||||
roughness: 0.4,
|
||||
metalness: 0.28,
|
||||
}),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THREE.Mesh {
|
||||
return new THREE.Mesh(
|
||||
export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): SceneNode {
|
||||
return createSceneNode(new THREE.Mesh(
|
||||
new THREE.TorusKnotGeometry(7, 2.2, 54, 8),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: site.state === "completed" ? 0x46d37f : 0x9df29c,
|
||||
@@ -93,10 +95,10 @@ export function createConstructionSiteMesh(site: ConstructionSiteSnapshot): THRE
|
||||
roughness: 0.34,
|
||||
metalness: 0.48,
|
||||
}),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
export function createStarCluster(system: SystemSnapshot): THREE.Group {
|
||||
export function createStarCluster(system: SystemSnapshot): SceneNode {
|
||||
const root = new THREE.Group();
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
|
||||
const offsets = system.starCount > 1
|
||||
@@ -123,22 +125,22 @@ export function createStarCluster(system: SystemSnapshot): THREE.Group {
|
||||
root.add(star, halo);
|
||||
}
|
||||
|
||||
return root;
|
||||
return createSceneNode(root);
|
||||
}
|
||||
|
||||
export function createPlanetOrbit(planet: PlanetSnapshot): THREE.LineLoop {
|
||||
export function createPlanetOrbit(planet: PlanetSnapshot): SceneNode {
|
||||
const points = Array.from({ length: 120 }, (_, index) => {
|
||||
const phaseDegrees = (index / 120) * 360;
|
||||
return computePlanetLocalPosition(planet, 0, phaseDegrees);
|
||||
});
|
||||
|
||||
return new THREE.LineLoop(
|
||||
return createSceneNode(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 {
|
||||
export function createPlanetRing(planet: PlanetSnapshot): SceneNode {
|
||||
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),
|
||||
@@ -151,7 +153,7 @@ export function createPlanetRing(planet: PlanetSnapshot): THREE.Mesh {
|
||||
);
|
||||
ring.rotation.x = Math.PI / 2;
|
||||
ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
|
||||
return ring;
|
||||
return createSceneNode(ring);
|
||||
}
|
||||
|
||||
export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVisual[] {
|
||||
@@ -185,23 +187,23 @@ export function createMoonVisuals(planet: PlanetSnapshot, seed: number): MoonVis
|
||||
}),
|
||||
);
|
||||
|
||||
moons.push({ mesh, orbit });
|
||||
moons.push({ systemId: "", planetIndex: -1, mesh: createSceneNode(mesh), orbit: createSceneNode(orbit) });
|
||||
}
|
||||
|
||||
return moons;
|
||||
}
|
||||
|
||||
export function createStationMesh(station: StationSnapshot): THREE.Mesh {
|
||||
export function createStationMesh(station: StationSnapshot): SceneNode {
|
||||
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;
|
||||
return createSceneNode(mesh);
|
||||
}
|
||||
|
||||
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): THREE.Mesh {
|
||||
export function createShipMesh(ship: ShipSnapshot, size: number, length: number, color: string): SceneNode {
|
||||
const geometry = new THREE.ConeGeometry(size, length, 7);
|
||||
geometry.rotateX(Math.PI / 2);
|
||||
const mesh = new THREE.Mesh(
|
||||
@@ -212,7 +214,7 @@ export function createShipMesh(ship: ShipSnapshot, size: number, length: number,
|
||||
}),
|
||||
);
|
||||
mesh.position.copy(toThreeVector(ship.localPosition));
|
||||
return mesh;
|
||||
return createSceneNode(mesh);
|
||||
}
|
||||
|
||||
export function createBackdropStars(): THREE.Points {
|
||||
@@ -324,7 +326,7 @@ export function createNebulaClouds(texture: THREE.Texture): THREE.Sprite[] {
|
||||
});
|
||||
}
|
||||
|
||||
export function createTacticalIcon(documentRef: Document, color: string, size: number): THREE.Sprite {
|
||||
export function createTacticalIcon(documentRef: Document, color: string, size: number): SceneNode {
|
||||
const canvas = documentRef.createElement("canvas");
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
@@ -356,7 +358,7 @@ export function createTacticalIcon(documentRef: Document, color: string, size: n
|
||||
}));
|
||||
sprite.scale.setScalar(size);
|
||||
sprite.visible = false;
|
||||
return sprite;
|
||||
return createSceneNode(sprite);
|
||||
}
|
||||
|
||||
export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.Vector3): SystemSummaryVisual {
|
||||
@@ -364,18 +366,18 @@ export function createSystemSummaryVisual(documentRef: Document, anchor: THREE.V
|
||||
canvas.width = 512;
|
||||
canvas.height = 160;
|
||||
const texture = new THREE.CanvasTexture(canvas);
|
||||
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
const sprite = createSceneNode(new THREE.Sprite(new THREE.SpriteMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
}));
|
||||
sprite.scale.set(520, 160, 1);
|
||||
sprite.visible = false;
|
||||
})));
|
||||
sprite.object.scale.set(520, 160, 1);
|
||||
sprite.setVisible(false);
|
||||
return { sprite, texture, anchor };
|
||||
}
|
||||
|
||||
export function createShellReticle(documentRef: Document, color: string, size: number): THREE.Sprite {
|
||||
export function createShellReticle(documentRef: Document, color: string, size: number): SceneNode {
|
||||
const canvas = documentRef.createElement("canvas");
|
||||
canvas.width = 128;
|
||||
canvas.height = 128;
|
||||
@@ -412,9 +414,9 @@ export function createShellReticle(documentRef: Document, color: string, size: n
|
||||
blending: THREE.AdditiveBlending,
|
||||
fog: false,
|
||||
});
|
||||
const sprite = new THREE.Sprite(material);
|
||||
sprite.scale.setScalar(size);
|
||||
sprite.visible = false;
|
||||
sprite.renderOrder = 1000;
|
||||
const sprite = createSceneNode(new THREE.Sprite(material));
|
||||
sprite.setScaleScalar(size);
|
||||
sprite.setVisible(false);
|
||||
sprite.setRenderOrder(1000);
|
||||
return sprite;
|
||||
}
|
||||
|
||||
159
apps/viewer/src/viewerScenePrimitives.ts
Normal file
159
apps/viewer/src/viewerScenePrimitives.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import * as THREE from "three";
|
||||
|
||||
export interface SceneNode {
|
||||
readonly object: THREE.Object3D;
|
||||
setPosition(position: THREE.Vector3): void;
|
||||
setVisible(visible: boolean): void;
|
||||
setScaleScalar(scale: number): void;
|
||||
setRotationX(radians: number): void;
|
||||
setRotationY(radians: number): void;
|
||||
setRotationZ(radians: number): void;
|
||||
setRenderOrder(order: number): void;
|
||||
add(...children: SceneNode[]): void;
|
||||
clear(): void;
|
||||
lookAt(target: THREE.Vector3): void;
|
||||
getWorldPosition(target?: THREE.Vector3): THREE.Vector3;
|
||||
traverse(visitor: (child: THREE.Object3D) => void): void;
|
||||
setOpacity(opacity: number): void;
|
||||
setColor(color: THREE.ColorRepresentation): void;
|
||||
setEmissive(color: THREE.ColorRepresentation, intensity?: number): void;
|
||||
}
|
||||
|
||||
class ThreeSceneNode implements SceneNode {
|
||||
constructor(public readonly object: THREE.Object3D) {}
|
||||
|
||||
setPosition(position: THREE.Vector3) {
|
||||
this.object.position.copy(position);
|
||||
}
|
||||
|
||||
setVisible(visible: boolean) {
|
||||
this.object.visible = visible;
|
||||
}
|
||||
|
||||
setScaleScalar(scale: number) {
|
||||
this.object.scale.setScalar(scale);
|
||||
}
|
||||
|
||||
setRotationX(radians: number) {
|
||||
this.object.rotation.x = radians;
|
||||
}
|
||||
|
||||
setRotationY(radians: number) {
|
||||
this.object.rotation.y = radians;
|
||||
}
|
||||
|
||||
setRotationZ(radians: number) {
|
||||
this.object.rotation.z = radians;
|
||||
}
|
||||
|
||||
setRenderOrder(order: number) {
|
||||
this.object.renderOrder = order;
|
||||
}
|
||||
|
||||
add(...children: SceneNode[]) {
|
||||
this.object.add(...children.map((child) => child.object));
|
||||
}
|
||||
|
||||
clear() {
|
||||
if ("clear" in this.object && typeof this.object.clear === "function") {
|
||||
this.object.clear();
|
||||
}
|
||||
}
|
||||
|
||||
lookAt(target: THREE.Vector3) {
|
||||
this.object.lookAt(target);
|
||||
}
|
||||
|
||||
getWorldPosition(target = new THREE.Vector3()) {
|
||||
return this.object.getWorldPosition(target);
|
||||
}
|
||||
|
||||
traverse(visitor: (child: THREE.Object3D) => void) {
|
||||
this.object.traverse(visitor);
|
||||
}
|
||||
|
||||
setOpacity(opacity: number) {
|
||||
const visible = opacity > 0.02;
|
||||
this.object.visible = visible;
|
||||
this.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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setColor(color: THREE.ColorRepresentation) {
|
||||
this.object.traverse((child) => {
|
||||
if (!("material" in child)) {
|
||||
return;
|
||||
}
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
for (const material of materials) {
|
||||
if ("color" in material) {
|
||||
material.color.set(color);
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setEmissive(color: THREE.ColorRepresentation, intensity = 1) {
|
||||
this.object.traverse((child) => {
|
||||
if (!("material" in child)) {
|
||||
return;
|
||||
}
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
for (const material of materials) {
|
||||
if ("emissive" in material) {
|
||||
material.emissive.set(color);
|
||||
if ("emissiveIntensity" in material) {
|
||||
material.emissiveIntensity = intensity;
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function createSceneNode<T extends THREE.Object3D>(object: T): SceneNode {
|
||||
return new ThreeSceneNode(object);
|
||||
}
|
||||
|
||||
export function rawObject(node: SceneNode) {
|
||||
return node.object;
|
||||
}
|
||||
|
||||
export function addToRawScene(scene: THREE.Scene, ...nodes: SceneNode[]) {
|
||||
scene.add(...nodes.map((node) => node.object));
|
||||
}
|
||||
|
||||
export function registerSelectableTarget(
|
||||
selectableTargets: Map<THREE.Object3D, unknown>,
|
||||
node: SceneNode,
|
||||
selectable: unknown,
|
||||
) {
|
||||
selectableTargets.set(node.object, selectable);
|
||||
}
|
||||
|
||||
export function registerSelectableDescendants(
|
||||
selectableTargets: Map<THREE.Object3D, unknown>,
|
||||
node: SceneNode,
|
||||
selectable: unknown,
|
||||
predicate: (child: THREE.Object3D) => boolean,
|
||||
) {
|
||||
node.traverse((child) => {
|
||||
if (predicate(child)) {
|
||||
selectableTargets.set(child, selectable);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ClaimVisual,
|
||||
ConstructionSiteVisual,
|
||||
NodeVisual,
|
||||
OrbitLineVisual,
|
||||
PlanetVisual,
|
||||
PresentationEntry,
|
||||
Selectable,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
computePlanetLocalPosition,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { getAnimatedShipLocalPosition } from "./viewerPresentation";
|
||||
import {
|
||||
createBubbleRing,
|
||||
createClaimMesh,
|
||||
@@ -55,10 +57,18 @@ import {
|
||||
createSystemSummaryVisual,
|
||||
createTacticalIcon,
|
||||
} from "./viewerSceneFactory";
|
||||
import {
|
||||
createSceneNode,
|
||||
rawObject,
|
||||
registerSelectableDescendants,
|
||||
registerSelectableTarget,
|
||||
} from "./viewerScenePrimitives";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
|
||||
interface SceneSyncContext {
|
||||
documentRef: Document;
|
||||
worldGeneratedAtUtc?: string;
|
||||
worldOrbitalTimeSeconds?: number;
|
||||
orbitalSimulationSpeed: number;
|
||||
worldSeed: number;
|
||||
worldTimeSyncMs: number;
|
||||
systemGroup: THREE.Group;
|
||||
@@ -74,7 +84,7 @@ interface SceneSyncContext {
|
||||
systemVisuals: Map<string, SystemVisual>;
|
||||
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
|
||||
planetVisuals: PlanetVisual[];
|
||||
orbitLines: THREE.Object3D[];
|
||||
orbitLines: OrbitLineVisual[];
|
||||
spatialNodeVisuals: Map<string, SpatialNodeVisual>;
|
||||
bubbleVisuals: Map<string, BubbleVisual>;
|
||||
nodeVisuals: Map<string, NodeVisual>;
|
||||
@@ -83,8 +93,8 @@ interface SceneSyncContext {
|
||||
constructionSiteVisuals: Map<string, ConstructionSiteVisual>;
|
||||
shipVisuals: Map<string, ShipVisual>;
|
||||
registerPresentation: (
|
||||
detail: THREE.Object3D,
|
||||
icon: THREE.Sprite,
|
||||
detail: SceneNode,
|
||||
icon: SceneNode,
|
||||
hideDetailInUniverse: boolean,
|
||||
hideIconInUniverse?: boolean,
|
||||
systemId?: string,
|
||||
@@ -111,8 +121,8 @@ interface SceneSyncContext {
|
||||
}
|
||||
|
||||
export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapshot[]) {
|
||||
const worldTimeSeconds = context.worldGeneratedAtUtc
|
||||
? ((Date.parse(context.worldGeneratedAtUtc) + (performance.now() - context.worldTimeSyncMs)) / 1000) + (context.worldSeed * 97)
|
||||
const worldTimeSeconds = context.worldOrbitalTimeSeconds !== undefined
|
||||
? context.worldOrbitalTimeSeconds + ((performance.now() - context.worldTimeSyncMs) / 1000 * context.orbitalSimulationSpeed)
|
||||
: 0;
|
||||
|
||||
context.systemGroup.clear();
|
||||
@@ -124,9 +134,9 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
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 root = createSceneNode(new THREE.Group());
|
||||
root.setPosition(new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y, system.galaxyPosition.z));
|
||||
const detailGroup = createSceneNode(new THREE.Group());
|
||||
const renderedStarSize = celestialRenderRadius(system.starSize, STAR_RENDER_SCALE, 8, 1.02);
|
||||
|
||||
const starCluster = createStarCluster(system);
|
||||
@@ -136,7 +146,7 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
context.documentRef,
|
||||
new THREE.Vector3(system.galaxyPosition.x, system.galaxyPosition.y + renderedStarSize + 140, system.galaxyPosition.z),
|
||||
);
|
||||
summaryVisual.sprite.position.set(0, renderedStarSize + 110, 0);
|
||||
summaryVisual.sprite.setPosition(new THREE.Vector3(0, renderedStarSize + 110, 0));
|
||||
root.add(starCluster, systemIcon, shellReticle, summaryVisual.sprite, detailGroup);
|
||||
context.registerPresentation(starCluster, systemIcon, true);
|
||||
context.systemVisuals.set(system.id, {
|
||||
@@ -150,18 +160,14 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
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 });
|
||||
registerSelectableDescendants(context.selectableTargets, starCluster, { kind: "system", id: system.id }, (child) => child instanceof THREE.Mesh);
|
||||
registerSelectableTarget(context.selectableTargets, systemIcon, { kind: "system", id: system.id });
|
||||
registerSelectableTarget(context.selectableTargets, 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(
|
||||
const planetMesh = createSceneNode(new THREE.Mesh(
|
||||
new THREE.SphereGeometry(renderedPlanetRadius, 18, 18),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: planet.color,
|
||||
@@ -169,13 +175,13 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
metalness: 0.08,
|
||||
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
|
||||
}),
|
||||
);
|
||||
planetMesh.position.copy(computePlanetLocalPosition(planet, worldTimeSeconds));
|
||||
));
|
||||
planetMesh.setPosition(computePlanetLocalPosition(planet, worldTimeSeconds));
|
||||
const planetIcon = createTacticalIcon(context.documentRef, planet.color, Math.max(24, renderedPlanetRadius * 2));
|
||||
planetIcon.position.copy(planetMesh.position);
|
||||
planetIcon.setPosition(rawObject(planetMesh).position.clone());
|
||||
const ring = planet.hasRing ? createPlanetRing(planet) : undefined;
|
||||
if (ring) {
|
||||
ring.position.copy(planetMesh.position);
|
||||
ring.setPosition(rawObject(planetMesh).position.clone());
|
||||
}
|
||||
const moons = createMoonVisuals(planet, context.worldSeed);
|
||||
detailGroup.add(orbit, planetMesh, planetIcon);
|
||||
@@ -183,23 +189,35 @@ export function rebuildSystems(context: SceneSyncContext, systems: SystemSnapsho
|
||||
detailGroup.add(ring);
|
||||
}
|
||||
for (const moon of moons) {
|
||||
moon.orbit.position.copy(planetMesh.position);
|
||||
moon.mesh.position.copy(planetMesh.position);
|
||||
moon.systemId = system.id;
|
||||
moon.planetIndex = planetIndex;
|
||||
moon.orbit.setPosition(rawObject(planetMesh).position.clone());
|
||||
moon.mesh.setPosition(rawObject(planetMesh).position.clone());
|
||||
detailGroup.add(moon.orbit, moon.mesh);
|
||||
context.orbitLines.push(moon.orbit);
|
||||
context.orbitLines.push({
|
||||
line: moon.orbit,
|
||||
systemId: system.id,
|
||||
kind: "moon",
|
||||
planetIndex,
|
||||
});
|
||||
context.registerPresentation(moon.mesh, planetIcon, true, true, system.id);
|
||||
}
|
||||
context.orbitLines.push(orbit);
|
||||
context.orbitLines.push({
|
||||
line: orbit,
|
||||
systemId: system.id,
|
||||
kind: "planet",
|
||||
planetIndex,
|
||||
});
|
||||
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 });
|
||||
registerSelectableTarget(context.selectableTargets, planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
||||
registerSelectableTarget(context.selectableTargets, planetIcon, { kind: "planet", systemId: system.id, planetIndex });
|
||||
}
|
||||
|
||||
context.systemGroup.add(root);
|
||||
context.systemGroup.add(rawObject(root));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,8 +229,8 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn
|
||||
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);
|
||||
mesh.setPosition(localPosition);
|
||||
icon.setPosition(localPosition);
|
||||
context.spatialNodeVisuals.set(node.id, {
|
||||
id: node.id,
|
||||
systemId: node.systemId,
|
||||
@@ -221,10 +239,10 @@ export function syncSpatialNodes(context: SceneSyncContext, nodes: SpatialNodeSn
|
||||
kind: node.kind,
|
||||
localPosition,
|
||||
});
|
||||
context.spatialNodeGroup.add(mesh, icon);
|
||||
context.spatialNodeGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "spatial-node", id: node.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "spatial-node", id: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,8 +256,8 @@ export function syncLocalBubbles(context: SceneSyncContext, bubbles: LocalBubble
|
||||
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 });
|
||||
context.bubbleGroup.add(rawObject(mesh));
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "bubble", id: bubble.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +268,7 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
|
||||
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);
|
||||
icon.setPosition(rawObject(mesh).position.clone());
|
||||
const localPosition = toThreeVector(node.localPosition);
|
||||
const anchor = context.resolveOrbitalAnchor(node.systemId, localPosition);
|
||||
const orbital = context.deriveNodeOrbital(node, anchor);
|
||||
@@ -265,10 +283,10 @@ export function syncNodes(context: SceneSyncContext, nodes: ResourceNodeSnapshot
|
||||
orbitPhase: orbital.phase,
|
||||
orbitInclination: orbital.inclination,
|
||||
});
|
||||
context.nodeGroup.add(mesh, icon);
|
||||
context.nodeGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "node", id: node.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "node", id: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,7 +297,7 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
|
||||
for (const station of stations) {
|
||||
const mesh = createStationMesh(station);
|
||||
const icon = createTacticalIcon(context.documentRef, station.color, 26);
|
||||
icon.position.copy(mesh.position);
|
||||
icon.setPosition(rawObject(mesh).position.clone());
|
||||
const localPosition = toThreeVector(station.localPosition);
|
||||
const anchor = context.resolveOrbitalAnchor(station.systemId, localPosition);
|
||||
const orbital = context.deriveOrbitalFromLocalPosition(localPosition, station.systemId, anchor);
|
||||
@@ -294,10 +312,10 @@ export function syncStations(context: SceneSyncContext, stations: StationSnapsho
|
||||
orbitInclination: orbital.inclination,
|
||||
localPosition,
|
||||
});
|
||||
context.stationGroup.add(mesh, icon);
|
||||
context.stationGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "station", id: station.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "station", id: station.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -309,8 +327,8 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
|
||||
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);
|
||||
mesh.setPosition(localPosition);
|
||||
icon.setPosition(localPosition);
|
||||
context.claimVisuals.set(claim.id, {
|
||||
id: claim.id,
|
||||
nodeId: claim.nodeId,
|
||||
@@ -319,10 +337,10 @@ export function syncClaims(context: SceneSyncContext, claims: ClaimSnapshot[]) {
|
||||
icon,
|
||||
localPosition,
|
||||
});
|
||||
context.claimGroup.add(mesh, icon);
|
||||
context.claimGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "claim", id: claim.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "claim", id: claim.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,8 +352,8 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
|
||||
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);
|
||||
mesh.setPosition(localPosition);
|
||||
icon.setPosition(localPosition);
|
||||
context.constructionSiteVisuals.set(site.id, {
|
||||
id: site.id,
|
||||
nodeId: site.nodeId,
|
||||
@@ -344,10 +362,10 @@ export function syncConstructionSites(context: SceneSyncContext, sites: Construc
|
||||
icon,
|
||||
localPosition,
|
||||
});
|
||||
context.constructionSiteGroup.add(mesh, icon);
|
||||
context.constructionSiteGroup.add(rawObject(mesh), rawObject(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 });
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "construction-site", id: site.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "construction-site", id: site.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,11 +378,11 @@ export function syncShips(context: SceneSyncContext, ships: ShipSnapshot[], tick
|
||||
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 });
|
||||
icon.setPosition(position);
|
||||
icon.setColor(shipColor);
|
||||
context.shipGroup.add(rawObject(mesh), rawObject(icon));
|
||||
registerSelectableTarget(context.selectableTargets, mesh, { kind: "ship", id: ship.id });
|
||||
registerSelectableTarget(context.selectableTargets, icon, { kind: "ship", id: ship.id });
|
||||
context.registerPresentation(mesh, icon, true, true, ship.systemId);
|
||||
context.shipVisuals.set(ship.id, {
|
||||
systemId: ship.systemId,
|
||||
@@ -390,9 +408,9 @@ export function applySpatialNodeDeltas(context: SceneSyncContext, nodes: Spatial
|
||||
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));
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.icon.setPosition(visual.localPosition);
|
||||
visual.mesh.setColor(context.spatialNodeColor(node.kind));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +424,8 @@ export function applyLocalBubbleDeltas(context: SceneSyncContext, bubbles: Local
|
||||
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));
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.mesh.setScaleScalar(Math.max(bubble.radius, 60));
|
||||
context.setBubbleVisualState(visual, bubble);
|
||||
}
|
||||
}
|
||||
@@ -427,7 +445,7 @@ export function applyNodeDeltas(context: SceneSyncContext, nodes: ResourceNodeDe
|
||||
visual.orbitRadius = orbital.radius;
|
||||
visual.orbitPhase = orbital.phase;
|
||||
visual.orbitInclination = orbital.inclination;
|
||||
visual.mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
visual.mesh.setScaleScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,9 +463,8 @@ export function applyStationDeltas(context: SceneSyncContext, stations: StationD
|
||||
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);
|
||||
visual.mesh.setColor(station.color);
|
||||
visual.mesh.setEmissive(station.color, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -460,11 +477,10 @@ export function applyClaimDeltas(context: SceneSyncContext, claims: ClaimDelta[]
|
||||
|
||||
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");
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.icon.setPosition(visual.localPosition);
|
||||
visual.mesh.setColor(claim.state === "active" ? "#ff7f50" : "#ff5b5b");
|
||||
visual.mesh.setEmissive(claim.state === "active" ? "#ffb27d" : "#7a2020");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -477,11 +493,10 @@ export function applyConstructionSiteDeltas(context: SceneSyncContext, sites: Co
|
||||
|
||||
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);
|
||||
visual.mesh.setPosition(visual.localPosition);
|
||||
visual.icon.setPosition(visual.localPosition);
|
||||
visual.mesh.setColor(site.state === "completed" ? "#46d37f" : "#9df29c");
|
||||
visual.mesh.setScaleScalar(0.75 + site.progress * 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,16 +508,15 @@ export function applyShipDeltas(context: SceneSyncContext, ships: ShipDelta[], t
|
||||
}
|
||||
|
||||
visual.systemId = ship.systemId;
|
||||
visual.startPosition.copy(visual.authoritativePosition);
|
||||
visual.startPosition.copy(getAnimatedShipLocalPosition(visual));
|
||||
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);
|
||||
visual.mesh.setColor(shipColor);
|
||||
visual.mesh.setEmissive(shipColor, 0.18);
|
||||
visual.icon.setColor(shipColor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SystemSnapshot } from "./contracts";
|
||||
import type { ShipSnapshot, SpatialNodeSnapshot, SystemSnapshot } from "./contracts";
|
||||
import type {
|
||||
CameraMode,
|
||||
OrbitalAnchor,
|
||||
@@ -214,3 +214,205 @@ export function renderSystemDetails(
|
||||
${followText}
|
||||
`;
|
||||
}
|
||||
|
||||
export function describeShipState(world: WorldState | undefined, ship: ShipSnapshot): string {
|
||||
const baseState = ship.state;
|
||||
if (baseState === "capacitor-starved") {
|
||||
return `${baseState} while ${describeControllerTask(ship.controllerTaskKind)}`;
|
||||
}
|
||||
|
||||
if (!world || (baseState !== "ftl" && baseState !== "spooling-ftl" && baseState !== "warping" && baseState !== "spooling-warp")) {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
const destinationNodeId = ship.spatialState.destinationNodeId ?? ship.spatialState.transit?.destinationNodeId;
|
||||
if (!destinationNodeId) {
|
||||
return baseState;
|
||||
}
|
||||
|
||||
const destinationNode = world.spatialNodes.get(destinationNodeId);
|
||||
if (!destinationNode) {
|
||||
return `${baseState} -> ${destinationNodeId}`;
|
||||
}
|
||||
|
||||
if (baseState === "warping" || baseState === "spooling-warp") {
|
||||
const destinationPath = describeSpatialNodePathWithinSystem(world, destinationNode.systemId, destinationNodeId);
|
||||
return `${baseState} -> ${destinationPath ?? destinationNodeId}`;
|
||||
}
|
||||
|
||||
const destinationSystem = world.systems.get(destinationNode.systemId);
|
||||
return `${baseState} -> ${destinationSystem?.label ?? destinationNode.systemId}`;
|
||||
}
|
||||
|
||||
function describeControllerTask(taskKind: string): string {
|
||||
switch (taskKind) {
|
||||
case "travel":
|
||||
return "travel";
|
||||
case "extract":
|
||||
return "mining";
|
||||
case "dock":
|
||||
return "docking";
|
||||
case "unload":
|
||||
return "transfer";
|
||||
case "refuel":
|
||||
return "refuel";
|
||||
case "deliver-construction":
|
||||
return "material delivery";
|
||||
case "build-construction-site":
|
||||
return "site construction";
|
||||
case "construct-module":
|
||||
return "module construction";
|
||||
case "undock":
|
||||
return "undocking";
|
||||
case "load-workers":
|
||||
return "worker loading";
|
||||
case "unload-workers":
|
||||
return "worker unloading";
|
||||
default:
|
||||
return taskKind;
|
||||
}
|
||||
}
|
||||
|
||||
export function describeShipCurrentAction(ship: ShipSnapshot): { label: string; progress: number } | undefined {
|
||||
if (!ship.currentAction) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
label: ship.currentAction.label,
|
||||
progress: Math.max(0, Math.min(ship.currentAction.progress, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
export function describeShipLocation(world: WorldState | undefined, ship: ShipSnapshot): { system: string; local?: string } {
|
||||
const systemId = ship.spatialState.currentSystemId || ship.systemId;
|
||||
const system = world?.systems.get(systemId);
|
||||
const systemLabel = system?.label ?? systemId;
|
||||
if (!world || !system) {
|
||||
return { system: systemLabel };
|
||||
}
|
||||
|
||||
if (ship.dockedStationId) {
|
||||
const station = world.stations.get(ship.dockedStationId);
|
||||
if (station) {
|
||||
const anchorPath = station.anchorNodeId
|
||||
? describeSpatialNodePathWithinSystem(world, station.systemId, station.anchorNodeId)
|
||||
: undefined;
|
||||
return {
|
||||
system: systemLabel,
|
||||
local: anchorPath ? `${anchorPath}/${station.label}` : station.label,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const currentNodeId = ship.spatialState.currentNodeId ?? ship.nodeId;
|
||||
if (currentNodeId) {
|
||||
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, currentNodeId);
|
||||
if (nodePath) {
|
||||
return { system: systemLabel, local: nodePath };
|
||||
}
|
||||
}
|
||||
|
||||
const currentBubbleId = ship.spatialState.currentBubbleId ?? ship.bubbleId;
|
||||
if (currentBubbleId) {
|
||||
const bubble = world.localBubbles.get(currentBubbleId);
|
||||
if (bubble?.nodeId) {
|
||||
const nodePath = describeSpatialNodePathWithinSystem(world, systemId, bubble.nodeId);
|
||||
if (nodePath) {
|
||||
return { system: systemLabel, local: nodePath };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { system: systemLabel };
|
||||
}
|
||||
|
||||
export function describeActiveSpace(
|
||||
world: WorldState | undefined,
|
||||
zoomLevel: "local" | "system" | "universe",
|
||||
activeSystemId: string | undefined,
|
||||
selectedItems: Selectable[],
|
||||
): string {
|
||||
if (!world || zoomLevel === "universe") {
|
||||
return "deep-space";
|
||||
}
|
||||
|
||||
const activeSystem = activeSystemId ? world.systems.get(activeSystemId) : undefined;
|
||||
if (!activeSystem) {
|
||||
return "deep-space";
|
||||
}
|
||||
|
||||
if (zoomLevel !== "local") {
|
||||
return activeSystem.label;
|
||||
}
|
||||
|
||||
const bubbleId = resolveFocusedBubbleId(world, selectedItems);
|
||||
if (bubbleId) {
|
||||
const bubble = world.localBubbles.get(bubbleId);
|
||||
const localPath = bubble?.nodeId
|
||||
? describeSpatialNodePathWithinSystem(world, activeSystem.id, bubble.nodeId)
|
||||
: undefined;
|
||||
return localPath
|
||||
? `${activeSystem.label} / ${localPath}`
|
||||
: activeSystem.label;
|
||||
}
|
||||
|
||||
const selected = selectedItems.length === 1 ? selectedItems[0] : undefined;
|
||||
if (selected?.kind === "planet" && selected.systemId === activeSystem.id) {
|
||||
const planet = activeSystem.planets[selected.planetIndex];
|
||||
return planet
|
||||
? `${activeSystem.label} / ${planet.label}`
|
||||
: activeSystem.label;
|
||||
}
|
||||
|
||||
return activeSystem.label;
|
||||
}
|
||||
|
||||
export function describeSpatialNodePathWithinSystem(world: WorldState, systemId: string, nodeId: string): string | undefined {
|
||||
const node = world.spatialNodes.get(nodeId);
|
||||
const system = world.systems.get(systemId);
|
||||
if (!node || !system) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (node.parentNodeId) {
|
||||
const parentPath = describeSpatialNodePathWithinSystem(world, systemId, node.parentNodeId);
|
||||
const segment = describeSpatialNodeSegment(world, system, node);
|
||||
return parentPath ? `${parentPath}/${segment}` : segment;
|
||||
}
|
||||
|
||||
if (node.kind === "star") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return describeSpatialNodeSegment(world, system, node);
|
||||
}
|
||||
|
||||
function describeSpatialNodeSegment(world: WorldState, system: SystemSnapshot, node: SpatialNodeSnapshot): string {
|
||||
const moonMatch = node.id.match(/-planet-(\d+)-moon-(\d+)$/);
|
||||
if (moonMatch) {
|
||||
const moonIndex = Number.parseInt(moonMatch[2], 10);
|
||||
return `Moon ${moonIndex}`;
|
||||
}
|
||||
|
||||
const lagrangeMatch = node.id.match(/-planet-\d+-(l[1-5])$/);
|
||||
if (lagrangeMatch) {
|
||||
return lagrangeMatch[1].toUpperCase();
|
||||
}
|
||||
|
||||
const planetMatch = node.id.match(/-planet-(\d+)$/);
|
||||
if (planetMatch) {
|
||||
const planetIndex = Number.parseInt(planetMatch[1], 10) - 1;
|
||||
return system.planets[planetIndex]?.label ?? `Planet ${planetMatch[1]}`;
|
||||
}
|
||||
|
||||
if (node.kind === "station" && node.occupyingStructureId) {
|
||||
return world.stations.get(node.occupyingStructureId)?.label ?? node.occupyingStructureId;
|
||||
}
|
||||
|
||||
if (node.kind === "resource-site") {
|
||||
return node.orbitReferenceId ?? "Resource Site";
|
||||
}
|
||||
|
||||
return node.orbitReferenceId ?? node.kind;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
|
||||
seed: snapshot.seed,
|
||||
sequence: snapshot.sequence,
|
||||
tickIntervalMs: snapshot.tickIntervalMs,
|
||||
orbitalTimeSeconds: snapshot.orbitalTimeSeconds,
|
||||
orbitalSimulation: snapshot.orbitalSimulation,
|
||||
generatedAtUtc: snapshot.generatedAtUtc,
|
||||
systems: new Map(snapshot.systems.map((system) => [system.id, system])),
|
||||
spatialNodes: new Map(snapshot.spatialNodes.map((node) => [node.id, node])),
|
||||
@@ -55,6 +57,8 @@ export function createWorldState(snapshot: WorldSnapshot): WorldState {
|
||||
export function applyDeltaToWorld(world: WorldState, delta: WorldDelta): boolean {
|
||||
world.sequence = delta.sequence;
|
||||
world.tickIntervalMs = delta.tickIntervalMs;
|
||||
world.orbitalTimeSeconds = delta.orbitalTimeSeconds;
|
||||
world.orbitalSimulation = delta.orbitalSimulation;
|
||||
world.generatedAtUtc = delta.generatedAtUtc;
|
||||
world.recentEvents = [...delta.events, ...world.recentEvents].slice(0, 18);
|
||||
|
||||
|
||||
@@ -36,6 +36,17 @@ export function updateNetworkPanel(networkPanelEl: HTMLDivElement, networkStats:
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function summarizeNetworkStats(networkStats: NetworkStats): string {
|
||||
const now = performance.now();
|
||||
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 direction = networkStats.streamConnected ? "live" : "offline";
|
||||
return `${direction} | down ${kbPerSecond.toFixed(1)} KB/s | ${networkStats.deltasReceived} d`;
|
||||
}
|
||||
|
||||
export function recordPerformanceStats(performanceStats: PerformanceStats, frameMs: number) {
|
||||
const now = performance.now();
|
||||
performanceStats.lastFrameMs = frameMs;
|
||||
@@ -89,3 +100,14 @@ export function updatePerformancePanel(
|
||||
].join("\n");
|
||||
performanceStats.lastPanelUpdateAtMs = now;
|
||||
}
|
||||
|
||||
export function summarizePerformanceStats(performanceStats: PerformanceStats): string {
|
||||
const samples = performanceStats.frameSamples;
|
||||
const elapsedWindowSeconds = samples.length > 1
|
||||
? Math.max((samples[samples.length - 1].atMs - samples[0].atMs) / 1000, 0.25)
|
||||
: 1;
|
||||
const fps = samples.length > 1
|
||||
? (samples.length - 1) / elapsedWindowSeconds
|
||||
: 0;
|
||||
return `FPS ${fps.toFixed(1)} | ${performanceStats.lastFrameMs.toFixed(1)} ms`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from "three";
|
||||
import type { SceneNode } from "./viewerScenePrimitives";
|
||||
import type {
|
||||
ClaimSnapshot,
|
||||
ConstructionSiteSnapshot,
|
||||
@@ -13,6 +14,7 @@ import type {
|
||||
SpatialNodeSnapshot,
|
||||
StationSnapshot,
|
||||
SystemSnapshot,
|
||||
OrbitalSimulationSnapshot,
|
||||
} from "./contracts";
|
||||
|
||||
export type ZoomLevel = "local" | "system" | "universe";
|
||||
@@ -33,8 +35,8 @@ export type Selectable =
|
||||
|
||||
export interface ShipVisual {
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
startPosition: THREE.Vector3;
|
||||
authoritativePosition: THREE.Vector3;
|
||||
targetPosition: THREE.Vector3;
|
||||
@@ -46,16 +48,25 @@ export interface ShipVisual {
|
||||
export interface PlanetVisual {
|
||||
systemId: string;
|
||||
planet: PlanetSnapshot;
|
||||
orbit: THREE.LineLoop;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
ring?: THREE.Mesh;
|
||||
orbit: SceneNode;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
ring?: SceneNode;
|
||||
moons: MoonVisual[];
|
||||
}
|
||||
|
||||
export interface MoonVisual {
|
||||
mesh: THREE.Mesh;
|
||||
orbit: THREE.LineLoop;
|
||||
systemId: string;
|
||||
planetIndex: number;
|
||||
mesh: SceneNode;
|
||||
orbit: SceneNode;
|
||||
}
|
||||
|
||||
export interface OrbitLineVisual {
|
||||
line: SceneNode;
|
||||
systemId: string;
|
||||
kind: "planet" | "moon";
|
||||
planetIndex: number;
|
||||
}
|
||||
|
||||
export type OrbitalAnchor =
|
||||
@@ -65,8 +76,8 @@ export type OrbitalAnchor =
|
||||
|
||||
export interface NodeVisual {
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
sourceKind: string;
|
||||
anchor: OrbitalAnchor;
|
||||
localPosition: THREE.Vector3;
|
||||
@@ -78,8 +89,8 @@ export interface NodeVisual {
|
||||
export interface SpatialNodeVisual {
|
||||
id: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
kind: string;
|
||||
localPosition: THREE.Vector3;
|
||||
}
|
||||
@@ -87,7 +98,7 @@ export interface SpatialNodeVisual {
|
||||
export interface BubbleVisual {
|
||||
id: string;
|
||||
systemId: string;
|
||||
mesh: THREE.LineLoop;
|
||||
mesh: SceneNode;
|
||||
localPosition: THREE.Vector3;
|
||||
radius: number;
|
||||
}
|
||||
@@ -96,8 +107,8 @@ export interface ClaimVisual {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
localPosition: THREE.Vector3;
|
||||
}
|
||||
|
||||
@@ -105,16 +116,16 @@ export interface ConstructionSiteVisual {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
localPosition: THREE.Vector3;
|
||||
}
|
||||
|
||||
export interface StructureVisual {
|
||||
id: string;
|
||||
systemId: string;
|
||||
mesh: THREE.Mesh;
|
||||
icon: THREE.Sprite;
|
||||
mesh: SceneNode;
|
||||
icon: SceneNode;
|
||||
anchor: OrbitalAnchor;
|
||||
orbitRadius: number;
|
||||
orbitPhase: number;
|
||||
@@ -123,12 +134,12 @@ export interface StructureVisual {
|
||||
}
|
||||
|
||||
export interface SystemVisual {
|
||||
root: THREE.Group;
|
||||
starCluster: THREE.Group;
|
||||
icon: THREE.Sprite;
|
||||
shellReticle: THREE.Sprite;
|
||||
root: SceneNode;
|
||||
starCluster: SceneNode;
|
||||
icon: SceneNode;
|
||||
shellReticle: SceneNode;
|
||||
shellReticleBaseScale: number;
|
||||
detailGroup: THREE.Group;
|
||||
detailGroup: SceneNode;
|
||||
summary: SystemSummaryVisual;
|
||||
galaxyPosition: THREE.Vector3;
|
||||
}
|
||||
@@ -138,6 +149,8 @@ export interface WorldState {
|
||||
seed: number;
|
||||
sequence: number;
|
||||
tickIntervalMs: number;
|
||||
orbitalTimeSeconds: number;
|
||||
orbitalSimulation: OrbitalSimulationSnapshot;
|
||||
generatedAtUtc: string;
|
||||
systems: Map<string, SystemSnapshot>;
|
||||
spatialNodes: Map<string, SpatialNodeSnapshot>;
|
||||
@@ -183,15 +196,15 @@ export interface PerformanceStats {
|
||||
}
|
||||
|
||||
export interface PresentationEntry {
|
||||
detail: THREE.Object3D;
|
||||
icon: THREE.Sprite;
|
||||
detail: SceneNode;
|
||||
icon: SceneNode;
|
||||
systemId?: string;
|
||||
hideDetailInUniverse?: boolean;
|
||||
hideIconInUniverse?: boolean;
|
||||
}
|
||||
|
||||
export interface SystemSummaryVisual {
|
||||
sprite: THREE.Sprite;
|
||||
sprite: SceneNode;
|
||||
texture: THREE.CanvasTexture;
|
||||
anchor: THREE.Vector3;
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ export class ViewerWorldLifecycle {
|
||||
}
|
||||
|
||||
this.context.setWorldTimeSyncMs(performance.now());
|
||||
const factionsChanged = applyDeltaToWorld(world, delta);
|
||||
applyDeltaToWorld(world, delta);
|
||||
this.context.applySpatialNodeDeltas(delta.spatialNodes);
|
||||
this.context.applyLocalBubbleDeltas(delta.localBubbles);
|
||||
this.context.applyNodeDeltas(delta.nodes);
|
||||
@@ -189,9 +189,7 @@ export class ViewerWorldLifecycle {
|
||||
this.context.applyClaimDeltas(delta.claims);
|
||||
this.context.applyConstructionSiteDeltas(delta.constructionSites);
|
||||
this.context.applyShipDeltas(delta.ships, delta.tickIntervalMs);
|
||||
if (factionsChanged) {
|
||||
this.rebuildFactions(cloneFactions(world));
|
||||
}
|
||||
this.rebuildFactions(cloneFactions(world));
|
||||
this.context.updateSystemSummaries();
|
||||
}
|
||||
|
||||
@@ -201,6 +199,8 @@ export class ViewerWorldLifecycle {
|
||||
this.context.getSelectedItems(),
|
||||
this.context.getCameraMode(),
|
||||
this.context.getCameraTargetShipId(),
|
||||
this.context.getZoomLevel(),
|
||||
this.context.getActiveSystemId(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ import {
|
||||
resolveOrbitalAnchorPosition,
|
||||
toThreeVector,
|
||||
} from "./viewerMath";
|
||||
import { describeActiveSpace } from "./viewerSelection";
|
||||
import {
|
||||
resolveShipHeading,
|
||||
updateSystemStarPresentation,
|
||||
updateSystemSummaryPresentation,
|
||||
getAnimatedShipLocalPosition,
|
||||
} from "./viewerPresentation";
|
||||
import { rawObject } from "./viewerScenePrimitives";
|
||||
import type {
|
||||
LocalBubbleDelta,
|
||||
LocalBubbleSnapshot,
|
||||
@@ -22,6 +24,7 @@ import type {
|
||||
import type {
|
||||
BubbleVisual,
|
||||
ClaimVisual,
|
||||
Selectable,
|
||||
ConstructionSiteVisual,
|
||||
NodeVisual,
|
||||
OrbitalAnchor,
|
||||
@@ -59,15 +62,17 @@ export interface WorldPresentationContext extends WorldOrbitalContext {
|
||||
systemSummaryVisuals: Map<string, SystemSummaryVisual>;
|
||||
toDisplayLocalPosition: (localPosition: THREE.Vector3, systemId?: string) => THREE.Vector3;
|
||||
updateSystemDetailVisibility: () => void;
|
||||
setShellReticleOpacity: (sprite: THREE.Sprite, opacity: number) => void;
|
||||
setShellReticleOpacity: (sprite: SystemVisual["shellReticle"], opacity: number) => void;
|
||||
}
|
||||
|
||||
export interface GameStatusParams {
|
||||
statusEl: HTMLDivElement;
|
||||
summaryEl?: HTMLSpanElement;
|
||||
world?: WorldState;
|
||||
activeSystemId?: string;
|
||||
cameraMode: CameraMode;
|
||||
zoomLevel: ZoomLevel;
|
||||
selectedItems: Selectable[];
|
||||
mode: string;
|
||||
}
|
||||
|
||||
@@ -77,59 +82,59 @@ export function updateWorldPresentation(context: WorldPresentationContext) {
|
||||
|
||||
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);
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(worldPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
const shipVisible = visual.systemId === context.activeSystemId;
|
||||
visual.mesh.visible = shipVisible;
|
||||
visual.icon.visible = shipVisible && visual.icon.visible;
|
||||
visual.mesh.setVisible(shipVisible);
|
||||
visual.icon.setVisible(shipVisible && rawObject(visual.icon).visible);
|
||||
const desiredHeading = resolveShipHeading(visual, worldPosition, context.orbitYaw);
|
||||
if (desiredHeading.lengthSq() > 0.01) {
|
||||
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
|
||||
visual.mesh.lookAt(rawObject(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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
visual.icon.setVisible(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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.mesh.setVisible(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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
visual.icon.setVisible(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;
|
||||
visual.mesh.setPosition(context.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
|
||||
visual.icon.setPosition(rawObject(visual.mesh).position.clone());
|
||||
visual.mesh.setVisible(visual.systemId === context.activeSystemId);
|
||||
visual.icon.setVisible(visual.systemId === context.activeSystemId);
|
||||
}
|
||||
|
||||
updateSystemStarPresentation(
|
||||
@@ -218,22 +223,26 @@ export function renderRecentEvents(world: WorldState | undefined, entityKind: st
|
||||
}
|
||||
|
||||
export function updateGameStatus(params: GameStatusParams) {
|
||||
const { statusEl, world, activeSystemId, cameraMode, zoomLevel, mode } = params;
|
||||
const { statusEl, summaryEl, world, activeSystemId, cameraMode, zoomLevel, selectedItems, 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";
|
||||
const displayZoomLevel = activeSystemId ? zoomLevel : "universe";
|
||||
const activeSpace = describeActiveSpace(world, displayZoomLevel, activeSystemId, selectedItems);
|
||||
const cameraModeLabel = cameraMode === "follow" ? "follow" : "map";
|
||||
|
||||
statusEl.textContent = [
|
||||
`mode: ${mode}`,
|
||||
`camera: ${cameraModeLabel}`,
|
||||
`zoom: ${zoomLevel}`,
|
||||
`system: ${activeSystem}`,
|
||||
`zoom: ${displayZoomLevel}`,
|
||||
`space: ${activeSpace}`,
|
||||
`sequence: ${sequence}`,
|
||||
`snapshot: ${generatedAt}`,
|
||||
].join("\n");
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = `${mode} | ${displayZoomLevel} | ${activeSpace}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveNodeOrbital(
|
||||
@@ -371,7 +380,7 @@ export function computeSpatialNodeLocalPositionById(
|
||||
|
||||
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;
|
||||
const material = (rawObject(visual.mesh) as THREE.LineLoop).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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user