Expand galaxy generation and viewer rendering
This commit is contained in:
@@ -1,5 +1,32 @@
|
|||||||
# Next Steps
|
# Next Steps
|
||||||
|
|
||||||
|
## Galaxy / Viewer Fit
|
||||||
|
|
||||||
|
The world is now much larger and more varied:
|
||||||
|
|
||||||
|
- roughly galaxy-scale system counts
|
||||||
|
- elevated system height variance
|
||||||
|
- procedural orbital metadata
|
||||||
|
- moons, rings, binary stars, asteroid belts, gas clouds
|
||||||
|
- special-case `Sol` system content
|
||||||
|
|
||||||
|
The next step is not “make the map larger.” That is already done for the current runtime.
|
||||||
|
|
||||||
|
Recommended work:
|
||||||
|
|
||||||
|
- tune galaxy readability at scale
|
||||||
|
- better starfield depth cues
|
||||||
|
- stronger color/size differentiation for star classes
|
||||||
|
- improved system label decluttering
|
||||||
|
- add galaxy navigation affordances
|
||||||
|
- jump-to-system search
|
||||||
|
- constellation / region overlays
|
||||||
|
- bookmarks for notable systems such as `Sol`
|
||||||
|
- tighten viewer performance
|
||||||
|
- reduce orbit/moon draw cost when zoomed out
|
||||||
|
- pool or simplify distant celestial meshes
|
||||||
|
- profile high-system-count scenes
|
||||||
|
|
||||||
## Economic Growth
|
## Economic Growth
|
||||||
|
|
||||||
The current economy already supports:
|
The current economy already supports:
|
||||||
@@ -25,6 +52,9 @@ Recommended work:
|
|||||||
- show ore throughput
|
- show ore throughput
|
||||||
- show fabricated goods
|
- show fabricated goods
|
||||||
- show queued faction priorities
|
- show queued faction priorities
|
||||||
|
- make resource type differences matter
|
||||||
|
- ore belts vs gas clouds
|
||||||
|
- gas-aware logistics and production choices
|
||||||
|
|
||||||
## Pirate Harassment
|
## Pirate Harassment
|
||||||
|
|
||||||
@@ -61,11 +91,12 @@ That turns the simulation into a real strategy loop.
|
|||||||
|
|
||||||
## Concrete Implementation Order
|
## Concrete Implementation Order
|
||||||
|
|
||||||
1. Add faction production heuristics based on current economy and losses.
|
1. Add viewer-scale performance controls for the larger galaxy.
|
||||||
2. Make pirate target selection explicitly prefer economic targets.
|
2. Add faction production heuristics based on current economy and losses.
|
||||||
3. Surface faction stocks, throughput, and build priorities in the HUD/debug views.
|
3. Make pirate target selection explicitly prefer economic targets.
|
||||||
4. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`.
|
4. Surface faction stocks, throughput, and build priorities in the HUD/debug views.
|
||||||
5. Break backend simulation responsibilities into smaller planning / faction / combat / logistics modules.
|
5. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`.
|
||||||
|
6. Break backend simulation responsibilities into smaller planning / faction / combat / logistics modules.
|
||||||
|
|
||||||
## Network / Multiplayer
|
## Network / Multiplayer
|
||||||
|
|
||||||
@@ -144,5 +175,26 @@ Recommended work:
|
|||||||
- add per-layer presentation tuning in the viewer
|
- add per-layer presentation tuning in the viewer
|
||||||
- smoother fade bands between local / system / universe
|
- smoother fade bands between local / system / universe
|
||||||
- better visual density control at galaxy scale
|
- better visual density control at galaxy scale
|
||||||
|
- moon/orbit LOD based on zoom level
|
||||||
- add resync handling when a client falls too far behind
|
- add resync handling when a client falls too far behind
|
||||||
- consider switching from SSE to websocket transport if bidirectional command traffic becomes heavy
|
- consider switching from SSE to websocket transport if bidirectional command traffic becomes heavy
|
||||||
|
|
||||||
|
## Celestial Depth
|
||||||
|
|
||||||
|
The current celestial layer is procedurally rich, but still mostly decorative outside of resource nodes.
|
||||||
|
|
||||||
|
Recommended work:
|
||||||
|
|
||||||
|
- add authored moon metadata when needed
|
||||||
|
- labels
|
||||||
|
- resource-bearing moons
|
||||||
|
- special landmarks
|
||||||
|
- support multiple belts / cloud bands per system explicitly
|
||||||
|
- add stellar gameplay hooks
|
||||||
|
- hazardous neutron-star systems
|
||||||
|
- high-value binary systems
|
||||||
|
- rich-gas outer systems
|
||||||
|
- expose notable-system summaries in the viewer
|
||||||
|
- star class
|
||||||
|
- resource profile
|
||||||
|
- moon count
|
||||||
|
|||||||
32
SESSION.md
32
SESSION.md
@@ -45,6 +45,8 @@ The viewer currently supports:
|
|||||||
- middle-mouse orbit camera
|
- middle-mouse orbit camera
|
||||||
- smooth wheel zoom across local, system, and universe scales
|
- smooth wheel zoom across local, system, and universe scales
|
||||||
- presentation fades between zoom bands instead of hard switches
|
- presentation fades between zoom bands instead of hard switches
|
||||||
|
- procedurally animated planets and moons from orbital metadata
|
||||||
|
- ringed planets, binary star presentation, and richer resource visuals
|
||||||
|
|
||||||
Universe-level presentation is now star-centric:
|
Universe-level presentation is now star-centric:
|
||||||
|
|
||||||
@@ -57,6 +59,14 @@ The viewer also includes plain-text HUD readouts for:
|
|||||||
- game state
|
- game state
|
||||||
- network statistics
|
- network statistics
|
||||||
|
|
||||||
|
The viewer now consumes richer celestial metadata from the backend:
|
||||||
|
|
||||||
|
- star kind and star count
|
||||||
|
- planet type, shape, moon count, and orbital elements
|
||||||
|
- resource node source kind such as `asteroid-belt` and `gas-cloud`
|
||||||
|
|
||||||
|
Planets and moons are not simulated as networked entities. Their positions are reconstructed client-side from snapshot time plus orbital configuration.
|
||||||
|
|
||||||
## Simulation Status
|
## Simulation Status
|
||||||
|
|
||||||
The backend simulation already includes:
|
The backend simulation already includes:
|
||||||
@@ -68,6 +78,11 @@ The backend simulation already includes:
|
|||||||
- refining / fabrication
|
- refining / fabrication
|
||||||
- faction growth through ship and outpost production
|
- faction growth through ship and outpost production
|
||||||
- pirate pressure and combat
|
- pirate pressure and combat
|
||||||
|
- procedural galaxy generation with deterministic expansion beyond the authored scenario
|
||||||
|
- handcrafted `Sol` system easter egg with Saturn and rings
|
||||||
|
- resource geography beyond simple ore nodes
|
||||||
|
- asteroid belts for ore
|
||||||
|
- gas clouds for gas
|
||||||
|
|
||||||
The runtime model still follows the intended layered control architecture:
|
The runtime model still follows the intended layered control architecture:
|
||||||
|
|
||||||
@@ -86,6 +101,11 @@ The runtime model still follows the intended layered control architecture:
|
|||||||
- added a plain-text network statistics readout
|
- added a plain-text network statistics readout
|
||||||
- reworked the camera with smoother zoom, orbit, panning, and marquee selection
|
- reworked the camera with smoother zoom, orbit, panning, and marquee selection
|
||||||
- cleaned up several viewer HUD elements and removed redundant panel content
|
- cleaned up several viewer HUD elements and removed redundant panel content
|
||||||
|
- expanded the backend world into a large procedural galaxy with elevated vertical variance
|
||||||
|
- added deterministic orbital metadata for planets and client-side orbital animation in the viewer
|
||||||
|
- added moon rendering in the viewer
|
||||||
|
- added backend-generated asteroid belts and gas clouds as resource sources
|
||||||
|
- injected the `Sol` system into the generated galaxy
|
||||||
|
|
||||||
## Current Known Limitations
|
## Current Known Limitations
|
||||||
|
|
||||||
@@ -94,6 +114,9 @@ The runtime model still follows the intended layered control architecture:
|
|||||||
- the viewer is still observer-focused
|
- the viewer is still observer-focused
|
||||||
- no command submission UI yet
|
- no command submission UI yet
|
||||||
- system/universe transitions are improved but still need tuning in feel and art direction
|
- system/universe transitions are improved but still need tuning in feel and art direction
|
||||||
|
- the galaxy is much larger now, so viewer performance and visual density need active tuning
|
||||||
|
- moon rendering is procedural from counts, not authored moon-by-moon data
|
||||||
|
- resource extraction behavior still treats all resource nodes generically
|
||||||
- piracy and faction growth are still functional rather than strategically deep
|
- piracy and faction growth are still functional rather than strategically deep
|
||||||
- no persistence for saves, seeds, or reconnect state
|
- no persistence for saves, seeds, or reconnect state
|
||||||
|
|
||||||
@@ -105,10 +128,18 @@ The runtime model still follows the intended layered control architecture:
|
|||||||
- authoritative world state and stream coordination
|
- authoritative world state and stream coordination
|
||||||
- [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs)
|
- [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs)
|
||||||
- simulation advancement
|
- simulation advancement
|
||||||
|
- [apps/backend/Simulation/ScenarioLoader.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs)
|
||||||
|
- faction bootstrap
|
||||||
|
- galaxy generation
|
||||||
|
- special systems
|
||||||
|
- procedural celestial/resource content
|
||||||
- [apps/viewer/src/GameViewer.ts](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts)
|
- [apps/viewer/src/GameViewer.ts](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts)
|
||||||
- camera, selection, streaming integration, and presentation
|
- camera, selection, streaming integration, and presentation
|
||||||
|
- orbital reconstruction and moon rendering
|
||||||
- [apps/viewer/src/api.ts](/home/jbourdon/repos/space-game/apps/viewer/src/api.ts)
|
- [apps/viewer/src/api.ts](/home/jbourdon/repos/space-game/apps/viewer/src/api.ts)
|
||||||
- snapshot fetch and SSE stream integration
|
- snapshot fetch and SSE stream integration
|
||||||
|
- [apps/viewer/src/contracts.ts](/home/jbourdon/repos/space-game/apps/viewer/src/contracts.ts)
|
||||||
|
- viewer-side snapshot contract for celestial metadata
|
||||||
- [shared/data](/home/jbourdon/repos/space-game/shared/data)
|
- [shared/data](/home/jbourdon/repos/space-game/shared/data)
|
||||||
- scenario and world data definitions
|
- scenario and world data definitions
|
||||||
|
|
||||||
@@ -116,4 +147,5 @@ The runtime model still follows the intended layered control architecture:
|
|||||||
|
|
||||||
Validation passing at the end of this session:
|
Validation passing at the end of this session:
|
||||||
|
|
||||||
|
- `dotnet build apps/backend/SpaceGame.Simulation.Api.csproj`
|
||||||
- `cd apps/viewer && npm run build`
|
- `cd apps/viewer && npm run build`
|
||||||
|
|||||||
@@ -34,13 +34,24 @@ public sealed record SystemSnapshot(
|
|||||||
string Id,
|
string Id,
|
||||||
string Label,
|
string Label,
|
||||||
Vector3Dto Position,
|
Vector3Dto Position,
|
||||||
|
string StarKind,
|
||||||
|
int StarCount,
|
||||||
string StarColor,
|
string StarColor,
|
||||||
float StarSize,
|
float StarSize,
|
||||||
IReadOnlyList<PlanetSnapshot> Planets);
|
IReadOnlyList<PlanetSnapshot> Planets);
|
||||||
|
|
||||||
public sealed record PlanetSnapshot(
|
public sealed record PlanetSnapshot(
|
||||||
string Label,
|
string Label,
|
||||||
|
string PlanetType,
|
||||||
|
string Shape,
|
||||||
|
int MoonCount,
|
||||||
float OrbitRadius,
|
float OrbitRadius,
|
||||||
|
float OrbitSpeed,
|
||||||
|
float OrbitEccentricity,
|
||||||
|
float OrbitInclination,
|
||||||
|
float OrbitLongitudeOfAscendingNode,
|
||||||
|
float OrbitArgumentOfPeriapsis,
|
||||||
|
float OrbitPhaseAtEpoch,
|
||||||
float Size,
|
float Size,
|
||||||
string Color,
|
string Color,
|
||||||
bool HasRing);
|
bool HasRing);
|
||||||
@@ -49,6 +60,7 @@ public sealed record ResourceNodeSnapshot(
|
|||||||
string Id,
|
string Id,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
Vector3Dto Position,
|
Vector3Dto Position,
|
||||||
|
string SourceKind,
|
||||||
float OreRemaining,
|
float OreRemaining,
|
||||||
float MaxOre,
|
float MaxOre,
|
||||||
string ItemId);
|
string ItemId);
|
||||||
@@ -57,6 +69,7 @@ public sealed record ResourceNodeDelta(
|
|||||||
string Id,
|
string Id,
|
||||||
string SystemId,
|
string SystemId,
|
||||||
Vector3Dto Position,
|
Vector3Dto Position,
|
||||||
|
string SourceKind,
|
||||||
float OreRemaining,
|
float OreRemaining,
|
||||||
float MaxOre,
|
float MaxOre,
|
||||||
string ItemId);
|
string ItemId);
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ public sealed class SolarSystemDefinition
|
|||||||
public required string Id { get; set; }
|
public required string Id { get; set; }
|
||||||
public required string Label { get; set; }
|
public required string Label { get; set; }
|
||||||
public required float[] Position { get; set; }
|
public required float[] Position { get; set; }
|
||||||
|
public string StarKind { get; set; } = "main-sequence";
|
||||||
|
public int StarCount { get; set; } = 1;
|
||||||
public required string StarColor { get; set; }
|
public required string StarColor { get; set; }
|
||||||
public required string StarGlow { get; set; }
|
public required string StarGlow { get; set; }
|
||||||
public float StarSize { get; set; }
|
public float StarSize { get; set; }
|
||||||
@@ -50,6 +52,7 @@ public sealed class AsteroidFieldDefinition
|
|||||||
|
|
||||||
public sealed class ResourceNodeDefinition
|
public sealed class ResourceNodeDefinition
|
||||||
{
|
{
|
||||||
|
public string SourceKind { get; set; } = "asteroid-belt";
|
||||||
public float Angle { get; set; }
|
public float Angle { get; set; }
|
||||||
public float RadiusOffset { get; set; }
|
public float RadiusOffset { get; set; }
|
||||||
public float OreAmount { get; set; }
|
public float OreAmount { get; set; }
|
||||||
@@ -60,8 +63,16 @@ public sealed class ResourceNodeDefinition
|
|||||||
public sealed class PlanetDefinition
|
public sealed class PlanetDefinition
|
||||||
{
|
{
|
||||||
public required string Label { get; set; }
|
public required string Label { get; set; }
|
||||||
|
public string PlanetType { get; set; } = "terrestrial";
|
||||||
|
public string Shape { get; set; } = "sphere";
|
||||||
|
public int MoonCount { get; set; }
|
||||||
public float OrbitRadius { get; set; }
|
public float OrbitRadius { get; set; }
|
||||||
public float OrbitSpeed { get; set; }
|
public float OrbitSpeed { get; set; }
|
||||||
|
public float OrbitEccentricity { get; set; }
|
||||||
|
public float OrbitInclination { get; set; }
|
||||||
|
public float OrbitLongitudeOfAscendingNode { get; set; }
|
||||||
|
public float OrbitArgumentOfPeriapsis { get; set; }
|
||||||
|
public float OrbitPhaseAtEpoch { get; set; }
|
||||||
public float Size { get; set; }
|
public float Size { get; set; }
|
||||||
public required string Color { get; set; }
|
public required string Color { get; set; }
|
||||||
public float Tilt { get; set; }
|
public float Tilt { get; set; }
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public sealed class ResourceNodeRuntime
|
|||||||
public required string Id { get; init; }
|
public required string Id { get; init; }
|
||||||
public required string SystemId { get; init; }
|
public required string SystemId { get; init; }
|
||||||
public required Vector3 Position { get; init; }
|
public required Vector3 Position { get; init; }
|
||||||
|
public required string SourceKind { get; init; }
|
||||||
public required string ItemId { get; init; }
|
public required string ItemId { get; init; }
|
||||||
public float OreRemaining { get; set; }
|
public float OreRemaining { get; set; }
|
||||||
public float MaxOre { get; init; }
|
public float MaxOre { get; init; }
|
||||||
|
|||||||
@@ -5,6 +5,71 @@ namespace SpaceGame.Simulation.Api.Simulation;
|
|||||||
|
|
||||||
public sealed class ScenarioLoader
|
public sealed class ScenarioLoader
|
||||||
{
|
{
|
||||||
|
private const string DefaultFactionId = "sol-dominion";
|
||||||
|
private const int TargetSystemCount = 160;
|
||||||
|
private const int WorldSeed = 1;
|
||||||
|
private const float MinimumFactionCredits = 240f;
|
||||||
|
private const float MinimumRefineryOre = 60f;
|
||||||
|
private const float MinimumRefineryStock = 40f;
|
||||||
|
private const float MinimumShipyardStock = 180f;
|
||||||
|
private const float MinimumSystemSeparation = 3200f;
|
||||||
|
private static readonly string[] GeneratedSystemNames =
|
||||||
|
[
|
||||||
|
"Aquila Verge",
|
||||||
|
"Orion Fold",
|
||||||
|
"Draco Span",
|
||||||
|
"Lyra Shoal",
|
||||||
|
"Cygnus March",
|
||||||
|
"Vela Crossing",
|
||||||
|
"Carina Wake",
|
||||||
|
"Phoenix Rest",
|
||||||
|
"Hydra Loom",
|
||||||
|
"Cassio Reach",
|
||||||
|
"Lupus Chain",
|
||||||
|
"Pavo Line",
|
||||||
|
"Serpens Rise",
|
||||||
|
"Cetus Hollow",
|
||||||
|
"Delphin Crown",
|
||||||
|
"Volans Drift",
|
||||||
|
"Ara Bastion",
|
||||||
|
"Indus Veil",
|
||||||
|
"Pyxis Trace",
|
||||||
|
"Lacerta Bloom",
|
||||||
|
"Columba Shroud",
|
||||||
|
"Dorado Expanse",
|
||||||
|
"Reticulum Run",
|
||||||
|
"Norma Edge",
|
||||||
|
"Crux Horizon",
|
||||||
|
"Sagitta Corridor",
|
||||||
|
"Monoceros Deep",
|
||||||
|
"Eridan Spur",
|
||||||
|
"Centauri Shelf",
|
||||||
|
"Antlia Reach",
|
||||||
|
"Horologium Gate",
|
||||||
|
"Telescopium Strand",
|
||||||
|
];
|
||||||
|
private static readonly StarProfile[] StarProfiles =
|
||||||
|
[
|
||||||
|
new("main-sequence", "#ffd27a", "#ffb14a", 54f, 1),
|
||||||
|
new("blue-white", "#9dc6ff", "#66a0ff", 50f, 1),
|
||||||
|
new("white-dwarf", "#f1f5ff", "#b8caff", 26f, 1),
|
||||||
|
new("brown-dwarf", "#b97d56", "#8a5438", 20f, 1),
|
||||||
|
new("neutron-star", "#d9ebff", "#7ab4ff", 18f, 1),
|
||||||
|
new("binary-main-sequence", "#ffe09f", "#ffbe6b", 64f, 2),
|
||||||
|
new("binary-white-dwarf", "#edf3ff", "#c8d6ff", 34f, 2),
|
||||||
|
];
|
||||||
|
private static readonly PlanetProfile[] PlanetProfiles =
|
||||||
|
[
|
||||||
|
new("barren", "sphere", "#bca48f", 18f, 38f, 0, false),
|
||||||
|
new("terrestrial", "sphere", "#58a36c", 24f, 46f, 1, false),
|
||||||
|
new("oceanic", "sphere", "#4f84c4", 26f, 44f, 2, false),
|
||||||
|
new("desert", "sphere", "#d4a373", 22f, 42f, 0, false),
|
||||||
|
new("ice", "sphere", "#c8e4ff", 24f, 40f, 1, false),
|
||||||
|
new("gas-giant", "oblate", "#d9b06f", 52f, 86f, 8, true),
|
||||||
|
new("ice-giant", "oblate", "#8fc0d8", 44f, 72f, 5, true),
|
||||||
|
new("lava", "sphere", "#db6846", 20f, 36f, 0, false),
|
||||||
|
];
|
||||||
|
|
||||||
private readonly string _dataRoot;
|
private readonly string _dataRoot;
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
{
|
{
|
||||||
@@ -18,7 +83,7 @@ public sealed class ScenarioLoader
|
|||||||
|
|
||||||
public SimulationWorld Load()
|
public SimulationWorld Load()
|
||||||
{
|
{
|
||||||
var systems = Read<List<SolarSystemDefinition>>("systems.json");
|
var systems = ExpandSystems(InjectSpecialSystems(Read<List<SolarSystemDefinition>>("systems.json")));
|
||||||
var scenario = Read<ScenarioDefinition>("scenario.json");
|
var scenario = Read<ScenarioDefinition>("scenario.json");
|
||||||
var ships = Read<List<ShipDefinition>>("ships.json");
|
var ships = Read<List<ShipDefinition>>("ships.json");
|
||||||
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
|
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
|
||||||
@@ -47,8 +112,9 @@ public sealed class ScenarioLoader
|
|||||||
SystemId = system.Definition.Id,
|
SystemId = system.Definition.Id,
|
||||||
Position = new Vector3(
|
Position = new Vector3(
|
||||||
system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset),
|
system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset),
|
||||||
balance.YPlane,
|
system.Position.Y + balance.YPlane,
|
||||||
system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)),
|
system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)),
|
||||||
|
SourceKind = node.SourceKind,
|
||||||
ItemId = node.ItemId,
|
ItemId = node.ItemId,
|
||||||
OreRemaining = node.OreAmount,
|
OreRemaining = node.OreAmount,
|
||||||
MaxOre = node.OreAmount,
|
MaxOre = node.OreAmount,
|
||||||
@@ -71,9 +137,9 @@ public sealed class ScenarioLoader
|
|||||||
SystemId = system.Definition.Id,
|
SystemId = system.Definition.Id,
|
||||||
Definition = definition,
|
Definition = definition,
|
||||||
Position = ResolveStationPosition(system, plan, balance),
|
Position = ResolveStationPosition(system, plan, balance),
|
||||||
FactionId = plan.FactionId ?? "sol-dominion",
|
FactionId = plan.FactionId ?? DefaultFactionId,
|
||||||
OreStored = definition.Category == "refining" ? 120f : 0f,
|
OreStored = 0f,
|
||||||
RefinedStock = definition.Category == "shipyard" ? 180f : 40f,
|
RefinedStock = 0f,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +170,7 @@ public sealed class ScenarioLoader
|
|||||||
Id = $"ship-{++shipIdCounter}",
|
Id = $"ship-{++shipIdCounter}",
|
||||||
SystemId = formation.SystemId,
|
SystemId = formation.SystemId,
|
||||||
Definition = definition,
|
Definition = definition,
|
||||||
FactionId = formation.FactionId ?? "sol-dominion",
|
FactionId = formation.FactionId ?? DefaultFactionId,
|
||||||
Position = position,
|
Position = position,
|
||||||
TargetPosition = position,
|
TargetPosition = position,
|
||||||
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
|
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
|
||||||
@@ -114,21 +180,13 @@ public sealed class ScenarioLoader
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var factions = new List<FactionRuntime>
|
var factions = CreateFactions(stations, shipsRuntime);
|
||||||
{
|
BootstrapFactionEconomy(factions, stations);
|
||||||
new()
|
|
||||||
{
|
|
||||||
Id = "sol-dominion",
|
|
||||||
Label = "Sol Dominion",
|
|
||||||
Color = "#7ed4ff",
|
|
||||||
Credits = 240f,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return new SimulationWorld
|
return new SimulationWorld
|
||||||
{
|
{
|
||||||
Label = "Split Viewer / Simulation World",
|
Label = "Split Viewer / Simulation World",
|
||||||
Seed = 1,
|
Seed = WorldSeed,
|
||||||
Balance = balance,
|
Balance = balance,
|
||||||
Systems = systemRuntimes,
|
Systems = systemRuntimes,
|
||||||
Nodes = nodes,
|
Nodes = nodes,
|
||||||
@@ -140,6 +198,594 @@ public sealed class ScenarioLoader
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static List<SolarSystemDefinition> InjectSpecialSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
|
||||||
|
{
|
||||||
|
var systems = authoredSystems
|
||||||
|
.Select(CloneSystemDefinition)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (systems.All((system) => system.Id != "sol"))
|
||||||
|
{
|
||||||
|
systems.Add(CreateSolSystem());
|
||||||
|
}
|
||||||
|
|
||||||
|
return systems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<SolarSystemDefinition> ExpandSystems(IReadOnlyList<SolarSystemDefinition> authoredSystems)
|
||||||
|
{
|
||||||
|
var systems = authoredSystems
|
||||||
|
.Select(CloneSystemDefinition)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (systems.Count >= TargetSystemCount || authoredSystems.Count == 0)
|
||||||
|
{
|
||||||
|
return systems;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingIds = systems
|
||||||
|
.Select((system) => system.Id)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var generatedPositions = BuildGalaxyPositions(authoredSystems.Select((system) => ToVector(system.Position)).ToList(), TargetSystemCount - systems.Count);
|
||||||
|
|
||||||
|
for (var index = systems.Count; index < TargetSystemCount; index += 1)
|
||||||
|
{
|
||||||
|
var template = authoredSystems[index % authoredSystems.Count];
|
||||||
|
var name = GeneratedSystemNames[(index - authoredSystems.Count) % GeneratedSystemNames.Length];
|
||||||
|
var id = BuildGeneratedSystemId(name, index + 1);
|
||||||
|
while (!existingIds.Add(id))
|
||||||
|
{
|
||||||
|
id = $"{id}-x";
|
||||||
|
}
|
||||||
|
|
||||||
|
systems.Add(CreateGeneratedSystem(template, name, id, index - authoredSystems.Count, generatedPositions[index - authoredSystems.Count]));
|
||||||
|
}
|
||||||
|
|
||||||
|
return systems;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SolarSystemDefinition CreateGeneratedSystem(
|
||||||
|
SolarSystemDefinition template,
|
||||||
|
string label,
|
||||||
|
string id,
|
||||||
|
int generatedIndex,
|
||||||
|
Vector3 position)
|
||||||
|
{
|
||||||
|
var starProfile = SelectStarProfile(generatedIndex);
|
||||||
|
var planets = BuildGeneratedPlanets(template, generatedIndex);
|
||||||
|
|
||||||
|
var resourceNodes = BuildProceduralResourceNodes(template, planets, generatedIndex)
|
||||||
|
.Select((node) => new ResourceNodeDefinition
|
||||||
|
{
|
||||||
|
SourceKind = node.SourceKind,
|
||||||
|
Angle = node.Angle,
|
||||||
|
RadiusOffset = node.RadiusOffset,
|
||||||
|
OreAmount = node.OreAmount,
|
||||||
|
ItemId = node.ItemId,
|
||||||
|
ShardCount = node.ShardCount,
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new SolarSystemDefinition
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Label = label,
|
||||||
|
Position = [position.X, position.Y, position.Z],
|
||||||
|
StarKind = starProfile.Kind,
|
||||||
|
StarCount = starProfile.StarCount,
|
||||||
|
StarColor = starProfile.StarColor,
|
||||||
|
StarGlow = starProfile.StarGlow,
|
||||||
|
StarSize = starProfile.BaseSize + ((generatedIndex % 4) * 2f),
|
||||||
|
GravityWellRadius = template.GravityWellRadius + ((generatedIndex % 3) * 12f),
|
||||||
|
AsteroidField = new AsteroidFieldDefinition
|
||||||
|
{
|
||||||
|
DecorationCount = template.AsteroidField.DecorationCount + ((generatedIndex % 5) * 10),
|
||||||
|
RadiusOffset = template.AsteroidField.RadiusOffset + ((generatedIndex % 4) * 18f),
|
||||||
|
RadiusVariance = template.AsteroidField.RadiusVariance + ((generatedIndex % 3) * 12f),
|
||||||
|
HeightVariance = template.AsteroidField.HeightVariance + ((generatedIndex % 4) * 4f),
|
||||||
|
},
|
||||||
|
ResourceNodes = resourceNodes,
|
||||||
|
Planets = planets,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SolarSystemDefinition CloneSystemDefinition(SolarSystemDefinition definition)
|
||||||
|
{
|
||||||
|
return new SolarSystemDefinition
|
||||||
|
{
|
||||||
|
Id = definition.Id,
|
||||||
|
Label = definition.Label,
|
||||||
|
Position = definition.Position.ToArray(),
|
||||||
|
StarKind = definition.StarKind,
|
||||||
|
StarCount = definition.StarCount,
|
||||||
|
StarColor = definition.StarColor,
|
||||||
|
StarGlow = definition.StarGlow,
|
||||||
|
StarSize = definition.StarSize,
|
||||||
|
GravityWellRadius = definition.GravityWellRadius,
|
||||||
|
AsteroidField = new AsteroidFieldDefinition
|
||||||
|
{
|
||||||
|
DecorationCount = definition.AsteroidField.DecorationCount,
|
||||||
|
RadiusOffset = definition.AsteroidField.RadiusOffset,
|
||||||
|
RadiusVariance = definition.AsteroidField.RadiusVariance,
|
||||||
|
HeightVariance = definition.AsteroidField.HeightVariance,
|
||||||
|
},
|
||||||
|
ResourceNodes = definition.ResourceNodes
|
||||||
|
.Select((node) => new ResourceNodeDefinition
|
||||||
|
{
|
||||||
|
Angle = node.Angle,
|
||||||
|
RadiusOffset = node.RadiusOffset,
|
||||||
|
OreAmount = node.OreAmount,
|
||||||
|
ItemId = node.ItemId,
|
||||||
|
ShardCount = node.ShardCount,
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
Planets = definition.Planets
|
||||||
|
.Select((planet) => new PlanetDefinition
|
||||||
|
{
|
||||||
|
Label = planet.Label,
|
||||||
|
PlanetType = planet.PlanetType,
|
||||||
|
Shape = planet.Shape,
|
||||||
|
MoonCount = planet.MoonCount,
|
||||||
|
OrbitRadius = planet.OrbitRadius,
|
||||||
|
OrbitSpeed = planet.OrbitSpeed,
|
||||||
|
OrbitEccentricity = planet.OrbitEccentricity,
|
||||||
|
OrbitInclination = planet.OrbitInclination,
|
||||||
|
OrbitLongitudeOfAscendingNode = planet.OrbitLongitudeOfAscendingNode,
|
||||||
|
OrbitArgumentOfPeriapsis = planet.OrbitArgumentOfPeriapsis,
|
||||||
|
OrbitPhaseAtEpoch = planet.OrbitPhaseAtEpoch,
|
||||||
|
Size = planet.Size,
|
||||||
|
Color = planet.Color,
|
||||||
|
Tilt = planet.Tilt,
|
||||||
|
HasRing = planet.HasRing,
|
||||||
|
})
|
||||||
|
.ToList(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<ResourceNodeDefinition> BuildProceduralResourceNodes(
|
||||||
|
SolarSystemDefinition template,
|
||||||
|
IReadOnlyList<PlanetDefinition> planets,
|
||||||
|
int generatedIndex)
|
||||||
|
{
|
||||||
|
var nodes = new List<ResourceNodeDefinition>();
|
||||||
|
if (template.ResourceNodes.Count > 0)
|
||||||
|
{
|
||||||
|
nodes.AddRange(template.ResourceNodes.Select((node) => new ResourceNodeDefinition
|
||||||
|
{
|
||||||
|
SourceKind = node.SourceKind,
|
||||||
|
Angle = node.Angle,
|
||||||
|
RadiusOffset = node.RadiusOffset,
|
||||||
|
OreAmount = node.OreAmount,
|
||||||
|
ItemId = node.ItemId,
|
||||||
|
ShardCount = node.ShardCount,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.AddRange(BuildAsteroidBeltNodes(generatedIndex, planets));
|
||||||
|
nodes.AddRange(BuildGasCloudNodes(generatedIndex, planets));
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Vector3> BuildGalaxyPositions(IReadOnlyCollection<Vector3> occupiedPositions, int count)
|
||||||
|
{
|
||||||
|
var allPositions = occupiedPositions.ToList();
|
||||||
|
var generated = new List<Vector3>(count);
|
||||||
|
|
||||||
|
for (var index = 0; index < count; index += 1)
|
||||||
|
{
|
||||||
|
Vector3? accepted = null;
|
||||||
|
for (var attempt = 0; attempt < 64; attempt += 1)
|
||||||
|
{
|
||||||
|
var candidate = ComputeGeneratedSystemPosition(index, attempt);
|
||||||
|
if (allPositions.All((existing) => existing.DistanceTo(candidate) >= MinimumSystemSeparation))
|
||||||
|
{
|
||||||
|
accepted = candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accepted ??= ComputeFallbackGeneratedSystemPosition(index);
|
||||||
|
generated.Add(accepted.Value);
|
||||||
|
allPositions.Add(accepted.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return generated;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector3 ComputeGeneratedSystemPosition(int generatedIndex, int attempt)
|
||||||
|
{
|
||||||
|
const int armCount = 4;
|
||||||
|
const float baseInnerRadius = 9000f;
|
||||||
|
const float radiusStep = 540f;
|
||||||
|
const float armOffset = MathF.PI * 2f / armCount;
|
||||||
|
|
||||||
|
var armIndex = (generatedIndex + attempt) % armCount;
|
||||||
|
var armDepth = generatedIndex / armCount;
|
||||||
|
var radius = baseInnerRadius + (armDepth * radiusStep) + Jitter(generatedIndex * 17 + attempt, 0, 900f);
|
||||||
|
var angle = (armIndex * armOffset) + (radius / 8200f) + Jitter(generatedIndex, 1 + attempt, 0.16f);
|
||||||
|
var x = MathF.Cos(angle) * radius;
|
||||||
|
var z = MathF.Sin(angle) * radius * 0.58f;
|
||||||
|
var y = ComputeSystemHeight(radius, generatedIndex, attempt);
|
||||||
|
return new Vector3(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Vector3 ComputeFallbackGeneratedSystemPosition(int generatedIndex)
|
||||||
|
{
|
||||||
|
const int ringCount = 5;
|
||||||
|
const float fallbackRadius = 42000f;
|
||||||
|
var angle = (generatedIndex % ringCount) * (MathF.PI * 2f / ringCount) + (generatedIndex / ringCount) * 0.22f;
|
||||||
|
var radius = fallbackRadius + (generatedIndex / ringCount) * 1800f;
|
||||||
|
return new Vector3(
|
||||||
|
MathF.Cos(angle) * radius,
|
||||||
|
ComputeSystemHeight(radius, generatedIndex, 99),
|
||||||
|
MathF.Sin(angle) * radius * 0.6f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildGeneratedSystemId(string label, int ordinal)
|
||||||
|
{
|
||||||
|
var slug = string.Concat(label
|
||||||
|
.ToLowerInvariant()
|
||||||
|
.Select((character) => char.IsLetterOrDigit(character) ? character : '-'))
|
||||||
|
.Trim('-');
|
||||||
|
|
||||||
|
return $"gen-{ordinal}-{slug}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ResourceNodeDefinition> BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
|
||||||
|
{
|
||||||
|
var beltRadius = ResolveAsteroidBeltRadius(planets, generatedIndex);
|
||||||
|
var nodeCount = 4 + (generatedIndex % 4);
|
||||||
|
var oreAmount = 2800f + ((generatedIndex % 5) * 320f);
|
||||||
|
|
||||||
|
for (var index = 0; index < nodeCount; index += 1)
|
||||||
|
{
|
||||||
|
yield return new ResourceNodeDefinition
|
||||||
|
{
|
||||||
|
SourceKind = "asteroid-belt",
|
||||||
|
Angle = ((MathF.PI * 2f) / nodeCount) * index + Jitter(generatedIndex, 180 + index, 0.22f),
|
||||||
|
RadiusOffset = beltRadius + Jitter(generatedIndex, 200 + index, 80f),
|
||||||
|
OreAmount = oreAmount,
|
||||||
|
ItemId = "ore",
|
||||||
|
ShardCount = 6 + (index % 4),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<ResourceNodeDefinition> BuildGasCloudNodes(int generatedIndex, IReadOnlyList<PlanetDefinition> planets)
|
||||||
|
{
|
||||||
|
var gasAnchor = planets
|
||||||
|
.Where((planet) => planet.PlanetType is "gas-giant" or "ice-giant")
|
||||||
|
.OrderByDescending((planet) => planet.OrbitRadius)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (gasAnchor is null)
|
||||||
|
{
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nodeCount = 2 + (generatedIndex % 3);
|
||||||
|
var gasAmount = 2200f + ((generatedIndex % 4) * 260f);
|
||||||
|
for (var index = 0; index < nodeCount; index += 1)
|
||||||
|
{
|
||||||
|
yield return new ResourceNodeDefinition
|
||||||
|
{
|
||||||
|
SourceKind = "gas-cloud",
|
||||||
|
Angle = gasAnchor.OrbitPhaseAtEpoch * (MathF.PI / 180f) + (((MathF.PI * 2f) / nodeCount) * index) + Jitter(generatedIndex, 240 + index, 0.18f),
|
||||||
|
RadiusOffset = gasAnchor.OrbitRadius + 90f + Jitter(generatedIndex, 260 + index, 70f),
|
||||||
|
OreAmount = gasAmount,
|
||||||
|
ItemId = "gas",
|
||||||
|
ShardCount = 10 + index,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ResolveAsteroidBeltRadius(IReadOnlyList<PlanetDefinition> planets, int generatedIndex)
|
||||||
|
{
|
||||||
|
var gap = planets
|
||||||
|
.Zip(planets.Skip(1), (left, right) => (LeftOrbitRadius: left.OrbitRadius, RightOrbitRadius: right.OrbitRadius, Gap: right.OrbitRadius - left.OrbitRadius))
|
||||||
|
.OrderByDescending((entry) => entry.Gap)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
if (gap.Gap > 1f)
|
||||||
|
{
|
||||||
|
return gap.LeftOrbitRadius + (gap.Gap * 0.52f);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 420f + ((generatedIndex % 5) * 60f);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<PlanetDefinition> BuildGeneratedPlanets(
|
||||||
|
SolarSystemDefinition template,
|
||||||
|
int generatedIndex)
|
||||||
|
{
|
||||||
|
var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f);
|
||||||
|
var planets = new List<PlanetDefinition>(planetCount);
|
||||||
|
var orbitRadius = 140f + (Hash01(generatedIndex, 3) * 35f);
|
||||||
|
var sourcePlanets = template.Planets.Count > 0 ? template.Planets : null;
|
||||||
|
|
||||||
|
for (var index = 0; index < planetCount; index += 1)
|
||||||
|
{
|
||||||
|
var profile = SelectPlanetProfile(generatedIndex, index);
|
||||||
|
var templatePlanet = sourcePlanets is not null && sourcePlanets.Count > 0
|
||||||
|
? sourcePlanets[index % sourcePlanets.Count]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
orbitRadius += profile.OrbitGapMin + (Hash01(generatedIndex, 10 + index) * (profile.OrbitGapMax - profile.OrbitGapMin));
|
||||||
|
var orbitEccentricity = 0.01f + (Hash01(generatedIndex, 20 + index) * 0.16f);
|
||||||
|
var orbitInclination = -9f + (Hash01(generatedIndex, 30 + index) * 18f);
|
||||||
|
var moonVariance = (int)MathF.Floor(Hash01(generatedIndex, 40 + index) * 3f);
|
||||||
|
|
||||||
|
planets.Add(new PlanetDefinition
|
||||||
|
{
|
||||||
|
Label = $"{BuildPlanetBaseName(generatedIndex, index)}-{index + 1}",
|
||||||
|
PlanetType = profile.Type,
|
||||||
|
Shape = profile.Shape,
|
||||||
|
MoonCount = profile.BaseMoonCount + moonVariance,
|
||||||
|
OrbitRadius = orbitRadius,
|
||||||
|
OrbitSpeed = 0.22f / MathF.Sqrt(MathF.Max(1f, orbitRadius / 120f)),
|
||||||
|
OrbitEccentricity = orbitEccentricity,
|
||||||
|
OrbitInclination = orbitInclination,
|
||||||
|
OrbitLongitudeOfAscendingNode = Hash01(generatedIndex, 120 + index) * 360f,
|
||||||
|
OrbitArgumentOfPeriapsis = Hash01(generatedIndex, 140 + index) * 360f,
|
||||||
|
OrbitPhaseAtEpoch = Hash01(generatedIndex, 160 + index) * 360f,
|
||||||
|
Size = profile.BaseSize + (Hash01(generatedIndex, 50 + index) * 10f),
|
||||||
|
Color = templatePlanet?.Color ?? profile.Color,
|
||||||
|
Tilt = -0.45f + (Hash01(generatedIndex, 60 + index) * 0.9f),
|
||||||
|
HasRing = profile.CanHaveRing && Hash01(generatedIndex, 70 + index) > 0.55f,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return planets;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static StarProfile SelectStarProfile(int generatedIndex)
|
||||||
|
{
|
||||||
|
var value = Hash01(generatedIndex, 80);
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
< 0.32f => StarProfiles[0],
|
||||||
|
< 0.54f => StarProfiles[1],
|
||||||
|
< 0.68f => StarProfiles[5],
|
||||||
|
< 0.8f => StarProfiles[2],
|
||||||
|
< 0.9f => StarProfiles[3],
|
||||||
|
< 0.97f => StarProfiles[6],
|
||||||
|
_ => StarProfiles[4],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanetProfile SelectPlanetProfile(int generatedIndex, int planetIndex)
|
||||||
|
{
|
||||||
|
var value = Hash01(generatedIndex, 90 + planetIndex);
|
||||||
|
return value switch
|
||||||
|
{
|
||||||
|
< 0.14f => PlanetProfiles[7],
|
||||||
|
< 0.28f => PlanetProfiles[0],
|
||||||
|
< 0.46f => PlanetProfiles[3],
|
||||||
|
< 0.62f => PlanetProfiles[1],
|
||||||
|
< 0.74f => PlanetProfiles[2],
|
||||||
|
< 0.86f => PlanetProfiles[4],
|
||||||
|
< 0.94f => PlanetProfiles[6],
|
||||||
|
_ => PlanetProfiles[5],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPlanetBaseName(int generatedIndex, int planetIndex)
|
||||||
|
{
|
||||||
|
var source = GeneratedSystemNames[generatedIndex % GeneratedSystemNames.Length]
|
||||||
|
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)[0];
|
||||||
|
return source[..Math.Min(source.Length, 6)];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float ComputeSystemHeight(float radius, int generatedIndex, int salt)
|
||||||
|
{
|
||||||
|
var normalized = MathF.Min(1f, MathF.Max(0f, (radius - 8000f) / 28000f));
|
||||||
|
var band = 220f + (normalized * 760f);
|
||||||
|
return (Hash01(generatedIndex, 100 + salt) * 2f - 1f) * band;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static float Jitter(int index, int salt, float amplitude) =>
|
||||||
|
(Hash01(index, salt) * 2f - 1f) * amplitude;
|
||||||
|
|
||||||
|
private static float Hash01(int index, int salt)
|
||||||
|
{
|
||||||
|
uint value = (uint)(index + 1);
|
||||||
|
value ^= (uint)(salt + 0x9e3779b9);
|
||||||
|
value *= 0x85ebca6b;
|
||||||
|
value ^= value >> 13;
|
||||||
|
value *= 0xc2b2ae35;
|
||||||
|
value ^= value >> 16;
|
||||||
|
return (value & 0x00ffffff) / 16777215f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record StarProfile(
|
||||||
|
string Kind,
|
||||||
|
string StarColor,
|
||||||
|
string StarGlow,
|
||||||
|
float BaseSize,
|
||||||
|
int StarCount);
|
||||||
|
|
||||||
|
private sealed record PlanetProfile(
|
||||||
|
string Type,
|
||||||
|
string Shape,
|
||||||
|
string Color,
|
||||||
|
float BaseSize,
|
||||||
|
float OrbitGapMin,
|
||||||
|
int BaseMoonCount,
|
||||||
|
bool CanHaveRing)
|
||||||
|
{
|
||||||
|
public float OrbitGapMax => OrbitGapMin + 44f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SolarSystemDefinition CreateSolSystem()
|
||||||
|
{
|
||||||
|
return new SolarSystemDefinition
|
||||||
|
{
|
||||||
|
Id = "sol",
|
||||||
|
Label = "Sol",
|
||||||
|
Position = [18200f, 24f, -11800f],
|
||||||
|
StarKind = "main-sequence",
|
||||||
|
StarCount = 1,
|
||||||
|
StarColor = "#fff1b8",
|
||||||
|
StarGlow = "#ffd35a",
|
||||||
|
StarSize = 58f,
|
||||||
|
GravityWellRadius = 240f,
|
||||||
|
AsteroidField = new AsteroidFieldDefinition
|
||||||
|
{
|
||||||
|
DecorationCount = 240,
|
||||||
|
RadiusOffset = 780f,
|
||||||
|
RadiusVariance = 180f,
|
||||||
|
HeightVariance = 22f,
|
||||||
|
},
|
||||||
|
ResourceNodes =
|
||||||
|
[
|
||||||
|
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 0.2f, RadiusOffset = 720f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||||
|
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 1.8f, RadiusOffset = 760f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||||
|
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 3.5f, RadiusOffset = 810f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||||
|
new ResourceNodeDefinition { SourceKind = "asteroid-belt", Angle = 5.1f, RadiusOffset = 780f, OreAmount = 4200f, ItemId = "ore", ShardCount = 9 },
|
||||||
|
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 0.9f, RadiusOffset = 1650f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
|
||||||
|
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 2.7f, RadiusOffset = 1710f, OreAmount = 2800f, ItemId = "gas", ShardCount = 12 },
|
||||||
|
new ResourceNodeDefinition { SourceKind = "gas-cloud", Angle = 4.8f, RadiusOffset = 2140f, OreAmount = 2600f, ItemId = "gas", ShardCount = 10 },
|
||||||
|
],
|
||||||
|
Planets =
|
||||||
|
[
|
||||||
|
CreateSolPlanet("Mercury", "barren", "sphere", 0, 180f, 0.19f, 0.2056f, 7.0f, 48f, 29f, 252f, "#b7a08f", 0.03f, false),
|
||||||
|
CreateSolPlanet("Venus", "desert", "sphere", 0, 270f, 0.14f, 0.0067f, 3.4f, 76f, 54f, 181f, "#d9b38c", 2.64f, false),
|
||||||
|
CreateSolPlanet("Earth", "terrestrial", "sphere", 1, 380f, 0.11f, 0.0167f, 0.0f, 0f, 114f, 100f, "#4f84c4", 0.41f, false),
|
||||||
|
CreateSolPlanet("Mars", "desert", "sphere", 2, 500f, 0.09f, 0.0934f, 1.85f, 49f, 286f, 54f, "#c56e52", 0.44f, false),
|
||||||
|
CreateSolPlanet("Jupiter", "gas-giant", "oblate", 95, 980f, 0.05f, 0.0489f, 1.3f, 100f, 275f, 34f, "#d9b06f", 0.05f, true),
|
||||||
|
CreateSolPlanet("Saturn", "gas-giant", "oblate", 146, 1380f, 0.035f, 0.0565f, 2.49f, 113f, 339f, 200f, "#dfc27d", 0.47f, true),
|
||||||
|
CreateSolPlanet("Uranus", "ice-giant", "oblate", 28, 1760f, 0.026f, 0.046f, 0.77f, 74f, 97f, 130f, "#9fd3df", 1.71f, true),
|
||||||
|
CreateSolPlanet("Neptune", "ice-giant", "oblate", 16, 2140f, 0.021f, 0.009f, 1.77f, 132f, 273f, 256f, "#4c79c9", 0.49f, true)
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlanetDefinition CreateSolPlanet(
|
||||||
|
string label,
|
||||||
|
string planetType,
|
||||||
|
string shape,
|
||||||
|
int moonCount,
|
||||||
|
float orbitRadius,
|
||||||
|
float orbitSpeed,
|
||||||
|
float orbitEccentricity,
|
||||||
|
float orbitInclination,
|
||||||
|
float ascendingNode,
|
||||||
|
float argumentOfPeriapsis,
|
||||||
|
float phaseAtEpoch,
|
||||||
|
string color,
|
||||||
|
float tilt,
|
||||||
|
bool hasRing)
|
||||||
|
{
|
||||||
|
return new PlanetDefinition
|
||||||
|
{
|
||||||
|
Label = label,
|
||||||
|
PlanetType = planetType,
|
||||||
|
Shape = shape,
|
||||||
|
MoonCount = moonCount,
|
||||||
|
OrbitRadius = orbitRadius,
|
||||||
|
OrbitSpeed = orbitSpeed,
|
||||||
|
OrbitEccentricity = orbitEccentricity,
|
||||||
|
OrbitInclination = orbitInclination,
|
||||||
|
OrbitLongitudeOfAscendingNode = ascendingNode,
|
||||||
|
OrbitArgumentOfPeriapsis = argumentOfPeriapsis,
|
||||||
|
OrbitPhaseAtEpoch = phaseAtEpoch,
|
||||||
|
Size = planetType switch
|
||||||
|
{
|
||||||
|
"gas-giant" => label == "Saturn" ? 66f : 72f,
|
||||||
|
"ice-giant" => 48f,
|
||||||
|
_ => label == "Earth" ? 28f : label == "Mars" ? 22f : label == "Venus" ? 26f : 20f,
|
||||||
|
},
|
||||||
|
Color = color,
|
||||||
|
Tilt = tilt,
|
||||||
|
HasRing = hasRing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FactionRuntime> CreateFactions(
|
||||||
|
IReadOnlyCollection<StationRuntime> stations,
|
||||||
|
IReadOnlyCollection<ShipRuntime> ships)
|
||||||
|
{
|
||||||
|
var factionIds = stations
|
||||||
|
.Select((station) => station.FactionId)
|
||||||
|
.Concat(ships.Select((ship) => ship.FactionId))
|
||||||
|
.Where((factionId) => !string.IsNullOrWhiteSpace(factionId))
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.OrderBy((factionId) => factionId, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (factionIds.Count == 0)
|
||||||
|
{
|
||||||
|
factionIds.Add(DefaultFactionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return factionIds.Select(CreateFaction).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static FactionRuntime CreateFaction(string factionId)
|
||||||
|
{
|
||||||
|
return factionId switch
|
||||||
|
{
|
||||||
|
DefaultFactionId => new FactionRuntime
|
||||||
|
{
|
||||||
|
Id = factionId,
|
||||||
|
Label = "Sol Dominion",
|
||||||
|
Color = "#7ed4ff",
|
||||||
|
Credits = MinimumFactionCredits,
|
||||||
|
},
|
||||||
|
_ => new FactionRuntime
|
||||||
|
{
|
||||||
|
Id = factionId,
|
||||||
|
Label = ToFactionLabel(factionId),
|
||||||
|
Color = "#c7d2e0",
|
||||||
|
Credits = MinimumFactionCredits,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void BootstrapFactionEconomy(
|
||||||
|
IReadOnlyCollection<FactionRuntime> factions,
|
||||||
|
IReadOnlyCollection<StationRuntime> stations)
|
||||||
|
{
|
||||||
|
foreach (var faction in factions)
|
||||||
|
{
|
||||||
|
faction.Credits = MathF.Max(faction.Credits, MinimumFactionCredits);
|
||||||
|
|
||||||
|
var ownedStations = stations
|
||||||
|
.Where((station) => station.FactionId == faction.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var refineries = ownedStations
|
||||||
|
.Where((station) => station.Definition.Category == "refining")
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (refineries.Count > 0)
|
||||||
|
{
|
||||||
|
foreach (var refinery in refineries)
|
||||||
|
{
|
||||||
|
refinery.RefinedStock = MathF.Max(refinery.RefinedStock, MinimumRefineryStock);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refineries.All((station) => station.OreStored < MinimumRefineryOre))
|
||||||
|
{
|
||||||
|
refineries[0].OreStored = MinimumRefineryOre;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var shipyard in ownedStations.Where((station) => station.Definition.Category == "shipyard"))
|
||||||
|
{
|
||||||
|
shipyard.RefinedStock = MathF.Max(shipyard.RefinedStock, MinimumShipyardStock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToFactionLabel(string factionId)
|
||||||
|
{
|
||||||
|
return string.Join(" ",
|
||||||
|
factionId
|
||||||
|
.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Select((segment) => char.ToUpperInvariant(segment[0]) + segment[1..]));
|
||||||
|
}
|
||||||
|
|
||||||
private T Read<T>(string fileName)
|
private T Read<T>(string fileName)
|
||||||
{
|
{
|
||||||
var path = Path.Combine(_dataRoot, fileName);
|
var path = Path.Combine(_dataRoot, fileName);
|
||||||
@@ -195,11 +841,11 @@ public sealed class ScenarioLoader
|
|||||||
var side = plan.LagrangeSide ?? 1;
|
var side = plan.LagrangeSide ?? 1;
|
||||||
return new Vector3(
|
return new Vector3(
|
||||||
system.Position.X + planet.OrbitRadius + (side * 72f),
|
system.Position.X + planet.OrbitRadius + (side * 72f),
|
||||||
balance.YPlane,
|
system.Position.Y + balance.YPlane,
|
||||||
system.Position.Z + ((planetIndex + 1) * 42f * side));
|
system.Position.Z + ((planetIndex + 1) * 42f * side));
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Vector3(system.Position.X + 180f, balance.YPlane, system.Position.Z);
|
return new Vector3(system.Position.X + 180f, system.Position.Y + balance.YPlane, system.Position.Z);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
|
private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
|
||||||
|
|||||||
@@ -55,11 +55,22 @@ public sealed class SimulationEngine
|
|||||||
system.Definition.Id,
|
system.Definition.Id,
|
||||||
system.Definition.Label,
|
system.Definition.Label,
|
||||||
ToDto(system.Position),
|
ToDto(system.Position),
|
||||||
|
system.Definition.StarKind,
|
||||||
|
system.Definition.StarCount,
|
||||||
system.Definition.StarColor,
|
system.Definition.StarColor,
|
||||||
system.Definition.StarSize,
|
system.Definition.StarSize,
|
||||||
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
|
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
|
||||||
planet.Label,
|
planet.Label,
|
||||||
|
planet.PlanetType,
|
||||||
|
planet.Shape,
|
||||||
|
planet.MoonCount,
|
||||||
planet.OrbitRadius,
|
planet.OrbitRadius,
|
||||||
|
planet.OrbitSpeed,
|
||||||
|
planet.OrbitEccentricity,
|
||||||
|
planet.OrbitInclination,
|
||||||
|
planet.OrbitLongitudeOfAscendingNode,
|
||||||
|
planet.OrbitArgumentOfPeriapsis,
|
||||||
|
planet.OrbitPhaseAtEpoch,
|
||||||
planet.Size,
|
planet.Size,
|
||||||
planet.Color,
|
planet.Color,
|
||||||
planet.HasRing)).ToList())).ToList(),
|
planet.HasRing)).ToList())).ToList(),
|
||||||
@@ -67,6 +78,7 @@ public sealed class SimulationEngine
|
|||||||
node.Id,
|
node.Id,
|
||||||
node.SystemId,
|
node.SystemId,
|
||||||
node.Position,
|
node.Position,
|
||||||
|
node.SourceKind,
|
||||||
node.OreRemaining,
|
node.OreRemaining,
|
||||||
node.MaxOre,
|
node.MaxOre,
|
||||||
node.ItemId)).ToList(),
|
node.ItemId)).ToList(),
|
||||||
@@ -238,6 +250,7 @@ public sealed class SimulationEngine
|
|||||||
node.Id,
|
node.Id,
|
||||||
node.SystemId,
|
node.SystemId,
|
||||||
ToDto(node.Position),
|
ToDto(node.Position),
|
||||||
|
node.SourceKind,
|
||||||
node.OreRemaining,
|
node.OreRemaining,
|
||||||
node.MaxOre,
|
node.MaxOre,
|
||||||
node.ItemId);
|
node.ItemId);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import * as THREE from "three";
|
|||||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||||
import type {
|
import type {
|
||||||
FactionSnapshot,
|
FactionSnapshot,
|
||||||
|
PlanetSnapshot,
|
||||||
ResourceNodeDelta,
|
ResourceNodeDelta,
|
||||||
ResourceNodeSnapshot,
|
ResourceNodeSnapshot,
|
||||||
ShipDelta,
|
ShipDelta,
|
||||||
@@ -36,6 +37,19 @@ interface ShipVisual {
|
|||||||
blendDurationMs: number;
|
blendDurationMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlanetVisual {
|
||||||
|
planet: PlanetSnapshot;
|
||||||
|
mesh: THREE.Mesh;
|
||||||
|
icon: THREE.Sprite;
|
||||||
|
ring?: THREE.Mesh;
|
||||||
|
moons: MoonVisual[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MoonVisual {
|
||||||
|
mesh: THREE.Mesh;
|
||||||
|
orbit: THREE.LineLoop;
|
||||||
|
}
|
||||||
|
|
||||||
interface WorldState {
|
interface WorldState {
|
||||||
label: string;
|
label: string;
|
||||||
seed: number;
|
seed: number;
|
||||||
@@ -116,6 +130,7 @@ export class GameViewer {
|
|||||||
private readonly stationMeshes = new Map<string, THREE.Mesh>();
|
private readonly stationMeshes = new Map<string, THREE.Mesh>();
|
||||||
private readonly shipVisuals = new Map<string, ShipVisual>();
|
private readonly shipVisuals = new Map<string, ShipVisual>();
|
||||||
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
|
private readonly systemSummaryVisuals = new Map<string, SystemSummaryVisual>();
|
||||||
|
private readonly planetVisuals: PlanetVisual[] = [];
|
||||||
private readonly orbitLines: THREE.Object3D[] = [];
|
private readonly orbitLines: THREE.Object3D[] = [];
|
||||||
private readonly statusEl: HTMLDivElement;
|
private readonly statusEl: HTMLDivElement;
|
||||||
private readonly detailTitleEl: HTMLHeadingElement;
|
private readonly detailTitleEl: HTMLHeadingElement;
|
||||||
@@ -126,6 +141,7 @@ export class GameViewer {
|
|||||||
private readonly marqueeEl: HTMLDivElement;
|
private readonly marqueeEl: HTMLDivElement;
|
||||||
|
|
||||||
private world?: WorldState;
|
private world?: WorldState;
|
||||||
|
private worldTimeSyncMs = performance.now();
|
||||||
private stream?: EventSource;
|
private stream?: EventSource;
|
||||||
private readonly networkStats: NetworkStats = {
|
private readonly networkStats: NetworkStats = {
|
||||||
snapshotBytes: 0,
|
snapshotBytes: 0,
|
||||||
@@ -286,6 +302,7 @@ export class GameViewer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applySnapshot(snapshot: WorldSnapshot) {
|
private applySnapshot(snapshot: WorldSnapshot) {
|
||||||
|
this.worldTimeSyncMs = performance.now();
|
||||||
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
||||||
if (signature !== this.worldSignature) {
|
if (signature !== this.worldSignature) {
|
||||||
this.worldSignature = signature;
|
this.worldSignature = signature;
|
||||||
@@ -306,6 +323,7 @@ export class GameViewer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.worldTimeSyncMs = performance.now();
|
||||||
this.world.sequence = delta.sequence;
|
this.world.sequence = delta.sequence;
|
||||||
this.world.tickIntervalMs = delta.tickIntervalMs;
|
this.world.tickIntervalMs = delta.tickIntervalMs;
|
||||||
this.world.generatedAtUtc = delta.generatedAtUtc;
|
this.world.generatedAtUtc = delta.generatedAtUtc;
|
||||||
@@ -337,6 +355,7 @@ export class GameViewer {
|
|||||||
this.systemGroup.clear();
|
this.systemGroup.clear();
|
||||||
this.selectableTargets.clear();
|
this.selectableTargets.clear();
|
||||||
this.presentationEntries.length = 0;
|
this.presentationEntries.length = 0;
|
||||||
|
this.planetVisuals.length = 0;
|
||||||
this.orbitLines.length = 0;
|
this.orbitLines.length = 0;
|
||||||
this.systemSummaryVisuals.clear();
|
this.systemSummaryVisuals.clear();
|
||||||
|
|
||||||
@@ -344,58 +363,56 @@ export class GameViewer {
|
|||||||
const root = new THREE.Group();
|
const root = new THREE.Group();
|
||||||
root.position.set(system.position.x, system.position.y, system.position.z);
|
root.position.set(system.position.x, system.position.y, system.position.z);
|
||||||
|
|
||||||
const star = new THREE.Mesh(
|
const starCluster = this.createStarCluster(system);
|
||||||
new THREE.SphereGeometry(system.starSize, 32, 32),
|
|
||||||
new THREE.MeshBasicMaterial({ color: system.starColor }),
|
|
||||||
);
|
|
||||||
const halo = new THREE.Mesh(
|
|
||||||
new THREE.SphereGeometry(system.starSize * 1.65, 24, 24),
|
|
||||||
new THREE.MeshBasicMaterial({
|
|
||||||
color: system.starColor,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.14,
|
|
||||||
side: THREE.BackSide,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const systemIcon = this.createTacticalIcon(system.starColor, 96);
|
const systemIcon = this.createTacticalIcon(system.starColor, 96);
|
||||||
const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 110, system.position.z));
|
const summaryVisual = this.createSystemSummaryVisual(new THREE.Vector3(system.position.x, system.position.y + system.starSize + 140, system.position.z));
|
||||||
summaryVisual.sprite.position.set(0, system.starSize + 110, 0);
|
summaryVisual.sprite.position.set(0, system.starSize + 110, 0);
|
||||||
root.add(star, halo, systemIcon, summaryVisual.sprite);
|
root.add(starCluster, systemIcon, summaryVisual.sprite);
|
||||||
this.registerPresentation(star, systemIcon, true);
|
this.registerPresentation(starCluster, systemIcon, true);
|
||||||
this.registerPresentation(halo, systemIcon, true);
|
|
||||||
this.systemSummaryVisuals.set(system.id, summaryVisual);
|
this.systemSummaryVisuals.set(system.id, summaryVisual);
|
||||||
this.selectableTargets.set(star, { kind: "system", id: system.id });
|
starCluster.traverse((child) => {
|
||||||
this.selectableTargets.set(halo, { kind: "system", id: system.id });
|
if (child instanceof THREE.Mesh) {
|
||||||
|
this.selectableTargets.set(child, { kind: "system", id: system.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
this.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
|
this.selectableTargets.set(systemIcon, { kind: "system", id: system.id });
|
||||||
|
|
||||||
for (const [planetIndex, planet] of system.planets.entries()) {
|
for (const [planetIndex, planet] of system.planets.entries()) {
|
||||||
const orbit = new THREE.LineLoop(
|
const orbit = this.createPlanetOrbit(planet);
|
||||||
new THREE.BufferGeometry().setFromPoints(
|
|
||||||
Array.from({ length: 80 }, (_, index) => {
|
|
||||||
const angle = (index / 80) * Math.PI * 2;
|
|
||||||
return new THREE.Vector3(
|
|
||||||
Math.cos(angle) * planet.orbitRadius,
|
|
||||||
0,
|
|
||||||
Math.sin(angle) * planet.orbitRadius,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.45 }),
|
|
||||||
);
|
|
||||||
const planetMesh = new THREE.Mesh(
|
const planetMesh = new THREE.Mesh(
|
||||||
new THREE.SphereGeometry(planet.size, 18, 18),
|
new THREE.SphereGeometry(planet.size, 18, 18),
|
||||||
new THREE.MeshStandardMaterial({
|
new THREE.MeshStandardMaterial({
|
||||||
color: planet.color,
|
color: planet.color,
|
||||||
roughness: 0.92,
|
roughness: 0.92,
|
||||||
metalness: 0.08,
|
metalness: 0.08,
|
||||||
|
emissive: new THREE.Color(planet.color).multiplyScalar(0.04),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
planetMesh.position.set(planet.orbitRadius, 0, 0);
|
planetMesh.position.copy(this.computePlanetLocalPosition(planet, this.currentWorldTimeSeconds()));
|
||||||
const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, planet.size * 2));
|
const planetIcon = this.createTacticalIcon(planet.color, Math.max(24, planet.size * 2));
|
||||||
planetIcon.position.copy(planetMesh.position);
|
planetIcon.position.copy(planetMesh.position);
|
||||||
|
const ring = planet.hasRing ? this.createPlanetRing(planet) : undefined;
|
||||||
|
if (ring) {
|
||||||
|
ring.position.copy(planetMesh.position);
|
||||||
|
}
|
||||||
|
const moons = this.createMoonVisuals(planet);
|
||||||
root.add(orbit, planetMesh, planetIcon);
|
root.add(orbit, planetMesh, planetIcon);
|
||||||
|
if (ring) {
|
||||||
|
root.add(ring);
|
||||||
|
}
|
||||||
|
for (const moon of moons) {
|
||||||
|
moon.orbit.position.copy(planetMesh.position);
|
||||||
|
moon.mesh.position.copy(planetMesh.position);
|
||||||
|
root.add(moon.orbit, moon.mesh);
|
||||||
|
this.orbitLines.push(moon.orbit);
|
||||||
|
this.registerPresentation(moon.mesh, planetIcon, true, true);
|
||||||
|
}
|
||||||
this.orbitLines.push(orbit);
|
this.orbitLines.push(orbit);
|
||||||
this.registerPresentation(planetMesh, planetIcon, true, true);
|
this.registerPresentation(planetMesh, planetIcon, true, true);
|
||||||
|
if (ring) {
|
||||||
|
this.registerPresentation(ring, planetIcon, true, true);
|
||||||
|
}
|
||||||
|
this.planetVisuals.push({ planet, mesh: planetMesh, icon: planetIcon, ring, moons });
|
||||||
this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
this.selectableTargets.set(planetMesh, { kind: "planet", systemId: system.id, planetIndex });
|
||||||
this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
|
this.selectableTargets.set(planetIcon, { kind: "planet", systemId: system.id, planetIndex });
|
||||||
}
|
}
|
||||||
@@ -410,7 +427,7 @@ export class GameViewer {
|
|||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const mesh = this.createNodeMesh(node);
|
const mesh = this.createNodeMesh(node);
|
||||||
const icon = this.createTacticalIcon("#d2b07a", 20);
|
const icon = this.createTacticalIcon(node.sourceKind === "gas-cloud" ? "#7fd6ff" : "#d2b07a", 20);
|
||||||
icon.position.copy(mesh.position);
|
icon.position.copy(mesh.position);
|
||||||
this.nodeMeshes.set(node.id, mesh);
|
this.nodeMeshes.set(node.id, mesh);
|
||||||
this.nodeGroup.add(mesh, icon);
|
this.nodeGroup.add(mesh, icon);
|
||||||
@@ -585,7 +602,8 @@ export class GameViewer {
|
|||||||
this.detailTitleEl.textContent = `Node ${node.id}`;
|
this.detailTitleEl.textContent = `Node ${node.id}`;
|
||||||
this.detailBodyEl.innerHTML = `
|
this.detailBodyEl.innerHTML = `
|
||||||
<p>${node.systemId}</p>
|
<p>${node.systemId}</p>
|
||||||
<p>${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
<p>Source ${node.sourceKind}<br>Resource ${node.itemId}</p>
|
||||||
|
<p>Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -599,7 +617,9 @@ export class GameViewer {
|
|||||||
this.detailTitleEl.textContent = planet.label;
|
this.detailTitleEl.textContent = planet.label;
|
||||||
this.detailBodyEl.innerHTML = `
|
this.detailBodyEl.innerHTML = `
|
||||||
<p>${system.label}</p>
|
<p>${system.label}</p>
|
||||||
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Size ${planet.size.toFixed(0)}</p>
|
<p>${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}</p>
|
||||||
|
<p>Orbit ${planet.orbitRadius.toFixed(0)}<br>Speed ${planet.orbitSpeed.toFixed(3)}<br>Ecc ${planet.orbitEccentricity.toFixed(3)}<br>Inc ${planet.orbitInclination.toFixed(1)}°</p>
|
||||||
|
<p>Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°</p>
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -611,12 +631,15 @@ export class GameViewer {
|
|||||||
this.detailTitleEl.textContent = system.label;
|
this.detailTitleEl.textContent = system.label;
|
||||||
this.detailBodyEl.innerHTML = `
|
this.detailBodyEl.innerHTML = `
|
||||||
<p>${system.id}</p>
|
<p>${system.id}</p>
|
||||||
|
<p>${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}</p>
|
||||||
|
<p>Planets ${system.planets.length}<br>Height ${system.position.y.toFixed(0)}</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private render() {
|
private render() {
|
||||||
const delta = Math.min(this.clock.getDelta(), 0.033);
|
const delta = Math.min(this.clock.getDelta(), 0.033);
|
||||||
this.updateCamera(delta);
|
this.updateCamera(delta);
|
||||||
|
this.updatePlanetPresentation();
|
||||||
this.updateShipPresentation();
|
this.updateShipPresentation();
|
||||||
this.updateNetworkPanel();
|
this.updateNetworkPanel();
|
||||||
this.applyZoomPresentation();
|
this.applyZoomPresentation();
|
||||||
@@ -770,16 +793,132 @@ export class GameViewer {
|
|||||||
this.updateSystemSummaryPresentation();
|
this.updateSystemSummaryPresentation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updatePlanetPresentation() {
|
||||||
|
const nowSeconds = this.currentWorldTimeSeconds();
|
||||||
|
for (const visual of this.planetVisuals) {
|
||||||
|
const position = this.computePlanetLocalPosition(visual.planet, nowSeconds);
|
||||||
|
visual.mesh.position.copy(position);
|
||||||
|
visual.icon.position.copy(position);
|
||||||
|
if (visual.ring) {
|
||||||
|
visual.ring.position.copy(position);
|
||||||
|
}
|
||||||
|
for (const [moonIndex, moon] of visual.moons.entries()) {
|
||||||
|
moon.orbit.position.copy(position);
|
||||||
|
moon.mesh.position.copy(position).add(this.computeMoonLocalPosition(visual.planet, moonIndex, nowSeconds));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private createNodeMesh(node: ResourceNodeSnapshot) {
|
private createNodeMesh(node: ResourceNodeSnapshot) {
|
||||||
|
const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas";
|
||||||
const mesh = new THREE.Mesh(
|
const mesh = new THREE.Mesh(
|
||||||
new THREE.IcosahedronGeometry(12, 0),
|
isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0),
|
||||||
new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }),
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: isGas ? 0x7fd6ff : 0xd2b07a,
|
||||||
|
flatShading: !isGas,
|
||||||
|
transparent: isGas,
|
||||||
|
opacity: isGas ? 0.68 : 1,
|
||||||
|
emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05),
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
mesh.position.copy(this.toThreeVector(node.position));
|
mesh.position.copy(this.toThreeVector(node.position));
|
||||||
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createStarCluster(system: SystemSnapshot) {
|
||||||
|
const root = new THREE.Group();
|
||||||
|
const offsets = system.starCount > 1
|
||||||
|
? [new THREE.Vector3(-system.starSize * 0.55, 0, 0), new THREE.Vector3(system.starSize * 0.75, system.starSize * 0.08, 0)]
|
||||||
|
: [new THREE.Vector3(0, 0, 0)];
|
||||||
|
|
||||||
|
for (const [index, offset] of offsets.entries()) {
|
||||||
|
const sizeScale = index === 0 ? 1 : 0.72;
|
||||||
|
const star = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(system.starSize * sizeScale, 28, 28),
|
||||||
|
new THREE.MeshBasicMaterial({ color: system.starColor }),
|
||||||
|
);
|
||||||
|
const halo = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(system.starSize * sizeScale * 1.72, 24, 24),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: system.starColor,
|
||||||
|
transparent: true,
|
||||||
|
opacity: this.starHaloOpacity(system.starKind),
|
||||||
|
side: THREE.BackSide,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
star.position.copy(offset);
|
||||||
|
halo.position.copy(offset);
|
||||||
|
root.add(star, halo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPlanetOrbit(planet: PlanetSnapshot) {
|
||||||
|
const points = Array.from({ length: 120 }, (_, index) => {
|
||||||
|
const phaseDegrees = (index / 120) * 360;
|
||||||
|
return this.computePlanetLocalPosition(planet, 0, phaseDegrees);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new THREE.LineLoop(
|
||||||
|
new THREE.BufferGeometry().setFromPoints(points),
|
||||||
|
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.22 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPlanetRing(planet: PlanetSnapshot) {
|
||||||
|
const ring = new THREE.Mesh(
|
||||||
|
new THREE.RingGeometry(planet.size * 1.35, planet.size * 2.15, 48),
|
||||||
|
new THREE.MeshBasicMaterial({
|
||||||
|
color: 0xdac89a,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.42,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
ring.rotation.x = Math.PI / 2;
|
||||||
|
ring.rotation.z = THREE.MathUtils.degToRad(planet.orbitInclination * 0.25);
|
||||||
|
return ring;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createMoonVisuals(planet: PlanetSnapshot) {
|
||||||
|
const moonCount = Math.min(planet.moonCount, 12);
|
||||||
|
const moons: MoonVisual[] = [];
|
||||||
|
|
||||||
|
for (let moonIndex = 0; moonIndex < moonCount; moonIndex += 1) {
|
||||||
|
const orbitRadius = this.computeMoonOrbitRadius(planet, moonIndex);
|
||||||
|
const orbit = new THREE.LineLoop(
|
||||||
|
new THREE.BufferGeometry().setFromPoints(
|
||||||
|
Array.from({ length: 48 }, (_, index) => {
|
||||||
|
const angle = (index / 48) * Math.PI * 2;
|
||||||
|
return new THREE.Vector3(
|
||||||
|
Math.cos(angle) * orbitRadius,
|
||||||
|
0,
|
||||||
|
Math.sin(angle) * orbitRadius,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
new THREE.LineBasicMaterial({ color: 0x3b5065, transparent: true, opacity: 0.1 }),
|
||||||
|
);
|
||||||
|
orbit.rotation.x = THREE.MathUtils.degToRad(planet.orbitInclination * 0.35);
|
||||||
|
|
||||||
|
const moonSize = this.computeMoonSize(planet, moonIndex);
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
new THREE.SphereGeometry(moonSize, 12, 12),
|
||||||
|
new THREE.MeshStandardMaterial({
|
||||||
|
color: new THREE.Color(planet.color).lerp(new THREE.Color("#d9dee7"), 0.55),
|
||||||
|
roughness: 0.96,
|
||||||
|
metalness: 0.02,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
moons.push({ mesh, orbit });
|
||||||
|
}
|
||||||
|
|
||||||
|
return moons;
|
||||||
|
}
|
||||||
|
|
||||||
private createStationMesh(station: StationSnapshot) {
|
private createStationMesh(station: StationSnapshot) {
|
||||||
const mesh = new THREE.Mesh(
|
const mesh = new THREE.Mesh(
|
||||||
new THREE.CylinderGeometry(24, 24, 18, 10),
|
new THREE.CylinderGeometry(24, 24, 18, 10),
|
||||||
@@ -893,6 +1032,7 @@ export class GameViewer {
|
|||||||
const ships = shipCounts.get(systemId) ?? 0;
|
const ships = shipCounts.get(systemId) ?? 0;
|
||||||
const stations = stationCounts.get(systemId) ?? 0;
|
const stations = stationCounts.get(systemId) ?? 0;
|
||||||
const structures = structureCounts.get(systemId) ?? 0;
|
const structures = structureCounts.get(systemId) ?? 0;
|
||||||
|
const gasClouds = [...this.world.nodes.values()].filter((node) => node.systemId === systemId && node.sourceKind === "gas-cloud").length;
|
||||||
const total = ships + stations + structures;
|
const total = ships + stations + structures;
|
||||||
if (total > 0) {
|
if (total > 0) {
|
||||||
context.fillStyle = "rgba(3, 8, 18, 0.72)";
|
context.fillStyle = "rgba(3, 8, 18, 0.72)";
|
||||||
@@ -902,7 +1042,7 @@ export class GameViewer {
|
|||||||
|
|
||||||
this.drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff");
|
this.drawCountIcon(context, "ship", 126, 98, ships, "#8bc0ff");
|
||||||
this.drawCountIcon(context, "station", 256, 98, stations, "#ffbf69");
|
this.drawCountIcon(context, "station", 256, 98, stations, "#ffbf69");
|
||||||
this.drawCountIcon(context, "structure", 386, 98, structures, "#98adc4");
|
this.drawCountIcon(context, "structure", 386, 98, structures, gasClouds > 0 ? "#7fd6ff" : "#98adc4");
|
||||||
}
|
}
|
||||||
|
|
||||||
visual.texture.needsUpdate = true;
|
visual.texture.needsUpdate = true;
|
||||||
@@ -1081,6 +1221,94 @@ export class GameViewer {
|
|||||||
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
return new THREE.Vector3(vector.x, vector.y, vector.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private currentWorldTimeSeconds() {
|
||||||
|
if (!this.world) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUtcMs = Date.parse(this.world.generatedAtUtc);
|
||||||
|
const elapsedMs = performance.now() - this.worldTimeSyncMs;
|
||||||
|
return ((baseUtcMs + elapsedMs) / 1000) + (this.world.seed * 97);
|
||||||
|
}
|
||||||
|
|
||||||
|
private computePlanetLocalPosition(planet: PlanetSnapshot, timeSeconds: number, phaseOverrideDegrees?: number) {
|
||||||
|
const eccentricity = THREE.MathUtils.clamp(planet.orbitEccentricity, 0, 0.85);
|
||||||
|
const meanAnomaly = THREE.MathUtils.degToRad(phaseOverrideDegrees ?? planet.orbitPhaseAtEpoch) + (timeSeconds * planet.orbitSpeed);
|
||||||
|
const eccentricAnomaly = meanAnomaly
|
||||||
|
+ (eccentricity * Math.sin(meanAnomaly))
|
||||||
|
+ (0.5 * eccentricity * eccentricity * Math.sin(2 * meanAnomaly));
|
||||||
|
const semiMajorAxis = planet.orbitRadius;
|
||||||
|
const semiMinorAxis = semiMajorAxis * Math.sqrt(Math.max(1 - (eccentricity * eccentricity), 0.05));
|
||||||
|
const local = new THREE.Vector3(
|
||||||
|
semiMajorAxis * (Math.cos(eccentricAnomaly) - eccentricity),
|
||||||
|
0,
|
||||||
|
semiMinorAxis * Math.sin(eccentricAnomaly),
|
||||||
|
);
|
||||||
|
|
||||||
|
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitArgumentOfPeriapsis));
|
||||||
|
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), THREE.MathUtils.degToRad(planet.orbitInclination));
|
||||||
|
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), THREE.MathUtils.degToRad(planet.orbitLongitudeOfAscendingNode));
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeMoonLocalPosition(planet: PlanetSnapshot, moonIndex: number, timeSeconds: number) {
|
||||||
|
const orbitRadius = this.computeMoonOrbitRadius(planet, moonIndex);
|
||||||
|
const speed = this.computeMoonOrbitSpeed(planet, moonIndex);
|
||||||
|
const phase = this.hashUnit(`${planet.label}:${moonIndex}:phase`) * Math.PI * 2;
|
||||||
|
const inclination = THREE.MathUtils.degToRad((this.hashUnit(`${planet.label}:${moonIndex}:inclination`) - 0.5) * 28);
|
||||||
|
const node = THREE.MathUtils.degToRad(this.hashUnit(`${planet.label}:${moonIndex}:node`) * 360);
|
||||||
|
const angle = phase + (timeSeconds * speed);
|
||||||
|
|
||||||
|
const local = new THREE.Vector3(
|
||||||
|
Math.cos(angle) * orbitRadius,
|
||||||
|
0,
|
||||||
|
Math.sin(angle) * orbitRadius,
|
||||||
|
);
|
||||||
|
local.applyAxisAngle(new THREE.Vector3(1, 0, 0), inclination);
|
||||||
|
local.applyAxisAngle(new THREE.Vector3(0, 1, 0), node);
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeMoonOrbitRadius(planet: PlanetSnapshot, moonIndex: number) {
|
||||||
|
const spacing = planet.size * 1.4;
|
||||||
|
const variance = this.hashUnit(`${planet.label}:${moonIndex}:radius`) * planet.size * 0.9;
|
||||||
|
return (planet.size * 1.8) + (moonIndex * spacing) + variance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeMoonOrbitSpeed(planet: PlanetSnapshot, moonIndex: number) {
|
||||||
|
const radius = this.computeMoonOrbitRadius(planet, moonIndex);
|
||||||
|
return 0.9 / Math.sqrt(Math.max(radius, 1)) + (moonIndex * 0.003);
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeMoonSize(planet: PlanetSnapshot, moonIndex: number) {
|
||||||
|
const base = Math.max(2.2, planet.size * 0.11);
|
||||||
|
const variance = this.hashUnit(`${planet.label}:${moonIndex}:size`) * Math.max(planet.size * 0.16, 2.5);
|
||||||
|
return Math.min(base + variance, planet.size * 0.42);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashUnit(value: string) {
|
||||||
|
let hash = this.world?.seed ?? 1;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
hash = ((hash << 5) - hash) + value.charCodeAt(index);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (hash >>> 0) / 0xffffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
private starHaloOpacity(starKind: string) {
|
||||||
|
if (starKind.includes("neutron")) {
|
||||||
|
return 0.22;
|
||||||
|
}
|
||||||
|
if (starKind.includes("white-dwarf")) {
|
||||||
|
return 0.18;
|
||||||
|
}
|
||||||
|
if (starKind.includes("brown-dwarf")) {
|
||||||
|
return 0.1;
|
||||||
|
}
|
||||||
|
return 0.14;
|
||||||
|
}
|
||||||
|
|
||||||
private screenPointFromClient(clientX: number, clientY: number) {
|
private screenPointFromClient(clientX: number, clientY: number) {
|
||||||
const bounds = this.renderer.domElement.getBoundingClientRect();
|
const bounds = this.renderer.domElement.getBoundingClientRect();
|
||||||
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
|
return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top);
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface SystemSnapshot {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
position: Vector3Dto;
|
position: Vector3Dto;
|
||||||
|
starKind: string;
|
||||||
|
starCount: number;
|
||||||
starColor: string;
|
starColor: string;
|
||||||
starSize: number;
|
starSize: number;
|
||||||
planets: PlanetSnapshot[];
|
planets: PlanetSnapshot[];
|
||||||
@@ -48,7 +50,16 @@ export interface SystemSnapshot {
|
|||||||
|
|
||||||
export interface PlanetSnapshot {
|
export interface PlanetSnapshot {
|
||||||
label: string;
|
label: string;
|
||||||
|
planetType: string;
|
||||||
|
shape: string;
|
||||||
|
moonCount: number;
|
||||||
orbitRadius: number;
|
orbitRadius: number;
|
||||||
|
orbitSpeed: number;
|
||||||
|
orbitEccentricity: number;
|
||||||
|
orbitInclination: number;
|
||||||
|
orbitLongitudeOfAscendingNode: number;
|
||||||
|
orbitArgumentOfPeriapsis: number;
|
||||||
|
orbitPhaseAtEpoch: number;
|
||||||
size: number;
|
size: number;
|
||||||
color: string;
|
color: string;
|
||||||
hasRing: boolean;
|
hasRing: boolean;
|
||||||
@@ -58,6 +69,7 @@ export interface ResourceNodeSnapshot {
|
|||||||
id: string;
|
id: string;
|
||||||
systemId: string;
|
systemId: string;
|
||||||
position: Vector3Dto;
|
position: Vector3Dto;
|
||||||
|
sourceKind: string;
|
||||||
oreRemaining: number;
|
oreRemaining: number;
|
||||||
maxOre: number;
|
maxOre: number;
|
||||||
itemId: string;
|
itemId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user