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

2
.gitignore vendored
View File

@@ -1,5 +1,7 @@
node_modules/ node_modules/
dist/ dist/
bin/
obj/
*.tsbuildinfo *.tsbuildinfo

6
SpaceGame.slnx Normal file
View File

@@ -0,0 +1,6 @@
<Solution>
<Folder Name="/apps/" />
<Folder Name="/apps/backend/">
<Project Path="apps/backend/SpaceGame.Simulation.Api.csproj" />
</Folder>
</Solution>

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": "*"
}

View File

@@ -3,10 +3,10 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Space Command</title> <title>Space Game Viewer</title>
<script type="module" src="/src/main.ts"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -1,11 +1,11 @@
{ {
"name": "space-game", "name": "space-game-viewer",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "space-game", "name": "space-game-viewer",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"three": "^0.179.1" "three": "^0.179.1"

View File

@@ -1,11 +1,11 @@
{ {
"name": "space-game", "name": "space-game-viewer",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc -p tsconfig.json && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {

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

View File

@@ -11,5 +11,5 @@
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["src"] "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,
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +0,0 @@
import balanceData from "./balance.json";
import constructiblesData from "./constructibles.json";
import itemsData from "./items.json";
import modulesData from "./modules.json";
import recipesData from "./recipes.json";
import scenarioData from "./scenario.json";
import shipsData from "./ships.json";
import systemsData from "./systems.json";
import type {
ConstructibleDefinition,
GameBalance,
ItemDefinition,
ModuleDefinition,
RecipeDefinition,
ScenarioDefinition,
ShipDefinition,
SolarSystemDefinition,
} from "../types";
export const itemDefinitions = itemsData as ItemDefinition[];
export const recipeDefinitions = recipesData as RecipeDefinition[];
export const moduleDefinitions = modulesData as ModuleDefinition[];
export const shipDefinitions = shipsData as ShipDefinition[];
export const constructibleDefinitions = constructiblesData as ConstructibleDefinition[];
export const solarSystemDefinitions = systemsData as SolarSystemDefinition[];
export const scenarioDefinition = scenarioData as ScenarioDefinition;
export const gameBalance = balanceData as GameBalance;
export const itemDefinitionsById = new Map(itemDefinitions.map((definition) => [definition.id, definition]));
export const recipeDefinitionsById = new Map(recipeDefinitions.map((definition) => [definition.id, definition]));
export const moduleDefinitionsById = new Map(moduleDefinitions.map((definition) => [definition.id, definition]));
export const shipDefinitionsById = new Map(shipDefinitions.map((definition) => [definition.id, definition]));
export const constructibleDefinitionsById = new Map(
constructibleDefinitions.map((definition) => [definition.id, definition]),
);
export const solarSystemDefinitionsById = new Map(solarSystemDefinitions.map((definition) => [definition.id, definition]));

View File

@@ -1,35 +0,0 @@
import type { InventoryState, ShipInstance } from "../types";
export function createEmptyInventory(): InventoryState {
return {
"bulk-solid": 0,
"bulk-liquid": 0,
"bulk-gas": 0,
container: 0,
manufactured: 0,
};
}
export function getShipCargoAmount(ship: ShipInstance) {
const kind = ship.definition.cargoKind;
return kind ? ship.inventory[kind] : 0;
}
export function addShipCargo(ship: ShipInstance, amount: number) {
const kind = ship.definition.cargoKind;
if (!kind) {
return 0;
}
ship.inventory[kind] += amount;
return amount;
}
export function removeShipCargo(ship: ShipInstance, amount: number) {
const kind = ship.definition.cargoKind;
if (!kind) {
return 0;
}
const transferred = Math.min(amount, ship.inventory[kind]);
ship.inventory[kind] -= transferred;
return transferred;
}

View File

@@ -1,164 +0,0 @@
import * as THREE from "three";
import type { PlanetInstance, ResourceNode, ShipInstance, SolarSystemInstance, StationInstance } from "../types";
export class SelectionManager {
private shipSelection: ShipInstance[] = [];
private stationSelection?: StationInstance;
private systemSelection?: SolarSystemInstance;
private planetSelection?: PlanetInstance;
private nodeSelection?: ResourceNode;
getShips() {
return this.shipSelection;
}
getStation() {
return this.stationSelection;
}
getSystem() {
return this.systemSelection;
}
getPlanet() {
return this.planetSelection;
}
getNode() {
return this.nodeSelection;
}
clear() {
this.shipSelection.forEach((ship) => this.setShipVisual(ship, false));
this.shipSelection = [];
if (this.stationSelection) {
this.setStationVisual(this.stationSelection, false);
this.stationSelection = undefined;
}
if (this.systemSelection) {
this.setSystemVisual(this.systemSelection, false);
this.systemSelection = undefined;
}
if (this.planetSelection) {
this.setPlanetVisual(this.planetSelection, false);
this.planetSelection = undefined;
}
if (this.nodeSelection) {
this.setNodeVisual(this.nodeSelection, false);
this.nodeSelection = undefined;
}
}
replaceShips(ships: ShipInstance[]) {
this.clear();
ships.forEach((ship) => this.addShip(ship));
}
setStation(station?: StationInstance) {
this.clear();
if (!station) {
return;
}
this.stationSelection = station;
this.setStationVisual(station, true);
}
setSystem(system?: SolarSystemInstance) {
this.clear();
if (!system) {
return;
}
this.systemSelection = system;
this.setSystemVisual(system, true);
}
setPlanet(planet?: PlanetInstance) {
this.clear();
if (!planet) {
return;
}
this.planetSelection = planet;
this.setPlanetVisual(planet, true);
}
setNode(node?: ResourceNode) {
this.clear();
if (!node) {
return;
}
this.nodeSelection = node;
this.setNodeVisual(node, true);
}
addShip(ship: ShipInstance) {
if (this.shipSelection.includes(ship)) {
return;
}
if (this.stationSelection) {
this.setStationVisual(this.stationSelection, false);
this.stationSelection = undefined;
}
if (this.systemSelection) {
this.setSystemVisual(this.systemSelection, false);
this.systemSelection = undefined;
}
if (this.planetSelection) {
this.setPlanetVisual(this.planetSelection, false);
this.planetSelection = undefined;
}
if (this.nodeSelection) {
this.setNodeVisual(this.nodeSelection, false);
this.nodeSelection = undefined;
}
this.shipSelection.push(ship);
this.setShipVisual(ship, true);
}
removeShip(ship: ShipInstance) {
this.shipSelection = this.shipSelection.filter((candidate) => candidate.id !== ship.id);
this.setShipVisual(ship, false);
}
toggleShip(ship: ShipInstance) {
if (this.shipSelection.includes(ship)) {
this.removeShip(ship);
return;
}
this.addShip(ship);
}
hasShip(ship: ShipInstance) {
return this.shipSelection.includes(ship);
}
private setShipVisual(ship: ShipInstance, selected: boolean) {
ship.selected = selected;
(ship.ring.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
}
private setStationVisual(station: StationInstance, selected: boolean) {
(station.ring.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
}
private setSystemVisual(system: SolarSystemInstance, selected: boolean) {
if (system.strategicMarker instanceof THREE.Group) {
system.strategicMarker.traverse((child) => {
if ("material" in child && child.material instanceof THREE.MeshBasicMaterial) {
child.material.opacity = selected ? Math.max(child.material.opacity, 0.9) : child === system.strategicMarker.children[0] ? 0.4 : 0.7;
}
});
}
}
private setPlanetVisual(planet: PlanetInstance, selected: boolean) {
if (planet.selectionRing) {
(planet.selectionRing.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
}
}
private setNodeVisual(node: ResourceNode, selected: boolean) {
if (node.selectionRing) {
(node.selectionRing.material as THREE.MeshBasicMaterial).opacity = selected ? 0.95 : 0;
}
}
}

View File

@@ -1,434 +0,0 @@
import * as THREE from "three";
export type ShipRole = "military" | "transport" | "mining";
export type ShipClass = "frigate" | "destroyer" | "cruiser" | "industrial" | "capital";
export type GameWindowId = "fleet-command" | "ship-designer" | "station-manager" | "debug";
export type FactionKind = "empire" | "pirate";
export type ConstructibleCategory =
| "station"
| "refining"
| "farm"
| "shipyard"
| "defense"
| "gate";
export type UnitState =
| "idle"
| "holding"
| "spooling-warp"
| "spooling-ftl"
| "ftl"
| "warping"
| "arriving"
| "approaching"
| "mining-approach"
| "mining"
| "transferring"
| "docking-approach"
| "docking"
| "docked"
| "undocking"
| "patrolling"
| "escorting"
| "forming";
export type ControllerTaskKind = "idle" | "travel" | "dock" | "extract" | "follow" | "undock";
export type OrderStatus = "queued" | "accepted" | "planning" | "executing" | "completed" | "failed" | "cancelled" | "blocked";
export type ItemStorageKind = "bulk-solid" | "bulk-liquid" | "bulk-gas" | "container" | "manufactured";
export type ModuleCategory =
| "bridge"
| "engine"
| "ftl"
| "mining"
| "cargo-bulk"
| "cargo-container"
| "dock"
| "refinery"
| "defense"
| "habitat"
| "production";
export type ViewLevel = "local" | "solar" | "universe";
export type TravelDestinationKind = "system" | "planet" | "station" | "resource-node" | "ship" | "orbit";
export interface ModuleDefinition {
id: string;
label: string;
category: ModuleCategory;
summary: string;
}
export interface ItemDefinition {
id: string;
label: string;
storage: ItemStorageKind;
summary?: string;
}
export interface RecipeComponentDefinition {
itemId: string;
amount: number;
}
export interface RecipeDefinition {
id: string;
label: string;
facilityCategory: ConstructibleCategory;
duration: number;
priority?: number;
requiredModules?: string[];
inputs: RecipeComponentDefinition[];
outputs: RecipeComponentDefinition[];
}
export interface ShipDefinition {
id: string;
label: string;
role: ShipRole;
shipClass: ShipClass;
speed: number;
ftlSpeed: number;
spoolTime: number;
cargoCapacity: number;
cargoKind?: ItemStorageKind;
cargoItemId?: string;
color: string;
hullColor: string;
size: number;
maxHealth: number;
modules: string[];
dockingCapacity?: number;
dockingClasses?: ShipClass[];
}
export interface ConstructibleDefinition {
id: string;
label: string;
category: ConstructibleCategory;
color: string;
radius: number;
dockingCapacity: number;
storage: Partial<Record<ItemStorageKind, number>>;
modules: string[];
}
export interface PlanetDefinition {
label: string;
orbitRadius: number;
orbitSpeed: number;
size: number;
color: string;
tilt: number;
hasRing?: boolean;
}
export interface ResourceNodeDefinition {
angle: number;
radiusOffset: number;
oreAmount: number;
itemId: string;
shardCount: number;
}
export interface AsteroidFieldDefinition {
decorationCount: number;
radiusOffset: number;
radiusVariance: number;
heightVariance: number;
}
export interface SolarSystemDefinition {
id: string;
label: string;
position: [number, number, number];
starColor: string;
starGlow: string;
starSize: number;
gravityWellRadius: number;
asteroidField: AsteroidFieldDefinition;
resourceNodes: ResourceNodeDefinition[];
planets: PlanetDefinition[];
}
export interface InitialStationDefinition {
constructibleId: string;
systemId: string;
factionId?: string;
planetIndex?: number;
lagrangeSide?: -1 | 1;
position?: [number, number, number];
seedStock?: Partial<Record<string, number>>;
}
export interface ShipFormationDefinition {
shipId: string;
count: number;
center: [number, number, number];
systemId: string;
factionId?: string;
}
export interface PatrolRouteDefinition {
systemId: string;
points: [number, number, number][];
}
export interface ScenarioDefinition {
initialStations: InitialStationDefinition[];
shipFormations: ShipFormationDefinition[];
patrolRoutes: PatrolRouteDefinition[];
miningDefaults: {
nodeSystemId: string;
refinerySystemId: string;
};
factions?: FactionDefinition[];
centralSystemIds?: string[];
}
export interface UniverseDefinition {
seed: number;
label: string;
systems: SolarSystemDefinition[];
scenario: ScenarioDefinition;
}
export interface FactionDefinition {
id: string;
label: string;
kind: FactionKind;
color: string;
accent: string;
homeSystemId: string;
miningSystemId?: string;
targetSystemIds: string[];
rivals: string[];
pirateForFactionId?: string;
}
export interface FactionInstance {
definition: FactionDefinition;
credits: number;
oreMined: number;
goodsProduced: number;
shipsBuilt: number;
stationsBuilt: number;
shipsLost: number;
enemyShipsDestroyed: number;
raidsCompleted: number;
stolenCargo: number;
ownedSystemIds: Set<string>;
shipBuildTimer: number;
stationBuildTimer: number;
commandTick: number;
}
export interface GameBalance {
yPlane: number;
arrivalThreshold: number;
miningRate: number;
transferRate: number;
dockingDuration: number;
undockDistance: number;
energy: {
idleDrain: number;
moveDrain: number;
warpDrain: number;
shipRechargeRate: number;
stationSolarCharge: number;
};
fuel: {
warpDrain: number;
};
}
export type ShipOrder =
| { kind: "move-to"; status: OrderStatus; destination: TravelDestination }
| { kind: "mine-this"; status: OrderStatus; nodeId: string; refineryId: string; phase: "travel-to-node" | "extract" }
| { kind: "dock-at"; status: OrderStatus; hostKind: "station" | "ship"; hostId: string };
export type DefaultBehavior =
| { kind: "idle" }
| {
kind: "auto-mine";
areaSystemId: string;
refineryId: string;
nodeId?: string;
phase: "travel-to-node" | "extract" | "travel-to-station" | "dock" | "unload" | "undock";
}
| { kind: "patrol"; points: TravelDestination[]; systemId: string; index: number }
| { kind: "escort-assigned"; offset: THREE.Vector3 };
export type Assignment =
| { kind: "unassigned" }
| { kind: "commander-subordinate"; commanderId: string; role: string }
| { kind: "station-based"; stationId: string; role: string }
| { kind: "mining-group"; controllerId: string };
export type ControllerTask =
| { kind: "idle" }
| { kind: "travel"; destination: TravelDestination; threshold: number; suppliedPlan?: TravelPlan }
| { kind: "dock"; hostKind: "station" | "ship"; hostId: string; portIndex: number }
| { kind: "extract"; nodeId: string }
| { kind: "unload"; stationId: string }
| { kind: "follow"; targetShipId: string; offset: THREE.Vector3; threshold: number }
| { kind: "undock"; hostKind: "station" | "ship"; hostId: string };
export interface InventoryState {
"bulk-solid": number;
"bulk-liquid": number;
"bulk-gas": number;
container: number;
manufactured: number;
}
export interface TravelDestination {
kind: TravelDestinationKind;
systemId: string;
label: string;
position: THREE.Vector3;
orbitalAnchor: THREE.Vector3;
id?: string;
}
export interface TravelPlan {
destination: TravelDestination;
arrivalPoint: THREE.Vector3;
interSystem: boolean;
}
export interface ShipInstance {
id: string;
definition: ShipDefinition;
group: THREE.Group;
target: THREE.Vector3;
velocity: THREE.Vector3;
selected: boolean;
ring: THREE.Mesh;
systemId: string;
state: UnitState;
order?: ShipOrder;
defaultBehavior: DefaultBehavior;
assignment: Assignment;
controllerTask: ControllerTask;
inventory: InventoryState;
cargoItemId?: string;
actionTimer: number;
travelPlan?: TravelPlan;
landedDestination?: TravelDestination;
landedOffset: THREE.Vector3;
dockingClearanceStatus?: string;
dockedStationId?: string;
dockedCarrierId?: string;
dockingPortIndex?: number;
factionId: string;
factionColor: string;
health: number;
maxHealth: number;
weaponRange: number;
weaponDamage: number;
weaponCooldown: number;
weaponTimer: number;
fuel: number;
energy: number;
maxFuel: number;
maxEnergy: number;
idleOrbitRadius: number;
idleOrbitAngle: number;
warpFx: THREE.Group;
dockedShipIds: Set<string>;
dockingPorts: THREE.Vector3[];
}
export interface StationInstance {
id: string;
definition: ConstructibleDefinition;
group: THREE.Group;
systemId: string;
ring: THREE.Mesh;
oreStored: number;
refinedStock: number;
processTimer: number;
activeBatch: number;
activeRecipeId?: string;
inventory: InventoryState;
itemStocks: Record<string, number>;
dockedShipIds: Set<string>;
dockingPorts: THREE.Vector3[];
modules: string[];
orbitalParentPlanetIndex?: number;
lagrangeSide?: -1 | 1;
factionId: string;
factionColor: string;
health: number;
maxHealth: number;
weaponRange: number;
weaponDamage: number;
weaponTimer: number;
fuel: number;
energy: number;
maxFuel: number;
maxEnergy: number;
}
export interface PlanetInstance {
definition: PlanetDefinition;
group: THREE.Group;
mesh: THREE.Mesh;
orbitSpeed: number;
ring?: THREE.Object3D;
selectionRing?: THREE.Mesh;
systemId: string;
index: number;
}
export interface ResourceNode {
id: string;
systemId: string;
position: THREE.Vector3;
mesh: THREE.Object3D;
selectionRing?: THREE.Mesh;
oreRemaining: number;
maxOre: number;
itemId: string;
}
export interface SolarSystemInstance {
definition: SolarSystemDefinition;
root: THREE.Group;
center: THREE.Vector3;
planets: PlanetInstance[];
star: THREE.Object3D;
light: THREE.PointLight;
gravityWellRadius: number;
orbitLines: THREE.LineLoop[];
asteroidDecorations: THREE.Object3D[];
strategicMarker: THREE.Object3D;
controllingFactionId?: string;
controlProgress: number;
strategicValue: "core" | "resource" | "frontier" | "central";
}
export type SelectableTarget =
| { kind: "ship"; ship: ShipInstance }
| { kind: "station"; station: StationInstance }
| { kind: "system"; system: SolarSystemInstance }
| { kind: "planet"; system: SolarSystemInstance; planet: PlanetInstance }
| { kind: "node"; node: ResourceNode };
export interface HudElements {
details: HTMLDivElement;
status: HTMLDivElement;
selectionTitle: HTMLHeadingElement;
selectionStrip: HTMLDivElement;
orders: HTMLDivElement;
sessionActions: HTMLDivElement;
minimap: HTMLCanvasElement;
minimapContext: CanvasRenderingContext2D;
marquee: HTMLDivElement;
strategicOverlay: HTMLCanvasElement;
strategicOverlayContext: CanvasRenderingContext2D;
fleetWindow: HTMLDivElement;
fleetWindowBody: HTMLDivElement;
fleetWindowTitle: HTMLHeadingElement;
debugWindow: HTMLDivElement;
debugHistory: HTMLDivElement;
debugAutoScrollToggle: HTMLButtonElement;
debugCopyHistory: HTMLButtonElement;
}

View File

@@ -1,187 +0,0 @@
import type { HudElements } from "../types";
interface HudHandlers {
onWindowAction: (action: string) => void;
onSelectionAction: (kind: string, id: string) => void;
}
export function createHud(container: HTMLElement, handlers: HudHandlers): HudElements {
const root = document.createElement("div");
root.className = "hud";
root.innerHTML = `
<canvas class="strategic-overlay"></canvas>
<section class="panel details">
<div class="selection-meta">
<h2 class="selection-title">No Selection</h2>
<div class="mode"></div>
</div>
<div class="window-launchers">
<button type="button" data-window-action="toggle-fleet-command">Fleet</button>
<button type="button" data-window-action="toggle-debug">Debug</button>
</div>
<div class="selection-strip"></div>
<div class="content"></div>
</section>
<canvas class="minimap minimap-hidden" width="220" height="160"></canvas>
<section class="app-window fleet-window" data-window-id="fleet-command">
<div class="window-header">
<div>
<h2>Ships</h2>
</div>
</div>
<div class="window-body fleet-window-body"></div>
<div class="window-resize-handle" aria-hidden="true"></div>
</section>
<section class="app-window debug-window" data-window-id="debug">
<div class="window-header">
<div>
<h2>Debug</h2>
<p class="window-subtitle">Simulation controls</p>
</div>
<button type="button" class="window-close" data-window-action="toggle-debug">Close</button>
</div>
<div class="window-body">
<div class="session-actions">
<button type="button" data-window-action="new-universe">New Universe</button>
<button type="button" data-window-action="toggle-debug-autoscroll">Pause Scroll</button>
<button type="button" data-window-action="copy-debug-history">Copy History</button>
</div>
<div class="debug-history"></div>
</div>
<div class="window-resize-handle" aria-hidden="true"></div>
</section>
<div class="marquee"></div>
`;
container.append(root);
initializeWindowInteractions(root);
root.querySelectorAll<HTMLButtonElement>("[data-window-action]").forEach((button) => {
button.addEventListener("click", () => handlers.onWindowAction(button.dataset.windowAction ?? ""));
});
const fleetWindowBody = root.querySelector<HTMLDivElement>(".fleet-window-body");
fleetWindowBody?.addEventListener("click", (event) => {
const mouseEvent = event as MouseEvent;
const target = event.target as HTMLElement;
const selectionNode = target.closest<HTMLElement>("[data-select-kind][data-select-id]");
if (selectionNode) {
const kind = selectionNode.dataset.selectKind ?? "";
const id = selectionNode.dataset.selectId ?? "";
handlers.onSelectionAction(mouseEvent.detail >= 2 ? `focus-${kind}` : kind, id);
}
});
const minimap = root.querySelector<HTMLCanvasElement>(".minimap");
const minimapContext = minimap?.getContext("2d");
if (!minimap || !minimapContext) {
throw new Error("Unable to create minimap canvas");
}
const strategicOverlay = root.querySelector<HTMLCanvasElement>(".strategic-overlay");
const strategicOverlayContext = strategicOverlay?.getContext("2d");
if (!strategicOverlay || !strategicOverlayContext) {
throw new Error("Unable to create strategic overlay canvas");
}
return {
details: root.querySelector(".content") as HTMLDivElement,
status: root.querySelector(".mode") as HTMLDivElement,
selectionTitle: root.querySelector(".selection-title") as HTMLHeadingElement,
selectionStrip: root.querySelector(".selection-strip") as HTMLDivElement,
orders: document.createElement("div"),
sessionActions: root.querySelector(".session-actions") as HTMLDivElement,
minimap,
minimapContext,
marquee: root.querySelector(".marquee") as HTMLDivElement,
strategicOverlay,
strategicOverlayContext,
fleetWindow: root.querySelector(".fleet-window") as HTMLDivElement,
fleetWindowBody: fleetWindowBody as HTMLDivElement,
fleetWindowTitle: root.querySelector(".fleet-window h2") as HTMLHeadingElement,
debugWindow: root.querySelector(".debug-window") as HTMLDivElement,
debugHistory: root.querySelector(".debug-history") as HTMLDivElement,
debugAutoScrollToggle: root.querySelector('[data-window-action="toggle-debug-autoscroll"]') as HTMLButtonElement,
debugCopyHistory: root.querySelector('[data-window-action="copy-debug-history"]') as HTMLButtonElement,
};
}
function initializeWindowInteractions(root: HTMLDivElement) {
root.querySelectorAll<HTMLElement>(".app-window").forEach((windowEl) => {
initializeWindowPosition(windowEl);
const header = windowEl.querySelector<HTMLElement>(".window-header");
const resizeHandle = windowEl.querySelector<HTMLElement>(".window-resize-handle");
header?.addEventListener("pointerdown", (event) => {
const target = event.target as HTMLElement;
if (target.closest("button")) {
return;
}
const rect = windowEl.getBoundingClientRect();
const offsetX = event.clientX - rect.left;
const offsetY = event.clientY - rect.top;
windowEl.dataset.dragging = "true";
const move = (moveEvent: PointerEvent) => {
const nextLeft = moveEvent.clientX - offsetX;
const nextTop = moveEvent.clientY - offsetY;
applyWindowRect(windowEl, nextLeft, nextTop, rect.width, rect.height);
};
const end = () => {
windowEl.dataset.dragging = "false";
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end);
});
resizeHandle?.addEventListener("pointerdown", (event) => {
event.preventDefault();
event.stopPropagation();
const rect = windowEl.getBoundingClientRect();
const startX = event.clientX;
const startY = event.clientY;
windowEl.dataset.resizing = "true";
const move = (moveEvent: PointerEvent) => {
const nextWidth = rect.width + (moveEvent.clientX - startX);
const nextHeight = rect.height + (moveEvent.clientY - startY);
applyWindowRect(windowEl, rect.left, rect.top, nextWidth, nextHeight);
};
const end = () => {
windowEl.dataset.resizing = "false";
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", end);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", end);
});
});
}
function initializeWindowPosition(windowEl: HTMLElement) {
const defaultWidth = Math.min(480, window.innerWidth - 48);
const defaultHeight = Math.min(680, Math.floor(window.innerHeight * 0.68));
const left = Math.max(16, Math.round(window.innerWidth * 0.5 - defaultWidth * 0.5));
const top = Math.max(24, 104);
applyWindowRect(windowEl, left, top, defaultWidth, defaultHeight);
}
function applyWindowRect(windowEl: HTMLElement, left: number, top: number, width: number, height: number) {
const minWidth = 340;
const minHeight = 240;
const maxWidth = window.innerWidth - 32;
const maxHeight = window.innerHeight - 32;
const clampedWidth = Math.max(minWidth, Math.min(width, maxWidth));
const clampedHeight = Math.max(minHeight, Math.min(height, maxHeight));
const clampedLeft = Math.max(16, Math.min(left, window.innerWidth - clampedWidth - 16));
const clampedTop = Math.max(16, Math.min(top, window.innerHeight - clampedHeight - 16));
windowEl.style.left = `${clampedLeft}px`;
windowEl.style.top = `${clampedTop}px`;
windowEl.style.width = `${clampedWidth}px`;
windowEl.style.height = `${clampedHeight}px`;
}

View File

@@ -1,396 +0,0 @@
import {
itemDefinitionsById,
moduleDefinitionsById,
recipeDefinitions,
} from "../data/catalog";
import { getShipCargoAmount } from "../state/inventory";
import type {
FactionInstance,
PlanetInstance,
ResourceNode,
ShipInstance,
SolarSystemInstance,
StationInstance,
ViewLevel,
} from "../types";
export function getSelectionTitle(
selection: ShipInstance[],
selectedStation?: StationInstance,
selectedSystem?: SolarSystemInstance,
selectedPlanet?: PlanetInstance,
selectedNode?: ResourceNode,
) {
if (selectedNode) {
return `Asteroid Field ${selectedNode.id}`;
}
if (selectedPlanet) {
return selectedPlanet.definition.label;
}
if (selectedSystem) {
return selectedSystem.definition.label;
}
if (selectedStation) {
return selectedStation.definition.label;
}
if (selection.length === 0) {
return "No Selection";
}
if (selection.length === 1) {
return selection[0].definition.label;
}
return `${selection.length} Ships Selected`;
}
export function getSelectionStripLabels(
selection: ShipInstance[],
selectedStation?: StationInstance,
selectedSystem?: SolarSystemInstance,
selectedPlanet?: PlanetInstance,
selectedNode?: ResourceNode,
) {
if (selectedNode) {
return [`Asteroid Field ${selectedNode.id}`];
}
if (selectedPlanet) {
return [selectedPlanet.definition.label];
}
if (selectedSystem) {
return [selectedSystem.definition.label];
}
if (selectedStation) {
return [selectedStation.definition.label];
}
if (selection.length === 0) {
return [];
}
return selection.map((ship) => ship.definition.label);
}
export function getSelectionCardsMarkup(
selection: ShipInstance[],
selectedStation: StationInstance | undefined,
selectedSystem: SolarSystemInstance | undefined,
selectedPlanet: PlanetInstance | undefined,
selectedNode?: ResourceNode,
) {
if (selectedNode) {
return renderCard(`Asteroid Field ${selectedNode.id}`, [
selectedNode.systemId,
getItemLabel(selectedNode.itemId),
`Ore ${Math.round(selectedNode.oreRemaining)}/${selectedNode.maxOre}`,
]);
}
if (selectedPlanet) {
return renderCard(
selectedPlanet.definition.label,
[
selectedPlanet.systemId,
`Orbit ${Math.round(selectedPlanet.definition.orbitRadius)}`,
`Size ${selectedPlanet.definition.size}`,
selectedPlanet.definition.hasRing ? "Ringed" : "No ring",
],
);
}
if (selectedSystem) {
return renderCard(
selectedSystem.definition.label,
[
selectedSystem.strategicValue,
`${selectedSystem.planets.length} planets`,
`${selectedSystem.definition.resourceNodes.length} nodes`,
`${selectedSystem.controllingFactionId ?? "Contested"} ${Math.round(selectedSystem.controlProgress)}%`,
],
);
}
if (selectedStation) {
return renderCard(
selectedStation.definition.label,
[
selectedStation.factionId,
selectedStation.definition.category,
`Ore ${Math.round(selectedStation.oreStored)}`,
`Refined ${Math.round(selectedStation.refinedStock)}`,
`HP ${Math.round(selectedStation.health)}/${selectedStation.maxHealth}`,
`Dock ${selectedStation.dockedShipIds.size}/${selectedStation.definition.dockingCapacity}`,
],
);
}
if (selection.length === 0) {
return `<span class="selection-strip-empty">No active selection</span>`;
}
return selection
.map((ship) =>
renderCard(ship.definition.label, [
ship.factionId,
ship.state,
`${getOrderSummary(ship)} / ${getBehaviorSummary(ship)} / ${ship.controllerTask.kind}`,
`Cargo ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}`,
`HP ${Math.round(ship.health)}/${ship.maxHealth}`,
]),
)
.join("");
}
export function getSelectionDetails(
selection: ShipInstance[],
selectedStation: StationInstance | undefined,
selectedSystem: SolarSystemInstance | undefined,
selectedPlanet: PlanetInstance | undefined,
selectedNode: ResourceNode | undefined,
systems: SolarSystemInstance[],
viewLevel: ViewLevel,
ships: ShipInstance[],
factions: FactionInstance[],
) {
if (selectedNode) {
return `Asteroid Field ${selectedNode.id}${selectedNode.systemId}\nResource: ${getItemLabel(selectedNode.itemId)}\nOre Remaining: ${Math.round(selectedNode.oreRemaining)}/${selectedNode.maxOre}`;
}
if (selectedPlanet) {
return `${selectedPlanet.definition.label}${selectedPlanet.systemId}\nOrbit Radius: ${Math.round(selectedPlanet.definition.orbitRadius)}\nSize: ${selectedPlanet.definition.size}\nOrbit Speed: ${selectedPlanet.definition.orbitSpeed.toFixed(2)}\nTilt: ${selectedPlanet.definition.tilt.toFixed(2)}\nRing: ${selectedPlanet.definition.hasRing ? "Yes" : "No"}`;
}
if (selectedSystem) {
return `${selectedSystem.definition.label}\nType: ${selectedSystem.strategicValue}\nControl: ${selectedSystem.controllingFactionId ?? "Contested"} ${Math.round(selectedSystem.controlProgress)}%\nPlanets: ${selectedSystem.planets.length}\nResource Nodes: ${selectedSystem.definition.resourceNodes.length}\nGravity Well: ${Math.round(selectedSystem.gravityWellRadius)}`;
}
if (selectedStation) {
return describeStation(selectedStation, ships);
}
if (selection.length === 0) {
const central = systems
.filter((system) => system.strategicValue === "central")
.map((system) => `${system.definition.label}: ${system.controllingFactionId ?? "contested"} ${Math.round(system.controlProgress)}%`)
.join("\n");
const factionLines = factions
.filter((faction) => faction.definition.kind === "empire")
.map(
(faction) =>
`${faction.definition.label}: systems ${faction.ownedSystemIds.size} • mined ${Math.round(faction.oreMined)} • built ${faction.shipsBuilt} ships • losses ${faction.shipsLost}`,
)
.join("\n");
return `Systems online: ${systems.length}\nShips tracked: ${ships.length}\nView: ${viewLevel}\n\nCentral systems:\n${central}\n\nEmpires:\n${factionLines}`;
}
return selection
.map(
(ship) => {
const dockedAt = ship.dockedCarrierId ?? ship.dockedStationId;
const hangarStatus =
ship.definition.dockingCapacity && ship.definition.dockingCapacity > 0
? `\nHangar: ${ship.dockedShipIds.size}/${ship.definition.dockingCapacity} for ${(ship.definition.dockingClasses ?? []).join(", ")}`
: "";
const controllerDestination = getControllerTaskDestinationLabel(ship);
const destination = controllerDestination
? `\nTask Target: ${controllerDestination}`
: "";
return `${ship.definition.label}${ship.systemId}\nFaction: ${ship.factionId}\nClass: ${ship.definition.shipClass}\nState: ${ship.state}${dockedAt ? ` @ ${dockedAt}` : ""}${destination}\nOrder: ${getOrderSummary(ship)}\nDefault: ${getBehaviorSummary(ship)}\nAssignment: ${getAssignmentSummary(ship)}\nController: ${ship.controllerTask.kind}\nHealth: ${Math.round(ship.health)}/${ship.maxHealth}\nCargo: ${Math.round(getShipCargoAmount(ship))}/${ship.definition.cargoCapacity || 0} ${getItemLabel(ship.cargoItemId)}\nFuel: ${ship.fuel.toFixed(0)}/${ship.maxFuel}\nEnergy: ${ship.energy.toFixed(0)}/${ship.maxEnergy}\nHold Type: ${ship.definition.cargoKind ?? "none"}${hangarStatus}\nModules: ${ship.definition.modules.map(getModuleLabel).join(", ")}`;
},
)
.join("\n\n");
}
export function describeStation(station: StationInstance, ships: ShipInstance[]) {
const miners = ships.filter((ship) => ship.systemId === station.systemId && ship.defaultBehavior.kind === "auto-mine").length;
const escorts = ships.filter((ship) => ship.systemId === station.systemId && ship.defaultBehavior.kind === "escort-assigned").length;
const patrols = ships.filter((ship) => ship.systemId === station.systemId && ship.defaultBehavior.kind === "patrol").length;
const localShips = ships.filter((ship) => ship.systemId === station.systemId).length;
const activeRecipe = station.activeRecipeId
? recipeDefinitions.find((recipe) => recipe.id === station.activeRecipeId)
: undefined;
const stockSummary = Object.entries(station.itemStocks)
.filter(([, amount]) => amount > 0)
.sort((left, right) => right[1] - left[1])
.slice(0, 5)
.map(([itemId, amount]) => `${getItemLabel(itemId)} ${Math.round(amount)}`)
.join(", ");
const productionStatus =
station.modules.includes("fabricator-array") || station.definition.category === "refining"
? `Ore: ${Math.round(station.oreStored)}\nRefined Metals: ${Math.round(station.refinedStock)}\nBatch: ${Math.round(station.activeBatch)}\nRecipe: ${activeRecipe?.label ?? "Idle"}\nTime Remaining: ${station.activeBatch > 0 ? `${station.processTimer.toFixed(1)}s` : "Idle"}\nStocks: ${stockSummary || "None"}\n`
: "";
const activity =
station.definition.category === "refining"
? `Refining and fabricating for ${miners} mining ships`
: station.definition.category === "shipyard"
? `Building ship parts for ${patrols} patrol craft`
: station.definition.category === "farm"
? "Supplying agricultural goods and industrial consumables"
: station.definition.category === "defense"
? `Coordinating ${escorts} escort ships`
: station.definition.category === "gate"
? "Assembling transit infrastructure and gate components"
: station.modules.includes("fabricator-array")
? "Fabricating industrial parts and equipment"
: "Managing local trade traffic";
return `${station.definition.label}${station.systemId}\nFaction: ${station.factionId}\nRole: ${station.definition.category}\nActivity: ${activity}\nLocal Ships: ${localShips}\nDocking: ${station.dockedShipIds.size}/${station.definition.dockingCapacity}\nHealth: ${Math.round(station.health)}/${station.maxHealth}\nFuel: ${station.fuel.toFixed(0)}/${station.maxFuel}\nEnergy: ${station.energy.toFixed(0)}/${station.maxEnergy}\nBulk Solid: ${Math.round(station.inventory["bulk-solid"])}\nContainer: ${Math.round(station.inventory.container)}\nManufactured: ${Math.round(station.inventory.manufactured)}\nModules: ${station.modules.map(getModuleLabel).join(", ")}\n${productionStatus}Radius: ${station.definition.radius}`;
}
export function getShipWindowMarkup(ships: ShipInstance[], selection: ShipInstance[]) {
if (ships.length === 0) {
return `<div class="ship-window-empty">No ships online.</div>`;
}
const selectedShipIds = new Set(selection.map((ship) => ship.id));
const sortedShips = [...ships]
.sort((left, right) =>
left.factionId.localeCompare(right.factionId) ||
left.systemId.localeCompare(right.systemId) ||
left.definition.label.localeCompare(right.definition.label) ||
left.id.localeCompare(right.id),
);
const shipsByFaction = new Map<string, ShipInstance[]>();
sortedShips.forEach((ship) => {
const bucket = shipsByFaction.get(ship.factionId) ?? [];
bucket.push(ship);
shipsByFaction.set(ship.factionId, bucket);
});
return [...shipsByFaction.entries()]
.map(
([factionId, factionShips]) => `
<section class="ship-window-group">
<h3 class="ship-window-group-title" data-select-kind="faction" data-select-id="${factionId}">${factionId}</h3>
${factionShips
.map(
(ship) => `
<div class="ship-window-row" data-select-kind="ship" data-select-id="${ship.id}" data-selected="${selectedShipIds.has(ship.id)}">
<span class="ship-window-name">${ship.definition.label}</span>
<span class="ship-window-meta">${describeShipNode(ship)}</span>
</div>
`,
)
.join("")}
</section>
`,
)
.join("");
}
export function getDebugHistoryMarkup(
selectedShip: ShipInstance | undefined,
historyByShipId: Map<string, string[]>,
) {
if (!selectedShip) {
return `<div class="debug-history-empty">Select a ship to inspect its history.</div>`;
}
const entries = historyByShipId.get(selectedShip.id);
if (!entries || entries.length === 0) {
return `
<section class="debug-history-ship" data-selected="true">
<h3 class="debug-history-title">${escapeHtml(selectedShip.definition.label)}${escapeHtml(selectedShip.id)}</h3>
<div class="debug-history-empty">No ship history recorded yet.</div>
</section>
`;
}
const destination = getControllerTaskDestinationLabel(selectedShip) ?? "none";
const anchor = selectedShip.landedDestination
? `${selectedShip.landedDestination.label} @ ${selectedShip.landedDestination.systemId}`
: "free";
return `
<section class="debug-history-ship" data-selected="true">
<h3 class="debug-history-title">${escapeHtml(selectedShip.definition.label)}${escapeHtml(selectedShip.id)}</h3>
<div class="debug-history-summary">
<div><strong>Order:</strong> ${escapeHtml(getOrderSummary(selectedShip))}</div>
<div><strong>Default behavior:</strong> ${escapeHtml(getBehaviorSummary(selectedShip))}</div>
<div><strong>Assignment:</strong> ${escapeHtml(getAssignmentSummary(selectedShip))}</div>
<div><strong>Controller task:</strong> ${escapeHtml(selectedShip.controllerTask.kind)}</div>
<div><strong>Flight state:</strong> ${escapeHtml(selectedShip.state)}</div>
<div><strong>Task target:</strong> ${escapeHtml(destination)}</div>
<div><strong>Anchor:</strong> ${escapeHtml(anchor)}</div>
</div>
${entries.map((entry) => `<div class="debug-history-entry">${escapeHtml(entry)}</div>`).join("")}
</section>
`;
}
function describeShipNode(ship: ShipInstance): string {
const intent = `${getOrderSummary(ship)} / ${getBehaviorSummary(ship)} / ${ship.controllerTask.kind}`;
const controllerDestination = getControllerTaskDestinationLabel(ship);
return controllerDestination
? `${intent}${ship.state} -> ${controllerDestination}`
: `${intent}${ship.state} - ${ship.systemId}`;
}
function getControllerTaskDestinationLabel(ship: ShipInstance) {
const task = ship.controllerTask;
if (task.kind === "travel") {
return `${task.destination.label} @ ${task.destination.systemId}`;
}
if (task.kind === "dock" || task.kind === "undock") {
return `${task.hostKind}:${task.hostId}`;
}
if (task.kind === "extract") {
return `Asteroid Field ${task.nodeId}`;
}
if (task.kind === "follow") {
return `ship:${task.targetShipId}`;
}
return undefined;
}
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function renderCard(title: string, lines: string[]) {
return `
<article class="selection-strip-card">
<span class="selection-strip-card-title">${title}</span>
${lines.map((line) => `<span class="selection-strip-card-line">${line}</span>`).join("")}
</article>
`;
}
function getOrderSummary(ship: ShipInstance) {
if (!ship.order) {
return "none";
}
if (ship.order.kind === "move-to") {
return `move-to (${ship.order.status})`;
}
if (ship.order.kind === "mine-this") {
return `mine-this:${ship.order.phase} (${ship.order.status})`;
}
return `dock-at (${ship.order.status})`;
}
function getBehaviorSummary(ship: ShipInstance) {
const behavior = ship.defaultBehavior;
if (behavior.kind === "auto-mine") {
return `auto-mine:${behavior.phase} ${behavior.areaSystemId}`;
}
if (behavior.kind === "patrol") {
return `patrol:${behavior.index + 1} ${behavior.systemId}`;
}
if (behavior.kind === "escort-assigned") {
return "escort-assigned";
}
return "idle";
}
function getAssignmentSummary(ship: ShipInstance) {
const assignment = ship.assignment;
if (assignment.kind === "commander-subordinate") {
return `${assignment.kind} ${assignment.commanderId}`;
}
if (assignment.kind === "station-based") {
return `${assignment.kind} ${assignment.stationId}`;
}
if (assignment.kind === "mining-group") {
return `${assignment.kind} ${assignment.controllerId}`;
}
return "unassigned";
}
export function getItemLabel(itemId?: string) {
return itemId ? itemDefinitionsById.get(itemId)?.label ?? itemId : "None";
}
export function getModuleLabel(moduleId: string) {
return moduleDefinitionsById.get(moduleId)?.label ?? moduleId;
}

View File

@@ -1,448 +0,0 @@
import * as THREE from "three";
import type { ShipRole, ShipInstance, SolarSystemInstance, StationInstance, ViewLevel } from "../types";
interface RenderMinimapOptions {
context: CanvasRenderingContext2D;
width: number;
height: number;
systems: SolarSystemInstance[];
stations: StationInstance[];
ships: ShipInstance[];
selection: ShipInstance[];
selectedStation?: StationInstance;
cameraFocus: THREE.Vector3;
}
interface RenderOverlayOptions {
context: CanvasRenderingContext2D;
width: number;
height: number;
camera: THREE.PerspectiveCamera;
systems: SolarSystemInstance[];
stations: StationInstance[];
ships: ShipInstance[];
selection: ShipInstance[];
selectedStation?: StationInstance;
viewLevel: ViewLevel;
}
export function drawMinimap({
context,
width,
height,
systems,
stations,
ships,
selection,
selectedStation,
cameraFocus,
}: RenderMinimapOptions) {
context.clearRect(0, 0, width, height);
context.fillStyle = "rgba(4, 9, 20, 0.92)";
context.fillRect(0, 0, width, height);
context.strokeStyle = "rgba(126, 212, 255, 0.18)";
context.strokeRect(0.5, 0.5, width - 1, height - 1);
const bounds = { minX: -400, maxX: 5000, minZ: -1000, maxZ: 1800 };
const mapPoint = (position: THREE.Vector3) => ({
x: ((position.x - bounds.minX) / (bounds.maxX - bounds.minX)) * width,
y: ((position.z - bounds.minZ) / (bounds.maxZ - bounds.minZ)) * height,
});
systems.forEach((system) => {
const point = mapPoint(system.center);
context.fillStyle = "#7ed4ff";
context.beginPath();
context.arc(point.x, point.y, 6, 0, Math.PI * 2);
context.fill();
context.strokeStyle = "rgba(126,212,255,0.25)";
context.beginPath();
context.arc(point.x, point.y, 18, 0, Math.PI * 2);
context.stroke();
});
stations.forEach((station) => {
const point = mapPoint(station.group.position);
context.fillStyle = station === selectedStation ? "#ffbf69" : "#b4c9da";
context.fillRect(point.x - 2, point.y - 2, 4, 4);
});
ships.forEach((ship) => {
const point = mapPoint(ship.group.position);
context.fillStyle = selection.includes(ship)
? "#ffbf69"
: ship.definition.role === "mining"
? "#ffdd75"
: ship.definition.role === "transport"
? "#b0ff8d"
: "#7ed4ff";
context.beginPath();
context.arc(point.x, point.y, selection.includes(ship) ? 3 : 2, 0, Math.PI * 2);
context.fill();
});
const focus = mapPoint(cameraFocus);
context.strokeStyle = "rgba(255,255,255,0.7)";
context.strokeRect(focus.x - 9, focus.y - 9, 18, 18);
}
export function drawStrategicOverlay({
context,
width,
height,
camera,
systems,
stations,
ships,
selection,
selectedStation,
viewLevel,
}: RenderOverlayOptions) {
context.clearRect(0, 0, width, height);
if (viewLevel === "local") {
return;
}
context.save();
context.scale(width / window.innerWidth, height / window.innerHeight);
context.lineJoin = "round";
context.lineCap = "round";
context.textAlign = "center";
context.textBaseline = "middle";
if (viewLevel === "solar") {
selection.forEach((ship) => {
const screen = projectWorldToScreen(ship.group.position, camera);
if (screen) {
drawShipSymbol(context, screen.x, screen.y, ship, 10, true);
}
});
if (selectedStation) {
const screen = projectWorldToScreen(selectedStation.group.position, camera);
if (screen) {
drawStationSymbol(context, screen.x, screen.y, selectedStation, 14, true);
}
}
} else {
systems.forEach((system) => {
const screen = projectWorldToScreen(system.center, camera);
if (!screen) {
return;
}
drawSystemFrame(context, screen.x, screen.y, system.definition.label);
const roleBuckets = new Map<ShipRole, ShipInstance[]>();
ships.forEach((ship) => {
if (ship.systemId !== system.definition.id) {
return;
}
const bucket = roleBuckets.get(ship.definition.role) ?? [];
bucket.push(ship);
roleBuckets.set(ship.definition.role, bucket);
});
const roleOrder: ShipRole[] = ["military", "transport", "mining"];
roleOrder.forEach((role, index) => {
const bucket = roleBuckets.get(role);
if (!bucket || bucket.length === 0) {
return;
}
drawRoleSymbol(context, screen.x - 52 + index * 52, screen.y + 32, role, bucket.length, bucket.some((ship) => selection.includes(ship)));
});
const stationCount = stations.filter((station) => station.systemId === system.definition.id).length;
const stationSelected = stations.some(
(station) => station.systemId === system.definition.id && station === selectedStation,
);
if (stationCount > 0) {
drawStrategicStationGroup(context, screen.x, screen.y - 38, stationCount, stationSelected);
}
});
}
context.restore();
}
function projectWorldToScreen(position: THREE.Vector3, camera: THREE.PerspectiveCamera) {
const screen = position.clone().project(camera);
if (screen.z < -1 || screen.z > 1) {
return undefined;
}
return {
x: ((screen.x + 1) * 0.5) * window.innerWidth,
y: ((-screen.y + 1) * 0.5) * window.innerHeight,
};
}
function drawSystemFrame(context: CanvasRenderingContext2D, x: number, y: number, label: string) {
context.strokeStyle = "rgba(126, 212, 255, 0.82)";
context.lineWidth = 1.25;
context.strokeRect(x - 28, y - 16, 56, 32);
context.beginPath();
context.moveTo(x - 40, y);
context.lineTo(x - 28, y);
context.moveTo(x + 28, y);
context.lineTo(x + 40, y);
context.stroke();
context.fillStyle = "rgba(235, 247, 255, 0.9)";
context.font = "600 11px Space Grotesk, sans-serif";
context.fillText(label.toUpperCase(), x, y - 28);
}
function drawRoleSymbol(
context: CanvasRenderingContext2D,
x: number,
y: number,
role: ShipRole,
count: number,
highlighted: boolean,
) {
context.save();
context.translate(x, y);
context.strokeStyle = highlighted ? "#ffbf69" : "rgba(208, 232, 244, 0.95)";
context.fillStyle = "rgba(5, 12, 26, 0.88)";
context.lineWidth = highlighted ? 2.2 : 1.5;
if (role === "military") {
context.beginPath();
context.moveTo(0, -12);
context.lineTo(12, 0);
context.lineTo(0, 12);
context.lineTo(-12, 0);
context.closePath();
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-5, 0);
context.lineTo(5, 0);
context.stroke();
} else if (role === "transport") {
context.beginPath();
context.rect(-13, -9, 26, 18);
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-4, -9);
context.lineTo(-4, 9);
context.moveTo(4, -9);
context.lineTo(4, 9);
context.stroke();
} else {
context.beginPath();
context.moveTo(-12, -7);
context.lineTo(-5, -12);
context.lineTo(5, -12);
context.lineTo(12, -7);
context.lineTo(12, 7);
context.lineTo(5, 12);
context.lineTo(-5, 12);
context.lineTo(-12, 7);
context.closePath();
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-8, 0);
context.lineTo(8, 0);
context.stroke();
}
context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)";
context.font = "700 12px Space Grotesk, sans-serif";
context.fillText(String(count), 0, 23);
context.restore();
}
function drawStrategicStationGroup(
context: CanvasRenderingContext2D,
x: number,
y: number,
count: number,
highlighted: boolean,
) {
context.save();
context.translate(x, y);
context.strokeStyle = highlighted ? "#ffbf69" : "rgba(180, 201, 218, 0.9)";
context.fillStyle = "rgba(5, 12, 26, 0.88)";
context.lineWidth = highlighted ? 2.2 : 1.5;
context.beginPath();
context.rect(-12, -12, 24, 24);
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-18, 0);
context.lineTo(-12, 0);
context.moveTo(12, 0);
context.lineTo(18, 0);
context.moveTo(0, -18);
context.lineTo(0, -12);
context.moveTo(0, 12);
context.lineTo(0, 18);
context.stroke();
context.fillStyle = highlighted ? "#ffbf69" : "rgba(235, 247, 255, 0.9)";
context.font = "700 12px Space Grotesk, sans-serif";
context.fillText(String(count), 0, 24);
context.restore();
}
function drawShipSymbol(
context: CanvasRenderingContext2D,
x: number,
y: number,
ship: ShipInstance,
size: number,
highlighted: boolean,
) {
context.save();
context.translate(x, y);
context.rotate(-ship.group.rotation.y);
context.strokeStyle = highlighted ? "#ffbf69" : getShipSymbolColor(ship);
context.lineWidth = highlighted ? 2.2 : 1.4;
context.fillStyle = "rgba(5, 12, 26, 0.74)";
if (ship.definition.role === "military") {
context.beginPath();
context.moveTo(0, -size);
context.lineTo(size, 0);
context.lineTo(0, size);
context.lineTo(-size, 0);
context.closePath();
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-size * 0.35, 0);
context.lineTo(size * 0.35, 0);
context.stroke();
} else if (ship.definition.role === "transport") {
context.beginPath();
context.rect(-size, -size * 0.68, size * 2, size * 1.36);
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-size * 0.25, -size * 0.68);
context.lineTo(-size * 0.25, size * 0.68);
context.moveTo(size * 0.25, -size * 0.68);
context.lineTo(size * 0.25, size * 0.68);
context.stroke();
} else {
context.beginPath();
context.moveTo(-size, -size * 0.5);
context.lineTo(-size * 0.35, -size);
context.lineTo(size * 0.35, -size);
context.lineTo(size, -size * 0.5);
context.lineTo(size, size * 0.5);
context.lineTo(size * 0.35, size);
context.lineTo(-size * 0.35, size);
context.lineTo(-size, size * 0.5);
context.closePath();
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-size * 0.65, 0);
context.lineTo(size * 0.65, 0);
context.stroke();
}
if (highlighted) {
context.strokeStyle = "rgba(255, 191, 105, 0.42)";
context.lineWidth = 1;
context.beginPath();
context.arc(0, 0, size + 7, 0, Math.PI * 2);
context.stroke();
}
context.restore();
}
function drawStationSymbol(
context: CanvasRenderingContext2D,
x: number,
y: number,
station: StationInstance,
size: number,
highlighted: boolean,
) {
context.save();
context.translate(x, y);
context.strokeStyle = highlighted ? "#ffbf69" : getStationSymbolColor(station);
context.fillStyle = "rgba(5, 12, 26, 0.78)";
context.lineWidth = highlighted ? 2.2 : 1.5;
context.beginPath();
context.rect(-size, -size, size * 2, size * 2);
context.fill();
context.stroke();
context.beginPath();
context.moveTo(-size - 7, 0);
context.lineTo(-size, 0);
context.moveTo(size, 0);
context.lineTo(size + 7, 0);
context.moveTo(0, -size - 7);
context.lineTo(0, -size);
context.moveTo(0, size);
context.lineTo(0, size + 7);
context.stroke();
if (station.definition.category === "refining") {
context.beginPath();
context.moveTo(-4, 5);
context.lineTo(0, -5);
context.lineTo(4, 5);
context.stroke();
} else if (station.definition.category === "defense") {
context.beginPath();
context.moveTo(-5, -5);
context.lineTo(5, 5);
context.moveTo(5, -5);
context.lineTo(-5, 5);
context.stroke();
} else if (station.definition.category === "shipyard") {
context.beginPath();
context.rect(-5, -3, 10, 6);
context.stroke();
} else if (station.definition.category === "farm") {
context.beginPath();
context.arc(0, 0, 5, 0, Math.PI * 2);
context.stroke();
} else if (station.definition.category === "gate") {
context.beginPath();
context.arc(0, 0, 6, 0, Math.PI * 2);
context.stroke();
context.beginPath();
context.moveTo(-6, 0);
context.lineTo(6, 0);
context.stroke();
}
context.restore();
}
function getShipSymbolColor(ship: ShipInstance) {
if (ship.definition.role === "military") {
return "rgba(126, 212, 255, 0.95)";
}
if (ship.definition.role === "transport") {
return "rgba(176, 255, 141, 0.95)";
}
return "rgba(255, 221, 117, 0.95)";
}
function getStationSymbolColor(station: StationInstance) {
if (station.definition.category === "refining") {
return "rgba(255, 184, 108, 0.95)";
}
if (station.definition.category === "farm") {
return "rgba(146, 239, 138, 0.95)";
}
if (station.definition.category === "defense") {
return "rgba(255, 122, 149, 0.95)";
}
if (station.definition.category === "shipyard") {
return "rgba(208, 162, 255, 0.95)";
}
if (station.definition.category === "gate") {
return "rgba(118, 240, 255, 0.95)";
}
return "rgba(180, 201, 218, 0.95)";
}

View File

@@ -1,337 +0,0 @@
import { solarSystemDefinitions } from "../data/catalog";
import type {
AsteroidFieldDefinition,
FactionDefinition,
PatrolRouteDefinition,
PlanetDefinition,
ResourceNodeDefinition,
ScenarioDefinition,
SolarSystemDefinition,
UniverseDefinition,
} from "../types";
const TOTAL_SYSTEMS = 28;
const STAR_PALETTES = [
{ starColor: "#ffd27a", starGlow: "#ffb14a" },
{ starColor: "#9dc6ff", starGlow: "#66a0ff" },
{ starColor: "#ffb7a1", starGlow: "#ff7d66" },
{ starColor: "#f3f0ff", starGlow: "#b49cff" },
{ starColor: "#b6ffe0", starGlow: "#5ed6b1" },
{ starColor: "#ffe49a", starGlow: "#ffc14a" },
];
const PLANET_COLORS = ["#d4a373", "#58a36c", "#6ea7d4", "#6958a8", "#c48f6a", "#4f84c4", "#8f8fb0", "#d46e8a"];
const FRONTIER_PREFIXES = ["Aquila", "Draco", "Lyra", "Cygnus", "Orion", "Vela", "Carina", "Pavo", "Vesper", "Altair"];
const FRONTIER_SUFFIXES = ["Reach", "Gate", "Crown", "Run", "March", "Drift", "Anchor", "Span", "Wake", "Vale"];
const EMPIRE_ARCHETYPES = [
{ id: "solar-dominion", label: "Solar Dominion", color: "#f0c36d", accent: "#ffefb0" },
{ id: "aegis-state", label: "Aegis State", color: "#72b7ff", accent: "#d5ecff" },
{ id: "verdant-combine", label: "Verdant Combine", color: "#77dd8c", accent: "#d7ffe2" },
{ id: "iron-clans", label: "Iron Clans", color: "#ff926c", accent: "#ffd8c9" },
];
const PIRATE_ARCHETYPES = [
{ id: "black-flag", label: "Black Flag Cartel", color: "#ff5a6f", accent: "#ffd0d6" },
{ id: "void-rats", label: "Void Rats", color: "#9a7cff", accent: "#e7dcff" },
{ id: "grim-sons", label: "Grim Sons", color: "#ff8d54", accent: "#ffe1d1" },
{ id: "night-jackals", label: "Night Jackals", color: "#a0ff7f", accent: "#e8ffd8" },
{ id: "red-knives", label: "Red Knives", color: "#ff6a8c", accent: "#ffd7e2" },
{ id: "dust-serpents", label: "Dust Serpents", color: "#c2a56f", accent: "#f0e1c3" },
];
export function generateUniverse(seed = Math.floor(Math.random() * 0x7fffffff)): UniverseDefinition {
const rng = createRng(seed);
const systems: SolarSystemDefinition[] = [];
const empires: FactionDefinition[] = [];
const pirates: FactionDefinition[] = [];
const centralSystems = Array.from({ length: 3 }, (_, index) => createCentralSystem(index, rng));
systems.push(...centralSystems);
EMPIRE_ARCHETYPES.forEach((archetype, index) => {
const angle = (index / EMPIRE_ARCHETYPES.length) * Math.PI * 2;
const capitalSystem = createEmpireCapitalSystem(archetype.label, archetype.id, angle, rng);
const miningSystem = createEmpireMiningSystem(archetype.label, archetype.id, angle + 0.22, rng);
systems.push(capitalSystem, miningSystem);
empires.push({
id: archetype.id,
label: archetype.label,
kind: "empire",
color: archetype.color,
accent: archetype.accent,
homeSystemId: capitalSystem.id,
miningSystemId: miningSystem.id,
targetSystemIds: centralSystems.map((system) => system.id),
rivals: EMPIRE_ARCHETYPES.filter((_, rivalIndex) => rivalIndex !== index).map((rival) => rival.id),
});
});
PIRATE_ARCHETYPES.forEach((archetype, index) => {
const targetEmpire = empires[index % empires.length];
const secondaryEmpire = empires[(index + 1) % empires.length];
const pirateSystem = createPirateBaseSystem(archetype.label, archetype.id, index, rng);
systems.push(pirateSystem);
pirates.push({
id: archetype.id,
label: archetype.label,
kind: "pirate",
color: archetype.color,
accent: archetype.accent,
homeSystemId: pirateSystem.id,
targetSystemIds: [targetEmpire.homeSystemId, targetEmpire.miningSystemId ?? targetEmpire.homeSystemId],
rivals: [targetEmpire.id, secondaryEmpire.id],
pirateForFactionId: targetEmpire.id,
});
});
while (systems.length < TOTAL_SYSTEMS) {
systems.push(createFrontierSystem(systems.length, rng));
}
const factions = [...empires, ...pirates];
empires.forEach((empire, index) => {
empire.rivals.push(
pirates[index].id,
pirates[(index + 4) % pirates.length].id,
);
});
return {
seed,
label: `Autonomous Cluster ${seed.toString(16).toUpperCase()}`,
systems,
scenario: createScenario(systems, factions),
};
}
function createScenario(systems: SolarSystemDefinition[], factions: FactionDefinition[]): ScenarioDefinition {
const empires = factions.filter((faction) => faction.kind === "empire");
const pirates = factions.filter((faction) => faction.kind === "pirate");
const initialStations: ScenarioDefinition["initialStations"] = [];
const shipFormations: ScenarioDefinition["shipFormations"] = [];
const patrolRoutes: PatrolRouteDefinition[] = [];
const centralSystemIds = systems.filter((system) => system.id.startsWith("central-")).map((system) => system.id);
empires.forEach((faction) => {
const capital = systems.find((system) => system.id === faction.homeSystemId);
const mining = systems.find((system) => system.id === faction.miningSystemId);
if (!capital || !mining) {
return;
}
initialStations.push(
{
constructibleId: "manufactory",
systemId: capital.id,
factionId: faction.id,
planetIndex: Math.min(1, capital.planets.length - 1),
lagrangeSide: 1,
seedStock: { water: 40 },
},
{
constructibleId: "refinery",
systemId: mining.id,
factionId: faction.id,
planetIndex: 0,
lagrangeSide: 1,
seedStock: { ore: 60 },
},
);
shipFormations.push({
shipId: "miner",
count: 1,
center: localPoint(mining, 180, 100),
systemId: mining.id,
factionId: faction.id,
});
patrolRoutes.push(createPatrolRoute(capital), createPatrolRoute(mining));
});
pirates.forEach((faction) => {
const base = systems.find((system) => system.id === faction.homeSystemId);
if (!base) {
return;
}
initialStations.push(
{
constructibleId: "trade-hub",
systemId: base.id,
factionId: faction.id,
planetIndex: 0,
lagrangeSide: 1,
seedStock: { "refined-metals": 60, "ammo-crates": 12 },
},
);
shipFormations.push({
shipId: "frigate",
count: 1,
center: localPoint(base, 180, 60),
systemId: base.id,
factionId: faction.id,
});
patrolRoutes.push(createPatrolRoute(base));
});
const firstEmpire = empires[0];
return {
initialStations,
shipFormations,
patrolRoutes,
miningDefaults: {
nodeSystemId: firstEmpire.miningSystemId ?? firstEmpire.homeSystemId,
refinerySystemId: firstEmpire.homeSystemId,
},
factions,
centralSystemIds,
};
}
function createEmpireCapitalSystem(label: string, factionId: string, angle: number, rng: () => number): SolarSystemDefinition {
const base = solarSystemDefinitions[0];
const radius = 6200 + Math.floor(rng() * 700);
return {
...base,
id: `${factionId}-capital`,
label: `${label} Prime`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starSize: 48 + Math.floor(rng() * 10),
gravityWellRadius: 210 + Math.floor(rng() * 18),
asteroidField: createAsteroidFieldDefinition(rng, false),
resourceNodes: [],
planets: createPlanets(4 + Math.floor(rng() * 2), rng),
};
}
function createEmpireMiningSystem(label: string, factionId: string, angle: number, rng: () => number): SolarSystemDefinition {
const base = solarSystemDefinitions[1];
const radius = 7700 + Math.floor(rng() * 900);
return {
...base,
id: `${factionId}-belt`,
label: `${label} Belt`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starSize: 42 + Math.floor(rng() * 10),
gravityWellRadius: 220 + Math.floor(rng() * 20),
asteroidField: createAsteroidFieldDefinition(rng, true),
resourceNodes: createResourceNodes(4 + Math.floor(rng() * 2), rng, 3600, 5200),
planets: createPlanets(3 + Math.floor(rng() * 2), rng),
};
}
function createCentralSystem(index: number, rng: () => number): SolarSystemDefinition {
const palette = STAR_PALETTES[(index + 1) % STAR_PALETTES.length];
const angle = (index / 3) * Math.PI * 2 + rng() * 0.3;
const radius = 900 + Math.floor(rng() * 500);
return {
id: `central-${index + 1}`,
label: ["Crown Basin", "Throne Verge", "Golden Axis"][index] ?? `Central ${index + 1}`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starColor: palette.starColor,
starGlow: palette.starGlow,
starSize: 50 + Math.floor(rng() * 14),
gravityWellRadius: 240 + Math.floor(rng() * 28),
asteroidField: createAsteroidFieldDefinition(rng, true),
resourceNodes: createResourceNodes(6 + Math.floor(rng() * 3), rng, 5200, 7600),
planets: createPlanets(4 + Math.floor(rng() * 2), rng),
};
}
function createPirateBaseSystem(label: string, factionId: string, index: number, rng: () => number): SolarSystemDefinition {
const palette = STAR_PALETTES[(index + 3) % STAR_PALETTES.length];
const angle = (index / PIRATE_ARCHETYPES.length) * Math.PI * 2 + 0.35;
const radius = 9800 + Math.floor(rng() * 1200);
return {
id: `${factionId}-den`,
label: `${label} Den`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starColor: palette.starColor,
starGlow: palette.starGlow,
starSize: 36 + Math.floor(rng() * 10),
gravityWellRadius: 180 + Math.floor(rng() * 30),
asteroidField: createAsteroidFieldDefinition(rng, true),
resourceNodes: createResourceNodes(2 + Math.floor(rng() * 2), rng, 1600, 2600),
planets: createPlanets(2 + Math.floor(rng() * 2), rng),
};
}
function createFrontierSystem(index: number, rng: () => number): SolarSystemDefinition {
const angle = index * 2.399963229728653 + rng() * 0.4;
const radius = 3600 + 900 * Math.sqrt(index) + rng() * 600;
const palette = STAR_PALETTES[Math.floor(rng() * STAR_PALETTES.length)];
const hasResources = rng() > 0.45;
return {
id: `frontier-${index + 1}`,
label: `${FRONTIER_PREFIXES[index % FRONTIER_PREFIXES.length]} ${FRONTIER_SUFFIXES[Math.floor(rng() * FRONTIER_SUFFIXES.length)]}`,
position: [Math.round(Math.cos(angle) * radius), 0, Math.round(Math.sin(angle) * radius)],
starColor: palette.starColor,
starGlow: palette.starGlow,
starSize: 34 + Math.round(rng() * 18),
gravityWellRadius: 185 + Math.round(rng() * 60),
asteroidField: createAsteroidFieldDefinition(rng, hasResources),
resourceNodes: hasResources ? createResourceNodes(1 + Math.floor(rng() * 3), rng, 1800, 3400) : [],
planets: createPlanets(2 + Math.floor(rng() * 3), rng),
};
}
function createAsteroidFieldDefinition(rng: () => number, dense: boolean): AsteroidFieldDefinition {
return {
decorationCount: dense ? 180 + Math.floor(rng() * 70) : 90 + Math.floor(rng() * 70),
radiusOffset: 290 + Math.floor(rng() * 100),
radiusVariance: 70 + Math.floor(rng() * 80),
heightVariance: 12 + Math.floor(rng() * 12),
};
}
function createPlanets(count: number, rng: () => number): PlanetDefinition[] {
const planets: PlanetDefinition[] = [];
let orbitRadius = 150 + Math.floor(rng() * 40);
for (let index = 0; index < count; index += 1) {
orbitRadius += 120 + Math.floor(rng() * 90);
planets.push({
label: `${String.fromCharCode(65 + index)}-${Math.floor(rng() * 90 + 10)}`,
orbitRadius,
orbitSpeed: Number((0.05 + rng() * 0.14).toFixed(3)),
size: 18 + Math.floor(rng() * 30),
color: PLANET_COLORS[Math.floor(rng() * PLANET_COLORS.length)],
tilt: Number(((rng() - 0.5) * 0.8).toFixed(2)),
hasRing: rng() > 0.72,
});
}
return planets;
}
function createResourceNodes(count: number, rng: () => number, minOre: number, maxOre: number): ResourceNodeDefinition[] {
return Array.from({ length: count }, (_, index) => ({
angle: Number((((index / count) * Math.PI * 2 + rng() * 0.7) % (Math.PI * 2)).toFixed(6)),
radiusOffset: 300 + Math.floor(rng() * 140),
oreAmount: minOre + Math.floor(rng() * (maxOre - minOre)),
itemId: "ore",
shardCount: 5 + Math.floor(rng() * 5),
}));
}
function createPatrolRoute(system: SolarSystemDefinition): PatrolRouteDefinition {
return {
systemId: system.id,
points: [
localPoint(system, 160, 90),
localPoint(system, 340, -180),
localPoint(system, 560, 210),
localPoint(system, 240, 340),
],
};
}
function localPoint(system: SolarSystemDefinition, x: number, z: number): [number, number, number] {
return [system.position[0] + x, 0, system.position[2] + z];
}
function createRng(seed: number) {
let value = seed >>> 0;
return () => {
value += 0x6d2b79f5;
let result = Math.imul(value ^ (value >>> 15), 1 | value);
result ^= result + Math.imul(result ^ (result >>> 7), 61 | result);
return ((result ^ (result >>> 14)) >>> 0) / 4294967296;
};
}

View File

@@ -1,847 +0,0 @@
import * as THREE from "three";
import {
constructibleDefinitionsById,
gameBalance,
shipDefinitionsById,
} from "../data/catalog";
import { createEmptyInventory } from "../state/inventory";
import type {
ConstructibleDefinition,
ResourceNode,
ScenarioDefinition,
SelectableTarget,
ShipDefinition,
ShipInstance,
SolarSystemDefinition,
SolarSystemInstance,
StationInstance,
} from "../types";
interface BuildWorldResult {
systems: SolarSystemInstance[];
nodes: ResourceNode[];
stations: StationInstance[];
ships: ShipInstance[];
shipsById: Map<string, ShipInstance>;
strategicLinks: THREE.Group;
starfield?: THREE.Points;
}
export function buildInitialWorld(
scene: THREE.Scene,
selectableTargets: Map<THREE.Object3D, SelectableTarget>,
systemsDefinition: SolarSystemDefinition[],
scenarioDefinition: ScenarioDefinition,
): BuildWorldResult {
const systems: SolarSystemInstance[] = [];
const nodes: ResourceNode[] = [];
const stations: StationInstance[] = [];
const ships: ShipInstance[] = [];
const shipsById = new Map<string, ShipInstance>();
const strategicLinks = new THREE.Group();
let shipId = 0;
let stationId = 0;
let nodeId = 0;
const factionColors = new Map(
(scenarioDefinition.factions ?? []).map((faction) => [faction.id, faction.color]),
);
scene.add(new THREE.HemisphereLight(0x6ba6ff, 0x03050a, 0.38));
scene.add(new THREE.AmbientLight(0x8397b8, 0.28));
scene.add(strategicLinks);
createNebulae(scene);
const starfield = createStarfield(scene);
systemsDefinition.forEach((definition) => {
systems.push(createSolarSystem(scene, definition, nodes, () => {
nodeId += 1;
return `node-${nodeId}`;
}, selectableTargets));
});
createStrategicLinks(strategicLinks, systems);
scenarioDefinition.initialStations.forEach((plan) => {
const definition = constructibleDefinitionsById.get(plan.constructibleId);
if (!definition) {
throw new Error(`Missing constructible definition ${plan.constructibleId}`);
}
stations.push(
createStationInstance({
id: `station-${++stationId}`,
scene,
definition,
systemId: plan.systemId,
position: plan.position ? new THREE.Vector3(...plan.position) : new THREE.Vector3(),
factionId: plan.factionId ?? "neutral",
factionColor: factionColors.get(plan.factionId ?? "") ?? "#b4c9da",
planetIndex: plan.planetIndex,
lagrangeSide: plan.lagrangeSide,
seedStock: plan.seedStock,
selectableTargets,
}),
);
});
scenarioDefinition.shipFormations.forEach((plan) => {
const definition = shipDefinitionsById.get(plan.shipId);
if (!definition) {
throw new Error(`Missing ship definition ${plan.shipId}`);
}
for (let i = 0; i < plan.count; i += 1) {
const ship = createShipInstance({
id: `ship-${++shipId}`,
definition,
systemId: plan.systemId,
factionId: plan.factionId ?? "neutral",
factionColor: factionColors.get(plan.factionId ?? "") ?? definition.color,
selectableTargets,
});
ship.group.position
.set(...plan.center)
.add(new THREE.Vector3((i % 3) * 18, gameBalance.yPlane, Math.floor(i / 3) * 18));
ship.target.copy(ship.group.position);
const systemCenter = getSystemCenter(systems, plan.systemId);
ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(systemCenter);
ship.idleOrbitAngle = Math.atan2(ship.group.position.z - systemCenter.z, ship.group.position.x - systemCenter.x);
scene.add(ship.group);
ships.push(ship);
shipsById.set(ship.id, ship);
}
});
return { systems, nodes, stations, ships, shipsById, strategicLinks, starfield };
}
function createSolarSystem(
scene: THREE.Scene,
definition: SolarSystemDefinition,
nodes: ResourceNode[],
nextNodeId: () => string,
selectableTargets?: Map<THREE.Object3D, SelectableTarget>,
) {
const root = new THREE.Group();
root.position.set(...definition.position);
scene.add(root);
const star = new THREE.Mesh(
new THREE.SphereGeometry(definition.starSize, 48, 48),
new THREE.MeshBasicMaterial({ color: definition.starColor }),
);
root.add(star);
const glow = new THREE.Mesh(
new THREE.SphereGeometry(definition.starSize * 1.6, 32, 32),
new THREE.MeshBasicMaterial({
color: definition.starGlow,
transparent: true,
opacity: 0.14,
side: THREE.BackSide,
}),
);
root.add(glow);
const light = new THREE.PointLight(definition.starColor, 3.2, 2800, 1.2);
light.castShadow = false;
root.add(light);
const planets = definition.planets.map((planetDefinition, index) => {
const orbitRoot = new THREE.Group();
orbitRoot.rotation.y = (index / definition.planets.length) * Math.PI * 2;
const planet = new THREE.Mesh(
new THREE.SphereGeometry(planetDefinition.size, 36, 36),
new THREE.MeshStandardMaterial({
color: planetDefinition.color,
metalness: 0.08,
roughness: 0.92,
emissive: new THREE.Color(planetDefinition.color).multiplyScalar(0.04),
}),
);
planet.position.x = planetDefinition.orbitRadius;
planet.rotation.z = planetDefinition.tilt;
planet.castShadow = true;
planet.receiveShadow = true;
orbitRoot.add(planet);
const selectionRing = new THREE.Mesh(
new THREE.RingGeometry(planetDefinition.size * 1.35, planetDefinition.size * 1.55, 40),
new THREE.MeshBasicMaterial({
color: 0xf5e8a5,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
}),
);
selectionRing.rotation.x = -Math.PI / 2;
selectionRing.position.x = planetDefinition.orbitRadius;
orbitRoot.add(selectionRing);
let ringObject: THREE.Object3D | undefined;
if (planetDefinition.hasRing) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(planetDefinition.size * 1.3, planetDefinition.size * 2, 72),
new THREE.MeshBasicMaterial({
color: 0xc1b299,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.4,
}),
);
ring.rotation.x = Math.PI / 2.35;
ring.position.x = planetDefinition.orbitRadius;
orbitRoot.add(ring);
ringObject = ring;
}
root.add(orbitRoot);
return {
definition: planetDefinition,
group: orbitRoot,
mesh: planet,
orbitSpeed: planetDefinition.orbitSpeed,
ring: ringObject,
selectionRing,
systemId: definition.id,
index,
};
});
const orbitLines = definition.planets.map((planetDefinition) => {
const orbitLine = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(
Array.from({ length: 120 }, (_, step) => {
const angle = (step / 120) * Math.PI * 2;
return new THREE.Vector3(
Math.cos(angle) * planetDefinition.orbitRadius,
0,
Math.sin(angle) * planetDefinition.orbitRadius,
);
}),
),
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.52 }),
);
root.add(orbitLine);
return orbitLine;
});
const asteroidDecorations = createAsteroidField(definition, root, nodes, nextNodeId, selectableTargets);
const strategicMarker = createStrategicMarker(scene, definition);
const system = {
definition,
root,
center: new THREE.Vector3(...definition.position),
planets,
star,
light,
gravityWellRadius: definition.gravityWellRadius,
orbitLines,
asteroidDecorations,
strategicMarker,
controlProgress: 0,
strategicValue: "frontier" as const,
};
if (selectableTargets) {
selectableTargets.set(star, { kind: "system", system });
selectableTargets.set(glow, { kind: "system", system });
selectableTargets.set(strategicMarker, { kind: "system", system });
planets.forEach((planet) => {
selectableTargets.set(planet.mesh, { kind: "planet", system, planet });
if (planet.ring) {
selectableTargets.set(planet.ring, { kind: "planet", system, planet });
}
});
}
return system;
}
function createAsteroidField(
definition: SolarSystemDefinition,
root: THREE.Group,
nodes: ResourceNode[],
nextNodeId: () => string,
selectableTargets?: Map<THREE.Object3D, SelectableTarget>,
) {
const rockGeometry = new THREE.IcosahedronGeometry(1, 0);
const rockMaterial = new THREE.MeshStandardMaterial({
color: 0x707582,
roughness: 1,
metalness: 0.05,
});
const decorations: THREE.Object3D[] = [];
const baseRadius = definition.gravityWellRadius + definition.asteroidField.radiusOffset;
for (let i = 0; i < definition.asteroidField.decorationCount; i += 1) {
const rock = new THREE.Mesh(rockGeometry, rockMaterial);
const angle = Math.random() * Math.PI * 2;
const radius = baseRadius + (Math.random() - 0.5) * definition.asteroidField.radiusVariance;
rock.position.set(
Math.cos(angle) * radius,
(Math.random() - 0.5) * definition.asteroidField.heightVariance,
Math.sin(angle) * radius,
);
rock.scale.setScalar(1.5 + Math.random() * 4);
rock.rotation.set(Math.random() * Math.PI, Math.random() * Math.PI, Math.random() * Math.PI);
root.add(rock);
decorations.push(rock);
}
definition.resourceNodes.forEach((resourceNode) => {
const cluster = new THREE.Group();
const position = new THREE.Vector3(
Math.cos(resourceNode.angle) * (baseRadius + resourceNode.radiusOffset - definition.asteroidField.radiusOffset),
0,
Math.sin(resourceNode.angle) * (baseRadius + resourceNode.radiusOffset - definition.asteroidField.radiusOffset),
);
cluster.position.copy(position);
for (let i = 0; i < resourceNode.shardCount; i += 1) {
const shard = new THREE.Mesh(
new THREE.DodecahedronGeometry(6 + Math.random() * 7, 0),
new THREE.MeshStandardMaterial({
color: 0xd1bd7c,
emissive: new THREE.Color("#ffdd75").multiplyScalar(0.08),
roughness: 0.9,
metalness: 0.15,
}),
);
shard.position.set((Math.random() - 0.5) * 18, (Math.random() - 0.5) * 12, (Math.random() - 0.5) * 18);
cluster.add(shard);
}
const selectionRing = new THREE.Mesh(
new THREE.RingGeometry(14, 19, 32),
new THREE.MeshBasicMaterial({
color: 0xffdd75,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
}),
);
selectionRing.rotation.x = -Math.PI / 2;
selectionRing.position.y = -6;
cluster.add(selectionRing);
root.add(cluster);
decorations.push(cluster);
const node = {
id: nextNodeId(),
systemId: definition.id,
position: cluster.getWorldPosition(new THREE.Vector3()),
mesh: cluster,
selectionRing,
oreRemaining: resourceNode.oreAmount,
maxOre: resourceNode.oreAmount,
itemId: resourceNode.itemId,
};
nodes.push(node);
cluster.traverse((child) => selectableTargets?.set(child, { kind: "node", node }));
});
return decorations;
}
function createStrategicMarker(scene: THREE.Scene, definition: SolarSystemDefinition) {
const marker = new THREE.Group();
marker.position.set(...definition.position);
const outer = new THREE.Mesh(
new THREE.RingGeometry(definition.gravityWellRadius * 0.9, definition.gravityWellRadius * 1.05, 64),
new THREE.MeshBasicMaterial({
color: definition.starColor,
transparent: true,
opacity: 0.4,
side: THREE.DoubleSide,
}),
);
outer.rotation.x = -Math.PI / 2;
marker.add(outer);
const core = new THREE.Mesh(
new THREE.CircleGeometry(definition.gravityWellRadius * 0.22, 32),
new THREE.MeshBasicMaterial({
color: definition.starColor,
transparent: true,
opacity: 0.7,
side: THREE.DoubleSide,
}),
);
core.rotation.x = -Math.PI / 2;
marker.add(core);
marker.visible = false;
scene.add(marker);
return marker;
}
function createStrategicLinks(strategicLinks: THREE.Group, systems: SolarSystemInstance[]) {
if (systems.length < 2) {
return;
}
const material = new THREE.LineDashedMaterial({
color: 0x5e8fbe,
dashSize: 120,
gapSize: 80,
transparent: true,
opacity: 0.5,
});
const links = new Set<string>();
systems.forEach((system) => {
systems
.filter((candidate) => candidate.definition.id !== system.definition.id)
.sort((left, right) => system.center.distanceToSquared(left.center) - system.center.distanceToSquared(right.center))
.slice(0, 2)
.forEach((neighbor) => {
const key = [system.definition.id, neighbor.definition.id].sort().join(":");
if (links.has(key)) {
return;
}
links.add(key);
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([system.center, neighbor.center]),
material,
);
line.computeLineDistances();
strategicLinks.add(line);
});
});
strategicLinks.visible = false;
}
export function createStationInstance({
id,
scene,
definition,
systemId,
position,
factionId,
factionColor,
planetIndex,
lagrangeSide,
seedStock,
selectableTargets,
}: {
id: string;
scene: THREE.Scene;
definition: ConstructibleDefinition;
systemId: string;
position: THREE.Vector3;
factionId: string;
factionColor: string;
planetIndex?: number;
lagrangeSide?: -1 | 1;
seedStock?: Partial<Record<string, number>>;
selectableTargets: Map<THREE.Object3D, SelectableTarget>;
}) {
const group = new THREE.Group();
group.position.copy(position);
const core = new THREE.Mesh(
new THREE.CylinderGeometry(definition.radius * 0.4, definition.radius * 0.6, definition.radius * 1.2, 8),
new THREE.MeshStandardMaterial({
color: definition.color,
emissive: new THREE.Color(factionColor).multiplyScalar(0.12),
roughness: 0.55,
metalness: 0.45,
}),
);
core.rotation.z = Math.PI / 2;
core.castShadow = true;
core.receiveShadow = true;
group.add(core);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(definition.radius, Math.max(2.4, definition.radius * 0.08), 18, 48),
new THREE.MeshStandardMaterial({
color: 0xcdd8e5,
emissive: new THREE.Color(factionColor).multiplyScalar(0.05),
roughness: 0.4,
metalness: 0.7,
}),
);
ring.rotation.x = Math.PI / 2;
group.add(ring);
const selectionRing = new THREE.Mesh(
new THREE.RingGeometry(definition.radius * 1.3, definition.radius * 1.5, 40),
new THREE.MeshBasicMaterial({
color: factionColor,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
}),
);
selectionRing.rotation.x = -Math.PI / 2;
selectionRing.position.y = -definition.radius * 0.32;
group.add(selectionRing);
const dockingPorts = Array.from({ length: definition.dockingCapacity }, (_, index) => {
const angle = (index / Math.max(1, definition.dockingCapacity)) * Math.PI * 2;
const port = new THREE.Vector3(
Math.cos(angle) * (definition.radius + 18),
gameBalance.yPlane,
Math.sin(angle) * (definition.radius + 18),
);
const beacon = new THREE.Mesh(
new THREE.BoxGeometry(5, 2, 9),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.75 }),
);
beacon.position.copy(port);
beacon.lookAt(new THREE.Vector3(0, gameBalance.yPlane, 0));
group.add(beacon);
return port;
});
for (let i = 0; i < 4; i += 1) {
const arm = new THREE.Mesh(
new THREE.BoxGeometry(definition.radius * 0.2, definition.radius * 0.15, definition.radius * 1.5),
new THREE.MeshStandardMaterial({ color: 0x8294a9, roughness: 0.55, metalness: 0.5 }),
);
arm.position.set(
Math.cos((i / 4) * Math.PI * 2) * definition.radius * 0.75,
0,
Math.sin((i / 4) * Math.PI * 2) * definition.radius * 0.75,
);
group.add(arm);
}
scene.add(group);
const station: StationInstance = {
id,
definition,
group,
systemId,
ring: selectionRing,
oreStored: 0,
refinedStock: 0,
processTimer: 0,
activeBatch: 0,
inventory: createEmptyInventory(),
itemStocks: Object.fromEntries(
Object.entries(seedStock ?? {}).map(([itemId, amount]) => [itemId, amount ?? 0]),
),
dockedShipIds: new Set(),
dockingPorts,
modules: definition.modules,
orbitalParentPlanetIndex: planetIndex,
lagrangeSide,
factionId,
factionColor,
health: definition.radius * 160,
maxHealth: definition.radius * 160,
weaponRange: definition.category === "defense" ? 280 : definition.category === "shipyard" ? 180 : 0,
weaponDamage: definition.category === "defense" ? 22 : definition.category === "shipyard" ? 8 : 0,
weaponTimer: 0,
fuel: 800,
energy: 1200,
maxFuel: 800,
maxEnergy: 1200,
};
Object.entries(seedStock ?? {}).forEach(([itemId, rawAmount]) => {
const amount = rawAmount ?? 0;
if (itemId === "ore") {
station.oreStored += amount;
station.inventory["bulk-solid"] += amount;
} else if (itemId === "water") {
station.inventory["bulk-liquid"] += amount;
} else if (itemId === "gas") {
station.inventory["bulk-gas"] += amount;
} else if (itemId === "refined-metals") {
station.refinedStock += amount;
station.inventory.manufactured += amount;
} else if (itemId === "ammo-crates" || itemId === "ship-equipment" || itemId === "drone-parts") {
station.inventory.container += amount;
} else {
station.inventory.manufactured += amount;
}
});
selectableTargets.set(core, { kind: "station", station });
selectableTargets.set(ring, { kind: "station", station });
return station;
}
export function createShipInstance({
id,
definition,
systemId,
factionId,
factionColor,
selectableTargets,
}: {
id: string;
definition: ShipDefinition;
systemId: string;
factionId: string;
factionColor: string;
selectableTargets: Map<THREE.Object3D, SelectableTarget>;
}) {
const group = new THREE.Group();
const visual = new THREE.Group();
visual.rotation.y = Math.PI / 2;
group.add(visual);
const dockingCapacity = definition.dockingCapacity ?? 0;
const warpFx = new THREE.Group();
warpFx.visible = false;
for (let i = 0; i < 5; i += 1) {
const streak = new THREE.Mesh(
new THREE.CylinderGeometry(0.12, 0.5, definition.size * 8, 8),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.22 }),
);
streak.rotation.z = Math.PI / 2;
streak.position.set(-definition.size * (2 + i * 1.7), (i - 2) * 0.45, 0);
warpFx.add(streak);
}
visual.add(warpFx);
const bodyMaterial = new THREE.MeshStandardMaterial({
color: definition.hullColor,
emissive: new THREE.Color(factionColor).multiplyScalar(0.08),
roughness: 0.45,
metalness: 0.7,
});
const hull = new THREE.Mesh(
new THREE.CylinderGeometry(definition.size * 0.3, definition.size, definition.size * 3, 6),
bodyMaterial,
);
hull.rotation.z = -Math.PI / 2;
hull.castShadow = true;
visual.add(hull);
const nose = new THREE.Mesh(
new THREE.ConeGeometry(definition.size * 0.7, definition.size * 1.8, 6),
new THREE.MeshStandardMaterial({
color: factionColor,
emissive: new THREE.Color(factionColor).multiplyScalar(0.12),
roughness: 0.35,
metalness: 0.65,
}),
);
nose.rotation.z = -Math.PI / 2;
nose.position.x = definition.size * 2.1;
visual.add(nose);
const wingGeometry = new THREE.BoxGeometry(definition.size * 0.25, definition.size * 1.8, definition.size * 0.7);
[-1, 1].forEach((side) => {
const wing = new THREE.Mesh(wingGeometry, bodyMaterial);
wing.position.set(0, side * definition.size * 0.9, 0);
visual.add(wing);
});
if (dockingCapacity > 0) {
const hangarBody = new THREE.Mesh(
new THREE.BoxGeometry(definition.size * 2.8, definition.size * 1.2, definition.size * 1.5),
new THREE.MeshStandardMaterial({
color: 0x4c6272,
emissive: new THREE.Color(factionColor).multiplyScalar(0.04),
roughness: 0.5,
metalness: 0.75,
}),
);
hangarBody.position.x = -definition.size * 0.5;
hangarBody.castShadow = true;
visual.add(hangarBody);
[-1, 1].forEach((side) => {
const bay = new THREE.Mesh(
new THREE.BoxGeometry(definition.size * 1.1, definition.size * 0.38, definition.size * 0.86),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.45 }),
);
bay.position.set(-definition.size * 0.3, side * definition.size * 0.52, 0);
visual.add(bay);
});
}
const engineGlow = new THREE.Mesh(
new THREE.SphereGeometry(definition.size * 0.35, 14, 14),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.72 }),
);
engineGlow.position.x = -definition.size * 1.8;
visual.add(engineGlow);
const ring = new THREE.Mesh(
new THREE.RingGeometry(definition.size * 1.5, definition.size * 1.9, 32),
new THREE.MeshBasicMaterial({
color: factionColor,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
}),
);
ring.rotation.x = -Math.PI / 2;
ring.position.y = -definition.size * 0.55;
group.add(ring);
const dockingPorts = Array.from({ length: dockingCapacity }, (_, index) => {
const lane = index % 2 === 0 ? -1 : 1;
const row = Math.floor(index / 2);
const port = new THREE.Vector3(
-definition.size * (0.4 + row * 0.7),
0,
lane * definition.size * 1.35,
);
const beacon = new THREE.Mesh(
new THREE.BoxGeometry(definition.size * 0.26, 0.9, definition.size * 0.42),
new THREE.MeshBasicMaterial({ color: factionColor, transparent: true, opacity: 0.52 }),
);
beacon.position.copy(port);
beacon.visible = dockingCapacity > 0;
group.add(beacon);
return port;
});
const pickHull = new THREE.Mesh(
new THREE.SphereGeometry(definition.size * 1.6, 12, 12),
new THREE.MeshBasicMaterial({ visible: false }),
);
group.add(pickHull);
const ship: ShipInstance = {
id,
definition,
group,
target: new THREE.Vector3(),
velocity: new THREE.Vector3(),
selected: false,
ring,
systemId,
state: "idle",
order: undefined,
defaultBehavior: { kind: "idle" },
assignment: { kind: "unassigned" },
controllerTask: { kind: "idle" },
inventory: createEmptyInventory(),
cargoItemId: definition.cargoItemId,
actionTimer: 0,
dockingClearanceStatus: undefined,
factionId,
factionColor,
health: definition.maxHealth,
maxHealth: definition.maxHealth,
weaponRange:
definition.shipClass === "capital" ? 260 : definition.shipClass === "cruiser" ? 220 : definition.shipClass === "destroyer" ? 180 : 140,
weaponDamage:
definition.shipClass === "capital" ? 30 : definition.shipClass === "cruiser" ? 18 : definition.shipClass === "destroyer" ? 12 : 7,
weaponCooldown: definition.shipClass === "capital" ? 1.2 : definition.shipClass === "cruiser" ? 0.9 : 0.7,
weaponTimer: 0,
fuel: 220,
energy: 260,
maxFuel: 220,
maxEnergy: 260,
landedOffset: new THREE.Vector3(),
idleOrbitRadius: Math.max(120, group.position.length()),
idleOrbitAngle: 0,
warpFx,
dockedShipIds: new Set(),
dockingPorts,
};
selectableTargets.set(pickHull, { kind: "ship", ship });
selectableTargets.set(hull, { kind: "ship", ship });
return ship;
}
function createNebulae(scene: THREE.Scene) {
const colors: [string, string, string][] = [
["rgba(126,212,255,0.75)", "rgba(197,111,255,0.32)", "rgba(0,0,0,0)"],
["rgba(255,157,102,0.72)", "rgba(255,102,129,0.28)", "rgba(0,0,0,0)"],
["rgba(138,255,199,0.7)", "rgba(72,111,255,0.2)", "rgba(0,0,0,0)"],
];
const positions = [
new THREE.Vector3(-1800, 260, -1100),
new THREE.Vector3(1800, -100, -1600),
new THREE.Vector3(3300, 160, 1800),
new THREE.Vector3(5200, 220, -900),
new THREE.Vector3(6400, 100, 1500),
];
positions.forEach((position, index) => {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: makeRadialTexture(colors[index % colors.length]),
transparent: true,
depthWrite: false,
opacity: 0.34,
blending: THREE.AdditiveBlending,
}),
);
sprite.position.copy(position);
sprite.scale.setScalar(1000 + (index % 3) * 220);
sprite.material.rotation = index * 0.67;
scene.add(sprite);
});
}
function createStarfield(scene: THREE.Scene) {
const starCount = 9000;
const positions = new Float32Array(starCount * 3);
const colors = new Float32Array(starCount * 3);
const color = new THREE.Color();
for (let i = 0; i < starCount; i += 1) {
const radius = 4200 + Math.random() * 7600;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(2 * Math.random() - 1);
const centerBias = Math.random() > 0.5 ? 2200 : 0;
positions[i * 3] = radius * Math.sin(phi) * Math.cos(theta) + centerBias;
positions[i * 3 + 1] = radius * Math.cos(phi);
positions[i * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta);
color.setHSL(0.55 + Math.random() * 0.15, 0.56, 0.7 + Math.random() * 0.28);
colors[i * 3] = color.r;
colors[i * 3 + 1] = color.g;
colors[i * 3 + 2] = color.b;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
const starfield = new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 8,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
opacity: 0.9,
depthWrite: false,
}),
);
scene.add(starfield);
return starfield;
}
function makeRadialTexture(stops: [string, string, string]) {
const canvas = document.createElement("canvas");
canvas.width = 512;
canvas.height = 512;
const context = canvas.getContext("2d");
if (!context) {
throw new Error("Unable to create 2D context for nebula texture");
}
const gradient = context.createRadialGradient(256, 256, 30, 256, 256, 256);
gradient.addColorStop(0, stops[0]);
gradient.addColorStop(0.45, stops[1]);
gradient.addColorStop(1, stops[2]);
context.fillStyle = gradient;
context.fillRect(0, 0, 512, 512);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
function getSystemCenter(systems: SolarSystemInstance[], systemId: string) {
const system = systems.find((candidate) => candidate.definition.id === systemId);
if (!system) {
throw new Error(`Missing solar system ${systemId}`);
}
return system.center.clone();
}

View File

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

View File

@@ -1,444 +0,0 @@
:root {
color-scheme: dark;
font-family: "Space Grotesk", "Segoe UI", sans-serif;
--bg: #050914;
--panel: rgba(5, 12, 26, 0.78);
--panel-border: rgba(126, 212, 255, 0.18);
--text: #ebf7ff;
--muted: #9fb6c8;
--accent: #7ed4ff;
--warning: #ffbf69;
}
* {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at top, rgba(75, 123, 236, 0.18), transparent 36%),
radial-gradient(circle at 20% 40%, rgba(255, 134, 91, 0.14), transparent 26%),
linear-gradient(180deg, #03070f 0%, #060c18 100%);
color: var(--text);
}
canvas {
display: block;
}
.hud {
position: fixed;
inset: 0;
pointer-events: none;
}
.strategic-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
opacity: 0.96;
}
.marquee {
position: fixed;
display: none;
border: 1px solid rgba(126, 212, 255, 0.85);
background: rgba(126, 212, 255, 0.14);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
z-index: 3;
}
.panel {
position: absolute;
backdrop-filter: blur(14px);
background: var(--panel);
border: 1px solid var(--panel-border);
border-radius: 18px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
z-index: 2;
}
.panel h1,
.panel h2,
.panel p {
margin: 0;
}
.session-actions {
display: flex;
gap: 10px;
}
.details {
left: 24px;
right: 24px;
bottom: 24px;
min-height: 138px;
padding: 16px 18px;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: auto;
}
.selection-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.details h2 {
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--accent);
}
.selection-strip {
display: flex;
gap: 10px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 4px;
align-items: stretch;
}
.selection-strip-card {
flex: 0 0 auto;
border: 1px solid rgba(126, 212, 255, 0.18);
border-radius: 14px;
min-width: 180px;
padding: 10px 12px;
color: var(--text);
background: linear-gradient(180deg, rgba(13, 30, 56, 0.7), rgba(8, 17, 33, 0.82));
display: flex;
flex-direction: column;
gap: 4px;
}
.selection-strip-card-title {
font-size: 0.84rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--text);
}
.selection-strip-card-line {
font-size: 0.76rem;
line-height: 1.35;
color: var(--muted);
white-space: nowrap;
}
.selection-strip-empty {
color: var(--muted);
font-size: 0.84rem;
padding: 8px 2px;
}
.details .content {
color: var(--muted);
line-height: 1.55;
white-space: pre-line;
max-height: 132px;
overflow: auto;
}
.selection-title,
.selection-meta .mode {
margin: 0;
font-size: 0.86rem;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.window-launchers {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.selection-meta .mode {
color: var(--warning);
text-shadow: 0 0 18px rgba(255, 191, 105, 0.24);
}
.window-launchers button,
.session-actions button,
.window-close {
border: 1px solid rgba(126, 212, 255, 0.16);
border-radius: 12px;
background: linear-gradient(180deg, rgba(13, 30, 56, 0.95), rgba(8, 17, 33, 0.95));
color: var(--text);
font: inherit;
padding: 12px 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease, background 120ms ease, opacity 120ms ease;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
}
.window-launchers button:hover,
.session-actions button:hover,
.window-close:hover {
border-color: rgba(126, 212, 255, 0.4);
transform: translateY(-1px);
}
button:disabled {
opacity: 0.35;
cursor: default;
transform: none;
}
.minimap {
width: 100%;
height: auto;
border-radius: 10px;
border: 1px solid rgba(126, 212, 255, 0.16);
background: rgba(2, 6, 13, 0.92);
}
.minimap-hidden {
display: none;
}
.app-window {
position: absolute;
top: 104px;
left: 50%;
width: min(480px, calc(100vw - 48px));
height: min(68vh, 680px);
display: none;
flex-direction: column;
gap: 14px;
padding: 18px;
pointer-events: auto;
backdrop-filter: blur(16px);
background:
linear-gradient(180deg, rgba(6, 13, 27, 0.94), rgba(4, 10, 21, 0.92)),
radial-gradient(circle at top, rgba(126, 212, 255, 0.08), transparent 60%);
border: 1px solid rgba(126, 212, 255, 0.2);
border-radius: 18px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.44);
z-index: 4;
}
.app-window[data-open="true"] {
display: flex;
}
.window-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
cursor: move;
user-select: none;
}
.window-header h2,
.window-header p {
margin: 0;
}
.window-header h2 {
font-size: 0.95rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.window-subtitle {
margin-top: 6px;
color: var(--muted);
line-height: 1.4;
}
.window-close {
padding-inline: 14px;
cursor: pointer;
}
.window-body {
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
min-height: 0;
}
.ship-window-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-bottom: 1px solid rgba(126, 212, 255, 0.1);
color: var(--muted);
cursor: pointer;
}
.ship-window-row:hover {
color: var(--text);
}
.ship-window-row[data-selected="true"] {
color: #ffbf69;
}
.ship-window-name {
color: inherit;
font-weight: 600;
line-height: 1.35;
}
.ship-window-meta {
color: inherit;
line-height: 1.35;
text-align: right;
font-size: 0.92em;
}
.ship-window-group + .ship-window-group {
margin-top: 10px;
}
.ship-window-group-title {
margin: 0 0 6px;
color: var(--text);
font-size: 0.8rem;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
}
.ship-window-group-title:hover {
color: #ffbf69;
}
.ship-window-empty {
color: var(--muted);
}
.debug-history {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
}
.debug-history-ship {
border: 1px solid rgba(126, 212, 255, 0.12);
border-radius: 14px;
padding: 10px 12px;
background: rgba(8, 17, 33, 0.55);
}
.debug-history-ship[data-selected="true"] {
border-color: rgba(255, 191, 105, 0.4);
}
.debug-history-title {
margin: 0 0 8px;
color: var(--text);
font-size: 0.82rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.debug-history-summary {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
margin-bottom: 10px;
padding: 8px 10px;
border-radius: 10px;
background: rgba(126, 212, 255, 0.08);
color: var(--muted);
font-size: 0.78rem;
line-height: 1.4;
}
.debug-history-summary strong {
color: var(--text);
font-weight: 600;
}
.debug-history-entry {
color: var(--muted);
font-size: 0.78rem;
line-height: 1.45;
white-space: pre-wrap;
}
.debug-history-empty {
color: var(--muted);
font-size: 0.82rem;
}
.window-resize-handle {
position: absolute;
right: 10px;
bottom: 10px;
width: 18px;
height: 18px;
border-right: 2px solid rgba(126, 212, 255, 0.42);
border-bottom: 2px solid rgba(126, 212, 255, 0.42);
border-radius: 0 0 8px 0;
cursor: nwse-resize;
}
.window-resize-handle::before,
.window-resize-handle::after {
content: "";
position: absolute;
right: 3px;
bottom: 3px;
border-right: 1px solid rgba(126, 212, 255, 0.24);
border-bottom: 1px solid rgba(126, 212, 255, 0.24);
}
.window-resize-handle::before {
width: 10px;
height: 10px;
}
.window-resize-handle::after {
width: 6px;
height: 6px;
}
@media (max-width: 900px) {
.details {
width: auto;
left: 16px;
right: 16px;
}
.details {
bottom: 16px;
}
.window-launchers {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.app-window {
top: 76px;
width: calc(100vw - 32px);
}
}

View File

@@ -1,17 +0,0 @@
import { defineConfig } from "vite";
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
three: ["three"],
},
},
},
},
server: {
host: "0.0.0.0",
port: 4173,
},
});