diff --git a/NEXT-STEPS.md b/NEXT-STEPS.md index 9db5041..ac3f3b8 100644 --- a/NEXT-STEPS.md +++ b/NEXT-STEPS.md @@ -1,5 +1,32 @@ # 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 The current economy already supports: @@ -25,6 +52,9 @@ Recommended work: - show ore throughput - show fabricated goods - show queued faction priorities +- make resource type differences matter + - ore belts vs gas clouds + - gas-aware logistics and production choices ## Pirate Harassment @@ -61,11 +91,12 @@ That turns the simulation into a real strategy loop. ## Concrete Implementation Order -1. Add faction production heuristics based on current economy and losses. -2. Make pirate target selection explicitly prefer economic targets. -3. Surface faction stocks, throughput, and build priorities in the HUD/debug views. -4. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`. -5. Break backend simulation responsibilities into smaller planning / faction / combat / logistics modules. +1. Add viewer-scale performance controls for the larger galaxy. +2. Add faction production heuristics based on current economy and losses. +3. Make pirate target selection explicitly prefer economic targets. +4. Surface faction stocks, throughput, and build priorities in the HUD/debug views. +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 @@ -144,5 +175,26 @@ Recommended work: - add per-layer presentation tuning in the viewer - smoother fade bands between local / system / universe - better visual density control at galaxy scale + - moon/orbit LOD based on zoom level - add resync handling when a client falls too far behind - 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 diff --git a/SESSION.md b/SESSION.md index 73fb763..d26043c 100644 --- a/SESSION.md +++ b/SESSION.md @@ -45,6 +45,8 @@ The viewer currently supports: - middle-mouse orbit camera - smooth wheel zoom across local, system, and universe scales - 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: @@ -57,6 +59,14 @@ The viewer also includes plain-text HUD readouts for: - game state - 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 The backend simulation already includes: @@ -68,6 +78,11 @@ The backend simulation already includes: - refining / fabrication - faction growth through ship and outpost production - 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: @@ -86,6 +101,11 @@ The runtime model still follows the intended layered control architecture: - added a plain-text network statistics readout - reworked the camera with smoother zoom, orbit, panning, and marquee selection - 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 @@ -94,6 +114,9 @@ The runtime model still follows the intended layered control architecture: - the viewer is still observer-focused - no command submission UI yet - 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 - 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 - [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) - 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) - 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) - 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) - 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: +- `dotnet build apps/backend/SpaceGame.Simulation.Api.csproj` - `cd apps/viewer && npm run build` diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs index da0cee2..3adf3c8 100644 --- a/apps/backend/Contracts/WorldContracts.cs +++ b/apps/backend/Contracts/WorldContracts.cs @@ -34,13 +34,24 @@ public sealed record SystemSnapshot( string Id, string Label, Vector3Dto Position, + string StarKind, + int StarCount, string StarColor, float StarSize, IReadOnlyList Planets); public sealed record PlanetSnapshot( string Label, + string PlanetType, + string Shape, + int MoonCount, float OrbitRadius, + float OrbitSpeed, + float OrbitEccentricity, + float OrbitInclination, + float OrbitLongitudeOfAscendingNode, + float OrbitArgumentOfPeriapsis, + float OrbitPhaseAtEpoch, float Size, string Color, bool HasRing); @@ -49,6 +60,7 @@ public sealed record ResourceNodeSnapshot( string Id, string SystemId, Vector3Dto Position, + string SourceKind, float OreRemaining, float MaxOre, string ItemId); @@ -57,6 +69,7 @@ public sealed record ResourceNodeDelta( string Id, string SystemId, Vector3Dto Position, + string SourceKind, float OreRemaining, float MaxOre, string ItemId); diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs index 35bcd9b..79e31c9 100644 --- a/apps/backend/Data/WorldDefinitions.cs +++ b/apps/backend/Data/WorldDefinitions.cs @@ -31,6 +31,8 @@ public sealed class SolarSystemDefinition public required string Id { get; set; } public required string Label { 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 StarGlow { get; set; } public float StarSize { get; set; } @@ -50,6 +52,7 @@ public sealed class AsteroidFieldDefinition public sealed class ResourceNodeDefinition { + public string SourceKind { get; set; } = "asteroid-belt"; public float Angle { get; set; } public float RadiusOffset { get; set; } public float OreAmount { get; set; } @@ -60,8 +63,16 @@ public sealed class ResourceNodeDefinition public sealed class PlanetDefinition { 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 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 required string Color { get; set; } public float Tilt { get; set; } diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs index 8c0c051..fd0b291 100644 --- a/apps/backend/Simulation/RuntimeModels.cs +++ b/apps/backend/Simulation/RuntimeModels.cs @@ -28,6 +28,7 @@ public sealed class ResourceNodeRuntime public required string Id { get; init; } public required string SystemId { get; init; } public required Vector3 Position { get; init; } + public required string SourceKind { get; init; } public required string ItemId { get; init; } public float OreRemaining { get; set; } public float MaxOre { get; init; } diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs index 8c3772f..2bea185 100644 --- a/apps/backend/Simulation/ScenarioLoader.cs +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -5,6 +5,71 @@ namespace SpaceGame.Simulation.Api.Simulation; 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 JsonSerializerOptions _jsonOptions = new() { @@ -18,7 +83,7 @@ public sealed class ScenarioLoader public SimulationWorld Load() { - var systems = Read>("systems.json"); + var systems = ExpandSystems(InjectSpecialSystems(Read>("systems.json"))); var scenario = Read("scenario.json"); var ships = Read>("ships.json"); var constructibles = Read>("constructibles.json"); @@ -47,8 +112,9 @@ public sealed class ScenarioLoader SystemId = system.Definition.Id, Position = new Vector3( 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)), + SourceKind = node.SourceKind, ItemId = node.ItemId, OreRemaining = node.OreAmount, MaxOre = node.OreAmount, @@ -71,9 +137,9 @@ public sealed class ScenarioLoader SystemId = system.Definition.Id, Definition = definition, Position = ResolveStationPosition(system, plan, balance), - FactionId = plan.FactionId ?? "sol-dominion", - OreStored = definition.Category == "refining" ? 120f : 0f, - RefinedStock = definition.Category == "shipyard" ? 180f : 40f, + FactionId = plan.FactionId ?? DefaultFactionId, + OreStored = 0f, + RefinedStock = 0f, }); } @@ -104,7 +170,7 @@ public sealed class ScenarioLoader Id = $"ship-{++shipIdCounter}", SystemId = formation.SystemId, Definition = definition, - FactionId = formation.FactionId ?? "sol-dominion", + FactionId = formation.FactionId ?? DefaultFactionId, Position = position, TargetPosition = position, DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery), @@ -114,21 +180,13 @@ public sealed class ScenarioLoader } } - var factions = new List - { - new() - { - Id = "sol-dominion", - Label = "Sol Dominion", - Color = "#7ed4ff", - Credits = 240f, - }, - }; + var factions = CreateFactions(stations, shipsRuntime); + BootstrapFactionEconomy(factions, stations); return new SimulationWorld { Label = "Split Viewer / Simulation World", - Seed = 1, + Seed = WorldSeed, Balance = balance, Systems = systemRuntimes, Nodes = nodes, @@ -140,6 +198,594 @@ public sealed class ScenarioLoader }; } + private static List InjectSpecialSystems(IReadOnlyList authoredSystems) + { + var systems = authoredSystems + .Select(CloneSystemDefinition) + .ToList(); + + if (systems.All((system) => system.Id != "sol")) + { + systems.Add(CreateSolSystem()); + } + + return systems; + } + + private static List ExpandSystems(IReadOnlyList 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 BuildProceduralResourceNodes( + SolarSystemDefinition template, + IReadOnlyList planets, + int generatedIndex) + { + var nodes = new List(); + 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 BuildGalaxyPositions(IReadOnlyCollection occupiedPositions, int count) + { + var allPositions = occupiedPositions.ToList(); + var generated = new List(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 BuildAsteroidBeltNodes(int generatedIndex, IReadOnlyList 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 BuildGasCloudNodes(int generatedIndex, IReadOnlyList 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 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 BuildGeneratedPlanets( + SolarSystemDefinition template, + int generatedIndex) + { + var planetCount = 2 + (int)MathF.Floor(Hash01(generatedIndex, 2) * 7f); + var planets = new List(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 CreateFactions( + IReadOnlyCollection stations, + IReadOnlyCollection 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 factions, + IReadOnlyCollection 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(string fileName) { var path = Path.Combine(_dataRoot, fileName); @@ -195,11 +841,11 @@ public sealed class ScenarioLoader var side = plan.LagrangeSide ?? 1; return new Vector3( system.Position.X + planet.OrbitRadius + (side * 72f), - balance.YPlane, + system.Position.Y + balance.YPlane, 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]); diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs index 67392c6..c9d12d7 100644 --- a/apps/backend/Simulation/SimulationEngine.cs +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -55,11 +55,22 @@ public sealed class SimulationEngine system.Definition.Id, system.Definition.Label, ToDto(system.Position), + system.Definition.StarKind, + system.Definition.StarCount, system.Definition.StarColor, system.Definition.StarSize, system.Definition.Planets.Select((planet) => new PlanetSnapshot( planet.Label, + planet.PlanetType, + planet.Shape, + planet.MoonCount, planet.OrbitRadius, + planet.OrbitSpeed, + planet.OrbitEccentricity, + planet.OrbitInclination, + planet.OrbitLongitudeOfAscendingNode, + planet.OrbitArgumentOfPeriapsis, + planet.OrbitPhaseAtEpoch, planet.Size, planet.Color, planet.HasRing)).ToList())).ToList(), @@ -67,6 +78,7 @@ public sealed class SimulationEngine node.Id, node.SystemId, node.Position, + node.SourceKind, node.OreRemaining, node.MaxOre, node.ItemId)).ToList(), @@ -238,6 +250,7 @@ public sealed class SimulationEngine node.Id, node.SystemId, ToDto(node.Position), + node.SourceKind, node.OreRemaining, node.MaxOre, node.ItemId); diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts index 06ce6d3..593976f 100644 --- a/apps/viewer/src/GameViewer.ts +++ b/apps/viewer/src/GameViewer.ts @@ -2,6 +2,7 @@ import * as THREE from "three"; import { fetchWorldSnapshot, openWorldStream } from "./api"; import type { FactionSnapshot, + PlanetSnapshot, ResourceNodeDelta, ResourceNodeSnapshot, ShipDelta, @@ -36,6 +37,19 @@ interface ShipVisual { 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 { label: string; seed: number; @@ -116,6 +130,7 @@ export class GameViewer { private readonly stationMeshes = new Map(); private readonly shipVisuals = new Map(); private readonly systemSummaryVisuals = new Map(); + private readonly planetVisuals: PlanetVisual[] = []; private readonly orbitLines: THREE.Object3D[] = []; private readonly statusEl: HTMLDivElement; private readonly detailTitleEl: HTMLHeadingElement; @@ -126,6 +141,7 @@ export class GameViewer { private readonly marqueeEl: HTMLDivElement; private world?: WorldState; + private worldTimeSyncMs = performance.now(); private stream?: EventSource; private readonly networkStats: NetworkStats = { snapshotBytes: 0, @@ -286,6 +302,7 @@ export class GameViewer { } private applySnapshot(snapshot: WorldSnapshot) { + this.worldTimeSyncMs = performance.now(); const signature = `${snapshot.seed}|${snapshot.systems.length}`; if (signature !== this.worldSignature) { this.worldSignature = signature; @@ -306,6 +323,7 @@ export class GameViewer { return; } + this.worldTimeSyncMs = performance.now(); this.world.sequence = delta.sequence; this.world.tickIntervalMs = delta.tickIntervalMs; this.world.generatedAtUtc = delta.generatedAtUtc; @@ -337,6 +355,7 @@ export class GameViewer { this.systemGroup.clear(); this.selectableTargets.clear(); this.presentationEntries.length = 0; + this.planetVisuals.length = 0; this.orbitLines.length = 0; this.systemSummaryVisuals.clear(); @@ -344,58 +363,56 @@ export class GameViewer { const root = new THREE.Group(); root.position.set(system.position.x, system.position.y, system.position.z); - const star = new THREE.Mesh( - 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 starCluster = this.createStarCluster(system); 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); - root.add(star, halo, systemIcon, summaryVisual.sprite); - this.registerPresentation(star, systemIcon, true); - this.registerPresentation(halo, systemIcon, true); + root.add(starCluster, systemIcon, summaryVisual.sprite); + this.registerPresentation(starCluster, systemIcon, true); this.systemSummaryVisuals.set(system.id, summaryVisual); - this.selectableTargets.set(star, { kind: "system", id: system.id }); - this.selectableTargets.set(halo, { kind: "system", id: system.id }); + starCluster.traverse((child) => { + if (child instanceof THREE.Mesh) { + this.selectableTargets.set(child, { kind: "system", id: system.id }); + } + }); this.selectableTargets.set(systemIcon, { kind: "system", id: system.id }); for (const [planetIndex, planet] of system.planets.entries()) { - const orbit = new THREE.LineLoop( - 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 orbit = this.createPlanetOrbit(planet); const planetMesh = new THREE.Mesh( new THREE.SphereGeometry(planet.size, 18, 18), new THREE.MeshStandardMaterial({ color: planet.color, roughness: 0.92, metalness: 0.08, + emissive: new THREE.Color(planet.color).multiplyScalar(0.04), }), ); - planetMesh.position.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)); 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); + 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.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(planetIcon, { kind: "planet", systemId: system.id, planetIndex }); } @@ -410,7 +427,7 @@ export class GameViewer { for (const node of nodes) { 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); this.nodeMeshes.set(node.id, mesh); this.nodeGroup.add(mesh, icon); @@ -585,7 +602,8 @@ export class GameViewer { this.detailTitleEl.textContent = `Node ${node.id}`; this.detailBodyEl.innerHTML = `

${node.systemId}

-

${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

+

Source ${node.sourceKind}
Resource ${node.itemId}

+

Stock ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}

`; return; } @@ -599,7 +617,9 @@ export class GameViewer { this.detailTitleEl.textContent = planet.label; this.detailBodyEl.innerHTML = `

${system.label}

-

Orbit ${planet.orbitRadius.toFixed(0)}
Size ${planet.size.toFixed(0)}

+

${planet.planetType} · ${planet.shape} · Moons ${planet.moonCount}

+

Orbit ${planet.orbitRadius.toFixed(0)}
Speed ${planet.orbitSpeed.toFixed(3)}
Ecc ${planet.orbitEccentricity.toFixed(3)}
Inc ${planet.orbitInclination.toFixed(1)}°

+

Phase ${planet.orbitPhaseAtEpoch.toFixed(1)}°

`; return; } @@ -611,12 +631,15 @@ export class GameViewer { this.detailTitleEl.textContent = system.label; this.detailBodyEl.innerHTML = `

${system.id}

+

${system.starKind} · ${system.starCount} star${system.starCount > 1 ? "s" : ""}

+

Planets ${system.planets.length}
Height ${system.position.y.toFixed(0)}

`; } private render() { const delta = Math.min(this.clock.getDelta(), 0.033); this.updateCamera(delta); + this.updatePlanetPresentation(); this.updateShipPresentation(); this.updateNetworkPanel(); this.applyZoomPresentation(); @@ -770,16 +793,132 @@ export class GameViewer { 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) { + const isGas = node.sourceKind === "gas-cloud" || node.itemId === "gas"; const mesh = new THREE.Mesh( - new THREE.IcosahedronGeometry(12, 0), - new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }), + isGas ? new THREE.SphereGeometry(18, 14, 14) : new THREE.IcosahedronGeometry(12, 0), + new THREE.MeshStandardMaterial({ + color: isGas ? 0x7fd6ff : 0xd2b07a, + flatShading: !isGas, + transparent: isGas, + opacity: isGas ? 0.68 : 1, + emissive: new THREE.Color(isGas ? 0x7fd6ff : 0xd2b07a).multiplyScalar(isGas ? 0.22 : 0.05), + }), ); mesh.position.copy(this.toThreeVector(node.position)); mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6); 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) { const mesh = new THREE.Mesh( new THREE.CylinderGeometry(24, 24, 18, 10), @@ -893,6 +1032,7 @@ export class GameViewer { const ships = shipCounts.get(systemId) ?? 0; const stations = stationCounts.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; if (total > 0) { 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, "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; @@ -1081,6 +1221,94 @@ export class GameViewer { 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) { const bounds = this.renderer.domElement.getBoundingClientRect(); return new THREE.Vector2(clientX - bounds.left, clientY - bounds.top); diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts index 5299642..33a357a 100644 --- a/apps/viewer/src/contracts.ts +++ b/apps/viewer/src/contracts.ts @@ -41,6 +41,8 @@ export interface SystemSnapshot { id: string; label: string; position: Vector3Dto; + starKind: string; + starCount: number; starColor: string; starSize: number; planets: PlanetSnapshot[]; @@ -48,7 +50,16 @@ export interface SystemSnapshot { export interface PlanetSnapshot { label: string; + planetType: string; + shape: string; + moonCount: number; orbitRadius: number; + orbitSpeed: number; + orbitEccentricity: number; + orbitInclination: number; + orbitLongitudeOfAscendingNode: number; + orbitArgumentOfPeriapsis: number; + orbitPhaseAtEpoch: number; size: number; color: string; hasRing: boolean; @@ -58,6 +69,7 @@ export interface ResourceNodeSnapshot { id: string; systemId: string; position: Vector3Dto; + sourceKind: string; oreRemaining: number; maxOre: number; itemId: string;