Split viewer and simulation into separate apps
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,5 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bin/
|
||||
obj/
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
6
SpaceGame.slnx
Normal file
6
SpaceGame.slnx
Normal file
@@ -0,0 +1,6 @@
|
||||
<Solution>
|
||||
<Folder Name="/apps/" />
|
||||
<Folder Name="/apps/backend/">
|
||||
<Project Path="apps/backend/SpaceGame.Simulation.Api.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
76
apps/backend/Contracts/WorldContracts.cs
Normal file
76
apps/backend/Contracts/WorldContracts.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
namespace SpaceGame.Simulation.Api.Contracts;
|
||||
|
||||
public sealed record WorldSnapshot(
|
||||
string Label,
|
||||
int Seed,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
IReadOnlyList<SystemSnapshot> Systems,
|
||||
IReadOnlyList<ResourceNodeSnapshot> Nodes,
|
||||
IReadOnlyList<StationSnapshot> Stations,
|
||||
IReadOnlyList<ShipSnapshot> Ships,
|
||||
IReadOnlyList<FactionSnapshot> Factions);
|
||||
|
||||
public sealed record SystemSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
Vector3Dto Position,
|
||||
string StarColor,
|
||||
float StarSize,
|
||||
IReadOnlyList<PlanetSnapshot> Planets);
|
||||
|
||||
public sealed record PlanetSnapshot(
|
||||
string Label,
|
||||
float OrbitRadius,
|
||||
float Size,
|
||||
string Color,
|
||||
bool HasRing);
|
||||
|
||||
public sealed record ResourceNodeSnapshot(
|
||||
string Id,
|
||||
string SystemId,
|
||||
Vector3Dto Position,
|
||||
float OreRemaining,
|
||||
float MaxOre,
|
||||
string ItemId);
|
||||
|
||||
public sealed record StationSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Category,
|
||||
string SystemId,
|
||||
Vector3Dto Position,
|
||||
string Color,
|
||||
int DockedShips,
|
||||
float OreStored,
|
||||
float RefinedStock,
|
||||
string FactionId);
|
||||
|
||||
public sealed record ShipSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Role,
|
||||
string ShipClass,
|
||||
string SystemId,
|
||||
Vector3Dto Position,
|
||||
string State,
|
||||
string? OrderKind,
|
||||
string DefaultBehaviorKind,
|
||||
string ControllerTaskKind,
|
||||
float Cargo,
|
||||
float CargoCapacity,
|
||||
string? CargoItemId,
|
||||
string FactionId,
|
||||
float Health,
|
||||
IReadOnlyList<string> History);
|
||||
|
||||
public sealed record FactionSnapshot(
|
||||
string Id,
|
||||
string Label,
|
||||
string Color,
|
||||
float Credits,
|
||||
float OreMined,
|
||||
float GoodsProduced,
|
||||
int ShipsBuilt,
|
||||
int ShipsLost);
|
||||
|
||||
public sealed record Vector3Dto(float X, float Y, float Z);
|
||||
136
apps/backend/Data/WorldDefinitions.cs
Normal file
136
apps/backend/Data/WorldDefinitions.cs
Normal file
@@ -0,0 +1,136 @@
|
||||
namespace SpaceGame.Simulation.Api.Data;
|
||||
|
||||
public sealed class BalanceDefinition
|
||||
{
|
||||
public float YPlane { get; set; }
|
||||
public float ArrivalThreshold { get; set; }
|
||||
public float MiningRate { get; set; }
|
||||
public float TransferRate { get; set; }
|
||||
public float DockingDuration { get; set; }
|
||||
public float UndockDistance { get; set; }
|
||||
public EnergyBalanceDefinition Energy { get; set; } = new();
|
||||
public FuelBalanceDefinition Fuel { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class EnergyBalanceDefinition
|
||||
{
|
||||
public float IdleDrain { get; set; }
|
||||
public float MoveDrain { get; set; }
|
||||
public float WarpDrain { get; set; }
|
||||
public float ShipRechargeRate { get; set; }
|
||||
public float StationSolarCharge { get; set; }
|
||||
}
|
||||
|
||||
public sealed class FuelBalanceDefinition
|
||||
{
|
||||
public float WarpDrain { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SolarSystemDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required float[] Position { get; set; }
|
||||
public required string StarColor { get; set; }
|
||||
public required string StarGlow { get; set; }
|
||||
public float StarSize { get; set; }
|
||||
public float GravityWellRadius { get; set; }
|
||||
public required AsteroidFieldDefinition AsteroidField { get; set; }
|
||||
public required List<ResourceNodeDefinition> ResourceNodes { get; set; }
|
||||
public required List<PlanetDefinition> Planets { get; set; }
|
||||
}
|
||||
|
||||
public sealed class AsteroidFieldDefinition
|
||||
{
|
||||
public int DecorationCount { get; set; }
|
||||
public float RadiusOffset { get; set; }
|
||||
public float RadiusVariance { get; set; }
|
||||
public float HeightVariance { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ResourceNodeDefinition
|
||||
{
|
||||
public float Angle { get; set; }
|
||||
public float RadiusOffset { get; set; }
|
||||
public float OreAmount { get; set; }
|
||||
public required string ItemId { get; set; }
|
||||
public int ShardCount { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PlanetDefinition
|
||||
{
|
||||
public required string Label { get; set; }
|
||||
public float OrbitRadius { get; set; }
|
||||
public float OrbitSpeed { get; set; }
|
||||
public float Size { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public float Tilt { get; set; }
|
||||
public bool HasRing { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required string Role { get; set; }
|
||||
public required string ShipClass { get; set; }
|
||||
public float Speed { get; set; }
|
||||
public float FtlSpeed { get; set; }
|
||||
public float SpoolTime { get; set; }
|
||||
public float CargoCapacity { get; set; }
|
||||
public string? CargoKind { get; set; }
|
||||
public string? CargoItemId { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public required string HullColor { get; set; }
|
||||
public float Size { get; set; }
|
||||
public float MaxHealth { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ConstructibleDefinition
|
||||
{
|
||||
public required string Id { get; set; }
|
||||
public required string Label { get; set; }
|
||||
public required string Category { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public float Radius { get; set; }
|
||||
public int DockingCapacity { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ScenarioDefinition
|
||||
{
|
||||
public required List<InitialStationDefinition> InitialStations { get; set; }
|
||||
public required List<ShipFormationDefinition> ShipFormations { get; set; }
|
||||
public required List<PatrolRouteDefinition> PatrolRoutes { get; set; }
|
||||
public required MiningDefaultsDefinition MiningDefaults { get; set; }
|
||||
}
|
||||
|
||||
public sealed class InitialStationDefinition
|
||||
{
|
||||
public required string ConstructibleId { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public string? FactionId { get; set; }
|
||||
public int? PlanetIndex { get; set; }
|
||||
public int? LagrangeSide { get; set; }
|
||||
public float[]? Position { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipFormationDefinition
|
||||
{
|
||||
public required string ShipId { get; set; }
|
||||
public int Count { get; set; }
|
||||
public required float[] Center { get; set; }
|
||||
public required string SystemId { get; set; }
|
||||
public string? FactionId { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PatrolRouteDefinition
|
||||
{
|
||||
public required string SystemId { get; set; }
|
||||
public required List<float[]> Points { get; set; }
|
||||
}
|
||||
|
||||
public sealed class MiningDefaultsDefinition
|
||||
{
|
||||
public required string NodeSystemId { get; set; }
|
||||
public required string RefinerySystemId { get; set; }
|
||||
}
|
||||
36
apps/backend/Program.cs
Normal file
36
apps/backend/Program.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:5079");
|
||||
builder.Services.AddCors((options) =>
|
||||
{
|
||||
options.AddDefaultPolicy((policy) =>
|
||||
{
|
||||
policy
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyOrigin();
|
||||
});
|
||||
});
|
||||
builder.Services.AddSingleton<WorldService>();
|
||||
builder.Services.AddHostedService<SimulationHostedService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
app.MapGet("/", () => Results.Redirect("/api/world"));
|
||||
app.MapGet("/api/world", (WorldService worldService) => Results.Ok(worldService.GetSnapshot()));
|
||||
app.MapGet("/api/world/health", (WorldService worldService) => Results.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
generatedAtUtc = worldService.GetSnapshot().GeneratedAtUtc,
|
||||
}));
|
||||
app.MapPost("/api/world/reset", (WorldService worldService) =>
|
||||
{
|
||||
var snapshot = worldService.Reset();
|
||||
return Results.Ok(snapshot);
|
||||
});
|
||||
|
||||
app.Run();
|
||||
23
apps/backend/Properties/launchSettings.json
Normal file
23
apps/backend/Properties/launchSettings.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:0",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:0;http://localhost:0",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
134
apps/backend/Simulation/RuntimeModels.cs
Normal file
134
apps/backend/Simulation/RuntimeModels.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using SpaceGame.Simulation.Api.Data;
|
||||
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class SimulationWorld
|
||||
{
|
||||
public required string Label { get; init; }
|
||||
public required int Seed { get; init; }
|
||||
public required BalanceDefinition Balance { get; init; }
|
||||
public required List<SystemRuntime> Systems { get; init; }
|
||||
public required List<ResourceNodeRuntime> Nodes { get; init; }
|
||||
public required List<StationRuntime> Stations { get; init; }
|
||||
public required List<ShipRuntime> Ships { get; init; }
|
||||
public required List<FactionRuntime> Factions { get; init; }
|
||||
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
|
||||
public DateTimeOffset GeneratedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
public sealed class SystemRuntime
|
||||
{
|
||||
public required SolarSystemDefinition Definition { get; init; }
|
||||
public required Vector3 Position { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ResourceNodeRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; init; }
|
||||
public required Vector3 Position { get; init; }
|
||||
public required string ItemId { get; init; }
|
||||
public float OreRemaining { get; set; }
|
||||
public float MaxOre { get; init; }
|
||||
}
|
||||
|
||||
public sealed class StationRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; init; }
|
||||
public required ConstructibleDefinition Definition { get; init; }
|
||||
public required Vector3 Position { get; init; }
|
||||
public required string FactionId { get; init; }
|
||||
public float OreStored { get; set; }
|
||||
public float RefinedStock { get; set; }
|
||||
public float ProcessTimer { get; set; }
|
||||
public HashSet<string> DockedShipIds { get; } = [];
|
||||
}
|
||||
|
||||
public sealed class ShipRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string SystemId { get; set; }
|
||||
public required ShipDefinition Definition { get; init; }
|
||||
public required string FactionId { get; init; }
|
||||
public required Vector3 Position { get; set; }
|
||||
public required Vector3 TargetPosition { get; set; }
|
||||
public string State { get; set; } = "idle";
|
||||
public ShipOrderRuntime? Order { get; set; }
|
||||
public required DefaultBehaviorRuntime DefaultBehavior { get; set; }
|
||||
public required ControllerTaskRuntime ControllerTask { get; set; }
|
||||
public float ActionTimer { get; set; }
|
||||
public float Cargo { get; set; }
|
||||
public string? DockedStationId { get; set; }
|
||||
public float Health { get; set; }
|
||||
public List<string> History { get; } = [];
|
||||
public string LastSignature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class FactionRuntime
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Label { get; init; }
|
||||
public required string Color { get; init; }
|
||||
public float Credits { get; set; }
|
||||
public float OreMined { get; set; }
|
||||
public float GoodsProduced { get; set; }
|
||||
public int ShipsBuilt { get; set; }
|
||||
public int ShipsLost { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ShipOrderRuntime
|
||||
{
|
||||
public required string Kind { get; init; }
|
||||
public string Status { get; set; } = "accepted";
|
||||
public required string DestinationSystemId { get; init; }
|
||||
public required Vector3 DestinationPosition { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DefaultBehaviorRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public string? AreaSystemId { get; set; }
|
||||
public string? RefineryId { get; set; }
|
||||
public string? NodeId { get; set; }
|
||||
public string? Phase { get; set; }
|
||||
public List<Vector3> PatrolPoints { get; set; } = [];
|
||||
public int PatrolIndex { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ControllerTaskRuntime
|
||||
{
|
||||
public required string Kind { get; set; }
|
||||
public string? TargetEntityId { get; set; }
|
||||
public string? TargetSystemId { get; set; }
|
||||
public Vector3? TargetPosition { get; set; }
|
||||
public float Threshold { get; set; }
|
||||
}
|
||||
|
||||
public readonly record struct Vector3(float X, float Y, float Z)
|
||||
{
|
||||
public static Vector3 Zero => new(0f, 0f, 0f);
|
||||
|
||||
public float DistanceTo(Vector3 other)
|
||||
{
|
||||
var dx = X - other.X;
|
||||
var dy = Y - other.Y;
|
||||
var dz = Z - other.Z;
|
||||
return MathF.Sqrt((dx * dx) + (dy * dy) + (dz * dz));
|
||||
}
|
||||
|
||||
public Vector3 MoveToward(Vector3 target, float maxDistance)
|
||||
{
|
||||
var distance = DistanceTo(target);
|
||||
if (distance <= maxDistance || distance <= 0.0001f)
|
||||
{
|
||||
return target;
|
||||
}
|
||||
|
||||
var t = maxDistance / distance;
|
||||
return new Vector3(
|
||||
X + ((target.X - X) * t),
|
||||
Y + ((target.Y - Y) * t),
|
||||
Z + ((target.Z - Z) * t));
|
||||
}
|
||||
}
|
||||
208
apps/backend/Simulation/ScenarioLoader.cs
Normal file
208
apps/backend/Simulation/ScenarioLoader.cs
Normal file
@@ -0,0 +1,208 @@
|
||||
using System.Text.Json;
|
||||
using SpaceGame.Simulation.Api.Data;
|
||||
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class ScenarioLoader
|
||||
{
|
||||
private readonly string _dataRoot;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
public ScenarioLoader(string contentRootPath)
|
||||
{
|
||||
_dataRoot = Path.GetFullPath(Path.Combine(contentRootPath, "..", "..", "shared", "data"));
|
||||
}
|
||||
|
||||
public SimulationWorld Load()
|
||||
{
|
||||
var systems = Read<List<SolarSystemDefinition>>("systems.json");
|
||||
var scenario = Read<ScenarioDefinition>("scenario.json");
|
||||
var ships = Read<List<ShipDefinition>>("ships.json");
|
||||
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
|
||||
var balance = Read<BalanceDefinition>("balance.json");
|
||||
|
||||
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
|
||||
var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
|
||||
var systemRuntimes = systems
|
||||
.Select((definition) => new SystemRuntime
|
||||
{
|
||||
Definition = definition,
|
||||
Position = ToVector(definition.Position),
|
||||
})
|
||||
.ToList();
|
||||
var systemsById = systemRuntimes.ToDictionary((system) => system.Definition.Id, StringComparer.Ordinal);
|
||||
|
||||
var nodes = new List<ResourceNodeRuntime>();
|
||||
var nodeIdCounter = 0;
|
||||
foreach (var system in systemRuntimes)
|
||||
{
|
||||
foreach (var node in system.Definition.ResourceNodes)
|
||||
{
|
||||
nodes.Add(new ResourceNodeRuntime
|
||||
{
|
||||
Id = $"node-{++nodeIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Position = new Vector3(
|
||||
system.Position.X + (MathF.Cos(node.Angle) * node.RadiusOffset),
|
||||
balance.YPlane,
|
||||
system.Position.Z + (MathF.Sin(node.Angle) * node.RadiusOffset)),
|
||||
ItemId = node.ItemId,
|
||||
OreRemaining = node.OreAmount,
|
||||
MaxOre = node.OreAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var stations = new List<StationRuntime>();
|
||||
var stationIdCounter = 0;
|
||||
foreach (var plan in scenario.InitialStations)
|
||||
{
|
||||
if (!constructibleDefinitions.TryGetValue(plan.ConstructibleId, out var definition) || !systemsById.TryGetValue(plan.SystemId, out var system))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
stations.Add(new StationRuntime
|
||||
{
|
||||
Id = $"station-{++stationIdCounter}",
|
||||
SystemId = system.Definition.Id,
|
||||
Definition = definition,
|
||||
Position = ResolveStationPosition(system, plan, balance),
|
||||
FactionId = plan.FactionId ?? "sol-dominion",
|
||||
OreStored = definition.Category == "refining" ? 120f : 0f,
|
||||
RefinedStock = definition.Category == "shipyard" ? 180f : 40f,
|
||||
});
|
||||
}
|
||||
|
||||
var refinery = stations.FirstOrDefault((station) =>
|
||||
station.Definition.Category == "refining" && station.SystemId == scenario.MiningDefaults.RefinerySystemId)
|
||||
?? stations.FirstOrDefault((station) => station.Definition.Category == "refining");
|
||||
|
||||
var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
|
||||
(route) => route.SystemId,
|
||||
(route) => route.Points.Select(ToVector).ToList(),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
var shipsRuntime = new List<ShipRuntime>();
|
||||
var shipIdCounter = 0;
|
||||
foreach (var formation in scenario.ShipFormations)
|
||||
{
|
||||
if (!shipDefinitions.TryGetValue(formation.ShipId, out var definition))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
for (var index = 0; index < formation.Count; index += 1)
|
||||
{
|
||||
var offset = new Vector3((index % 3) * 18f, balance.YPlane, (index / 3) * 18f);
|
||||
var position = Add(ToVector(formation.Center), offset);
|
||||
shipsRuntime.Add(new ShipRuntime
|
||||
{
|
||||
Id = $"ship-{++shipIdCounter}",
|
||||
SystemId = formation.SystemId,
|
||||
Definition = definition,
|
||||
FactionId = formation.FactionId ?? "sol-dominion",
|
||||
Position = position,
|
||||
TargetPosition = position,
|
||||
DefaultBehavior = CreateBehavior(definition, formation.SystemId, scenario, patrolRoutes, refinery),
|
||||
ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = balance.ArrivalThreshold },
|
||||
Health = definition.MaxHealth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
var factions = new List<FactionRuntime>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "sol-dominion",
|
||||
Label = "Sol Dominion",
|
||||
Color = "#7ed4ff",
|
||||
Credits = 240f,
|
||||
},
|
||||
};
|
||||
|
||||
return new SimulationWorld
|
||||
{
|
||||
Label = "Split Viewer / Simulation World",
|
||||
Seed = 1,
|
||||
Balance = balance,
|
||||
Systems = systemRuntimes,
|
||||
Nodes = nodes,
|
||||
Stations = stations,
|
||||
Ships = shipsRuntime,
|
||||
Factions = factions,
|
||||
ShipDefinitions = shipDefinitions,
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
};
|
||||
}
|
||||
|
||||
private T Read<T>(string fileName)
|
||||
{
|
||||
var path = Path.Combine(_dataRoot, fileName);
|
||||
var json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<T>(json, _jsonOptions)
|
||||
?? throw new InvalidOperationException($"Unable to read {fileName}.");
|
||||
}
|
||||
|
||||
private static DefaultBehaviorRuntime CreateBehavior(
|
||||
ShipDefinition definition,
|
||||
string systemId,
|
||||
ScenarioDefinition scenario,
|
||||
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
|
||||
StationRuntime? refinery)
|
||||
{
|
||||
if (definition.Role == "mining" && refinery is not null)
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "auto-mine",
|
||||
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
|
||||
RefineryId = refinery.Id,
|
||||
Phase = "travel-to-node",
|
||||
};
|
||||
}
|
||||
|
||||
if (definition.Role == "military" && patrolRoutes.TryGetValue(systemId, out var route))
|
||||
{
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "patrol",
|
||||
PatrolPoints = route,
|
||||
PatrolIndex = 0,
|
||||
};
|
||||
}
|
||||
|
||||
return new DefaultBehaviorRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
};
|
||||
}
|
||||
|
||||
private static Vector3 ResolveStationPosition(SystemRuntime system, InitialStationDefinition plan, BalanceDefinition balance)
|
||||
{
|
||||
if (plan.Position is { Length: 3 })
|
||||
{
|
||||
return ToVector(plan.Position);
|
||||
}
|
||||
|
||||
if (plan.PlanetIndex is int planetIndex && planetIndex >= 0 && planetIndex < system.Definition.Planets.Count)
|
||||
{
|
||||
var planet = system.Definition.Planets[planetIndex];
|
||||
var side = plan.LagrangeSide ?? 1;
|
||||
return new Vector3(
|
||||
system.Position.X + planet.OrbitRadius + (side * 72f),
|
||||
balance.YPlane,
|
||||
system.Position.Z + ((planetIndex + 1) * 42f * side));
|
||||
}
|
||||
|
||||
return new Vector3(system.Position.X + 180f, balance.YPlane, system.Position.Z);
|
||||
}
|
||||
|
||||
private static Vector3 ToVector(float[] values) => new(values[0], values[1], values[2]);
|
||||
|
||||
private static Vector3 Add(Vector3 left, Vector3 right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z);
|
||||
}
|
||||
488
apps/backend/Simulation/SimulationEngine.cs
Normal file
488
apps/backend/Simulation/SimulationEngine.cs
Normal file
@@ -0,0 +1,488 @@
|
||||
using SpaceGame.Simulation.Api.Contracts;
|
||||
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class SimulationEngine
|
||||
{
|
||||
public void Tick(SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
UpdateStations(world, deltaSeconds);
|
||||
|
||||
foreach (var ship in world.Ships)
|
||||
{
|
||||
RefreshControlLayers(ship);
|
||||
PlanControllerTask(ship, world);
|
||||
var controllerEvent = UpdateControllerTask(ship, world, deltaSeconds);
|
||||
AdvanceControlState(ship, controllerEvent);
|
||||
TrackHistory(ship);
|
||||
}
|
||||
|
||||
world.GeneratedAtUtc = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public WorldSnapshot BuildSnapshot(SimulationWorld world)
|
||||
{
|
||||
return new WorldSnapshot(
|
||||
world.Label,
|
||||
world.Seed,
|
||||
world.GeneratedAtUtc,
|
||||
world.Systems.Select((system) => new SystemSnapshot(
|
||||
system.Definition.Id,
|
||||
system.Definition.Label,
|
||||
ToDto(system.Position),
|
||||
system.Definition.StarColor,
|
||||
system.Definition.StarSize,
|
||||
system.Definition.Planets.Select((planet) => new PlanetSnapshot(
|
||||
planet.Label,
|
||||
planet.OrbitRadius,
|
||||
planet.Size,
|
||||
planet.Color,
|
||||
planet.HasRing)).ToList())).ToList(),
|
||||
world.Nodes.Select((node) => new ResourceNodeSnapshot(
|
||||
node.Id,
|
||||
node.SystemId,
|
||||
ToDto(node.Position),
|
||||
node.OreRemaining,
|
||||
node.MaxOre,
|
||||
node.ItemId)).ToList(),
|
||||
world.Stations.Select((station) => new StationSnapshot(
|
||||
station.Id,
|
||||
station.Definition.Label,
|
||||
station.Definition.Category,
|
||||
station.SystemId,
|
||||
ToDto(station.Position),
|
||||
station.Definition.Color,
|
||||
station.DockedShipIds.Count,
|
||||
station.OreStored,
|
||||
station.RefinedStock,
|
||||
station.FactionId)).ToList(),
|
||||
world.Ships.Select((ship) => new ShipSnapshot(
|
||||
ship.Id,
|
||||
ship.Definition.Label,
|
||||
ship.Definition.Role,
|
||||
ship.Definition.ShipClass,
|
||||
ship.SystemId,
|
||||
ToDto(ship.Position),
|
||||
ship.State,
|
||||
ship.Order?.Kind,
|
||||
ship.DefaultBehavior.Kind,
|
||||
ship.ControllerTask.Kind,
|
||||
ship.Cargo,
|
||||
ship.Definition.CargoCapacity,
|
||||
ship.Definition.CargoItemId,
|
||||
ship.FactionId,
|
||||
ship.Health,
|
||||
ship.History.ToList())).ToList(),
|
||||
world.Factions.Select((faction) => new FactionSnapshot(
|
||||
faction.Id,
|
||||
faction.Label,
|
||||
faction.Color,
|
||||
faction.Credits,
|
||||
faction.OreMined,
|
||||
faction.GoodsProduced,
|
||||
faction.ShipsBuilt,
|
||||
faction.ShipsLost)).ToList());
|
||||
}
|
||||
|
||||
private void UpdateStations(SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
foreach (var station in world.Stations)
|
||||
{
|
||||
if (station.Definition.Category != "refining" || station.OreStored < 60f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
station.ProcessTimer += deltaSeconds;
|
||||
if (station.ProcessTimer < 8f)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
station.ProcessTimer = 0f;
|
||||
station.OreStored -= 60f;
|
||||
station.RefinedStock += 60f;
|
||||
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == station.FactionId);
|
||||
if (faction is not null)
|
||||
{
|
||||
faction.GoodsProduced += 60f;
|
||||
faction.Credits += 18f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshControlLayers(ShipRuntime ship)
|
||||
{
|
||||
if (ship.Order is not null && ship.Order.Status == "queued")
|
||||
{
|
||||
ship.Order.Status = "accepted";
|
||||
}
|
||||
}
|
||||
|
||||
private void PlanControllerTask(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
if (ship.Order is not null)
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
TargetEntityId = null,
|
||||
TargetSystemId = ship.Order.DestinationSystemId,
|
||||
TargetPosition = ship.Order.DestinationPosition,
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.DefaultBehavior.Kind == "auto-mine")
|
||||
{
|
||||
PlanAutoMine(ship, world);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.DefaultBehavior.Kind == "patrol" && ship.DefaultBehavior.PatrolPoints.Count > 0)
|
||||
{
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
TargetPosition = ship.DefaultBehavior.PatrolPoints[ship.DefaultBehavior.PatrolIndex],
|
||||
TargetSystemId = ship.SystemId,
|
||||
Threshold = 18f,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "idle",
|
||||
Threshold = world.Balance.ArrivalThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
private void PlanAutoMine(ShipRuntime ship, SimulationWorld world)
|
||||
{
|
||||
var behavior = ship.DefaultBehavior;
|
||||
var refinery = world.Stations.FirstOrDefault((station) => station.Id == behavior.RefineryId);
|
||||
var node = behavior.NodeId is null
|
||||
? world.Nodes
|
||||
.Where((candidate) => candidate.SystemId == behavior.AreaSystemId)
|
||||
.OrderByDescending((candidate) => candidate.OreRemaining)
|
||||
.FirstOrDefault()
|
||||
: world.Nodes.FirstOrDefault((candidate) => candidate.Id == behavior.NodeId);
|
||||
|
||||
if (refinery is null || node is null)
|
||||
{
|
||||
behavior.Kind = "idle";
|
||||
ship.ControllerTask = new ControllerTaskRuntime { Kind = "idle", Threshold = world.Balance.ArrivalThreshold };
|
||||
return;
|
||||
}
|
||||
|
||||
behavior.NodeId ??= node.Id;
|
||||
switch (behavior.Phase)
|
||||
{
|
||||
case "extract":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "extract",
|
||||
TargetEntityId = node.Id,
|
||||
TargetSystemId = node.SystemId,
|
||||
TargetPosition = node.Position,
|
||||
Threshold = 14f,
|
||||
};
|
||||
break;
|
||||
case "travel-to-station":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
Threshold = refinery.Definition.Radius + 8f,
|
||||
};
|
||||
break;
|
||||
case "dock":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "dock",
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
Threshold = refinery.Definition.Radius + 4f,
|
||||
};
|
||||
break;
|
||||
case "unload":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "unload",
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = refinery.Position,
|
||||
Threshold = 0f,
|
||||
};
|
||||
break;
|
||||
case "undock":
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "undock",
|
||||
TargetEntityId = refinery.Id,
|
||||
TargetSystemId = refinery.SystemId,
|
||||
TargetPosition = new Vector3(refinery.Position.X + world.Balance.UndockDistance, refinery.Position.Y, refinery.Position.Z),
|
||||
Threshold = 8f,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
ship.ControllerTask = new ControllerTaskRuntime
|
||||
{
|
||||
Kind = "travel",
|
||||
TargetEntityId = node.Id,
|
||||
TargetSystemId = node.SystemId,
|
||||
TargetPosition = node.Position,
|
||||
Threshold = 18f,
|
||||
};
|
||||
behavior.Phase = "travel-to-node";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private string UpdateControllerTask(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var task = ship.ControllerTask;
|
||||
switch (task.Kind)
|
||||
{
|
||||
case "idle":
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
case "travel":
|
||||
return UpdateTravel(ship, world, deltaSeconds);
|
||||
case "extract":
|
||||
return UpdateExtract(ship, world, deltaSeconds);
|
||||
case "dock":
|
||||
return UpdateDock(ship, world, deltaSeconds);
|
||||
case "unload":
|
||||
return UpdateUnload(ship, world, deltaSeconds);
|
||||
case "undock":
|
||||
return UpdateUndock(ship, world, deltaSeconds);
|
||||
default:
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
private string UpdateTravel(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var task = ship.ControllerTask;
|
||||
if (task.TargetPosition is null || task.TargetSystemId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
}
|
||||
|
||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||
if (distance <= task.Threshold)
|
||||
{
|
||||
ship.Position = task.TargetPosition.Value;
|
||||
ship.TargetPosition = ship.Position;
|
||||
ship.SystemId = task.TargetSystemId;
|
||||
ship.State = "arriving";
|
||||
return "arrived";
|
||||
}
|
||||
|
||||
var speed = ship.Definition.Speed;
|
||||
if (ship.SystemId != task.TargetSystemId)
|
||||
{
|
||||
ship.State = distance > 800f ? "ftl" : "spooling-ftl";
|
||||
speed = ship.Definition.FtlSpeed;
|
||||
}
|
||||
else if (distance > 200f)
|
||||
{
|
||||
ship.State = distance > 500f ? "warping" : "spooling-warp";
|
||||
speed = ship.Definition.Speed * 4.5f;
|
||||
}
|
||||
else
|
||||
{
|
||||
ship.State = "approaching";
|
||||
speed = ship.Definition.Speed;
|
||||
}
|
||||
|
||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, speed * deltaSeconds);
|
||||
ship.TargetPosition = task.TargetPosition.Value;
|
||||
return "none";
|
||||
}
|
||||
|
||||
private string UpdateExtract(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var task = ship.ControllerTask;
|
||||
var node = world.Nodes.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
|
||||
if (node is null || task.TargetPosition is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
}
|
||||
|
||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||
if (distance > task.Threshold)
|
||||
{
|
||||
ship.State = "mining-approach";
|
||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "mining";
|
||||
ship.ActionTimer += deltaSeconds;
|
||||
if (ship.ActionTimer < 1f)
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.ActionTimer = 0f;
|
||||
var mined = MathF.Min(world.Balance.MiningRate, ship.Definition.CargoCapacity - ship.Cargo);
|
||||
mined = MathF.Min(mined, node.OreRemaining);
|
||||
ship.Cargo += mined;
|
||||
node.OreRemaining -= mined;
|
||||
if (node.OreRemaining <= 0f)
|
||||
{
|
||||
node.OreRemaining = node.MaxOre;
|
||||
}
|
||||
|
||||
return ship.Cargo >= ship.Definition.CargoCapacity ? "cargo-full" : "none";
|
||||
}
|
||||
|
||||
private string UpdateDock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var task = ship.ControllerTask;
|
||||
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == task.TargetEntityId);
|
||||
if (station is null || task.TargetPosition is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
}
|
||||
|
||||
var distance = ship.Position.DistanceTo(task.TargetPosition.Value);
|
||||
if (distance > task.Threshold)
|
||||
{
|
||||
ship.State = "docking-approach";
|
||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "docking";
|
||||
ship.ActionTimer += deltaSeconds;
|
||||
if (ship.ActionTimer < world.Balance.DockingDuration)
|
||||
{
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.ActionTimer = 0f;
|
||||
ship.State = "docked";
|
||||
ship.DockedStationId = station.Id;
|
||||
station.DockedShipIds.Add(ship.Id);
|
||||
ship.Position = station.Position;
|
||||
return "docked";
|
||||
}
|
||||
|
||||
private string UpdateUnload(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
if (ship.DockedStationId is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
||||
if (station is null)
|
||||
{
|
||||
ship.DockedStationId = null;
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
}
|
||||
|
||||
ship.State = "transferring";
|
||||
var moved = MathF.Min(ship.Cargo, world.Balance.TransferRate * deltaSeconds);
|
||||
ship.Cargo -= moved;
|
||||
station.OreStored += moved;
|
||||
var faction = world.Factions.FirstOrDefault((candidate) => candidate.Id == ship.FactionId);
|
||||
if (faction is not null)
|
||||
{
|
||||
faction.OreMined += moved;
|
||||
faction.Credits += moved * 0.4f;
|
||||
}
|
||||
|
||||
return ship.Cargo <= 0.01f ? "unloaded" : "none";
|
||||
}
|
||||
|
||||
private string UpdateUndock(ShipRuntime ship, SimulationWorld world, float deltaSeconds)
|
||||
{
|
||||
var task = ship.ControllerTask;
|
||||
if (ship.DockedStationId is null || task.TargetPosition is null)
|
||||
{
|
||||
ship.State = "idle";
|
||||
return "none";
|
||||
}
|
||||
|
||||
var station = world.Stations.FirstOrDefault((candidate) => candidate.Id == ship.DockedStationId);
|
||||
station?.DockedShipIds.Remove(ship.Id);
|
||||
ship.DockedStationId = null;
|
||||
ship.State = "undocking";
|
||||
ship.Position = ship.Position.MoveToward(task.TargetPosition.Value, ship.Definition.Speed * deltaSeconds);
|
||||
return ship.Position.DistanceTo(task.TargetPosition.Value) <= task.Threshold ? "undocked" : "none";
|
||||
}
|
||||
|
||||
private void AdvanceControlState(ShipRuntime ship, string controllerEvent)
|
||||
{
|
||||
if (ship.Order is not null && controllerEvent == "arrived")
|
||||
{
|
||||
ship.Order = null;
|
||||
ship.ControllerTask.Kind = "idle";
|
||||
return;
|
||||
}
|
||||
|
||||
if (ship.DefaultBehavior.Kind == "auto-mine")
|
||||
{
|
||||
switch (ship.DefaultBehavior.Phase, controllerEvent)
|
||||
{
|
||||
case ("travel-to-node", "arrived"):
|
||||
ship.DefaultBehavior.Phase = ship.Cargo >= ship.Definition.CargoCapacity ? "travel-to-station" : "extract";
|
||||
break;
|
||||
case ("extract", "cargo-full"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-station";
|
||||
break;
|
||||
case ("travel-to-station", "arrived"):
|
||||
ship.DefaultBehavior.Phase = "dock";
|
||||
break;
|
||||
case ("dock", "docked"):
|
||||
ship.DefaultBehavior.Phase = "unload";
|
||||
break;
|
||||
case ("unload", "unloaded"):
|
||||
ship.DefaultBehavior.Phase = "undock";
|
||||
break;
|
||||
case ("undock", "undocked"):
|
||||
ship.DefaultBehavior.Phase = "travel-to-node";
|
||||
ship.DefaultBehavior.NodeId = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ship.DefaultBehavior.Kind == "patrol" && controllerEvent == "arrived" && ship.DefaultBehavior.PatrolPoints.Count > 0)
|
||||
{
|
||||
ship.DefaultBehavior.PatrolIndex = (ship.DefaultBehavior.PatrolIndex + 1) % ship.DefaultBehavior.PatrolPoints.Count;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TrackHistory(ShipRuntime ship)
|
||||
{
|
||||
var signature = $"{ship.State}|{ship.DefaultBehavior.Kind}|{ship.ControllerTask.Kind}|{ship.Cargo:0.0}";
|
||||
if (signature == ship.LastSignature)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ship.LastSignature = signature;
|
||||
ship.History.Add($"{DateTimeOffset.UtcNow:HH:mm:ss} state={ship.State} behavior={ship.DefaultBehavior.Kind} task={ship.ControllerTask.Kind} cargo={ship.Cargo:0.#}");
|
||||
if (ship.History.Count > 18)
|
||||
{
|
||||
ship.History.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static Vector3Dto ToDto(Vector3 value) => new(value.X, value.Y, value.Z);
|
||||
}
|
||||
19
apps/backend/Simulation/SimulationHostedService.cs
Normal file
19
apps/backend/Simulation/SimulationHostedService.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class SimulationHostedService(WorldService worldService) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(200));
|
||||
try
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested && await timer.WaitForNextTickAsync(stoppingToken))
|
||||
{
|
||||
worldService.Tick(0.2f);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps/backend/Simulation/WorldService.cs
Normal file
36
apps/backend/Simulation/WorldService.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using SpaceGame.Simulation.Api.Contracts;
|
||||
|
||||
namespace SpaceGame.Simulation.Api.Simulation;
|
||||
|
||||
public sealed class WorldService(IWebHostEnvironment environment)
|
||||
{
|
||||
private readonly object _sync = new();
|
||||
private readonly ScenarioLoader _loader = new(environment.ContentRootPath);
|
||||
private readonly SimulationEngine _engine = new();
|
||||
private SimulationWorld _world = new ScenarioLoader(environment.ContentRootPath).Load();
|
||||
|
||||
public WorldSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _engine.BuildSnapshot(_world);
|
||||
}
|
||||
}
|
||||
|
||||
public void Tick(float deltaSeconds)
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_engine.Tick(_world, deltaSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
public WorldSnapshot Reset()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_world = _loader.Load();
|
||||
return _engine.BuildSnapshot(_world);
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/backend/SpaceGame.Simulation.Api.csproj
Normal file
9
apps/backend/SpaceGame.Simulation.Api.csproj
Normal file
@@ -0,0 +1,9 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
8
apps/backend/appsettings.Development.json
Normal file
8
apps/backend/appsettings.Development.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
apps/backend/appsettings.json
Normal file
9
apps/backend/appsettings.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -3,10 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Space Command</title>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<title>Space Game Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "space-game",
|
||||
"name": "space-game-viewer",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "space-game",
|
||||
"name": "space-game-viewer",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"three": "^0.179.1"
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "space-game",
|
||||
"name": "space-game-viewer",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"build": "tsc -p tsconfig.json && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
427
apps/viewer/src/GameViewer.ts
Normal file
427
apps/viewer/src/GameViewer.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import * as THREE from "three";
|
||||
import { fetchWorldSnapshot, resetWorld } from "./api";
|
||||
import type {
|
||||
FactionSnapshot,
|
||||
ResourceNodeSnapshot,
|
||||
ShipSnapshot,
|
||||
StationSnapshot,
|
||||
SystemSnapshot,
|
||||
WorldSnapshot,
|
||||
} from "./contracts";
|
||||
|
||||
type Selectable =
|
||||
| { kind: "ship"; id: string }
|
||||
| { kind: "station"; id: string }
|
||||
| { kind: "node"; id: string }
|
||||
| { kind: "system"; id: string };
|
||||
|
||||
export class GameViewer {
|
||||
private readonly container: HTMLElement;
|
||||
private readonly renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
private readonly scene = new THREE.Scene();
|
||||
private readonly camera = new THREE.PerspectiveCamera(50, 1, 0.1, 40000);
|
||||
private readonly clock = new THREE.Clock();
|
||||
private readonly raycaster = new THREE.Raycaster();
|
||||
private readonly mouse = new THREE.Vector2();
|
||||
private readonly focus = new THREE.Vector3(2200, 0, 300);
|
||||
private readonly systemGroup = new THREE.Group();
|
||||
private readonly nodeGroup = new THREE.Group();
|
||||
private readonly stationGroup = new THREE.Group();
|
||||
private readonly shipGroup = new THREE.Group();
|
||||
private readonly selectableTargets = new Map<THREE.Object3D, Selectable>();
|
||||
private readonly statusEl: HTMLDivElement;
|
||||
private readonly detailTitleEl: HTMLHeadingElement;
|
||||
private readonly detailBodyEl: HTMLDivElement;
|
||||
private readonly factionStripEl: HTMLDivElement;
|
||||
private readonly resetButton: HTMLButtonElement;
|
||||
private readonly errorEl: HTMLDivElement;
|
||||
private snapshot?: WorldSnapshot;
|
||||
private selected?: Selectable;
|
||||
private dragging = false;
|
||||
private lastPointer = new THREE.Vector2();
|
||||
private worldSignature = "";
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
this.scene.background = new THREE.Color(0x040912);
|
||||
this.scene.fog = new THREE.FogExp2(0x040912, 0.00011);
|
||||
this.scene.add(new THREE.AmbientLight(0x90a6c0, 0.55));
|
||||
const keyLight = new THREE.DirectionalLight(0xdcecff, 1.3);
|
||||
keyLight.position.set(1000, 1200, 800);
|
||||
this.scene.add(keyLight);
|
||||
this.scene.add(this.systemGroup, this.nodeGroup, this.stationGroup, this.shipGroup);
|
||||
|
||||
this.camera.position.set(2500, 1700, 2800);
|
||||
this.camera.lookAt(this.focus);
|
||||
|
||||
const hud = document.createElement("div");
|
||||
hud.className = "viewer-shell";
|
||||
hud.innerHTML = `
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Frontend Viewer</p>
|
||||
<h1>Space Game Observer</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div class="status-pill">Connecting</div>
|
||||
<button type="button" class="reset-button">Reset World</button>
|
||||
</div>
|
||||
</header>
|
||||
<aside class="details-panel">
|
||||
<h2>Selection</h2>
|
||||
<h3 class="detail-title">Nothing selected</h3>
|
||||
<div class="detail-body">Click a star, station, node, or ship to inspect the server snapshot.</div>
|
||||
<div class="error-strip" hidden></div>
|
||||
</aside>
|
||||
<section class="faction-strip"></section>
|
||||
`;
|
||||
|
||||
this.statusEl = hud.querySelector(".status-pill") as HTMLDivElement;
|
||||
this.detailTitleEl = hud.querySelector(".detail-title") as HTMLHeadingElement;
|
||||
this.detailBodyEl = hud.querySelector(".detail-body") as HTMLDivElement;
|
||||
this.factionStripEl = hud.querySelector(".faction-strip") as HTMLDivElement;
|
||||
this.resetButton = hud.querySelector(".reset-button") as HTMLButtonElement;
|
||||
this.errorEl = hud.querySelector(".error-strip") as HTMLDivElement;
|
||||
|
||||
this.container.append(this.renderer.domElement, hud);
|
||||
|
||||
this.resetButton.addEventListener("click", () => void this.handleReset());
|
||||
this.renderer.domElement.addEventListener("pointerdown", this.onPointerDown);
|
||||
this.renderer.domElement.addEventListener("pointermove", this.onPointerMove);
|
||||
this.renderer.domElement.addEventListener("pointerup", this.onPointerUp);
|
||||
this.renderer.domElement.addEventListener("click", this.onClick);
|
||||
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
|
||||
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
|
||||
window.addEventListener("resize", this.onResize);
|
||||
this.onResize();
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.refreshSnapshot();
|
||||
window.setInterval(() => {
|
||||
void this.refreshSnapshot();
|
||||
}, 500);
|
||||
this.renderer.setAnimationLoop(() => this.render());
|
||||
}
|
||||
|
||||
private async refreshSnapshot() {
|
||||
try {
|
||||
const snapshot = await fetchWorldSnapshot();
|
||||
this.snapshot = snapshot;
|
||||
this.statusEl.textContent = `Live ${new Date(snapshot.generatedAtUtc).toLocaleTimeString()}`;
|
||||
this.errorEl.hidden = true;
|
||||
this.applySnapshot(snapshot);
|
||||
this.updatePanels();
|
||||
} catch (error) {
|
||||
this.statusEl.textContent = "Backend offline";
|
||||
this.errorEl.hidden = false;
|
||||
this.errorEl.textContent = error instanceof Error ? error.message : "Unable to load the backend snapshot.";
|
||||
}
|
||||
}
|
||||
|
||||
private async handleReset() {
|
||||
this.resetButton.disabled = true;
|
||||
try {
|
||||
const snapshot = await resetWorld();
|
||||
this.snapshot = snapshot;
|
||||
this.applySnapshot(snapshot);
|
||||
this.updatePanels();
|
||||
} finally {
|
||||
this.resetButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private applySnapshot(snapshot: WorldSnapshot) {
|
||||
const signature = `${snapshot.seed}|${snapshot.systems.length}`;
|
||||
if (signature !== this.worldSignature) {
|
||||
this.worldSignature = signature;
|
||||
this.rebuildSystems(snapshot.systems);
|
||||
}
|
||||
this.rebuildNodes(snapshot.nodes);
|
||||
this.rebuildStations(snapshot.stations);
|
||||
this.rebuildShips(snapshot.ships);
|
||||
this.rebuildFactions(snapshot.factions);
|
||||
}
|
||||
|
||||
private rebuildSystems(systems: SystemSnapshot[]) {
|
||||
this.systemGroup.clear();
|
||||
this.selectableTargets.clear();
|
||||
for (const system of systems) {
|
||||
const root = new THREE.Group();
|
||||
root.position.set(system.position.x, system.position.y, system.position.z);
|
||||
const star = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(system.starSize, 32, 32),
|
||||
new THREE.MeshBasicMaterial({ color: system.starColor }),
|
||||
);
|
||||
const halo = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(system.starSize * 1.65, 24, 24),
|
||||
new THREE.MeshBasicMaterial({
|
||||
color: system.starColor,
|
||||
transparent: true,
|
||||
opacity: 0.14,
|
||||
side: THREE.BackSide,
|
||||
}),
|
||||
);
|
||||
root.add(star, halo);
|
||||
this.selectableTargets.set(star, { kind: "system", id: system.id });
|
||||
this.selectableTargets.set(halo, { kind: "system", id: system.id });
|
||||
for (const planet of system.planets) {
|
||||
const orbit = new THREE.LineLoop(
|
||||
new THREE.BufferGeometry().setFromPoints(
|
||||
Array.from({ length: 80 }, (_, index) => {
|
||||
const angle = (index / 80) * Math.PI * 2;
|
||||
return new THREE.Vector3(
|
||||
Math.cos(angle) * planet.orbitRadius,
|
||||
0,
|
||||
Math.sin(angle) * planet.orbitRadius,
|
||||
);
|
||||
}),
|
||||
),
|
||||
new THREE.LineBasicMaterial({ color: 0x17314d, transparent: true, opacity: 0.45 }),
|
||||
);
|
||||
const planetMesh = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(planet.size, 18, 18),
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: planet.color,
|
||||
roughness: 0.92,
|
||||
metalness: 0.08,
|
||||
}),
|
||||
);
|
||||
planetMesh.position.set(planet.orbitRadius, 0, 0);
|
||||
root.add(orbit, planetMesh);
|
||||
}
|
||||
this.systemGroup.add(root);
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildNodes(nodes: ResourceNodeSnapshot[]) {
|
||||
this.nodeGroup.clear();
|
||||
for (const node of nodes) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(12, 0),
|
||||
new THREE.MeshStandardMaterial({ color: 0xd2b07a, flatShading: true }),
|
||||
);
|
||||
mesh.position.set(node.position.x, node.position.y, node.position.z);
|
||||
mesh.scale.setScalar(0.8 + (node.oreRemaining / Math.max(node.maxOre, 1)) * 0.6);
|
||||
this.nodeGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "node", id: node.id });
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildStations(stations: StationSnapshot[]) {
|
||||
this.stationGroup.clear();
|
||||
for (const station of stations) {
|
||||
const mesh = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(24, 24, 18, 10),
|
||||
new THREE.MeshStandardMaterial({ color: station.color, emissive: new THREE.Color(station.color).multiplyScalar(0.1) }),
|
||||
);
|
||||
mesh.rotation.x = Math.PI / 2;
|
||||
mesh.position.set(station.position.x, station.position.y, station.position.z);
|
||||
this.stationGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "station", id: station.id });
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildShips(ships: ShipSnapshot[]) {
|
||||
this.shipGroup.clear();
|
||||
for (const ship of ships) {
|
||||
const geometry = new THREE.ConeGeometry(this.shipSize(ship), this.shipLength(ship), 7);
|
||||
geometry.rotateX(Math.PI / 2);
|
||||
const mesh = new THREE.Mesh(
|
||||
geometry,
|
||||
new THREE.MeshStandardMaterial({ color: this.shipColor(ship.role) }),
|
||||
);
|
||||
mesh.position.set(ship.position.x, ship.position.y, ship.position.z);
|
||||
this.shipGroup.add(mesh);
|
||||
this.selectableTargets.set(mesh, { kind: "ship", id: ship.id });
|
||||
}
|
||||
}
|
||||
|
||||
private rebuildFactions(factions: FactionSnapshot[]) {
|
||||
this.factionStripEl.innerHTML = factions
|
||||
.map((faction) => `
|
||||
<article class="faction-card">
|
||||
<div class="swatch" style="background:${faction.color}"></div>
|
||||
<div>
|
||||
<h3>${faction.label}</h3>
|
||||
<p>Credits ${faction.credits.toFixed(0)} · Ore ${faction.oreMined.toFixed(0)} · Goods ${faction.goodsProduced.toFixed(0)}</p>
|
||||
</div>
|
||||
</article>
|
||||
`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
private updatePanels() {
|
||||
if (!this.snapshot) {
|
||||
return;
|
||||
}
|
||||
if (!this.selected) {
|
||||
this.detailTitleEl.textContent = this.snapshot.label;
|
||||
this.detailBodyEl.innerHTML = `Systems ${this.snapshot.systems.length}<br>Stations ${this.snapshot.stations.length}<br>Ships ${this.snapshot.ships.length}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.selected;
|
||||
if (selected.kind === "ship") {
|
||||
const ship = this.snapshot.ships.find((candidate) => candidate.id === selected.id);
|
||||
if (ship) {
|
||||
this.detailTitleEl.textContent = ship.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
|
||||
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p>
|
||||
<p>Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}</p>
|
||||
<p class="history">${ship.history.join("<br>")}</p>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.kind === "station") {
|
||||
const station = this.snapshot.stations.find((candidate) => candidate.id === selected.id);
|
||||
if (station) {
|
||||
this.detailTitleEl.textContent = station.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${station.category} · ${station.systemId}</p>
|
||||
<p>Ore ${station.oreStored.toFixed(0)}<br>Refined ${station.refinedStock.toFixed(0)}<br>Docked ${station.dockedShips}</p>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected.kind === "node") {
|
||||
const node = this.snapshot.nodes.find((candidate) => candidate.id === selected.id);
|
||||
if (node) {
|
||||
this.detailTitleEl.textContent = `Node ${node.id}`;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${node.systemId}</p>
|
||||
<p>${node.itemId} ${node.oreRemaining.toFixed(0)} / ${node.maxOre.toFixed(0)}</p>
|
||||
`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const system = this.snapshot.systems.find((candidate) => candidate.id === selected.id);
|
||||
if (system) {
|
||||
this.detailTitleEl.textContent = system.label;
|
||||
this.detailBodyEl.innerHTML = `
|
||||
<p>${system.id}</p>
|
||||
<p>Planets ${system.planets.length}</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
private render() {
|
||||
const delta = Math.min(this.clock.getDelta(), 0.033);
|
||||
this.camera.position.lerp(new THREE.Vector3(this.focus.x + 2200, 1600, this.focus.z + 2200), Math.min(1, delta * 2));
|
||||
this.camera.lookAt(this.focus);
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
private onPointerDown = (event: PointerEvent) => {
|
||||
this.dragging = true;
|
||||
this.lastPointer.set(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
private onPointerMove = (event: PointerEvent) => {
|
||||
if (!this.dragging) {
|
||||
return;
|
||||
}
|
||||
const dx = event.clientX - this.lastPointer.x;
|
||||
const dy = event.clientY - this.lastPointer.y;
|
||||
this.focus.x -= dx * 2.4;
|
||||
this.focus.z += dy * 2.4;
|
||||
this.lastPointer.set(event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
private onPointerUp = () => {
|
||||
this.dragging = false;
|
||||
};
|
||||
|
||||
private onClick = (event: MouseEvent) => {
|
||||
const bounds = this.renderer.domElement.getBoundingClientRect();
|
||||
this.mouse.x = ((event.clientX - bounds.left) / bounds.width) * 2 - 1;
|
||||
this.mouse.y = -(((event.clientY - bounds.top) / bounds.height) * 2 - 1);
|
||||
this.raycaster.setFromCamera(this.mouse, this.camera);
|
||||
const hit = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false)[0];
|
||||
this.selected = hit ? this.selectableTargets.get(hit.object) : undefined;
|
||||
this.updatePanels();
|
||||
};
|
||||
|
||||
private onDoubleClick = () => {
|
||||
if (!this.snapshot || !this.selected) {
|
||||
return;
|
||||
}
|
||||
const nextFocus = this.resolveSelectionPosition(this.selected);
|
||||
if (nextFocus) {
|
||||
this.focus.copy(nextFocus);
|
||||
}
|
||||
};
|
||||
|
||||
private onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
const offset = this.camera.position.clone().sub(this.focus);
|
||||
offset.multiplyScalar(event.deltaY > 0 ? 1.08 : 0.92);
|
||||
offset.clampLength(500, 12000);
|
||||
this.camera.position.copy(this.focus).add(offset);
|
||||
};
|
||||
|
||||
private resolveSelectionPosition(selection: Selectable) {
|
||||
if (!this.snapshot) {
|
||||
return undefined;
|
||||
}
|
||||
if (selection.kind === "ship") {
|
||||
const ship = this.snapshot.ships.find((candidate) => candidate.id === selection.id);
|
||||
return ship ? new THREE.Vector3(ship.position.x, ship.position.y, ship.position.z) : undefined;
|
||||
}
|
||||
if (selection.kind === "station") {
|
||||
const station = this.snapshot.stations.find((candidate) => candidate.id === selection.id);
|
||||
return station ? new THREE.Vector3(station.position.x, station.position.y, station.position.z) : undefined;
|
||||
}
|
||||
if (selection.kind === "node") {
|
||||
const node = this.snapshot.nodes.find((candidate) => candidate.id === selection.id);
|
||||
return node ? new THREE.Vector3(node.position.x, node.position.y, node.position.z) : undefined;
|
||||
}
|
||||
const system = this.snapshot.systems.find((candidate) => candidate.id === selection.id);
|
||||
return system ? new THREE.Vector3(system.position.x, system.position.y, system.position.z) : undefined;
|
||||
}
|
||||
|
||||
private shipSize(ship: ShipSnapshot) {
|
||||
switch (ship.shipClass) {
|
||||
case "capital":
|
||||
return 18;
|
||||
case "cruiser":
|
||||
return 13;
|
||||
case "destroyer":
|
||||
return 10;
|
||||
case "industrial":
|
||||
return 11;
|
||||
default:
|
||||
return 8;
|
||||
}
|
||||
}
|
||||
|
||||
private shipLength(ship: ShipSnapshot) {
|
||||
return this.shipSize(ship) * 2.6;
|
||||
}
|
||||
|
||||
private shipColor(role: ShipSnapshot["role"]) {
|
||||
if (role === "mining") {
|
||||
return "#ffcf6e";
|
||||
}
|
||||
if (role === "transport") {
|
||||
return "#9ff0aa";
|
||||
}
|
||||
return "#8bc0ff";
|
||||
}
|
||||
|
||||
private onResize = () => {
|
||||
const width = window.innerWidth;
|
||||
const height = window.innerHeight;
|
||||
this.camera.aspect = width / height;
|
||||
this.camera.updateProjectionMatrix();
|
||||
this.renderer.setSize(width, height);
|
||||
};
|
||||
}
|
||||
19
apps/viewer/src/api.ts
Normal file
19
apps/viewer/src/api.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { WorldSnapshot } from "./contracts";
|
||||
|
||||
export async function fetchWorldSnapshot(signal?: AbortSignal) {
|
||||
const response = await fetch("/api/world", { signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`World request failed with ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<WorldSnapshot>;
|
||||
}
|
||||
|
||||
export async function resetWorld() {
|
||||
const response = await fetch("/api/world/reset", {
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Reset request failed with ${response.status}`);
|
||||
}
|
||||
return response.json() as Promise<WorldSnapshot>;
|
||||
}
|
||||
85
apps/viewer/src/contracts.ts
Normal file
85
apps/viewer/src/contracts.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
export interface WorldSnapshot {
|
||||
label: string;
|
||||
seed: number;
|
||||
generatedAtUtc: string;
|
||||
systems: SystemSnapshot[];
|
||||
nodes: ResourceNodeSnapshot[];
|
||||
stations: StationSnapshot[];
|
||||
ships: ShipSnapshot[];
|
||||
factions: FactionSnapshot[];
|
||||
}
|
||||
|
||||
export interface Vector3Dto {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export interface SystemSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
position: Vector3Dto;
|
||||
starColor: string;
|
||||
starSize: number;
|
||||
planets: PlanetSnapshot[];
|
||||
}
|
||||
|
||||
export interface PlanetSnapshot {
|
||||
label: string;
|
||||
orbitRadius: number;
|
||||
size: number;
|
||||
color: string;
|
||||
hasRing: boolean;
|
||||
}
|
||||
|
||||
export interface ResourceNodeSnapshot {
|
||||
id: string;
|
||||
systemId: string;
|
||||
position: Vector3Dto;
|
||||
oreRemaining: number;
|
||||
maxOre: number;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export interface StationSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
category: string;
|
||||
systemId: string;
|
||||
position: Vector3Dto;
|
||||
color: string;
|
||||
dockedShips: number;
|
||||
oreStored: number;
|
||||
refinedStock: number;
|
||||
factionId: string;
|
||||
}
|
||||
|
||||
export interface ShipSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
role: string;
|
||||
shipClass: string;
|
||||
systemId: string;
|
||||
position: Vector3Dto;
|
||||
state: string;
|
||||
orderKind: string | null;
|
||||
defaultBehaviorKind: string;
|
||||
controllerTaskKind: string;
|
||||
cargo: number;
|
||||
cargoCapacity: number;
|
||||
cargoItemId: string | null;
|
||||
factionId: string;
|
||||
health: number;
|
||||
history: string[];
|
||||
}
|
||||
|
||||
export interface FactionSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
color: string;
|
||||
credits: number;
|
||||
oreMined: number;
|
||||
goodsProduced: number;
|
||||
shipsBuilt: number;
|
||||
shipsLost: number;
|
||||
}
|
||||
11
apps/viewer/src/main.ts
Normal file
11
apps/viewer/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import "./style.css";
|
||||
import { GameViewer } from "./GameViewer";
|
||||
|
||||
const root = document.querySelector<HTMLDivElement>("#app");
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Missing #app root element");
|
||||
}
|
||||
|
||||
const viewer = new GameViewer(root);
|
||||
void viewer.start();
|
||||
225
apps/viewer/src/style.css
Normal file
225
apps/viewer/src/style.css
Normal file
@@ -0,0 +1,225 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Space Grotesk", "Segoe UI", sans-serif;
|
||||
--bg: #050812;
|
||||
--panel: rgba(9, 18, 34, 0.78);
|
||||
--panel-border: rgba(132, 196, 255, 0.18);
|
||||
--text: #eaf4ff;
|
||||
--muted: #98adc4;
|
||||
--accent: #7fd6ff;
|
||||
--warning: #ffbf69;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(89, 132, 247, 0.16), transparent 30%),
|
||||
radial-gradient(circle at 18% 42%, rgba(255, 136, 92, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, #03060d 0%, #060c18 100%);
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.viewer-shell {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.details-panel,
|
||||
.faction-strip {
|
||||
position: absolute;
|
||||
backdrop-filter: blur(18px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
border-radius: 22px;
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 6px;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.18em;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar h1,
|
||||
.details-panel h2,
|
||||
.details-panel h3,
|
||||
.faction-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.status-pill,
|
||||
.reset-button {
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(127, 214, 255, 0.18);
|
||||
background: rgba(12, 25, 46, 0.92);
|
||||
color: var(--text);
|
||||
padding: 11px 16px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.reset-button {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
transition: transform 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
|
||||
.reset-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(127, 214, 255, 0.42);
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
top: 110px;
|
||||
right: 20px;
|
||||
width: min(380px, calc(100vw - 40px));
|
||||
bottom: 20px;
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
color: var(--text);
|
||||
pointer-events: auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.details-panel h2 {
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
margin-top: 12px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin-top: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.detail-body p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.history {
|
||||
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-strip {
|
||||
margin-top: 14px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 116, 88, 0.14);
|
||||
color: #ffd8cf;
|
||||
}
|
||||
|
||||
.faction-strip {
|
||||
left: 20px;
|
||||
bottom: 20px;
|
||||
width: min(920px, calc(100vw - 440px));
|
||||
min-height: 110px;
|
||||
border-radius: 24px;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.faction-card {
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(127, 214, 255, 0.14);
|
||||
background: linear-gradient(180deg, rgba(11, 23, 43, 0.85), rgba(7, 15, 28, 0.9));
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.faction-card p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 14px;
|
||||
height: 48px;
|
||||
border-radius: 999px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.faction-strip {
|
||||
right: 20px;
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.topbar {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.details-panel {
|
||||
position: absolute;
|
||||
top: auto;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 148px;
|
||||
width: auto;
|
||||
max-height: 38vh;
|
||||
}
|
||||
|
||||
.faction-strip {
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: auto;
|
||||
min-height: 100px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,5 @@
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
18
apps/viewer/vite.config.ts
Normal file
18
apps/viewer/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const root = new URL(".", import.meta.url).pathname;
|
||||
|
||||
export default defineConfig({
|
||||
root,
|
||||
server: {
|
||||
host: true,
|
||||
port: 5174,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:5079",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "../../dist/viewer",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
3237
src/game/GameApp.ts
3237
src/game/GameApp.ts
File diff suppressed because it is too large
Load Diff
@@ -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]));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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)";
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
11
src/main.ts
11
src/main.ts
@@ -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();
|
||||
444
src/style.css
444
src/style.css
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user