Expand galaxy generation and viewer rendering

This commit is contained in:
2026-03-12 21:48:14 -04:00
parent e57378ad2a
commit b57b04d90a
9 changed files with 1071 additions and 63 deletions

View File

@@ -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

View File

@@ -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`

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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]);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;