Split viewer and simulation into separate apps
This commit is contained in:
76
apps/backend/Contracts/WorldContracts.cs
Normal file
76
apps/backend/Contracts/WorldContracts.cs
Normal 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);
|
||||
136
apps/backend/Data/WorldDefinitions.cs
Normal file
136
apps/backend/Data/WorldDefinitions.cs
Normal 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
36
apps/backend/Program.cs
Normal 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();
|
||||
23
apps/backend/Properties/launchSettings.json
Normal file
23
apps/backend/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
apps/backend/Simulation/RuntimeModels.cs
Normal file
134
apps/backend/Simulation/RuntimeModels.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
208
apps/backend/Simulation/ScenarioLoader.cs
Normal file
208
apps/backend/Simulation/ScenarioLoader.cs
Normal 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);
|
||||
}
|
||||
488
apps/backend/Simulation/SimulationEngine.cs
Normal file
488
apps/backend/Simulation/SimulationEngine.cs
Normal 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);
|
||||
}
|
||||
19
apps/backend/Simulation/SimulationHostedService.cs
Normal file
19
apps/backend/Simulation/SimulationHostedService.cs
Normal 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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps/backend/Simulation/WorldService.cs
Normal file
36
apps/backend/Simulation/WorldService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/backend/SpaceGame.Simulation.Api.csproj
Normal file
9
apps/backend/SpaceGame.Simulation.Api.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
8
apps/backend/appsettings.Development.json
Normal file
8
apps/backend/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/backend/appsettings.json
Normal file
9
apps/backend/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
12
apps/viewer/index.html
Normal file
12
apps/viewer/index.html
Normal 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
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
19
apps/viewer/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
427
apps/viewer/src/GameViewer.ts
Normal file
427
apps/viewer/src/GameViewer.ts
Normal 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
19
apps/viewer/src/api.ts
Normal 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>;
|
||||
}
|
||||
85
apps/viewer/src/contracts.ts
Normal file
85
apps/viewer/src/contracts.ts
Normal 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
11
apps/viewer/src/main.ts
Normal 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
225
apps/viewer/src/style.css
Normal 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
15
apps/viewer/tsconfig.json
Normal 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"]
|
||||
}
|
||||
18
apps/viewer/vite.config.ts
Normal file
18
apps/viewer/vite.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user