Split viewer and simulation into separate apps

This commit is contained in:
2026-03-12 17:18:29 -04:00
parent 0a76c60ab1
commit 2fb90162ef
45 changed files with 1982 additions and 6600 deletions

View File

@@ -0,0 +1,76 @@
namespace SpaceGame.Simulation.Api.Contracts;
public sealed record WorldSnapshot(
string Label,
int Seed,
DateTimeOffset GeneratedAtUtc,
IReadOnlyList<SystemSnapshot> Systems,
IReadOnlyList<ResourceNodeSnapshot> Nodes,
IReadOnlyList<StationSnapshot> Stations,
IReadOnlyList<ShipSnapshot> Ships,
IReadOnlyList<FactionSnapshot> Factions);
public sealed record SystemSnapshot(
string Id,
string Label,
Vector3Dto Position,
string StarColor,
float StarSize,
IReadOnlyList<PlanetSnapshot> Planets);
public sealed record PlanetSnapshot(
string Label,
float OrbitRadius,
float Size,
string Color,
bool HasRing);
public sealed record ResourceNodeSnapshot(
string Id,
string SystemId,
Vector3Dto Position,
float OreRemaining,
float MaxOre,
string ItemId);
public sealed record StationSnapshot(
string Id,
string Label,
string Category,
string SystemId,
Vector3Dto Position,
string Color,
int DockedShips,
float OreStored,
float RefinedStock,
string FactionId);
public sealed record ShipSnapshot(
string Id,
string Label,
string Role,
string ShipClass,
string SystemId,
Vector3Dto Position,
string State,
string? OrderKind,
string DefaultBehaviorKind,
string ControllerTaskKind,
float Cargo,
float CargoCapacity,
string? CargoItemId,
string FactionId,
float Health,
IReadOnlyList<string> History);
public sealed record FactionSnapshot(
string Id,
string Label,
string Color,
float Credits,
float OreMined,
float GoodsProduced,
int ShipsBuilt,
int ShipsLost);
public sealed record Vector3Dto(float X, float Y, float Z);

View File

@@ -0,0 +1,136 @@
namespace SpaceGame.Simulation.Api.Data;
public sealed class BalanceDefinition
{
public float YPlane { get; set; }
public float ArrivalThreshold { get; set; }
public float MiningRate { get; set; }
public float TransferRate { get; set; }
public float DockingDuration { get; set; }
public float UndockDistance { get; set; }
public EnergyBalanceDefinition Energy { get; set; } = new();
public FuelBalanceDefinition Fuel { get; set; } = new();
}
public sealed class EnergyBalanceDefinition
{
public float IdleDrain { get; set; }
public float MoveDrain { get; set; }
public float WarpDrain { get; set; }
public float ShipRechargeRate { get; set; }
public float StationSolarCharge { get; set; }
}
public sealed class FuelBalanceDefinition
{
public float WarpDrain { get; set; }
}
public sealed class SolarSystemDefinition
{
public required string Id { get; set; }
public required string Label { get; set; }
public required float[] Position { get; set; }
public required string StarColor { get; set; }
public required string StarGlow { get; set; }
public float StarSize { get; set; }
public float GravityWellRadius { get; set; }
public required AsteroidFieldDefinition AsteroidField { get; set; }
public required List<ResourceNodeDefinition> ResourceNodes { get; set; }
public required List<PlanetDefinition> Planets { get; set; }
}
public sealed class AsteroidFieldDefinition
{
public int DecorationCount { get; set; }
public float RadiusOffset { get; set; }
public float RadiusVariance { get; set; }
public float HeightVariance { get; set; }
}
public sealed class ResourceNodeDefinition
{
public float Angle { get; set; }
public float RadiusOffset { get; set; }
public float OreAmount { get; set; }
public required string ItemId { get; set; }
public int ShardCount { get; set; }
}
public sealed class PlanetDefinition
{
public required string Label { get; set; }
public float OrbitRadius { get; set; }
public float OrbitSpeed { get; set; }
public float Size { get; set; }
public required string Color { get; set; }
public float Tilt { get; set; }
public bool HasRing { get; set; }
}
public sealed class ShipDefinition
{
public required string Id { get; set; }
public required string Label { get; set; }
public required string Role { get; set; }
public required string ShipClass { get; set; }
public float Speed { get; set; }
public float FtlSpeed { get; set; }
public float SpoolTime { get; set; }
public float CargoCapacity { get; set; }
public string? CargoKind { get; set; }
public string? CargoItemId { get; set; }
public required string Color { get; set; }
public required string HullColor { get; set; }
public float Size { get; set; }
public float MaxHealth { get; set; }
}
public sealed class ConstructibleDefinition
{
public required string Id { get; set; }
public required string Label { get; set; }
public required string Category { get; set; }
public required string Color { get; set; }
public float Radius { get; set; }
public int DockingCapacity { get; set; }
}
public sealed class ScenarioDefinition
{
public required List<InitialStationDefinition> InitialStations { get; set; }
public required List<ShipFormationDefinition> ShipFormations { get; set; }
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
public required MiningDefaultsDefinition MiningDefaults { get; set; }
}
public sealed class InitialStationDefinition
{
public required string ConstructibleId { get; set; }
public required string SystemId { get; set; }
public string? FactionId { get; set; }
public int? PlanetIndex { get; set; }
public int? LagrangeSide { get; set; }
public float[]? Position { get; set; }
}
public sealed class ShipFormationDefinition
{
public required string ShipId { get; set; }
public int Count { get; set; }
public required float[] Center { get; set; }
public required string SystemId { get; set; }
public string? FactionId { get; set; }
}
public sealed class PatrolRouteDefinition
{
public required string SystemId { get; set; }
public required List<float[]> Points { get; set; }
}
public sealed class MiningDefaultsDefinition
{
public required string NodeSystemId { get; set; }
public required string RefinerySystemId { get; set; }
}

36
apps/backend/Program.cs Normal file
View File

@@ -0,0 +1,36 @@
using SpaceGame.Simulation.Api.Simulation;
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.UseUrls("http://127.0.0.1:5079");
builder.Services.AddCors((options) =>
{
options.AddDefaultPolicy((policy) =>
{
policy
.AllowAnyHeader()
.AllowAnyMethod()
.AllowAnyOrigin();
});
});
builder.Services.AddSingleton<WorldService>();
builder.Services.AddHostedService<SimulationHostedService>();
var app = builder.Build();
app.UseCors();
app.MapGet("/", () => Results.Redirect("/api/world"));
app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot()));
app.MapGet("/api/world/health", (WorldService worldService) => Results.Ok(new
{
ok = true,
generatedAtUtc = worldService.GetSnapshot().GeneratedAtUtc,
}));
app.MapPost("/api/world/reset", (WorldService worldService) =>
{
var snapshot = worldService.Reset();
return Results.Ok(snapshot);
});
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:0;http://localhost:0",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,134 @@
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SimulationWorld
{
public required string Label { get; init; }
public required int Seed { get; init; }
public required BalanceDefinition Balance { get; init; }
public required List<SystemRuntime> Systems { get; init; }
public required List<ResourceNodeRuntime> Nodes { get; init; }
public required List<StationRuntime> Stations { get; init; }
public required List<ShipRuntime> Ships { get; init; }
public required List<FactionRuntime> Factions { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
public DateTimeOffset GeneratedAtUtc { get; set; }
}
public sealed class SystemRuntime
{
public required SolarSystemDefinition Definition { get; init; }
public required Vector3 Position { get; init; }
}
public sealed class ResourceNodeRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required Vector3 Position { get; init; }
public required string ItemId { get; init; }
public float OreRemaining { get; set; }
public float MaxOre { get; init; }
}
public sealed class StationRuntime
{
public required string Id { get; init; }
public required string SystemId { get; init; }
public required ConstructibleDefinition Definition { get; init; }
public required Vector3 Position { get; init; }
public required string FactionId { get; init; }
public float OreStored { get; set; }
public float RefinedStock { get; set; }
public float ProcessTimer { get; set; }
public HashSet<string> DockedShipIds { get; } = [];
}
public sealed class ShipRuntime
{
public required string Id { get; init; }
public required string SystemId { get; set; }
public required ShipDefinition Definition { get; init; }
public required string FactionId { get; init; }
public required Vector3 Position { get; set; }
public required Vector3 TargetPosition { get; set; }
public string State { get; set; } = "idle";
public ShipOrderRuntime? Order { get; set; }
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
public required ControllerTaskRuntime ControllerTask { get; set; }
public float ActionTimer { get; set; }
public float Cargo { get; set; }
public string? DockedStationId { get; set; }
public float Health { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
}
public sealed class FactionRuntime
{
public required string Id { get; init; }
public required string Label { get; init; }
public required string Color { get; init; }
public float Credits { get; set; }
public float OreMined { get; set; }
public float GoodsProduced { get; set; }
public int ShipsBuilt { get; set; }
public int ShipsLost { get; set; }
}
public sealed class ShipOrderRuntime
{
public required string Kind { get; init; }
public string Status { get; set; } = "accepted";
public required string DestinationSystemId { get; init; }
public required Vector3 DestinationPosition { get; init; }
}
public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? AreaSystemId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }
public string? Phase { get; set; }
public List<Vector3> PatrolPoints { get; set; } = [];
public int PatrolIndex { get; set; }
}
public sealed class ControllerTaskRuntime
{
public required string Kind { get; set; }
public string? TargetEntityId { get; set; }
public string? TargetSystemId { get; set; }
public Vector3? TargetPosition { get; set; }
public float Threshold { get; set; }
}
public readonly record struct Vector3(float X, float Y, float Z)
{
public static Vector3 Zero => new(0f, 0f, 0f);
public float DistanceTo(Vector3 other)
{
var dx = X - other.X;
var dy = Y - other.Y;
var dz = Z - other.Z;
return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
}
public Vector3 MoveToward(Vector3 target, float maxDistance)
{
var distance = DistanceTo(target);
if (distance <= maxDistance || distance <= 0.0001f)
{
return target;
}
var t = maxDistance / distance;
return new Vector3(
X + ((target.X - X) * t),
Y + ((target.Y - Y) * t),
Z + ((target.Z - Z) * t));
}
}

View File

@@ -0,0 +1,208 @@
using System.Text.Json;
using SpaceGame.Simulation.Api.Data;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class ScenarioLoader
{
private readonly string _dataRoot;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public ScenarioLoader(string contentRootPath)
{
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
}
public SimulationWorld Load()
{
var systems = Read<List<SolarSystemDefinition>>("systems.json");
var scenario = Read<ScenarioDefinition>("scenario.json");
var ships = Read<List<ShipDefinition>>("ships.json");
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
var balance = Read<BalanceDefinition>("balance.json");
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var systemRuntimes = systems
.Select((definition) => new SystemRuntime
{
Definition = definition,
Position = ToVector(definition.Position),
})
.ToList();
var systemsById = systemRuntimes.ToDictionary((system) => system.Definition.Id, StringComparer.Ordinal);
var nodes = new List<ResourceNodeRuntime>();
var nodeIdCounter = 0;
foreach (var system in systemRuntimes)
{
foreach (var node in system.Definition.ResourceNodes)
{
nodes.Add(new ResourceNodeRuntime
{
Id = $"node-{++nodeIdCounter}",
SystemId = system.Definition.Id,
Position = new Vector3(
system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset),
balance.YPlane,
system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)),
ItemId = node.ItemId,
OreRemaining = node.OreAmount,
MaxOre = node.OreAmount,
});
}
}
var stations = new List<StationRuntime>();
var stationIdCounter = 0;
foreach (var plan in scenario.InitialStations)
{
if (!constructibleDefinitions.TryGetValue(plan.ConstructibleId, out var definition) || !systemsById.TryGetValue(plan.SystemId, out var system))
{
continue;
}
stations.Add(new StationRuntime
{
Id = $"station-{++stationIdCounter}",
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,
});
}
var refinery = stations.FirstOrDefault((station) =>
station.Definition.Category == "refining" && station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault((station) => station.Definition.Category == "refining");
var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
(route) => route.SystemId,
(route) => route.Points.Select(ToVector).ToList(),
StringComparer.Ordinal);
var shipsRuntime = new List<ShipRuntime>();
var shipIdCounter = 0;
foreach (var formation in scenario.ShipFormations)
{
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
{
continue;
}
for (var index = 0; index < formation.Count; index += 1)
{
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
var position = Add(ToVector(formation.Center), offset);
shipsRuntime.Add(new ShipRuntime
{
Id = $"ship-{++shipIdCounter}",
SystemId = formation.SystemId,
Definition = definition,
FactionId = formation.FactionId ?? "sol-dominion",
Position = position,
TargetPosition = position,
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold },
Health = definition.MaxHealth,
});
}
}
var factions = new List<FactionRuntime>
{
new()
{
Id = "sol-dominion",
Label = "Sol Dominion",
Color = "#7ed4ff",
Credits = 240f,
},
};
return new SimulationWorld
{
Label = "Split Viewer / Simulation World",
Seed = 1,
Balance = balance,
Systems = systemRuntimes,
Nodes = nodes,
Stations = stations,
Ships = shipsRuntime,
Factions = factions,
ShipDefinitions = shipDefinitions,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
private T Read<T>(string fileName)
{
var path = Path.Combine(_dataRoot, fileName);
var json = File.ReadAllText(path);
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
?? throw new InvalidOperationException($"Unable to read {fileName}.");
}
private static DefaultBehaviorRuntime CreateBehavior(
ShipDefinition definition,
string systemId,
ScenarioDefinition scenario,
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery)
{
if (definition.Role == "mining" && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-mine",
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
RefineryId = refinery.Id,
Phase = "travel-to-node",
};
}
if (definition.Role == "military" && patrolRoutes.TryGetValue(systemId, out var route))
{
return new DefaultBehaviorRuntime
{
Kind = "patrol",
PatrolPoints = route,
PatrolIndex = 0,
};
}
return new DefaultBehaviorRuntime
{
Kind = "idle",
};
}
private static Vector3 ResolveStationPosition(SystemRuntime system, InitialStationDefinition plan, BalanceDefinition balance)
{
if (plan.Position is { Length: 3 })
{
return ToVector(plan.Position);
}
if (plan.PlanetIndex is int planetIndex && planetIndex >= 0 && planetIndex < system.Definition.Planets.Count)
{
var planet = system.Definition.Planets[planetIndex];
var side = plan.LagrangeSide ?? 1;
return new Vector3(
system.Position.X + planet.OrbitRadius + (side * 72f),
balance.YPlane,
system.Position.Z + ((planetIndex + 1) * 42f * side));
}
return new Vector3(system.Position.X + 180f, balance.YPlane, system.Position.Z);
}
private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
}

View File

@@ -0,0 +1,488 @@
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SimulationEngine
{
public void Tick(SimulationWorld world, float deltaSeconds)
{
UpdateStations(world, deltaSeconds);
foreach (var ship in world.Ships)
{
RefreshControlLayers(ship);
PlanControllerTask(ship, world);
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
AdvanceControlState(ship, controllerEvent);
TrackHistory(ship);
}
world.GeneratedAtUtc = DateTimeOffset.UtcNow;
}
public WorldSnapshot BuildSnapshot(SimulationWorld world)
{
return new WorldSnapshot(
world.Label,
world.Seed,
world.GeneratedAtUtc,
world.Systems.Select((system) => new SystemSnapshot(
system.Definition.Id,
system.Definition.Label,
ToDto(system.Position),
system.Definition.StarColor,
system.Definition.StarSize,
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
planet.Label,
planet.OrbitRadius,
planet.Size,
planet.Color,
planet.HasRing)).ToList())).ToList(),
world.Nodes.Select((node) => new ResourceNodeSnapshot(
node.Id,
node.SystemId,
ToDto(node.Position),
node.OreRemaining,
node.MaxOre,
node.ItemId)).ToList(),
world.Stations.Select((station) => new StationSnapshot(
station.Id,
station.Definition.Label,
station.Definition.Category,
station.SystemId,
ToDto(station.Position),
station.Definition.Color,
station.DockedShipIds.Count,
station.OreStored,
station.RefinedStock,
station.FactionId)).ToList(),
world.Ships.Select((ship) => new ShipSnapshot(
ship.Id,
ship.Definition.Label,
ship.Definition.Role,
ship.Definition.ShipClass,
ship.SystemId,
ToDto(ship.Position),
ship.State,
ship.Order?.Kind,
ship.DefaultBehavior.Kind,
ship.ControllerTask.Kind,
ship.Cargo,
ship.Definition.CargoCapacity,
ship.Definition.CargoItemId,
ship.FactionId,
ship.Health,
ship.History.ToList())).ToList(),
world.Factions.Select((faction) => new FactionSnapshot(
faction.Id,
faction.Label,
faction.Color,
faction.Credits,
faction.OreMined,
faction.GoodsProduced,
faction.ShipsBuilt,
faction.ShipsLost)).ToList());
}
private void UpdateStations(SimulationWorld world, float deltaSeconds)
{
foreach (var station in world.Stations)
{
if (station.Definition.Category != "refining" || station.OreStored < 60f)
{
continue;
}
station.ProcessTimer += deltaSeconds;
if (station.ProcessTimer < 8f)
{
continue;
}
station.ProcessTimer = 0f;
station.OreStored -= 60f;
station.RefinedStock += 60f;
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
if (faction is not null)
{
faction.GoodsProduced += 60f;
faction.Credits += 18f;
}
}
}
private void RefreshControlLayers(ShipRuntime ship)
{
if (ship.Order is not null && ship.Order.Status == "queued")
{
ship.Order.Status = "accepted";
}
}
private void PlanControllerTask(ShipRuntime ship, SimulationWorld world)
{
if (ship.Order is not null)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = null,
TargetSystemId = ship.Order.DestinationSystemId,
TargetPosition = ship.Order.DestinationPosition,
Threshold = world.Balance.ArrivalThreshold,
};
return;
}
if (ship.DefaultBehavior.Kind == "auto-mine")
{
PlanAutoMine(ship, world);
return;
}
if (ship.DefaultBehavior.Kind == "patrol" && ship.DefaultBehavior.PatrolPoints.Count > 0)
{
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
TargetSystemId = ship.SystemId,
Threshold = 18f,
};
return;
}
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "idle",
Threshold = world.Balance.ArrivalThreshold,
};
}
private void PlanAutoMine(ShipRuntime ship, SimulationWorld world)
{
var behavior = ship.DefaultBehavior;
var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.RefineryId);
var node = behavior.NodeId is null
? world.Nodes
.Where((candidate) => candidate.SystemId == behavior.AreaSystemId)
.OrderByDescending((candidate) => candidate.OreRemaining)
.FirstOrDefault()
: world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId);
if (refinery is null || node is null)
{
behavior.Kind = "idle";
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
return;
}
behavior.NodeId ??= node.Id;
switch (behavior.Phase)
{
case "extract":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "extract",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 14f,
};
break;
case "travel-to-station":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Definition.Radius + 8f,
};
break;
case "dock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "dock",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = refinery.Definition.Radius + 4f,
};
break;
case "unload":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "unload",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = refinery.Position,
Threshold = 0f,
};
break;
case "undock":
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "undock",
TargetEntityId = refinery.Id,
TargetSystemId = refinery.SystemId,
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
Threshold = 8f,
};
break;
default:
ship.ControllerTask = new ControllerTaskRuntime
{
Kind = "travel",
TargetEntityId = node.Id,
TargetSystemId = node.SystemId,
TargetPosition = node.Position,
Threshold = 18f,
};
behavior.Phase = "travel-to-node";
break;
}
}
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
switch (task.Kind)
{
case "idle":
ship.State = "idle";
return "none";
case "travel":
return UpdateTravel(ship, world, deltaSeconds);
case "extract":
return UpdateExtract(ship, world, deltaSeconds);
case "dock":
return UpdateDock(ship, world, deltaSeconds);
case "unload":
return UpdateUnload(ship, world, deltaSeconds);
case "undock":
return UpdateUndock(ship, world, deltaSeconds);
default:
ship.State = "idle";
return "none";
}
}
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (task.TargetPosition is null || task.TargetSystemId is null)
{
ship.State = "idle";
return "none";
}
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance <= task.Threshold)
{
ship.Position = task.TargetPosition.Value;
ship.TargetPosition = ship.Position;
ship.SystemId = task.TargetSystemId;
ship.State = "arriving";
return "arrived";
}
var speed = ship.Definition.Speed;
if (ship.SystemId != task.TargetSystemId)
{
ship.State = distance > 800f ? "ftl" : "spooling-ftl";
speed = ship.Definition.FtlSpeed;
}
else if (distance > 200f)
{
ship.State = distance > 500f ? "warping" : "spooling-warp";
speed = ship.Definition.Speed * 4.5f;
}
else
{
ship.State = "approaching";
speed = ship.Definition.Speed;
}
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
ship.TargetPosition = task.TargetPosition.Value;
return "none";
}
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
if (node is null || task.TargetPosition is null)
{
ship.State = "idle";
return "none";
}
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
ship.State = "mining-approach";
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return "none";
}
ship.State = "mining";
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < 1f)
{
return "none";
}
ship.ActionTimer = 0f;
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - ship.Cargo);
mined = MathF.Min(mined, node.OreRemaining);
ship.Cargo += mined;
node.OreRemaining -= mined;
if (node.OreRemaining <= 0f)
{
node.OreRemaining = node.MaxOre;
}
return ship.Cargo >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
}
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
if (station is null || task.TargetPosition is null)
{
ship.State = "idle";
return "none";
}
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
if (distance > task.Threshold)
{
ship.State = "docking-approach";
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return "none";
}
ship.State = "docking";
ship.ActionTimer += deltaSeconds;
if (ship.ActionTimer < world.Balance.DockingDuration)
{
return "none";
}
ship.ActionTimer = 0f;
ship.State = "docked";
ship.DockedStationId = station.Id;
station.DockedShipIds.Add(ship.Id);
ship.Position = station.Position;
return "docked";
}
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
if (ship.DockedStationId is null)
{
ship.State = "idle";
return "none";
}
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
if (station is null)
{
ship.DockedStationId = null;
ship.State = "idle";
return "none";
}
ship.State = "transferring";
var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds);
ship.Cargo -= moved;
station.OreStored += moved;
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId);
if (faction is not null)
{
faction.OreMined += moved;
faction.Credits += moved * 0.4f;
}
return ship.Cargo <= 0.01f ? "unloaded" : "none";
}
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
{
var task = ship.ControllerTask;
if (ship.DockedStationId is null || task.TargetPosition is null)
{
ship.State = "idle";
return "none";
}
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
station?.DockedShipIds.Remove(ship.Id);
ship.DockedStationId = null;
ship.State = "undocking";
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
return ship.Position.DistanceTo(task.TargetPosition.Value) <= task.Threshold ? "undocked" : "none";
}
private void AdvanceControlState(ShipRuntime ship, string controllerEvent)
{
if (ship.Order is not null && controllerEvent == "arrived")
{
ship.Order = null;
ship.ControllerTask.Kind = "idle";
return;
}
if (ship.DefaultBehavior.Kind == "auto-mine")
{
switch (ship.DefaultBehavior.Phase, controllerEvent)
{
case ("travel-to-node", "arrived"):
ship.DefaultBehavior.Phase = ship.Cargo >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
break;
case ("extract", "cargo-full"):
ship.DefaultBehavior.Phase = "travel-to-station";
break;
case ("travel-to-station", "arrived"):
ship.DefaultBehavior.Phase = "dock";
break;
case ("dock", "docked"):
ship.DefaultBehavior.Phase = "unload";
break;
case ("unload", "unloaded"):
ship.DefaultBehavior.Phase = "undock";
break;
case ("undock", "undocked"):
ship.DefaultBehavior.Phase = "travel-to-node";
ship.DefaultBehavior.NodeId = null;
break;
}
}
if (ship.DefaultBehavior.Kind == "patrol" && controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
{
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
}
}
private static void TrackHistory(ShipRuntime ship)
{
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{ship.Cargo:0.0}";
if (signature == ship.LastSignature)
{
return;
}
ship.LastSignature = signature;
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={ship.Cargo:0.#}");
if (ship.History.Count > 18)
{
ship.History.RemoveAt(0);
}
}
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
}

View File

@@ -0,0 +1,19 @@
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class SimulationHostedService(WorldService worldService) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
try
{
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
{
worldService.Tick(0.2f);
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
}

View File

@@ -0,0 +1,36 @@
using SpaceGame.Simulation.Api.Contracts;
namespace SpaceGame.Simulation.Api.Simulation;
public sealed class WorldService(IWebHostEnvironment environment)
{
private readonly object _sync = new();
private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
private readonly SimulationEngine _engine = new();
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load();
public WorldSnapshot GetSnapshot()
{
lock (_sync)
{
return _engine.BuildSnapshot(_world);
}
}
public void Tick(float deltaSeconds)
{
lock (_sync)
{
_engine.Tick(_world, deltaSeconds);
}
}
public WorldSnapshot Reset()
{
lock (_sync)
{
_world = _loader.Load();
return _engine.BuildSnapshot(_world);
}
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

12
apps/viewer/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Space Game Viewer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1121
apps/viewer/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
apps/viewer/package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "space-game-viewer",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"three": "^0.179.1"
},
"devDependencies": {
"@types/three": "^0.183.1",
"typescript": "^5.9.2",
"vite": "^7.1.3"
}
}

View File

@@ -0,0 +1,427 @@
import * as THREE from "three";
import { fetchWorldSnapshot, resetWorld } from "./api";
import type {
FactionSnapshot,
ResourceNodeSnapshot,
ShipSnapshot,
StationSnapshot,
SystemSnapshot,
WorldSnapshot,
} from "./contracts";
type Selectable =
| { kind: "ship"; id: string }
| { kind: "station"; id: string }
| { kind: "node"; id: string }
| { kind: "system"; id: string };
export class GameViewer {
private readonly container: HTMLElement;
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
private readonly scene = new THREE.Scene();
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 40000);
private readonly clock = new THREE.Clock();
private readonly raycaster = new THREE.Raycaster();
private readonly mouse = new THREE.Vector2();
private readonly focus = new THREE.Vector3(2200, 0, 300);
private readonly systemGroup = new THREE.Group();
private readonly nodeGroup = new THREE.Group();
private readonly stationGroup = new THREE.Group();
private readonly shipGroup = new THREE.Group();
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
private readonly statusEl: HTMLDivElement;
private readonly detailTitleEl: HTMLHeadingElement;
private readonly detailBodyEl: HTMLDivElement;
private readonly factionStripEl: HTMLDivElement;
private readonly resetButton: HTMLButtonElement;
private readonly errorEl: HTMLDivElement;
private snapshot?: WorldSnapshot;
private selected?: Selectable;
private dragging = false;
private lastPointer = new THREE.Vector2();
private worldSignature = "";
constructor(container: HTMLElement) {
this.container = container;
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
this.scene.background = new THREE.Color(0x040912);
this.scene.fog = new THREE.FogExp2(0x040912, 0.00011);
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
keyLight.position.set(1000, 1200, 800);
this.scene.add(keyLight);
this.scene.add(this.systemGroup, this.nodeGroup, this.stationGroup, this.shipGroup);
this.camera.position.set(2500, 1700, 2800);
this.camera.lookAt(this.focus);
const hud = document.createElement("div");
hud.className = "viewer-shell";
hud.innerHTML = `
<header class="topbar">
<div>
<p class="eyebrow">Frontend Viewer</p>
<h1>Space Game Observer</h1>
</div>
<div class="topbar-actions">
<div class="status-pill">Connecting</div>
<button type="button" class="reset-button">Reset World</button>
</div>
</header>
<aside class="details-panel">
<h2>Selection</h2>
<h3 class="detail-title">Nothing selected</h3>
<div class="detail-body">Click a star, station, node, or ship to inspect the server snapshot.</div>
<div class="error-strip" hidden></div>
</aside>
<section class="faction-strip"></section>
`;
this.statusEl = hud.querySelector(".status-pill") as HTMLDivElement;
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
this.resetButton = hud.querySelector(".reset-button") as HTMLButtonElement;
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
this.container.append(this.renderer.domElement, hud);
this.resetButton.addEventListener("click", () => void this.handleReset());
this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown);
this.renderer.domElement.addEventListener("pointermove", this.onPointerMove);
this.renderer.domElement.addEventListener("pointerup", this.onPointerUp);
this.renderer.domElement.addEventListener("click", this.onClick);
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
window.addEventListener("resize", this.onResize);
this.onResize();
}
async start() {
await this.refreshSnapshot();
window.setInterval(() => {
void this.refreshSnapshot();
}, 500);
this.renderer.setAnimationLoop(() => this.render());
}
private async refreshSnapshot() {
try {
const snapshot = await fetchWorldSnapshot();
this.snapshot = snapshot;
this.statusEl.textContent = `Live ${new Date(snapshot.generatedAtUtc).toLocaleTimeString()}`;
this.errorEl.hidden = true;
this.applySnapshot(snapshot);
this.updatePanels();
} catch (error) {
this.statusEl.textContent = "Backend offline";
this.errorEl.hidden = false;
this.errorEl.textContent = error instanceof Error ? error.message : "Unable to load the backend snapshot.";
}
}
private async handleReset() {
this.resetButton.disabled = true;
try {
const snapshot = await resetWorld();
this.snapshot = snapshot;
this.applySnapshot(snapshot);
this.updatePanels();
} finally {
this.resetButton.disabled = false;
}
}
private applySnapshot(snapshot: WorldSnapshot) {
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
if (signature !== this.worldSignature) {
this.worldSignature = signature;
this.rebuildSystems(snapshot.systems);
}
this.rebuildNodes(snapshot.nodes);
this.rebuildStations(snapshot.stations);
this.rebuildShips(snapshot.ships);
this.rebuildFactions(snapshot.factions);
}
private rebuildSystems(systems: SystemSnapshot[]) {
this.systemGroup.clear();
this.selectableTargets.clear();
for (const system of systems) {
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,
}),
);
root.add(star, halo);
this.selectableTargets.set(star, { kind: "system", id: system.id });
this.selectableTargets.set(halo, { kind: "system", id: system.id });
for (const planet of system.planets) {
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 planetMesh = new THREE.Mesh(
new THREE.SphereGeometry(planet.size, 18, 18),
new THREE.MeshStandardMaterial({
color: planet.color,
roughness: 0.92,
metalness: 0.08,
}),
);
planetMesh.position.set(planet.orbitRadius, 0, 0);
root.add(orbit, planetMesh);
}
this.systemGroup.add(root);
}
}
private rebuildNodes(nodes: ResourceNodeSnapshot[]) {
this.nodeGroup.clear();
for (const node of nodes) {
const mesh = new THREE.Mesh(
new THREE.IcosahedronGeometry(12, 0),
new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }),
);
mesh.position.set(node.position.x, node.position.y, node.position.z);
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
this.nodeGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "node", id: node.id });
}
}
private rebuildStations(stations: StationSnapshot[]) {
this.stationGroup.clear();
for (const station of stations) {
const mesh = new THREE.Mesh(
new THREE.CylinderGeometry(24, 24, 18, 10),
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
);
mesh.rotation.x = Math.PI / 2;
mesh.position.set(station.position.x, station.position.y, station.position.z);
this.stationGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "station", id: station.id });
}
}
private rebuildShips(ships: ShipSnapshot[]) {
this.shipGroup.clear();
for (const ship of ships) {
const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7);
geometry.rotateX(Math.PI / 2);
const mesh = new THREE.Mesh(
geometry,
new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }),
);
mesh.position.set(ship.position.x, ship.position.y, ship.position.z);
this.shipGroup.add(mesh);
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
}
}
private rebuildFactions(factions: FactionSnapshot[]) {
this.factionStripEl.innerHTML = factions
.map((faction) => `
<article class="faction-card">
<div class="swatch" style="background:${faction.color}"></div>
<div>
<h3>${faction.label}</h3>
<p>Credits ${faction.credits.toFixed(0)} · Ore ${faction.oreMined.toFixed(0)} · Goods ${faction.goodsProduced.toFixed(0)}</p>
</div>
</article>
`)
.join("");
}
private updatePanels() {
if (!this.snapshot) {
return;
}
if (!this.selected) {
this.detailTitleEl.textContent = this.snapshot.label;
this.detailBodyEl.innerHTML = `Systems ${this.snapshot.systems.length}<br>Stations ${this.snapshot.stations.length}<br>Ships ${this.snapshot.ships.length}`;
return;
}
const selected = this.selected;
if (selected.kind === "ship") {
const ship = this.snapshot.ships.find((candidate) => candidate.id === selected.id);
if (ship) {
this.detailTitleEl.textContent = ship.label;
this.detailBodyEl.innerHTML = `
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
<p class="history">${ship.history.join("<br>")}</p>
`;
}
return;
}
if (selected.kind === "station") {
const station = this.snapshot.stations.find((candidate) => candidate.id === selected.id);
if (station) {
this.detailTitleEl.textContent = station.label;
this.detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p>
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
`;
}
return;
}
if (selected.kind === "node") {
const node = this.snapshot.nodes.find((candidate) => candidate.id === selected.id);
if (node) {
this.detailTitleEl.textContent = `Node ${node.id}`;
this.detailBodyEl.innerHTML = `
<p>${node.systemId}</p>
<p>${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
`;
}
return;
}
const system = this.snapshot.systems.find((candidate) => candidate.id === selected.id);
if (system) {
this.detailTitleEl.textContent = system.label;
this.detailBodyEl.innerHTML = `
<p>${system.id}</p>
<p>Planets ${system.planets.length}</p>
`;
}
}
private render() {
const delta = Math.min(this.clock.getDelta(), 0.033);
this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2));
this.camera.lookAt(this.focus);
this.renderer.render(this.scene, this.camera);
}
private onPointerDown = (event: PointerEvent) => {
this.dragging = true;
this.lastPointer.set(event.clientX, event.clientY);
};
private onPointerMove = (event: PointerEvent) => {
if (!this.dragging) {
return;
}
const dx = event.clientX - this.lastPointer.x;
const dy = event.clientY - this.lastPointer.y;
this.focus.x -= dx * 2.4;
this.focus.z += dy * 2.4;
this.lastPointer.set(event.clientX, event.clientY);
};
private onPointerUp = () => {
this.dragging = false;
};
private onClick = (event: MouseEvent) => {
const bounds = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
this.mouse.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
this.raycaster.setFromCamera(this.mouse, this.camera);
const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0];
this.selected = hit ? this.selectableTargets.get(hit.object) : undefined;
this.updatePanels();
};
private onDoubleClick = () => {
if (!this.snapshot || !this.selected) {
return;
}
const nextFocus = this.resolveSelectionPosition(this.selected);
if (nextFocus) {
this.focus.copy(nextFocus);
}
};
private onWheel = (event: WheelEvent) => {
event.preventDefault();
const offset = this.camera.position.clone().sub(this.focus);
offset.multiplyScalar(event.deltaY > 0 ? 1.08 : 0.92);
offset.clampLength(500, 12000);
this.camera.position.copy(this.focus).add(offset);
};
private resolveSelectionPosition(selection: Selectable) {
if (!this.snapshot) {
return undefined;
}
if (selection.kind === "ship") {
const ship = this.snapshot.ships.find((candidate) => candidate.id === selection.id);
return ship ? new THREE.Vector3(ship.position.x, ship.position.y, ship.position.z) : undefined;
}
if (selection.kind === "station") {
const station = this.snapshot.stations.find((candidate) => candidate.id === selection.id);
return station ? new THREE.Vector3(station.position.x, station.position.y, station.position.z) : undefined;
}
if (selection.kind === "node") {
const node = this.snapshot.nodes.find((candidate) => candidate.id === selection.id);
return node ? new THREE.Vector3(node.position.x, node.position.y, node.position.z) : undefined;
}
const system = this.snapshot.systems.find((candidate) => candidate.id === selection.id);
return system ? new THREE.Vector3(system.position.x, system.position.y, system.position.z) : undefined;
}
private shipSize(ship: ShipSnapshot) {
switch (ship.shipClass) {
case "capital":
return 18;
case "cruiser":
return 13;
case "destroyer":
return 10;
case "industrial":
return 11;
default:
return 8;
}
}
private shipLength(ship: ShipSnapshot) {
return this.shipSize(ship) * 2.6;
}
private shipColor(role: ShipSnapshot["role"]) {
if (role === "mining") {
return "#ffcf6e";
}
if (role === "transport") {
return "#9ff0aa";
}
return "#8bc0ff";
}
private onResize = () => {
const width = window.innerWidth;
const height = window.innerHeight;
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.renderer.setSize(width, height);
};
}

19
apps/viewer/src/api.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { WorldSnapshot } from "./contracts";
export async function fetchWorldSnapshot(signal?: AbortSignal) {
const response = await fetch("/api/world", { signal });
if (!response.ok) {
throw new Error(`World request failed with ${response.status}`);
}
return response.json() as Promise<WorldSnapshot>;
}
export async function resetWorld() {
const response = await fetch("/api/world/reset", {
method: "POST",
});
if (!response.ok) {
throw new Error(`Reset request failed with ${response.status}`);
}
return response.json() as Promise<WorldSnapshot>;
}

View File

@@ -0,0 +1,85 @@
export interface WorldSnapshot {
label: string;
seed: number;
generatedAtUtc: string;
systems: SystemSnapshot[];
nodes: ResourceNodeSnapshot[];
stations: StationSnapshot[];
ships: ShipSnapshot[];
factions: FactionSnapshot[];
}
export interface Vector3Dto {
x: number;
y: number;
z: number;
}
export interface SystemSnapshot {
id: string;
label: string;
position: Vector3Dto;
starColor: string;
starSize: number;
planets: PlanetSnapshot[];
}
export interface PlanetSnapshot {
label: string;
orbitRadius: number;
size: number;
color: string;
hasRing: boolean;
}
export interface ResourceNodeSnapshot {
id: string;
systemId: string;
position: Vector3Dto;
oreRemaining: number;
maxOre: number;
itemId: string;
}
export interface StationSnapshot {
id: string;
label: string;
category: string;
systemId: string;
position: Vector3Dto;
color: string;
dockedShips: number;
oreStored: number;
refinedStock: number;
factionId: string;
}
export interface ShipSnapshot {
id: string;
label: string;
role: string;
shipClass: string;
systemId: string;
position: Vector3Dto;
state: string;
orderKind: string | null;
defaultBehaviorKind: string;
controllerTaskKind: string;
cargo: number;
cargoCapacity: number;
cargoItemId: string | null;
factionId: string;
health: number;
history: string[];
}
export interface FactionSnapshot {
id: string;
label: string;
color: string;
credits: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
shipsLost: number;
}

11
apps/viewer/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import "./style.css";
import { GameViewer } from "./GameViewer";
const root = document.querySelector<HTMLDivElement>("#app");
if (!root) {
throw new Error("Missing #app root element");
}
const viewer = new GameViewer(root);
void viewer.start();

225
apps/viewer/src/style.css Normal file
View File

@@ -0,0 +1,225 @@
:root {
color-scheme: dark;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
--bg: #050812;
--panel: rgba(9, 18, 34, 0.78);
--panel-border: rgba(132, 196, 255, 0.18);
--text: #eaf4ff;
--muted: #98adc4;
--accent: #7fd6ff;
--warning: #ffbf69;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
radial-gradient(circle at 18% 42%, rgba(255, 136, 92, 0.14), transparent 24%),
linear-gradient(180deg, #03060d 0%, #060c18 100%);
}
canvas {
display: block;
}
.viewer-shell {
position: fixed;
inset: 0;
pointer-events: none;
}
.topbar,
.details-panel,
.faction-strip {
position: absolute;
backdrop-filter: blur(18px);
background: var(--panel);
border: 1px solid var(--panel-border);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.35);
}
.topbar {
top: 20px;
left: 20px;
right: 20px;
border-radius: 22px;
padding: 18px 20px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
pointer-events: auto;
}
.eyebrow {
margin: 0 0 6px;
color: var(--accent);
letter-spacing: 0.18em;
font-size: 0.72rem;
text-transform: uppercase;
}
.topbar h1,
.details-panel h2,
.details-panel h3,
.faction-card h3 {
margin: 0;
}
.topbar h1 {
font-size: 1.2rem;
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
.status-pill,
.reset-button {
border-radius: 999px;
border: 1px solid rgba(127, 214, 255, 0.18);
background: rgba(12, 25, 46, 0.92);
color: var(--text);
padding: 11px 16px;
font: inherit;
}
.reset-button {
cursor: pointer;
pointer-events: auto;
transition: transform 120ms ease, border-color 120ms ease;
}
.reset-button:hover {
transform: translateY(-1px);
border-color: rgba(127, 214, 255, 0.42);
}
.details-panel {
top: 110px;
right: 20px;
width: min(380px, calc(100vw - 40px));
bottom: 20px;
border-radius: 24px;
padding: 18px;
color: var(--text);
pointer-events: auto;
overflow: auto;
}
.details-panel h2 {
color: var(--accent);
letter-spacing: 0.16em;
font-size: 0.72rem;
text-transform: uppercase;
}
.detail-title {
margin-top: 12px;
font-size: 1.05rem;
}
.detail-body {
margin-top: 12px;
color: var(--muted);
line-height: 1.55;
}
.detail-body p {
margin: 0 0 12px;
}
.history {
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.78rem;
line-height: 1.6;
}
.error-strip {
margin-top: 14px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 116, 88, 0.14);
color: #ffd8cf;
}
.faction-strip {
left: 20px;
bottom: 20px;
width: min(920px, calc(100vw - 440px));
min-height: 110px;
border-radius: 24px;
padding: 16px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
pointer-events: auto;
}
.faction-card {
border-radius: 18px;
border: 1px solid rgba(127, 214, 255, 0.14);
background: linear-gradient(180deg, rgba(11, 23, 43, 0.85), rgba(7, 15, 28, 0.9));
padding: 14px;
display: flex;
gap: 12px;
align-items: flex-start;
color: var(--text);
}
.faction-card p {
margin: 6px 0 0;
color: var(--muted);
line-height: 1.45;
}
.swatch {
width: 14px;
height: 48px;
border-radius: 999px;
flex: none;
}
@media (max-width: 1080px) {
.faction-strip {
right: 20px;
width: auto;
}
}
@media (max-width: 760px) {
.topbar {
flex-direction: column;
align-items: flex-start;
}
.details-panel {
position: absolute;
top: auto;
left: 20px;
right: 20px;
bottom: 148px;
width: auto;
max-height: 38vh;
}
.faction-strip {
left: 20px;
right: 20px;
bottom: 20px;
width: auto;
min-height: 100px;
grid-template-columns: 1fr;
}
}

15
apps/viewer/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"lib": ["ES2022", "DOM"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src", "vite.config.ts"]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vite";
const root = new URL(".", import.meta.url).pathname;
export default defineConfig({
root,
server: {
host: true,
port: 5174,
proxy: {
"/api": "http://127.0.0.1:5079",
},
},
build: {
outDir: "../../dist/viewer",
emptyOutDir: true,
},
});