diff --git a/.gitignore b/.gitignore index af5f487..b165f9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ node_modules/ dist/ +bin/ +obj/ *.tsbuildinfo diff --git a/SpaceGame.slnx b/SpaceGame.slnx new file mode 100644 index 0000000..2f6f43f --- /dev/null +++ b/SpaceGame.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/backend/Contracts/WorldContracts.cs b/apps/backend/Contracts/WorldContracts.cs new file mode 100644 index 0000000..3ca2a3a --- /dev/null +++ b/apps/backend/Contracts/WorldContracts.cs @@ -0,0 +1,76 @@ +namespace SpaceGame.Simulation.Api.Contracts; + +public sealed record WorldSnapshot( + string Label, + int Seed, + DateTimeOffset GeneratedAtUtc, + IReadOnlyList Systems, + IReadOnlyList Nodes, + IReadOnlyList Stations, + IReadOnlyList Ships, + IReadOnlyList Factions); + +public sealed record SystemSnapshot( + string Id, + string Label, + Vector3Dto Position, + string StarColor, + float StarSize, + IReadOnlyList 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 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); diff --git a/apps/backend/Data/WorldDefinitions.cs b/apps/backend/Data/WorldDefinitions.cs new file mode 100644 index 0000000..35bcd9b --- /dev/null +++ b/apps/backend/Data/WorldDefinitions.cs @@ -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 ResourceNodes { get; set; } + public required List 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 InitialStations { get; set; } + public required List ShipFormations { get; set; } + public required List 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 Points { get; set; } +} + +public sealed class MiningDefaultsDefinition +{ + public required string NodeSystemId { get; set; } + public required string RefinerySystemId { get; set; } +} diff --git a/apps/backend/Program.cs b/apps/backend/Program.cs new file mode 100644 index 0000000..93466bf --- /dev/null +++ b/apps/backend/Program.cs @@ -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(); +builder.Services.AddHostedService(); + +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(); diff --git a/apps/backend/Properties/launchSettings.json b/apps/backend/Properties/launchSettings.json new file mode 100644 index 0000000..c55f6a2 --- /dev/null +++ b/apps/backend/Properties/launchSettings.json @@ -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" + } + } + } +} diff --git a/apps/backend/Simulation/RuntimeModels.cs b/apps/backend/Simulation/RuntimeModels.cs new file mode 100644 index 0000000..f2868cb --- /dev/null +++ b/apps/backend/Simulation/RuntimeModels.cs @@ -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 Systems { get; init; } + public required List Nodes { get; init; } + public required List Stations { get; init; } + public required List Ships { get; init; } + public required List Factions { get; init; } + public required Dictionary 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 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 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 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)); + } +} diff --git a/apps/backend/Simulation/ScenarioLoader.cs b/apps/backend/Simulation/ScenarioLoader.cs new file mode 100644 index 0000000..8c3772f --- /dev/null +++ b/apps/backend/Simulation/ScenarioLoader.cs @@ -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>("systems.json"); + var scenario = Read("scenario.json"); + var ships = Read>("ships.json"); + var constructibles = Read>("constructibles.json"); + var balance = Read("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(); + 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(); + 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(); + 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 + { + 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(string fileName) + { + var path = Path.Combine(_dataRoot, fileName); + var json = File.ReadAllText(path); + return JsonSerializer.Deserialize(json, _jsonOptions) + ?? throw new InvalidOperationException($"Unable to read {fileName}."); + } + + private static DefaultBehaviorRuntime CreateBehavior( + ShipDefinition definition, + string systemId, + ScenarioDefinition scenario, + IReadOnlyDictionary> 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); +} diff --git a/apps/backend/Simulation/SimulationEngine.cs b/apps/backend/Simulation/SimulationEngine.cs new file mode 100644 index 0000000..09d643a --- /dev/null +++ b/apps/backend/Simulation/SimulationEngine.cs @@ -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); +} diff --git a/apps/backend/Simulation/SimulationHostedService.cs b/apps/backend/Simulation/SimulationHostedService.cs new file mode 100644 index 0000000..d5f5b86 --- /dev/null +++ b/apps/backend/Simulation/SimulationHostedService.cs @@ -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) + { + } + } +} diff --git a/apps/backend/Simulation/WorldService.cs b/apps/backend/Simulation/WorldService.cs new file mode 100644 index 0000000..f43d2bb --- /dev/null +++ b/apps/backend/Simulation/WorldService.cs @@ -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); + } + } +} diff --git a/apps/backend/SpaceGame.Simulation.Api.csproj b/apps/backend/SpaceGame.Simulation.Api.csproj new file mode 100644 index 0000000..a3a34b6 --- /dev/null +++ b/apps/backend/SpaceGame.Simulation.Api.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/apps/backend/appsettings.Development.json b/apps/backend/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/apps/backend/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/apps/backend/appsettings.json b/apps/backend/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/apps/backend/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/index.html b/apps/viewer/index.html similarity index 87% rename from index.html rename to apps/viewer/index.html index a5b9aba..f832d08 100644 --- a/index.html +++ b/apps/viewer/index.html @@ -3,10 +3,10 @@ - Space Command - + Space Game Viewer
+ diff --git a/package-lock.json b/apps/viewer/package-lock.json similarity index 99% rename from package-lock.json rename to apps/viewer/package-lock.json index cc10d8b..c71ee53 100644 --- a/package-lock.json +++ b/apps/viewer/package-lock.json @@ -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" diff --git a/package.json b/apps/viewer/package.json similarity index 78% rename from package.json rename to apps/viewer/package.json index 791d41c..549cae3 100644 --- a/package.json +++ b/apps/viewer/package.json @@ -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": { diff --git a/apps/viewer/src/GameViewer.ts b/apps/viewer/src/GameViewer.ts new file mode 100644 index 0000000..3337e9f --- /dev/null +++ b/apps/viewer/src/GameViewer.ts @@ -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(); + 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 = ` +
+
+

Frontend Viewer

+

Space Game Observer

+
+
+
Connecting
+ +
+
+ +
+ `; + + 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) => ` +
+
+
+

${faction.label}

+

Credits ${faction.credits.toFixed(0)} · Ore ${faction.oreMined.toFixed(0)} · Goods ${faction.goodsProduced.toFixed(0)}

+
+
+ `) + .join(""); + } + + private updatePanels() { + if (!this.snapshot) { + return; + } + if (!this.selected) { + this.detailTitleEl.textContent = this.snapshot.label; + this.detailBodyEl.innerHTML = `Systems ${this.snapshot.systems.length}
Stations ${this.snapshot.stations.length}
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 = ` +

${ship.shipClass} · ${ship.role} · ${ship.systemId}

+

State ${ship.state}
Behavior ${ship.defaultBehaviorKind}
Task ${ship.controllerTaskKind}

+

Cargo ${ship.cargo.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)} ${ship.cargoItemId ?? ""}

+

${ship.history.join("
")}

+ `; + } + 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 = ` +

${station.category} · ${station.systemId}

+

Ore ${station.oreStored.toFixed(0)}
Refined ${station.refinedStock.toFixed(0)}
Docked ${station.dockedShips}

+ `; + } + 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 = ` +

${node.systemId}

+

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

+ `; + } + return; + } + + const system = this.snapshot.systems.find((candidate) => candidate.id === selected.id); + if (system) { + this.detailTitleEl.textContent = system.label; + this.detailBodyEl.innerHTML = ` +

${system.id}

+

Planets ${system.planets.length}

+ `; + } + } + + 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); + }; +} diff --git a/apps/viewer/src/api.ts b/apps/viewer/src/api.ts new file mode 100644 index 0000000..c84c174 --- /dev/null +++ b/apps/viewer/src/api.ts @@ -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; +} + +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; +} diff --git a/apps/viewer/src/contracts.ts b/apps/viewer/src/contracts.ts new file mode 100644 index 0000000..4ec6b4f --- /dev/null +++ b/apps/viewer/src/contracts.ts @@ -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; +} diff --git a/apps/viewer/src/main.ts b/apps/viewer/src/main.ts new file mode 100644 index 0000000..3f84ab7 --- /dev/null +++ b/apps/viewer/src/main.ts @@ -0,0 +1,11 @@ +import "./style.css"; +import { GameViewer } from "./GameViewer"; + +const root = document.querySelector("#app"); + +if (!root) { + throw new Error("Missing #app root element"); +} + +const viewer = new GameViewer(root); +void viewer.start(); diff --git a/apps/viewer/src/style.css b/apps/viewer/src/style.css new file mode 100644 index 0000000..ba3c842 --- /dev/null +++ b/apps/viewer/src/style.css @@ -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; + } +} diff --git a/tsconfig.json b/apps/viewer/tsconfig.json similarity index 88% rename from tsconfig.json rename to apps/viewer/tsconfig.json index 2dadc30..99eed22 100644 --- a/tsconfig.json +++ b/apps/viewer/tsconfig.json @@ -11,5 +11,5 @@ "esModuleInterop": true, "skipLibCheck": true }, - "include": ["src"] + "include": ["src", "vite.config.ts"] } diff --git a/apps/viewer/vite.config.ts b/apps/viewer/vite.config.ts new file mode 100644 index 0000000..4243777 --- /dev/null +++ b/apps/viewer/vite.config.ts @@ -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, + }, +}); diff --git a/src/game/data/balance.json b/shared/data/balance.json similarity index 100% rename from src/game/data/balance.json rename to shared/data/balance.json diff --git a/src/game/data/constructibles.json b/shared/data/constructibles.json similarity index 100% rename from src/game/data/constructibles.json rename to shared/data/constructibles.json diff --git a/src/game/data/items.json b/shared/data/items.json similarity index 100% rename from src/game/data/items.json rename to shared/data/items.json diff --git a/src/game/data/modules.json b/shared/data/modules.json similarity index 100% rename from src/game/data/modules.json rename to shared/data/modules.json diff --git a/src/game/data/recipes.json b/shared/data/recipes.json similarity index 100% rename from src/game/data/recipes.json rename to shared/data/recipes.json diff --git a/src/game/data/scenario.json b/shared/data/scenario.json similarity index 100% rename from src/game/data/scenario.json rename to shared/data/scenario.json diff --git a/src/game/data/ships.json b/shared/data/ships.json similarity index 100% rename from src/game/data/ships.json rename to shared/data/ships.json diff --git a/src/game/data/systems.json b/shared/data/systems.json similarity index 100% rename from src/game/data/systems.json rename to shared/data/systems.json diff --git a/src/game/GameApp.ts b/src/game/GameApp.ts deleted file mode 100644 index ce9a71a..0000000 --- a/src/game/GameApp.ts +++ /dev/null @@ -1,3237 +0,0 @@ -import * as THREE from "three"; -import { - constructibleDefinitions, - gameBalance, - itemDefinitionsById, - recipeDefinitions, - shipDefinitionsById, -} from "./data/catalog"; -import { addShipCargo, getShipCargoAmount, removeShipCargo } from "./state/inventory"; -import { SelectionManager } from "./state/selectionManager"; -import type { - FactionInstance, - GameWindowId, - ResourceNode, - SelectableTarget, - ShipInstance, - SolarSystemInstance, - StationInstance, - TravelDestination, - TravelPlan, - UnitState, - UniverseDefinition, - ViewLevel, -} from "./types"; -import { createHud } from "./ui/hud"; -import { getDebugHistoryMarkup, getSelectionCardsMarkup, getSelectionDetails, getSelectionTitle, getShipWindowMarkup } from "./ui/presenters"; -import { drawMinimap, drawStrategicOverlay } from "./ui/strategicRenderer"; -import { buildInitialWorld, createShipInstance, createStationInstance } from "./world/worldFactory"; -import { generateUniverse } from "./world/universeGenerator"; - -const MOVING_STATES = new Set([ - "spooling-warp", - "warping", - "ftl", - "mining-approach", - "transferring", - "docking-approach", - "docking", - "undocking", - "arriving", - "patrolling", - "escorting", -]); - -type DockingHost = StationInstance | ShipInstance; -type ControllerEvent = - | { kind: "none" } - | { kind: "arrived"; destination: TravelDestination } - | { kind: "docking-begin"; portIndex: number } - | { kind: "docked" } - | { kind: "undocked" } - | { kind: "unloaded" }; -type DockingClearance = - | { kind: "accepted"; portIndex: number } - | { kind: "rejected"; reason: "permission-denied" | "no-free-bay" }; - -export class GameApp { - private readonly container: HTMLElement; - private readonly renderer: THREE.WebGLRenderer; - private readonly scene = new THREE.Scene(); - private readonly camera = new THREE.PerspectiveCamera(54, 1, 0.1, 24000); - private readonly clock = new THREE.Clock(); - private readonly raycaster = new THREE.Raycaster(); - private readonly mouse = new THREE.Vector2(); - private readonly keyState = new Set(); - private readonly cameraFocus = new THREE.Vector3(); - private readonly selectableTargets = new Map(); - - private readonly ships: ShipInstance[] = []; - private readonly shipsById = new Map(); - private readonly stations: StationInstance[] = []; - private readonly nodes: ResourceNode[] = []; - private readonly systems: SolarSystemInstance[] = []; - private readonly factions: FactionInstance[] = []; - private readonly factionsById = new Map(); - - private strategicLinks!: THREE.Group; - private starfield?: THREE.Points; - - private buildMode = false; - private selectedConstructible = 0; - private selectedSystemIndex = 0; - private readonly selectionManager = new SelectionManager(); - private followShipId?: string; - private viewLevel: ViewLevel = "local"; - private marqueeStart?: THREE.Vector2; - private marqueeModifiers = { shift: false, ctrl: false }; - private marqueeActive = false; - private suppressClickSelection = false; - private cameraDragMode?: "orbit" | "pan"; - private cameraDragPointerId?: number; - private cameraDragLast?: THREE.Vector2; - private stationIdCounter = 0; - private readonly windowState: Record = { - "fleet-command": true, - "ship-designer": false, - "station-manager": false, - debug: true, - }; - - private readonly detailsEl: HTMLDivElement; - private readonly statusEl: HTMLDivElement; - private readonly selectionTitleEl: HTMLHeadingElement; - private readonly selectionStripEl: HTMLDivElement; - private readonly ordersEl: HTMLDivElement; - private readonly minimapEl: HTMLCanvasElement; - private readonly minimapContext: CanvasRenderingContext2D; - private readonly marqueeEl: HTMLDivElement; - private readonly strategicOverlayEl: HTMLCanvasElement; - private readonly strategicOverlayContext: CanvasRenderingContext2D; - private readonly fleetWindowEl: HTMLDivElement; - private readonly fleetWindowBodyEl: HTMLDivElement; - private readonly fleetWindowTitleEl: HTMLHeadingElement; - private readonly debugWindowEl: HTMLDivElement; - private readonly debugHistoryEl: HTMLDivElement; - private readonly debugAutoScrollToggleEl: HTMLButtonElement; - private readonly debugCopyHistoryEl: HTMLButtonElement; - private readonly sessionActionsEl: HTMLDivElement; - private lastShipWindowMarkup = ""; - private lastDebugHistoryMarkup = ""; - private debugHistoryPinnedShipId?: string; - private debugAutoScroll = true; - private readonly shipHistoryById = new Map(); - private readonly shipHistorySnapshotById = new Map(); - private universe: UniverseDefinition; - - constructor(container: HTMLElement) { - this.container = container; - this.universe = generateUniverse(); - this.renderer = new THREE.WebGLRenderer({ antialias: true }); - this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - this.renderer.outputColorSpace = THREE.SRGBColorSpace; - this.renderer.shadowMap.enabled = true; - this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; - - this.scene.fog = new THREE.FogExp2(0x030811, 0.00006); - this.scene.background = new THREE.Color(0x02060d); - - const initialSystem = this.universe.systems[0]; - this.cameraFocus.set(...initialSystem.position); - this.camera.position.set(initialSystem.position[0] + 320, 260, initialSystem.position[2] + 300); - this.camera.lookAt(this.cameraFocus); - - this.container.append(this.renderer.domElement); - const hud = createHud(this.container, { - onWindowAction: (action) => this.handleWindowAction(action), - onSelectionAction: (kind, id) => this.handleWindowSelection(kind, id), - }); - this.detailsEl = hud.details; - this.statusEl = hud.status; - this.selectionTitleEl = hud.selectionTitle; - this.selectionStripEl = hud.selectionStrip; - this.ordersEl = hud.orders; - this.minimapEl = hud.minimap; - this.minimapContext = hud.minimapContext; - this.marqueeEl = hud.marquee; - this.strategicOverlayEl = hud.strategicOverlay; - this.strategicOverlayContext = hud.strategicOverlayContext; - this.fleetWindowEl = hud.fleetWindow; - this.fleetWindowBodyEl = hud.fleetWindowBody; - this.fleetWindowTitleEl = hud.fleetWindowTitle; - this.debugWindowEl = hud.debugWindow; - this.debugHistoryEl = hud.debugHistory; - this.debugAutoScrollToggleEl = hud.debugAutoScrollToggle; - this.debugCopyHistoryEl = hud.debugCopyHistory; - this.sessionActionsEl = hud.sessionActions; - - this.setupScene(); - this.bindEvents(); - this.onResize(); - this.updateHud(); - } - - start() { - this.renderer.setAnimationLoop(() => this.tick()); - } - - private get selection() { - return this.selectionManager.getShips(); - } - - private get selectedStation() { - return this.selectionManager.getStation(); - } - - private get selectedSystem() { - return this.selectionManager.getSystem(); - } - - private get selectedPlanet() { - return this.selectionManager.getPlanet(); - } - - private get selectedNode() { - return this.selectionManager.getNode(); - } - - private setupScene() { - const world = buildInitialWorld(this.scene, this.selectableTargets, this.universe.systems, this.universe.scenario); - this.systems.push(...world.systems); - this.nodes.push(...world.nodes); - this.stations.push(...world.stations); - this.ships.push(...world.ships); - world.shipsById.forEach((ship, id) => this.shipsById.set(id, ship)); - this.strategicLinks = world.strategicLinks; - this.starfield = world.starfield; - this.stationIdCounter = this.stations.length; - - this.initializeFactions(); - this.applyViewLevel(); - this.ships.forEach((ship) => this.trackShipHistory(ship)); - } - - private generateNewUniverse() { - this.selectionManager.clear(); - this.selectableTargets.clear(); - this.ships.length = 0; - this.shipsById.clear(); - this.stations.length = 0; - this.nodes.length = 0; - this.systems.length = 0; - this.factions.length = 0; - this.factionsById.clear(); - this.shipHistoryById.clear(); - this.shipHistorySnapshotById.clear(); - this.debugHistoryPinnedShipId = undefined; - this.debugAutoScroll = true; - this.followShipId = undefined; - this.buildMode = false; - this.selectedSystemIndex = 0; - this.stationIdCounter = 0; - this.marqueeStart = undefined; - this.marqueeActive = false; - this.suppressClickSelection = false; - this.cameraDragMode = undefined; - this.cameraDragPointerId = undefined; - this.cameraDragLast = undefined; - this.hideMarqueeBox(); - this.scene.clear(); - this.scene.rotation.y = 0; - - this.universe = generateUniverse(); - const initialSystem = this.universe.systems[0]; - this.cameraFocus.set(...initialSystem.position); - this.camera.position.set(initialSystem.position[0] + 320, 260, initialSystem.position[2] + 300); - this.camera.lookAt(this.cameraFocus); - - this.setupScene(); - this.updateHud(); - } - - private bindEvents() { - window.addEventListener("resize", this.onResize); - window.addEventListener("keydown", this.onKeyDown); - window.addEventListener("keyup", this.onKeyUp); - 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("pointerleave", this.onPointerUp); - this.renderer.domElement.addEventListener("click", this.onClick); - this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick); - this.renderer.domElement.addEventListener("contextmenu", this.onContextMenu); - this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false }); - } - - private onResize = () => { - const width = window.innerWidth; - const height = window.innerHeight; - const pixelRatio = Math.min(window.devicePixelRatio, 2); - this.camera.aspect = width / height; - this.camera.updateProjectionMatrix(); - this.renderer.setSize(width, height); - this.strategicOverlayEl.width = Math.floor(width * pixelRatio); - this.strategicOverlayEl.height = Math.floor(height * pixelRatio); - this.strategicOverlayEl.style.width = `${width}px`; - this.strategicOverlayEl.style.height = `${height}px`; - }; - - private onKeyDown = (event: KeyboardEvent) => { - if (event.repeat) { - return; - } - - const key = event.key.toLowerCase(); - this.keyState.add(key); - - if (key === "b") { - this.buildMode = !this.buildMode; - this.updateHud(); - return; - } - - if (key === "tab") { - event.preventDefault(); - this.selectedSystemIndex = (this.selectedSystemIndex + 1) % this.systems.length; - this.focusSystem(this.systems[this.selectedSystemIndex].definition.id); - return; - } - - if (key === "f") { - this.focusSelection(); - return; - } - - if (key === "g") { - this.toggleWindow("fleet-command"); - return; - } - - if (key === "escape") { - this.windowState["fleet-command"] = false; - this.updateHud(); - return; - } - - if (key === "-" || key === "_") { - this.adjustZoom(1.18); - return; - } - - if (key === "=" || key === "+") { - this.adjustZoom(1 / 1.18); - return; - } - - if (key === "m") { - this.selection - .filter((ship) => ship.definition.role === "mining") - .forEach((ship) => - this.assignMineOrder( - ship, - this.findBestMiningNode(this.universe.scenario.miningDefaults.nodeSystemId), - this.findRefinery(this.universe.scenario.miningDefaults.refinerySystemId), - ), - ); - this.updateHud(); - return; - } - - if (key === "p") { - this.selection - .filter((ship) => ship.definition.role === "military") - .forEach((ship) => this.setPatrolOrder(ship, this.makePatrolPoints(ship.systemId), 0)); - this.updateHud(); - return; - } - - if (key === "e") { - this.selection - .filter((ship) => ship.definition.role !== "mining") - .forEach((ship) => { - const target = this.findNearestFriendlyToEscort(ship); - if (target) { - this.setEscortOrder(ship, target); - } - }); - this.updateHud(); - return; - } - - if (key === "r") { - this.selection.forEach((ship) => { - const carrier = this.findNearestFriendlyCarrier(ship); - if (carrier) { - this.assignDockOrder(ship, carrier); - } - }); - this.updateHud(); - return; - } - - const slot = Number(key); - if (!Number.isNaN(slot) && slot >= 1 && slot <= constructibleDefinitions.length) { - this.selectedConstructible = slot - 1; - this.updateHud(); - } - }; - - private onKeyUp = (event: KeyboardEvent) => { - this.keyState.delete(event.key.toLowerCase()); - }; - - private onPointerDown = (event: PointerEvent) => { - if (event.button === 1) { - event.preventDefault(); - this.followShipId = undefined; - this.cameraDragMode = event.shiftKey ? "pan" : "orbit"; - this.cameraDragPointerId = event.pointerId; - this.cameraDragLast = new THREE.Vector2(event.clientX, event.clientY); - this.renderer.domElement.setPointerCapture(event.pointerId); - return; - } - if (event.button !== 0) { - return; - } - this.marqueeStart = new THREE.Vector2(event.clientX, event.clientY); - this.marqueeModifiers = { shift: event.shiftKey, ctrl: event.ctrlKey || event.metaKey }; - this.marqueeActive = false; - this.suppressClickSelection = false; - this.updateMarqueeBox(event.clientX, event.clientY); - }; - - private onPointerMove = (event: PointerEvent) => { - if (this.cameraDragMode && this.cameraDragPointerId === event.pointerId && this.cameraDragLast) { - const dx = event.clientX - this.cameraDragLast.x; - const dy = this.cameraDragLast.y - event.clientY; - if (this.cameraDragMode === "orbit") { - this.orbitCamera(dx, dy); - } else { - this.panCamera(dx, dy); - } - this.cameraDragLast.set(event.clientX, event.clientY); - return; - } - if (!this.marqueeStart) { - return; - } - const dx = event.clientX - this.marqueeStart.x; - const dy = event.clientY - this.marqueeStart.y; - if (!this.marqueeActive && Math.hypot(dx, dy) > 8) { - this.marqueeActive = true; - this.suppressClickSelection = true; - } - if (this.marqueeActive) { - this.updateMarqueeBox(event.clientX, event.clientY); - } - }; - - private onPointerUp = (event: PointerEvent) => { - if (this.cameraDragMode && this.cameraDragPointerId === event.pointerId) { - this.cameraDragMode = undefined; - this.cameraDragPointerId = undefined; - this.cameraDragLast = undefined; - if (this.renderer.domElement.hasPointerCapture(event.pointerId)) { - this.renderer.domElement.releasePointerCapture(event.pointerId); - } - return; - } - if (!this.marqueeStart) { - return; - } - if (this.marqueeActive) { - this.applyMarqueeSelection(event.clientX, event.clientY); - } - this.marqueeStart = undefined; - this.marqueeActive = false; - this.hideMarqueeBox(); - }; - - private onClick = (event: MouseEvent) => { - if (this.suppressClickSelection) { - this.suppressClickSelection = false; - return; - } - this.updateMouse(event.clientX, event.clientY); - this.raycaster.setFromCamera(this.mouse, this.camera); - const hits = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false); - - const additive = event.shiftKey; - const toggle = event.ctrlKey || event.metaKey; - if (!additive && !toggle) { - this.clearSelection(); - } - - if (hits.length > 0) { - const target = this.selectableTargets.get(hits[0].object); - if (target?.kind === "ship") { - if (toggle) { - this.selectionManager.toggleShip(target.ship); - } else if (!this.selectionManager.hasShip(target.ship)) { - this.selectionManager.addShip(target.ship); - } - } - if (target?.kind === "station") { - this.selectionManager.setStation(target.station); - } - if (target?.kind === "system") { - this.selectionManager.setSystem(target.system); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id); - } - if (target?.kind === "planet") { - this.selectionManager.setPlanet(target.planet); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id); - } - if (target?.kind === "node") { - this.selectionManager.setNode(target.node); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.node.systemId); - } - } - - this.updateHud(); - }; - - private onDoubleClick = (event: MouseEvent) => { - this.updateMouse(event.clientX, event.clientY); - this.raycaster.setFromCamera(this.mouse, this.camera); - const hits = this.raycaster.intersectObjects([...this.selectableTargets.keys()], false); - const target = hits.length > 0 ? this.selectableTargets.get(hits[0].object) : undefined; - if (!target) { - return; - } - - this.followShipId = undefined; - if (target.kind === "ship") { - this.followShipId = target.ship.id; - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.ship.systemId); - this.focusPoint(target.ship.group.position, 520); - } else if (target.kind === "station") { - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.station.systemId); - this.focusPoint(target.station.group.position, 640); - } else if (target.kind === "system") { - this.focusSystem(target.system.definition.id); - return; - } else if (target.kind === "planet") { - const worldPosition = target.planet.mesh.getWorldPosition(new THREE.Vector3()); - this.focusPoint(worldPosition, 760); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.system.definition.id); - } else if (target.kind === "node") { - this.focusPoint(target.node.position, 680); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === target.node.systemId); - } - this.updateHud(); - }; - - private onContextMenu = (event: MouseEvent) => { - event.preventDefault(); - }; - - private onWheel = (event: WheelEvent) => { - event.preventDefault(); - this.adjustZoom(1 + event.deltaY * 0.0012); - }; - - private orbitCamera(deltaX: number, deltaY: number) { - const focus = this.getCameraFocus(); - const offset = this.camera.position.clone().sub(focus); - const spherical = new THREE.Spherical().setFromVector3(offset); - spherical.theta -= deltaX * 0.005; - spherical.phi = THREE.MathUtils.clamp(spherical.phi + deltaY * 0.005, 0.15, Math.PI - 0.15); - const nextOffset = new THREE.Vector3().setFromSpherical(spherical); - this.camera.position.copy(focus).add(nextOffset); - this.camera.lookAt(focus); - this.applyViewLevel(); - } - - private panCamera(deltaX: number, deltaY: number) { - const focus = this.getCameraFocus(); - const offset = this.camera.position.clone().sub(focus); - const distance = offset.length(); - const scale = Math.max(0.2, distance * 0.0014); - const forward = new THREE.Vector3(); - this.camera.getWorldDirection(forward); - forward.y = 0; - if (forward.lengthSq() === 0) { - forward.set(0, 0, -1); - } - forward.normalize(); - const right = new THREE.Vector3().crossVectors(forward, new THREE.Vector3(0, 1, 0)).normalize(); - const translation = right.multiplyScalar(-deltaX * scale).add(forward.multiplyScalar(deltaY * scale)); - focus.add(translation); - this.camera.position.add(translation); - this.camera.lookAt(focus); - } - - private initializeFactions() { - this.factions.length = 0; - this.factionsById.clear(); - (this.universe.scenario.factions ?? []).forEach((definition) => { - const faction: FactionInstance = { - definition, - credits: definition.kind === "empire" ? 1800 : 900, - oreMined: 0, - goodsProduced: 0, - shipsBuilt: 0, - stationsBuilt: 0, - shipsLost: 0, - enemyShipsDestroyed: 0, - raidsCompleted: 0, - stolenCargo: 0, - ownedSystemIds: new Set([definition.homeSystemId, ...(definition.miningSystemId ? [definition.miningSystemId] : [])]), - shipBuildTimer: 10, - stationBuildTimer: 30, - commandTick: 0.5, - }; - this.factions.push(faction); - this.factionsById.set(definition.id, faction); - }); - - this.systems.forEach((system) => { - system.controlProgress = 0; - if (this.universe.scenario.centralSystemIds?.includes(system.definition.id)) { - system.strategicValue = "central"; - return; - } - const homeOwner = this.factions.find((faction) => faction.definition.homeSystemId === system.definition.id); - if (homeOwner) { - system.strategicValue = "core"; - system.controllingFactionId = homeOwner.definition.id; - return; - } - const miningOwner = this.factions.find((faction) => faction.definition.miningSystemId === system.definition.id); - if (miningOwner) { - system.strategicValue = "resource"; - system.controllingFactionId = miningOwner.definition.id; - return; - } - system.strategicValue = "frontier"; - }); - } - - private makePatrolPoints(systemId: string) { - const orbitalDestinations = this.getSystemOrbitalDestinations(systemId); - const route = this.universe.scenario.patrolRoutes.find((candidate) => candidate.systemId === systemId); - if (!route) { - return orbitalDestinations.slice(0, 4); - } - return route.points.map((point, index) => - this.resolveTravelDestination( - new THREE.Vector3(...point).setY(gameBalance.yPlane), - systemId, - `${this.getSystem(systemId).definition.label} Patrol ${index + 1}`, - orbitalDestinations, - ), - ); - } - - private cloneTravelDestination(destination: TravelDestination): TravelDestination { - return { - ...destination, - position: destination.position.clone(), - orbitalAnchor: destination.orbitalAnchor.clone(), - }; - } - - private sameTravelDestination(left: TravelDestination, right: TravelDestination) { - return ( - left.kind === right.kind && - left.id === right.id && - left.systemId === right.systemId && - left.label === right.label - ); - } - - private sameTravelDestinationList(left: TravelDestination[], right: TravelDestination[]) { - return left.length === right.length && left.every((destination, index) => this.sameTravelDestination(destination, right[index])); - } - - private sameShipOrder(ship: ShipInstance, nextOrder: ShipInstance["order"]) { - const currentOrder = ship.order; - if (!currentOrder || !nextOrder) { - return currentOrder === nextOrder; - } - if (currentOrder.kind !== nextOrder.kind) { - return false; - } - if (nextOrder.kind === "move-to" && currentOrder.kind === "move-to") { - return this.sameTravelDestination(currentOrder.destination, nextOrder.destination); - } - if (nextOrder.kind === "mine-this" && currentOrder.kind === "mine-this") { - return currentOrder.nodeId === nextOrder.nodeId && currentOrder.refineryId === nextOrder.refineryId; - } - if (nextOrder.kind === "dock-at" && currentOrder.kind === "dock-at") { - return currentOrder.hostKind === nextOrder.hostKind && currentOrder.hostId === nextOrder.hostId; - } - return false; - } - - private setShipOrder(ship: ShipInstance, nextOrder: ShipInstance["order"]) { - if (this.sameShipOrder(ship, nextOrder)) { - return; - } - ship.order = nextOrder; - } - - private sameDefaultBehavior(ship: ShipInstance, nextBehavior: ShipInstance["defaultBehavior"]) { - const currentBehavior = ship.defaultBehavior; - if (currentBehavior.kind !== nextBehavior.kind) { - return false; - } - if (nextBehavior.kind === "idle") { - return true; - } - if (nextBehavior.kind === "auto-mine" && currentBehavior.kind === "auto-mine") { - return ( - currentBehavior.areaSystemId === nextBehavior.areaSystemId && - currentBehavior.refineryId === nextBehavior.refineryId && - currentBehavior.nodeId === nextBehavior.nodeId && - currentBehavior.phase === nextBehavior.phase - ); - } - if (nextBehavior.kind === "patrol" && currentBehavior.kind === "patrol") { - return ( - currentBehavior.systemId === nextBehavior.systemId && - currentBehavior.index === nextBehavior.index && - this.sameTravelDestinationList(currentBehavior.points, nextBehavior.points) - ); - } - if (nextBehavior.kind === "escort-assigned" && currentBehavior.kind === "escort-assigned") { - return currentBehavior.offset.distanceToSquared(nextBehavior.offset) < 1; - } - return false; - } - - private setDefaultBehavior(ship: ShipInstance, nextBehavior: ShipInstance["defaultBehavior"]) { - if (this.sameDefaultBehavior(ship, nextBehavior)) { - return; - } - ship.defaultBehavior = nextBehavior; - } - - private sameAssignment(ship: ShipInstance, nextAssignment: ShipInstance["assignment"]) { - const currentAssignment = ship.assignment; - if (currentAssignment.kind !== nextAssignment.kind) { - return false; - } - if (nextAssignment.kind === "unassigned") { - return true; - } - if (nextAssignment.kind === "commander-subordinate" && currentAssignment.kind === "commander-subordinate") { - return currentAssignment.commanderId === nextAssignment.commanderId && currentAssignment.role === nextAssignment.role; - } - if (nextAssignment.kind === "station-based" && currentAssignment.kind === "station-based") { - return currentAssignment.stationId === nextAssignment.stationId && currentAssignment.role === nextAssignment.role; - } - if (nextAssignment.kind === "mining-group" && currentAssignment.kind === "mining-group") { - return currentAssignment.controllerId === nextAssignment.controllerId; - } - return false; - } - - private setAssignment(ship: ShipInstance, nextAssignment: ShipInstance["assignment"]) { - if (this.sameAssignment(ship, nextAssignment)) { - return; - } - ship.assignment = nextAssignment; - } - - private sameControllerTask(ship: ShipInstance, nextTask: ShipInstance["controllerTask"]) { - const currentTask = ship.controllerTask; - if (currentTask.kind !== nextTask.kind) { - return false; - } - if (nextTask.kind === "idle") { - return true; - } - if (nextTask.kind === "travel" && currentTask.kind === "travel") { - return ( - this.sameTravelDestination(currentTask.destination, nextTask.destination) && - currentTask.threshold === nextTask.threshold - ); - } - if (nextTask.kind === "dock" && currentTask.kind === "dock") { - return ( - currentTask.hostKind === nextTask.hostKind && - currentTask.hostId === nextTask.hostId && - currentTask.portIndex === nextTask.portIndex - ); - } - if (nextTask.kind === "extract" && currentTask.kind === "extract") { - return currentTask.nodeId === nextTask.nodeId; - } - if (nextTask.kind === "unload" && currentTask.kind === "unload") { - return currentTask.stationId === nextTask.stationId; - } - if (nextTask.kind === "follow" && currentTask.kind === "follow") { - return currentTask.targetShipId === nextTask.targetShipId && currentTask.threshold === nextTask.threshold && currentTask.offset.distanceToSquared(nextTask.offset) < 1; - } - if (nextTask.kind === "undock" && currentTask.kind === "undock") { - return currentTask.hostKind === nextTask.hostKind && currentTask.hostId === nextTask.hostId; - } - return false; - } - - private setControllerTask(ship: ShipInstance, nextTask: ShipInstance["controllerTask"]) { - if (this.sameControllerTask(ship, nextTask)) { - return; - } - ship.controllerTask = nextTask; - const preserveLandedAnchor = - (nextTask.kind === "dock" && - ((nextTask.hostKind === "station" && - ship.landedDestination?.kind === "station" && - ship.landedDestination.id === nextTask.hostId) || - (nextTask.hostKind === "ship" && - ship.landedDestination?.kind === "ship" && - ship.landedDestination.id === nextTask.hostId))) || - (nextTask.kind === "extract" && - ship.landedDestination?.kind === "resource-node" && - ship.landedDestination.id === nextTask.nodeId) || - (nextTask.kind === "unload" && - ship.landedDestination?.kind === "station" && - ship.landedDestination.id === nextTask.stationId); - if (nextTask.kind !== "idle" && !preserveLandedAnchor) { - this.clearShipLandedDestination(ship); - } - } - - private clearShipLandedDestination(ship: ShipInstance) { - ship.landedDestination = undefined; - ship.landedOffset.set(0, 0, 0); - } - - private setShipLandedDestination(ship: ShipInstance, destination: TravelDestination) { - ship.landedDestination = this.cloneTravelDestination(destination); - const anchorPosition = this.resolveTravelDestinationPosition(destination); - ship.landedOffset.copy(ship.group.position.clone().setY(gameBalance.yPlane).sub(anchorPosition)); - } - - private resolveTravelDestinationPosition(destination: TravelDestination) { - if (destination.kind === "planet") { - const system = this.getSystem(destination.systemId); - const planet = system.planets.find((candidate) => `${system.definition.id}-planet-${candidate.index}` === destination.id); - if (planet) { - return planet.mesh.getWorldPosition(new THREE.Vector3()).setY(gameBalance.yPlane); - } - } - if (destination.kind === "station" && destination.id) { - const station = this.stations.find((candidate) => candidate.id === destination.id); - if (station) { - return station.group.position.clone().setY(gameBalance.yPlane); - } - } - if (destination.kind === "resource-node" && destination.id) { - const node = this.nodes.find((candidate) => candidate.id === destination.id); - if (node) { - return node.mesh.getWorldPosition(new THREE.Vector3()).setY(gameBalance.yPlane); - } - } - if (destination.kind === "ship" && destination.id) { - const ship = this.shipsById.get(destination.id); - if (ship) { - return ship.group.position.clone().setY(gameBalance.yPlane); - } - } - return destination.position.clone().setY(gameBalance.yPlane); - } - - private hydrateTravelDestination(destination: TravelDestination) { - const hydrated = this.cloneTravelDestination(destination); - const livePosition = this.resolveTravelDestinationPosition(destination); - hydrated.position.copy(livePosition); - hydrated.orbitalAnchor.copy(livePosition); - return hydrated; - } - - private updateAnchoredShipPosition(ship: ShipInstance) { - if (!ship.landedDestination) { - return false; - } - const anchor = this.resolveTravelDestinationPosition(ship.landedDestination); - const nextPosition = anchor.add(ship.landedOffset).setY(gameBalance.yPlane); - ship.group.position.lerp(nextPosition, 0.2); - if (ship.landedOffset.lengthSq() > 1) { - ship.group.lookAt(ship.group.position.clone().add(ship.landedOffset)); - } - return true; - } - - private formatHistoryTime() { - return this.clock.elapsedTime.toFixed(1).padStart(6, "0"); - } - - private recordShipHistoryEvent(ship: ShipInstance, message: string) { - this.recordShipHistoryEvents(ship, [message]); - } - - private recordShipHistoryEvents(ship: ShipInstance, messages: string[]) { - if (messages.length === 0) { - return; - } - const entries = this.shipHistoryById.get(ship.id) ?? []; - const stampedMessages = messages.map((message) => `${this.formatHistoryTime()} ${message}`); - this.shipHistoryById.set(ship.id, [...stampedMessages, ...entries].slice(0, 16)); - } - - private formatControllerEvent(event: ControllerEvent) { - if (event.kind === "none") { - return ""; - } - if (event.kind === "arrived") { - return ``; - } - if (event.kind === "docking-begin") { - return ``; - } - if (event.kind === "docked") { - return ""; - } - if (event.kind === "unloaded") { - return ""; - } - return ""; - } - - private getControllerTaskDestination(ship: ShipInstance) { - const task = ship.controllerTask; - if (task.kind === "travel") { - return this.hydrateTravelDestination(task.destination); - } - if (task.kind === "dock" || task.kind === "undock") { - const host = task.hostKind === "ship" - ? this.shipsById.get(task.hostId) - : this.stations.find((candidate) => candidate.id === task.hostId); - if (!host) { - return undefined; - } - return task.hostKind === "ship" - ? this.createShipTravelDestination(host as ShipInstance) - : this.createStationTravelDestination(host as StationInstance); - } - if (task.kind === "extract") { - const node = this.nodes.find((candidate) => candidate.id === task.nodeId); - return node ? this.createNodeTravelDestination(node) : undefined; - } - if (task.kind === "unload") { - const station = this.stations.find((candidate) => candidate.id === task.stationId); - return station ? this.createStationTravelDestination(station) : undefined; - } - if (task.kind === "follow") { - const targetShip = this.shipsById.get(task.targetShipId); - return targetShip ? this.createShipTravelDestination(targetShip) : undefined; - } - return undefined; - } - - private getDockingReservationLabel(ship: ShipInstance) { - if (ship.dockingPortIndex === undefined) { - return "none"; - } - const host = this.getDockingHostForShip(ship); - if (!host) { - return `port-${ship.dockingPortIndex}`; - } - const hostLabel = this.isCarrierHost(host) ? `${host.definition.label} ${host.id}` : `${host.definition.label} ${host.id}`; - return `${hostLabel} bay-${ship.dockingPortIndex}`; - } - - private getDockingWaitLabel(ship: ShipInstance) { - return ship.dockingClearanceStatus ?? "none"; - } - - private getCargoStateLabel(ship: ShipInstance) { - const cargo = getShipCargoAmount(ship); - if (cargo <= 0) { - return "empty"; - } - if (cargo >= ship.definition.cargoCapacity) { - return "full"; - } - return "partial"; - } - - private getOrderLabel(ship: ShipInstance) { - if (!ship.order) { - return "none"; - } - if (ship.order.kind === "move-to") { - return `move-to:${ship.order.destination.label}:${ship.order.status}`; - } - if (ship.order.kind === "mine-this") { - return `mine-this:${ship.order.nodeId}:${ship.order.status}`; - } - return `dock-at:${ship.order.hostKind}:${ship.order.hostId}:${ship.order.status}`; - } - - private getOrderPhaseLabel(ship: ShipInstance) { - if (!ship.order || ship.order.kind !== "mine-this") { - return "n/a"; - } - return ship.order.phase; - } - - private getDefaultBehaviorLabel(ship: ShipInstance) { - const behavior = ship.defaultBehavior; - if (behavior.kind === "auto-mine") { - return `auto-mine:${behavior.areaSystemId}:${behavior.refineryId}`; - } - if (behavior.kind === "patrol") { - return `patrol:${behavior.systemId}`; - } - if (behavior.kind === "escort-assigned") { - return "escort-assigned"; - } - return "idle"; - } - - private getAssignmentLabel(ship: ShipInstance) { - const assignment = ship.assignment; - if (assignment.kind === "commander-subordinate") { - return `commander:${assignment.commanderId}:${assignment.role}`; - } - if (assignment.kind === "station-based") { - return `station:${assignment.stationId}:${assignment.role}`; - } - if (assignment.kind === "mining-group") { - return `mining-group:${assignment.controllerId}`; - } - return "unassigned"; - } - - private getBehaviorPhaseLabel(ship: ShipInstance) { - const behavior = ship.defaultBehavior; - if (behavior.kind === "auto-mine") { - return behavior.phase; - } - if (behavior.kind === "patrol") { - return `point-${behavior.index + 1}`; - } - return "n/a"; - } - - private formatStateLine(ship: ShipInstance) { - return `state=${this.getOrderLabel(ship)}/${this.getBehaviorPhaseLabel(ship)} [${ship.controllerTask.kind}]/(${ship.state})`; - } - - private trackShipHistory(ship: ShipInstance) { - const controllerDestination = this.getControllerTaskDestination(ship); - const destination = controllerDestination ? `${controllerDestination.label} @ ${controllerDestination.systemId}` : "none"; - const landed = ship.landedDestination ? `${ship.landedDestination.label} @ ${ship.landedDestination.systemId}` : "free"; - const reservation = this.getDockingReservationLabel(ship); - const dockingWait = this.getDockingWaitLabel(ship); - const cargoState = this.getCargoStateLabel(ship); - const order = this.getOrderLabel(ship); - const orderPhase = this.getOrderPhaseLabel(ship); - const behavior = this.getDefaultBehaviorLabel(ship); - const behaviorPhase = this.getBehaviorPhaseLabel(ship); - const assignment = this.getAssignmentLabel(ship); - const snapshot = `state:${ship.state}|order:${order}|orderPhase:${orderPhase}|behavior:${behavior}|behaviorPhase:${behaviorPhase}|assignment:${assignment}|controller:${ship.controllerTask.kind}|destination:${destination}|system:${ship.systemId}|landed:${landed}|reservation:${reservation}|dockwait:${dockingWait}|cargo:${cargoState}`; - const previous = this.shipHistorySnapshotById.get(ship.id); - if (!previous) { - this.shipHistorySnapshotById.set(ship.id, snapshot); - return [ - ``, - ]; - } - if (previous === snapshot) { - return []; - } - - const previousParts = new Map(previous.split("|").map((part) => { - const [key, value] = part.split(":"); - return [key, value]; - })); - const nextParts = new Map(snapshot.split("|").map((part) => { - const [key, value] = part.split(":"); - return [key, value]; - })); - const changes = [...nextParts.entries()] - .filter(([key, value]) => previousParts.get(key) !== value) - .map(([key, value]) => { - const before = previousParts.get(key) ?? "none"; - if (key === "state") { - return this.formatStateLine(ship); - } - if (key === "controller") { - return `[${value}]`; - } - if (key === "order") { - return ` ${value}>`; - } - if (key === "orderPhase") { - return ` ${value}>`; - } - if (key === "behavior") { - return ` ${value}>`; - } - if (key === "behaviorPhase") { - return ` ${value}>`; - } - if (key === "assignment") { - return ` ${value}>`; - } - if (key === "destination") { - return ` ${value}>`; - } - if (key === "landed") { - return ` ${value}>`; - } - if (key === "system") { - return ` ${value}>`; - } - if (key === "reservation") { - return ` ${value}>`; - } - if (key === "dockwait") { - return ` ${value}>`; - } - if (key === "cargo") { - if (value === "full") { - return ""; - } - if (value === "empty") { - return ""; - } - return ""; - } - return `<${key} ${before} -> ${value}>`; - }) - .filter((change) => change.length > 0); - - this.shipHistorySnapshotById.set(ship.id, snapshot); - return changes; - } - - private getDebugShip() { - const selectedShip = this.selection[0]; - if (selectedShip) { - this.debugHistoryPinnedShipId = selectedShip.id; - return selectedShip; - } - if (this.debugHistoryPinnedShipId) { - return this.shipsById.get(this.debugHistoryPinnedShipId); - } - return undefined; - } - - private copyDebugHistory() { - const ship = this.getDebugShip(); - if (!ship) { - return; - } - const entries = this.shipHistoryById.get(ship.id) ?? []; - const controllerDestination = this.getControllerTaskDestination(ship); - const destination = controllerDestination ? `${controllerDestination.label} @ ${controllerDestination.systemId}` : "none"; - const anchor = ship.landedDestination ? `${ship.landedDestination.label} @ ${ship.landedDestination.systemId}` : "free"; - const payload = [ - `${ship.definition.label} • ${ship.id}`, - `Order: ${this.getOrderLabel(ship)}`, - `Order phase: ${this.getOrderPhaseLabel(ship)}`, - `Default behavior: ${this.getDefaultBehaviorLabel(ship)}`, - `Behavior phase: ${this.getBehaviorPhaseLabel(ship)}`, - `Assignment: ${this.getAssignmentLabel(ship)}`, - `Controller task: ${ship.controllerTask.kind}`, - `Flight state: ${ship.state}`, - `Task target: ${destination}`, - `Anchor: ${anchor}`, - "", - ...entries, - ].join("\n"); - void this.writeTextToClipboard(payload); - } - - private async writeTextToClipboard(text: string) { - if (navigator.clipboard?.writeText) { - try { - await navigator.clipboard.writeText(text); - return; - } catch { - // Fall back to a direct selection-based copy for browsers that reject clipboard API calls. - } - } - - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.setAttribute("readonly", "true"); - textarea.style.position = "fixed"; - textarea.style.opacity = "0"; - textarea.style.pointerEvents = "none"; - document.body.append(textarea); - textarea.select(); - textarea.setSelectionRange(0, textarea.value.length); - document.execCommand("copy"); - textarea.remove(); - } - - private createOrbitTravelDestination(systemId: string, label: string, position: THREE.Vector3, id?: string): TravelDestination { - const normalizedPosition = position.clone().setY(gameBalance.yPlane); - return { - kind: "orbit", - id, - systemId, - label, - position: normalizedPosition, - orbitalAnchor: normalizedPosition.clone(), - }; - } - - private createSystemTravelDestination(systemId: string): TravelDestination { - const system = this.getSystem(systemId); - const radius = Math.max(system.gravityWellRadius + 120, system.definition.starSize * 6); - return this.createOrbitTravelDestination( - systemId, - `${system.definition.label} Primary Orbit`, - system.center.clone().add(new THREE.Vector3(radius, 0, 0)), - system.definition.id, - ); - } - - private createPlanetTravelDestination(system: SolarSystemInstance, planetIndex: number): TravelDestination { - const planet = system.planets[planetIndex]; - const position = planet.mesh.getWorldPosition(new THREE.Vector3()).setY(gameBalance.yPlane); - return { - kind: "planet", - id: `${system.definition.id}-planet-${planet.index}`, - systemId: system.definition.id, - label: planet.definition.label, - position: position.clone(), - orbitalAnchor: position, - }; - } - - private createStationTravelDestination(station: StationInstance): TravelDestination { - const position = station.group.position.clone().setY(gameBalance.yPlane); - return { - kind: "station", - id: station.id, - systemId: station.systemId, - label: station.definition.label, - position: position.clone(), - orbitalAnchor: position, - }; - } - - private createNodeTravelDestination(node: ResourceNode): TravelDestination { - const position = node.position.clone().setY(gameBalance.yPlane); - return { - kind: "resource-node", - id: node.id, - systemId: node.systemId, - label: `Asteroid Field ${node.id}`, - position: position.clone(), - orbitalAnchor: position, - }; - } - - private createShipTravelDestination(ship: ShipInstance): TravelDestination { - const position = ship.group.position.clone().setY(gameBalance.yPlane); - return { - kind: "ship", - id: ship.id, - systemId: ship.systemId, - label: ship.definition.label, - position: position.clone(), - orbitalAnchor: position, - }; - } - - private getSystemOrbitalDestinations(systemId: string) { - const system = this.getSystem(systemId); - const stationDestinations = this.stations - .filter((station) => station.systemId === systemId) - .map((station) => this.createStationTravelDestination(station)); - const planetDestinations = system.planets.map((_, index) => this.createPlanetTravelDestination(system, index)); - const destinations = [...stationDestinations, ...planetDestinations]; - if (destinations.length === 0) { - destinations.push(this.createSystemTravelDestination(systemId)); - } - return destinations; - } - - private resolveTravelDestination( - position: THREE.Vector3, - systemId = this.findNearestSystem(position).definition.id, - fallbackLabel?: string, - candidates = this.getSystemOrbitalDestinations(systemId), - ) { - const normalizedPosition = position.clone().setY(gameBalance.yPlane); - const nearestCandidate = [...candidates].sort( - (left, right) => - normalizedPosition.distanceToSquared(left.position) - normalizedPosition.distanceToSquared(right.position), - )[0]; - if (nearestCandidate && normalizedPosition.distanceTo(nearestCandidate.position) < 240) { - return nearestCandidate; - } - const system = this.getSystem(systemId); - return this.createOrbitTravelDestination( - systemId, - fallbackLabel ?? `${system.definition.label} Orbit`, - normalizedPosition, - `${system.definition.id}-orbit-${Math.round(normalizedPosition.x)}-${Math.round(normalizedPosition.z)}`, - ); - } - - private getFactionShips(factionId: string) { - return this.ships.filter((ship) => ship.factionId === factionId); - } - - private getFactionMilitaryShips(factionId: string) { - return this.getFactionShips(factionId).filter((ship) => ship.definition.role === "military"); - } - - private getFactionIndustryShips(factionId: string) { - return this.getFactionShips(factionId).filter((ship) => ship.definition.role !== "military"); - } - - private makeEscortOffset(index: number, spacing = 26) { - if (index === 0) { - return new THREE.Vector3(0, 0, 22); - } - const side = index % 2 === 0 ? 1 : -1; - const rank = Math.ceil(index / 2); - return new THREE.Vector3(side * rank * spacing, 0, 22 + rank * 10); - } - - private updateFactionSimulation(delta: number) { - this.updateSystemControl(delta); - - this.factions.forEach((faction) => { - faction.commandTick -= delta; - faction.shipBuildTimer -= delta; - faction.stationBuildTimer -= delta; - - if (faction.commandTick <= 0) { - faction.commandTick = faction.definition.kind === "empire" ? 2.5 : 3.2; - if (faction.definition.kind === "empire") { - this.commandEmpireFaction(faction); - } else { - this.commandPirateFaction(faction); - } - } - - if (faction.shipBuildTimer <= 0) { - this.tryBuildShipForFaction(faction); - faction.shipBuildTimer = faction.definition.kind === "empire" ? 18 : 22; - } - - if (faction.stationBuildTimer <= 0) { - this.tryBuildOutpostForFaction(faction); - faction.stationBuildTimer = 45; - } - }); - } - - private commandEmpireFaction(faction: FactionInstance) { - const miningSystems = [ - faction.definition.miningSystemId, - ...this.systems - .filter((system) => system.strategicValue === "central" && system.controllingFactionId === faction.definition.id) - .map((system) => system.definition.id), - ].filter((systemId): systemId is string => Boolean(systemId)); - const threatenedSystemId = this.findThreatenedSystem(faction.definition.id); - const centralTarget = this.pickCentralTargetSystem(faction); - const militaryTargetSystemId = threatenedSystemId ?? centralTarget ?? faction.definition.homeSystemId; - const miningSystemId = miningSystems[0] ?? faction.definition.miningSystemId ?? faction.definition.homeSystemId; - const refinery = this.findRefinery(faction.definition.miningSystemId ?? faction.definition.homeSystemId, faction.definition.id); - const miners = this.getFactionIndustryShips(faction.definition.id).filter((ship) => ship.definition.role === "mining"); - const transports = this.getFactionIndustryShips(faction.definition.id).filter((ship) => ship.definition.role === "transport"); - miners.forEach((ship) => this.assignMineOrder(ship, this.findBestMiningNode(miningSystemId), refinery)); - transports.forEach((ship, index) => { - const anchor = miners[index % Math.max(1, miners.length)]; - if (anchor) { - this.setEscortOrder(ship, anchor, this.makeEscortOffset(index)); - } else { - this.setPatrolOrder(ship, this.makePatrolPoints(faction.definition.homeSystemId), index, faction.definition.homeSystemId); - } - }); - - const targetSystem = this.getSystem(militaryTargetSystemId); - const rally = targetSystem.center.clone().add(new THREE.Vector3(-140, 0, 120)); - this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => { - if (ship.systemId !== militaryTargetSystemId) { - this.issueMoveOrder( - ship, - this.resolveTravelDestination( - rally.clone().add(this.makeEscortOffset(index, 18)), - militaryTargetSystemId, - `${targetSystem.definition.label} Rally`, - ), - ); - return; - } - this.setPatrolOrder(ship, this.makePatrolPoints(militaryTargetSystemId), index, militaryTargetSystemId); - }); - } - - private commandPirateFaction(faction: FactionInstance) { - const targetSystemId = faction.definition.targetSystemIds[0] ?? faction.definition.homeSystemId; - const targetSystem = this.getSystem(targetSystemId); - const raidPoint = targetSystem.center.clone().add(new THREE.Vector3(120, 0, 160)); - this.getFactionMilitaryShips(faction.definition.id).forEach((ship, index) => { - if (ship.systemId !== targetSystemId) { - this.issueMoveOrder( - ship, - this.resolveTravelDestination( - raidPoint.clone().add(this.makeEscortOffset(index, 20)), - targetSystemId, - `${targetSystem.definition.label} Raid Orbit`, - ), - ); - return; - } - this.setPatrolOrder(ship, this.makePatrolPoints(targetSystemId), index, targetSystemId); - }); - this.getFactionIndustryShips(faction.definition.id).forEach((ship, index) => { - this.setPatrolOrder(ship, this.makePatrolPoints(faction.definition.homeSystemId), index, faction.definition.homeSystemId); - }); - } - - private updateSystemControl(delta: number) { - const empireIds = new Set(this.factions.filter((faction) => faction.definition.kind === "empire").map((faction) => faction.definition.id)); - this.factions.forEach((faction) => { - faction.ownedSystemIds = new Set([faction.definition.homeSystemId, ...(faction.definition.miningSystemId ? [faction.definition.miningSystemId] : [])]); - }); - - this.systems - .filter((system) => system.strategicValue === "central") - .forEach((system) => { - const powerByFaction = new Map(); - this.ships - .filter((ship) => ship.systemId === system.definition.id && ship.definition.role === "military" && empireIds.has(ship.factionId)) - .forEach((ship) => { - const power = ship.definition.shipClass === "capital" ? 8 : ship.definition.shipClass === "cruiser" ? 4 : ship.definition.shipClass === "destroyer" ? 2 : 1; - powerByFaction.set(ship.factionId, (powerByFaction.get(ship.factionId) ?? 0) + power); - }); - this.stations - .filter((station) => station.systemId === system.definition.id && station.definition.category === "defense" && empireIds.has(station.factionId)) - .forEach((station) => { - powerByFaction.set(station.factionId, (powerByFaction.get(station.factionId) ?? 0) + 3); - }); - - const sorted = [...powerByFaction.entries()].sort((left, right) => right[1] - left[1]); - const leader = sorted[0]; - const runnerUp = sorted[1]; - if (!leader || leader[1] <= (runnerUp?.[1] ?? 0)) { - system.controlProgress = Math.max(0, system.controlProgress - delta * 2); - } else if (system.controllingFactionId === leader[0]) { - system.controlProgress = Math.min(100, system.controlProgress + delta * leader[1] * 0.8); - } else { - system.controlProgress -= delta * ((runnerUp?.[1] ?? 0) + 2); - if (system.controlProgress <= 0) { - system.controllingFactionId = leader[0]; - system.controlProgress = 10; - } - } - - if (system.controllingFactionId) { - this.factionsById.get(system.controllingFactionId)?.ownedSystemIds.add(system.definition.id); - } - }); - } - - private updateCombat(delta: number) { - this.ships.forEach((ship) => { - ship.weaponTimer = Math.max(0, ship.weaponTimer - delta); - if (ship.state === "docked" || ship.weaponRange <= 0 || ship.weaponTimer > 0) { - return; - } - const target = this.findCombatTarget(ship); - if (!target) { - return; - } - target.health -= ship.weaponDamage; - ship.weaponTimer = ship.weaponCooldown; - if (target.health <= 0) { - this.destroyShip(target, ship.factionId); - } - }); - - this.stations.forEach((station) => { - station.weaponTimer = Math.max(0, station.weaponTimer - delta); - if (station.weaponRange <= 0 || station.weaponTimer > 0) { - return; - } - const target = this.ships - .filter((ship) => ship.systemId === station.systemId && ship.factionId !== station.factionId && ship.state !== "docked") - .sort((left, right) => station.group.position.distanceTo(left.group.position) - station.group.position.distanceTo(right.group.position))[0]; - if (!target || station.group.position.distanceTo(target.group.position) > station.weaponRange) { - return; - } - target.health -= station.weaponDamage; - station.weaponTimer = 1.1; - if (target.health <= 0) { - this.destroyShip(target, station.factionId); - } - }); - - this.updatePirateRaids(delta); - } - - private updatePirateRaids(_delta: number) { - this.ships - .filter((ship) => this.factionsById.get(ship.factionId)?.definition.kind === "pirate") - .forEach((pirate) => { - const victim = this.ships - .filter( - (ship) => - ship.systemId === pirate.systemId && - ship.factionId !== pirate.factionId && - ship.definition.role !== "military" && - getShipCargoAmount(ship) > 0, - ) - .sort((left, right) => pirate.group.position.distanceTo(left.group.position) - pirate.group.position.distanceTo(right.group.position))[0]; - if (!victim || pirate.group.position.distanceTo(victim.group.position) > 60) { - return; - } - const stolen = removeShipCargo(victim, Math.min(12, getShipCargoAmount(victim))); - if (stolen <= 0) { - return; - } - const pirateFaction = this.factionsById.get(pirate.factionId); - if (pirateFaction) { - pirateFaction.raidsCompleted += 1; - pirateFaction.stolenCargo += stolen; - pirateFaction.credits += stolen * 2; - } - }); - } - - private tick() { - const delta = Math.min(this.clock.getDelta(), 0.033); - const elapsed = this.clock.elapsedTime; - - this.updateCamera(delta); - this.updateFactionSimulation(delta); - this.updateShips(delta, elapsed); - this.updateCombat(delta); - this.updateSystems(delta); - this.applyViewLevel(); - if (this.selection.length > 0 || this.selectedStation || this.selectedSystem || this.selectedPlanet || this.selectedNode || this.followShipId) { - this.updateHud(); - } - this.renderHudCanvases(); - - this.scene.rotation.y = Math.sin(elapsed * 0.02) * 0.008; - this.renderer.render(this.scene, this.camera); - } - - private updateCamera(delta: number) { - const focus = this.getCameraFocus(); - const followedShip = this.followShipId ? this.shipsById.get(this.followShipId) : undefined; - if (followedShip) { - focus.lerp(followedShip.group.position, Math.min(1, delta * 3.2)); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === followedShip.systemId); - } - - const panSpeed = Math.max(80, this.camera.position.distanceTo(focus) * 0.7) * delta; - let manualCameraInput = false; - if (this.keyState.has("w")) { - this.panCamera(0, panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014)); - manualCameraInput = true; - } - if (this.keyState.has("s")) { - this.panCamera(0, -panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014)); - manualCameraInput = true; - } - if (this.keyState.has("a")) { - this.panCamera(panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014), 0); - manualCameraInput = true; - } - if (this.keyState.has("d")) { - this.panCamera(-panSpeed / Math.max(0.2, this.camera.position.distanceTo(focus) * 0.0014), 0); - manualCameraInput = true; - } - if (this.keyState.has("q") || this.keyState.has("e")) { - const angle = (this.keyState.has("q") ? 1 : -1) * delta * 0.9; - const offset = this.camera.position.clone().sub(focus); - offset.applyAxisAngle(new THREE.Vector3(0, 1, 0), angle); - this.camera.position.copy(focus).add(offset); - manualCameraInput = true; - } - - if (manualCameraInput) { - this.followShipId = undefined; - } - - this.camera.lookAt(focus); - } - - private applyViewLevel() { - const distance = this.camera.position.distanceTo(this.getCameraFocus()); - const nextLevel: ViewLevel = distance < 950 ? "local" : distance < 3200 ? "solar" : "universe"; - const fog = this.scene.fog as THREE.FogExp2; - if (nextLevel !== this.viewLevel) { - this.viewLevel = nextLevel; - this.updateHud(); - } - - if (this.viewLevel === "local") { - this.camera.fov = 54; - fog.density = 0.00006; - } else if (this.viewLevel === "solar") { - this.camera.fov = 42; - fog.density = 0.00003; - } else { - this.camera.fov = 28; - fog.density = 0.000012; - } - this.camera.updateProjectionMatrix(); - - const focusedSystemId = this.findNearestSystem(this.getCameraFocus()).definition.id; - - this.systems.forEach((system) => { - const universe = this.viewLevel === "universe"; - - system.orbitLines.forEach((orbit) => { - orbit.visible = !universe; - (orbit.material as THREE.LineBasicMaterial).opacity = this.viewLevel === "local" ? 0.52 : 0.24; - }); - - system.asteroidDecorations.forEach((object) => { - object.visible = this.viewLevel === "local"; - }); - - system.planets.forEach((planet) => { - planet.mesh.visible = !universe; - if (planet.ring) { - planet.ring.visible = !universe; - } - }); - - system.star.visible = !universe; - system.light.visible = !universe && system.definition.id === focusedSystemId; - system.strategicMarker.visible = universe; - }); - - this.nodes.forEach((node) => { - node.mesh.visible = this.viewLevel === "local"; - }); - - this.stations.forEach((station) => { - station.group.visible = this.viewLevel !== "universe"; - station.group.scale.setScalar(this.getStationPresentationScale(station)); - }); - - this.ships.forEach((ship) => { - ship.group.visible = this.viewLevel !== "universe"; - ship.group.scale.setScalar(this.getShipPresentationScale(ship)); - }); - - this.strategicLinks.visible = this.viewLevel === "universe"; - if (this.starfield) { - const material = this.starfield.material as THREE.PointsMaterial; - material.size = this.viewLevel === "universe" ? 12 : this.viewLevel === "solar" ? 9 : 8; - material.opacity = this.viewLevel === "local" ? 0.9 : this.viewLevel === "solar" ? 0.7 : 0.45; - material.needsUpdate = true; - } - } - - private getStationPresentationScale(station: StationInstance) { - if (this.viewLevel === "universe") { - return 1; - } - const distance = this.camera.position.distanceTo(station.group.position); - if (this.viewLevel === "solar") { - return THREE.MathUtils.clamp(distance / 260, 2.2, 4.8); - } - return THREE.MathUtils.clamp(distance / 340, 1.2, 2.8); - } - - private getShipPresentationScale(ship: ShipInstance) { - if (this.viewLevel === "universe") { - return 1; - } - const distance = this.camera.position.distanceTo(ship.group.position); - if (this.viewLevel === "solar") { - return THREE.MathUtils.clamp(distance / 180, 3.2, 7.5); - } - return THREE.MathUtils.clamp(distance / 240, 1.5, 4.2); - } - - private refreshControlLayers(ship: ShipInstance) { - if (ship.order?.status === "queued") { - ship.order = { ...ship.order, status: "accepted" }; - } - if (ship.defaultBehavior.kind === "idle") { - const derivedBehavior = this.deriveBehaviorFromAssignment(ship); - if (derivedBehavior.kind !== "idle") { - ship.defaultBehavior = derivedBehavior; - } - } - } - - private deriveBehaviorFromAssignment(ship: ShipInstance): ShipInstance["defaultBehavior"] { - const { assignment } = ship; - if (assignment.kind === "commander-subordinate") { - return { kind: "escort-assigned", offset: this.makeEscortOffset(this.ships.indexOf(ship)) }; - } - if (assignment.kind === "mining-group") { - const controller = this.stations.find((station) => station.id === assignment.controllerId); - return controller - ? { - kind: "auto-mine", - areaSystemId: controller.systemId, - refineryId: controller.id, - nodeId: undefined, - phase: getShipCargoAmount(ship) >= ship.definition.cargoCapacity ? "travel-to-station" : "travel-to-node", - } - : { kind: "idle" }; - } - if (assignment.kind === "station-based") { - const station = this.stations.find((candidate) => candidate.id === assignment.stationId); - return station - ? { kind: "patrol", systemId: station.systemId, points: this.makePatrolPoints(station.systemId), index: 0 } - : { kind: "idle" }; - } - return { kind: "idle" }; - } - - private clearShipOrder(ship: ShipInstance, status: "completed" | "failed" | "cancelled" | "blocked") { - if (!ship.order) { - return; - } - ship.order = undefined; - this.recordShipHistoryEvent(ship, ``); - } - - private planControllerTask(ship: ShipInstance) { - ship.dockingClearanceStatus = undefined; - if (ship.order && ["queued", "accepted", "planning"].includes(ship.order.status)) { - ship.order = { ...ship.order, status: ship.controllerTask.kind === "idle" ? "planning" : "executing" }; - } - if (ship.order) { - this.planTaskFromOrder(ship, ship.order); - return; - } - if (ship.defaultBehavior.kind !== "idle") { - this.planTaskFromBehavior(ship, ship.defaultBehavior); - return; - } - this.setControllerTask(ship, { kind: "idle" }); - } - - private planTaskFromOrder(ship: ShipInstance, order: NonNullable) { - if (order.kind === "move-to") { - this.setControllerTask(ship, { - kind: "travel", - destination: this.cloneTravelDestination(order.destination), - threshold: gameBalance.arrivalThreshold, - }); - return; - } - if (order.kind === "mine-this") { - const node = this.nodes.find((candidate) => candidate.id === order.nodeId); - const refinery = this.stations.find((candidate) => candidate.id === order.refineryId); - if (!node || !refinery) { - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - this.clearShipOrder(ship, "failed"); - return; - } - if (order.phase === "travel-to-node") { - this.setControllerTask(ship, { - kind: "travel", - destination: this.createNodeTravelDestination(node), - threshold: 26, - }); - return; - } - this.setControllerTask(ship, { kind: "extract", nodeId: node.id }); - return; - } - const host = order.hostKind === "ship" - ? this.shipsById.get(order.hostId) - : this.stations.find((candidate) => candidate.id === order.hostId); - if (!host || !this.canShipDockAtHost(ship, host)) { - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - this.clearShipOrder(ship, "failed"); - return; - } - const radius = this.isCarrierHost(host) ? host.definition.size + 28 : host.definition.radius + 30; - if (ship.systemId !== host.systemId) { - this.setControllerTask(ship, { - kind: "travel", - destination: this.isCarrierHost(host) - ? this.createShipTravelDestination(host) - : this.createStationTravelDestination(host), - threshold: radius, - }); - return; - } - const clearance = this.requestDockingClearance(host, ship); - ship.dockingClearanceStatus = this.formatDockingClearance(host, clearance); - if (clearance.kind === "rejected") { - this.setControllerTask(ship, { kind: "idle" }); - ship.order = { ...order, status: "blocked" }; - return; - } - this.setControllerTask(ship, { kind: "dock", hostKind: order.hostKind, hostId: host.id, portIndex: clearance.portIndex }); - } - - private planTaskFromBehavior(ship: ShipInstance, behavior: ShipInstance["defaultBehavior"]) { - if (behavior.kind === "auto-mine") { - const refinery = this.stations.find((candidate) => candidate.id === behavior.refineryId); - const node = behavior.nodeId - ? this.nodes.find((candidate) => candidate.id === behavior.nodeId) - : this.findBestMiningNode(behavior.areaSystemId); - if (!node || !refinery) { - this.setDefaultBehavior(ship, { kind: "idle" }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - return; - } - if (behavior.nodeId !== node.id) { - this.setDefaultBehavior(ship, { ...behavior, nodeId: node.id }); - } - if (behavior.phase === "travel-to-node") { - this.setControllerTask(ship, { - kind: "travel", - destination: this.createNodeTravelDestination(node), - threshold: 26, - }); - return; - } - if (behavior.phase === "extract") { - this.setControllerTask(ship, { kind: "extract", nodeId: node.id }); - return; - } - if (behavior.phase === "travel-to-station") { - this.setControllerTask(ship, { - kind: "travel", - destination: this.createStationTravelDestination(refinery), - threshold: refinery.definition.radius + 30, - }); - return; - } - if (behavior.phase === "dock") { - const clearance = this.requestDockingClearance(refinery, ship); - ship.dockingClearanceStatus = this.formatDockingClearance(refinery, clearance); - if (clearance.kind === "rejected") { - this.setControllerTask(ship, { kind: "idle" }); - return; - } - this.setControllerTask(ship, { kind: "dock", hostKind: "station", hostId: refinery.id, portIndex: clearance.portIndex }); - return; - } - if (behavior.phase === "unload") { - this.setControllerTask(ship, { kind: "unload", stationId: refinery.id }); - return; - } - if (behavior.phase === "undock") { - this.setControllerTask(ship, { kind: "undock", hostKind: "station", hostId: refinery.id }); - } - return; - } - if (behavior.kind === "patrol") { - const target = behavior.points[behavior.index]; - this.setControllerTask(ship, { - kind: "travel", - destination: this.cloneTravelDestination(target), - threshold: 20, - }); - return; - } - if (behavior.kind !== "escort-assigned") { - this.setControllerTask(ship, { kind: "idle" }); - return; - } - const commanderId = ship.assignment.kind === "commander-subordinate" ? ship.assignment.commanderId : undefined; - const commander = commanderId ? this.shipsById.get(commanderId) : undefined; - if (!commander) { - this.setDefaultBehavior(ship, { kind: "idle" }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - return; - } - if (commander.systemId !== ship.systemId) { - this.setControllerTask(ship, { - kind: "travel", - destination: this.createShipTravelDestination(commander), - threshold: commander.definition.size + 28, - }); - return; - } - this.setControllerTask(ship, { - kind: "follow", - targetShipId: commander.id, - offset: behavior.offset.clone(), - threshold: 18, - }); - } - - private updateControllerTask(ship: ShipInstance, delta: number): ControllerEvent { - const task = ship.controllerTask; - if (task.kind === "idle") { - if (ship.state !== "docked" && ship.state !== "undocking") { - ship.state = "idle"; - ship.velocity.multiplyScalar(0.9); - if (!this.updateAnchoredShipPosition(ship)) { - this.updateIdleOrbit(ship, delta); - } - } - return { kind: "none" }; - } - if (task.kind === "travel") { - return this.updateTravelState(ship, task.destination, delta, task.threshold, task.suppliedPlan) - ? { kind: "arrived", destination: this.hydrateTravelDestination(task.destination) } - : { kind: "none" }; - } - if (task.kind === "dock") { - const host = task.hostKind === "ship" ? this.shipsById.get(task.hostId) : this.stations.find((candidate) => candidate.id === task.hostId); - if (!host) { - ship.state = "idle"; - return { kind: "none" }; - } - const previousState = ship.state; - const docked = this.updateDockingState(ship, host, task.portIndex, delta); - if (previousState !== ship.state && ship.state === "docking") { - return { kind: "docking-begin", portIndex: task.portIndex }; - } - return docked ? { kind: "docked" } : { kind: "none" }; - } - if (task.kind === "extract") { - const node = this.nodes.find((candidate) => candidate.id === task.nodeId); - if (!node) { - ship.state = "idle"; - return { kind: "none" }; - } - ship.state = "mining"; - ship.actionTimer += delta; - ship.velocity.multiplyScalar(0.75); - if (ship.actionTimer >= 1) { - const cargo = getShipCargoAmount(ship); - const mined = Math.min(gameBalance.miningRate, ship.definition.cargoCapacity - cargo, node.oreRemaining); - addShipCargo(ship, mined); - node.oreRemaining = Math.max(0, node.oreRemaining - mined); - ship.actionTimer = 0; - if (node.oreRemaining <= 0) { - node.oreRemaining = node.maxOre; - } - } - return { kind: "none" }; - } - if (task.kind === "unload") { - const station = this.stations.find((candidate) => candidate.id === task.stationId); - if (!station) { - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - return { kind: "none" }; - } - ship.state = "transferring"; - const cargo = getShipCargoAmount(ship); - const transferred = removeShipCargo(ship, Math.min(cargo, gameBalance.transferRate * delta)); - this.addStationItem(station, "ore", transferred); - const faction = this.factionsById.get(ship.factionId); - if (faction) { - faction.oreMined += transferred; - faction.credits += transferred * 0.4; - } - if (getShipCargoAmount(ship) <= 0) { - ship.state = "docked"; - return { kind: "unloaded" }; - } - return { kind: "none" }; - } - if (task.kind === "follow") { - const targetShip = this.shipsById.get(task.targetShipId); - if (!targetShip) { - ship.state = "idle"; - return { kind: "none" }; - } - ship.state = "escorting"; - const anchor = targetShip.group.position.clone().add(task.offset); - this.moveShipToward(ship, anchor, ship.definition.speed * 1.05, delta, task.threshold); - return { kind: "none" }; - } - if (task.kind === "undock") { - const host = task.hostKind === "ship" ? this.shipsById.get(task.hostId) : this.stations.find((candidate) => candidate.id === task.hostId); - if (!host) { - ship.state = "idle"; - return { kind: "none" }; - } - if (ship.state === "docked") { - this.beginUndock(ship, host); - } - if (ship.state === "undocking" && this.moveShipToward(ship, ship.target, ship.definition.speed * 0.8, delta, 8, true)) { - ship.state = "idle"; - return { kind: "undocked" }; - } - return { kind: "none" }; - } - return { kind: "none" }; - } - - private advanceControlState(ship: ShipInstance, controllerEvent: ControllerEvent) { - if (controllerEvent.kind === "arrived") { - this.setShipLandedDestination(ship, controllerEvent.destination); - } - if (ship.order?.kind === "move-to" && controllerEvent.kind === "arrived") { - this.setControllerTask(ship, { kind: "idle" }); - this.clearShipOrder(ship, "completed"); - return true; - } - if (ship.order?.kind === "mine-this") { - const cargo = getShipCargoAmount(ship); - if (ship.order.phase === "travel-to-node" && controllerEvent.kind === "arrived") { - ship.order = { ...ship.order, phase: "extract" }; - return true; - } - if (ship.order.phase === "extract" && cargo >= ship.definition.cargoCapacity) { - this.clearShipOrder(ship, "completed"); - this.setControllerTask(ship, { kind: "idle" }); - return true; - } - return false; - } - if (ship.order?.kind === "dock-at" && controllerEvent.kind === "docked") { - this.setControllerTask(ship, { kind: "idle" }); - this.clearShipOrder(ship, "completed"); - return true; - } - - const behavior = ship.defaultBehavior; - if (behavior.kind === "auto-mine") { - const cargo = getShipCargoAmount(ship); - if (behavior.phase === "travel-to-node" && controllerEvent.kind === "arrived") { - this.setDefaultBehavior(ship, { - ...behavior, - phase: cargo >= ship.definition.cargoCapacity ? "travel-to-station" : "extract", - }); - return true; - } - if (behavior.phase === "extract" && cargo >= ship.definition.cargoCapacity) { - this.setDefaultBehavior(ship, { ...behavior, phase: "travel-to-station" }); - return true; - } - if (behavior.phase === "travel-to-station" && controllerEvent.kind === "arrived") { - this.setDefaultBehavior(ship, { ...behavior, phase: "dock" }); - return true; - } - if (behavior.phase === "dock" && controllerEvent.kind === "docked") { - this.setDefaultBehavior(ship, { ...behavior, phase: "unload" }); - return true; - } - if (behavior.phase === "unload" && controllerEvent.kind === "unloaded") { - this.setDefaultBehavior(ship, { ...behavior, phase: "undock" }); - return true; - } - if (behavior.phase === "undock" && controllerEvent.kind === "undocked") { - this.setDefaultBehavior(ship, { ...behavior, phase: "travel-to-node", nodeId: undefined }); - return true; - } - return false; - } - if (behavior.kind === "patrol" && controllerEvent.kind === "arrived") { - this.setDefaultBehavior(ship, { - ...behavior, - index: (behavior.index + 1) % behavior.points.length, - }); - return true; - } - return false; - } - - private updateShips(delta: number, elapsed: number) { - this.ships.forEach((ship, index) => { - this.consumeShipResources(ship, delta); - this.refreshControlLayers(ship); - this.planControllerTask(ship); - const tickHistoryEntries: string[] = []; - for (let iteration = 0; iteration < 3; iteration += 1) { - const controllerEvent = this.updateControllerTask(ship, delta); - const eventLabel = this.formatControllerEvent(controllerEvent); - if (eventLabel) { - tickHistoryEntries.push(eventLabel); - } - const controlChanged = this.advanceControlState(ship, controllerEvent); - if (!controlChanged) { - break; - } - this.refreshControlLayers(ship); - this.planControllerTask(ship); - } - - if (ship.state === "docked") { - this.updateDockedShipTransform(ship); - ship.group.rotation.z = 0; - ship.energy = Math.min(ship.maxEnergy, ship.energy + gameBalance.energy.shipRechargeRate * delta); - } else if (ship.state !== "warping" && ship.state !== "ftl") { - ship.group.position.y = gameBalance.yPlane + Math.sin(elapsed * 1.2 + index) * 0.7; - ship.group.rotation.z = Math.sin(elapsed * 2 + index) * 0.04; - } else { - ship.group.position.y = gameBalance.yPlane; - ship.group.rotation.z = 0; - } - - ship.warpFx.visible = - ship.state === "spooling-warp" || ship.state === "warping" || ship.state === "spooling-ftl" || ship.state === "ftl"; - ship.warpFx.scale.x = ship.state === "ftl" ? 3.2 : ship.state === "warping" ? 2.4 : 1.2; - ship.warpFx.traverse((child) => { - if ("material" in child && child.material instanceof THREE.MeshBasicMaterial) { - child.material.opacity = - ship.state === "ftl" ? 0.65 : ship.state === "warping" ? 0.5 : ship.state === "spooling-warp" || ship.state === "spooling-ftl" ? 0.28 : 0.22; - } - }); - tickHistoryEntries.push(...this.trackShipHistory(ship)); - this.recordShipHistoryEvents(ship, tickHistoryEntries); - }); - } - - private updateDockedShipTransform(ship: ShipInstance) { - const host = this.getDockingHostForShip(ship); - if (!host || ship.dockingPortIndex === undefined) { - return; - } - const port = host.group.localToWorld(host.dockingPorts[ship.dockingPortIndex].clone()); - ship.group.position.copy(port); - ship.systemId = host.systemId; - } - - private updateSystems(delta: number) { - this.systems.forEach((system) => { - system.planets.forEach((planet) => { - planet.group.rotation.y += planet.orbitSpeed * delta * 0.3; - planet.mesh.rotation.y += delta * 0.18; - }); - }); - - this.stations.forEach((station) => { - if (station.orbitalParentPlanetIndex !== undefined && station.lagrangeSide) { - const system = this.getSystem(station.systemId); - const parentPlanet = system.planets[station.orbitalParentPlanetIndex]; - const planetPosition = parentPlanet.mesh.getWorldPosition(new THREE.Vector3()); - const radial = planetPosition.clone().sub(system.center); - const lagrange = radial - .clone() - .applyAxisAngle(new THREE.Vector3(0, 1, 0), station.lagrangeSide * Math.PI / 3) - .add(system.center) - .setY(0); - station.group.position.copy(lagrange); - } - - station.energy = Math.min(station.maxEnergy, station.energy + gameBalance.energy.stationSolarCharge * delta); - this.updateStationProduction(station, delta); - }); - } - - private updateStationProduction(station: StationInstance, delta: number) { - if (station.activeBatch <= 0) { - const nextRecipe = this.findNextStationRecipe(station); - if (nextRecipe) { - station.activeRecipeId = nextRecipe.id; - station.activeBatch = nextRecipe.inputs.reduce((total, component) => total + component.amount, 0); - station.processTimer = nextRecipe.duration; - this.consumeFactionItems(station.factionId, nextRecipe.inputs); - } - } - - if (station.activeBatch <= 0 || !station.activeRecipeId) { - return; - } - - const recipe = recipeDefinitions.find((candidate) => candidate.id === station.activeRecipeId); - if (!recipe) { - station.activeBatch = 0; - station.activeRecipeId = undefined; - station.processTimer = 0; - return; - } - - station.processTimer = Math.max(0, station.processTimer - delta); - if (station.processTimer > 0) { - return; - } - - recipe.outputs.forEach((component) => this.addStationItem(station, component.itemId, component.amount)); - this.factionsById.get(station.factionId)!.goodsProduced += recipe.outputs.reduce((total, output) => total + output.amount, 0); - station.activeBatch = 0; - station.activeRecipeId = undefined; - } - - private findNextStationRecipe(station: StationInstance) { - return recipeDefinitions - .filter((recipe) => this.canStationRunRecipe(station, recipe)) - .sort((left, right) => (right.priority ?? 0) - (left.priority ?? 0))[0]; - } - - private canStationRunRecipe(station: StationInstance, recipe: (typeof recipeDefinitions)[number]) { - const categoryMatches = - recipe.facilityCategory === station.definition.category || - (recipe.facilityCategory === "station" && station.modules.includes("fabricator-array")); - const modulesMatch = (recipe.requiredModules ?? []).every((moduleId) => station.modules.includes(moduleId)); - const inputsMatch = recipe.inputs.every((component) => this.getFactionItemAmount(station.factionId, component.itemId) >= component.amount); - return categoryMatches && modulesMatch && inputsMatch; - } - - private addStationItem(station: StationInstance, itemId: string, amount: number) { - if (amount <= 0) { - return; - } - station.itemStocks[itemId] = (station.itemStocks[itemId] ?? 0) + amount; - const storage = itemDefinitionsById.get(itemId)?.storage; - if (storage) { - station.inventory[storage] += amount; - } - if (itemId === "ore") { - station.oreStored += amount; - } - if (itemId === "refined-metals") { - station.refinedStock += amount; - } - } - - private removeStationItem(station: StationInstance, itemId: string, amount: number) { - if (amount <= 0) { - return 0; - } - const available = station.itemStocks[itemId] ?? 0; - const removed = Math.min(available, amount); - station.itemStocks[itemId] = Math.max(0, available - removed); - const storage = itemDefinitionsById.get(itemId)?.storage; - if (storage) { - station.inventory[storage] = Math.max(0, station.inventory[storage] - removed); - } - if (itemId === "ore") { - station.oreStored = Math.max(0, station.oreStored - removed); - } - if (itemId === "refined-metals") { - station.refinedStock = Math.max(0, station.refinedStock - removed); - } - return removed; - } - - private consumeShipResources(ship: ShipInstance, delta: number) { - if (ship.state === "spooling-warp" || ship.state === "warping" || ship.state === "spooling-ftl" || ship.state === "ftl") { - ship.energy = Math.max(0, ship.energy - gameBalance.energy.warpDrain * delta); - ship.fuel = Math.max(0, ship.fuel - gameBalance.fuel.warpDrain * delta); - return; - } - if (MOVING_STATES.has(ship.state)) { - ship.energy = Math.max(0, ship.energy - gameBalance.energy.moveDrain * delta); - return; - } - if (ship.state !== "docked") { - ship.energy = Math.max(0, ship.energy - gameBalance.energy.idleDrain * delta); - } - } - - private updateIdleOrbit(ship: ShipInstance, delta: number) { - const systemCenter = this.getSystem(ship.systemId).center; - if (ship.idleOrbitRadius < 40) { - 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); - } - ship.idleOrbitAngle += delta * (14 / Math.max(ship.idleOrbitRadius, 120)); - const nextPosition = new THREE.Vector3( - systemCenter.x + Math.cos(ship.idleOrbitAngle) * ship.idleOrbitRadius, - gameBalance.yPlane, - systemCenter.z + Math.sin(ship.idleOrbitAngle) * ship.idleOrbitRadius, - ); - ship.group.position.lerp(nextPosition, 0.12); - const tangent = new THREE.Vector3(-Math.sin(ship.idleOrbitAngle), 0, Math.cos(ship.idleOrbitAngle)); - ship.group.lookAt(ship.group.position.clone().add(tangent)); - } - - private ensureTravelPlan(ship: ShipInstance, destination: TravelDestination, suppliedPlan?: TravelPlan) { - const liveDestination = this.hydrateTravelDestination(destination); - if (ship.travelPlan && this.sameTravelDestination(ship.travelPlan.destination, liveDestination)) { - ship.travelPlan.destination = liveDestination; - return ship.travelPlan; - } - - if (suppliedPlan) { - ship.travelPlan = { - destination: this.hydrateTravelDestination(suppliedPlan.destination), - arrivalPoint: suppliedPlan.arrivalPoint.clone().setY(gameBalance.yPlane), - interSystem: suppliedPlan.interSystem, - }; - return ship.travelPlan; - } - - const currentSystem = this.getSystem(ship.systemId); - const destinationSystem = this.getSystem(liveDestination.systemId); - const arrivalDirection = liveDestination.orbitalAnchor.clone().sub(destinationSystem.center).setY(0).normalize(); - if (arrivalDirection.lengthSq() === 0) { - arrivalDirection.copy(currentSystem.center.clone().sub(destinationSystem.center).setY(0).normalize()); - } - const starArrivalRadius = Math.max(destinationSystem.definition.starSize * 6, destinationSystem.gravityWellRadius * 0.35, 120); - - ship.travelPlan = { - destination: liveDestination, - arrivalPoint: destinationSystem.center - .clone() - .add(arrivalDirection.multiplyScalar(starArrivalRadius)) - .setY(gameBalance.yPlane), - interSystem: currentSystem.definition.id !== destination.systemId, - }; - return ship.travelPlan; - } - - private updateTravelState( - ship: ShipInstance, - destination: TravelDestination, - delta: number, - threshold: number, - suppliedPlan?: TravelPlan, - ) { - if (ship.state === "docked") { - const host = this.getDockingHostForShip(ship); - if (host) { - this.beginUndock(ship, host); - } - } - - const plan = this.ensureTravelPlan(ship, destination, suppliedPlan); - if (ship.state === "undocking") { - return false; - } - - if (ship.systemId === plan.destination.systemId && ship.group.position.distanceTo(plan.destination.position) <= threshold) { - const systemCenter = this.getSystem(plan.destination.systemId).center; - ship.idleOrbitRadius = plan.destination.position.clone().setY(0).distanceTo(systemCenter); - ship.idleOrbitAngle = Math.atan2( - plan.destination.position.z - systemCenter.z, - plan.destination.position.x - systemCenter.x, - ); - this.setShipLandedDestination(ship, plan.destination); - return true; - } - - if ( - !plan.interSystem && - ship.state !== "spooling-warp" && - ship.state !== "spooling-ftl" && - ship.state !== "ftl" && - ship.state !== "warping" && - ship.state !== "arriving" - ) { - ship.state = "spooling-warp"; - ship.actionTimer = ship.definition.spoolTime * 0.7; - } - - if (!plan.interSystem && ship.state === "spooling-warp") { - ship.actionTimer -= delta; - ship.velocity.multiplyScalar(0.8); - if (ship.actionTimer <= 0) { - ship.state = "warping"; - } - return false; - } - - if (!plan.interSystem && ship.state === "warping") { - if ( - this.moveShipToward( - ship, - plan.destination.orbitalAnchor, - Math.max(ship.definition.speed * 4, ship.definition.ftlSpeed * 0.18), - delta, - Math.max(42, threshold * 2), - true, - ) - ) { - ship.state = "arriving"; - } - return false; - } - - if (!plan.interSystem && ship.state === "arriving") { - if (this.moveShipToward(ship, plan.destination.position, ship.definition.speed, delta, threshold, true)) { - const systemCenter = this.getSystem(plan.destination.systemId).center; - ship.idleOrbitRadius = plan.destination.position.clone().setY(0).distanceTo(systemCenter); - ship.idleOrbitAngle = Math.atan2( - plan.destination.position.z - systemCenter.z, - plan.destination.position.x - systemCenter.x, - ); - this.setShipLandedDestination(ship, plan.destination); - return true; - } - return false; - } - - if (ship.state !== "spooling-ftl" && ship.state !== "ftl" && ship.state !== "arriving") { - ship.state = "spooling-ftl"; - ship.actionTimer = ship.definition.spoolTime; - } - - if (ship.state === "spooling-ftl") { - ship.actionTimer -= delta; - ship.velocity.multiplyScalar(0.8); - if (ship.actionTimer <= 0) { - ship.state = "ftl"; - } - return false; - } - - if (ship.state === "ftl") { - if (this.moveShipToward(ship, plan.arrivalPoint, ship.definition.ftlSpeed, delta, 50)) { - ship.systemId = plan.destination.systemId; - ship.state = "arriving"; - } - return false; - } - - if (ship.state === "arriving") { - if (this.moveShipToward(ship, plan.destination.position, ship.definition.speed, delta, threshold)) { - const systemCenter = this.getSystem(plan.destination.systemId).center; - ship.idleOrbitRadius = plan.destination.position.clone().setY(0).distanceTo(systemCenter); - ship.idleOrbitAngle = Math.atan2( - plan.destination.position.z - systemCenter.z, - plan.destination.position.x - systemCenter.x, - ); - this.setShipLandedDestination(ship, plan.destination); - return true; - } - return false; - } - - return false; - } - - private updateDockingState(ship: ShipInstance, host: DockingHost, portIndex: number, delta: number) { - const portPosition = host.group.localToWorld(host.dockingPorts[portIndex].clone()); - if (ship.state !== "docking" && ship.state !== "docked") { - ship.state = "docking-approach"; - } - - if (ship.state === "docking-approach") { - if (this.moveShipTowardDockingPort(ship, host, portIndex, delta, ship.definition.speed * 0.75, 8)) { - ship.state = "docking"; - ship.actionTimer = gameBalance.dockingDuration; - } - return false; - } - - if (ship.state === "docking") { - ship.group.position.lerp(portPosition, 0.18); - ship.actionTimer -= delta; - if (ship.actionTimer <= 0) { - ship.state = "docked"; - ship.group.position.copy(portPosition); - ship.velocity.setScalar(0); - } - return false; - } - - if (ship.state === "docked") { - ship.group.position.copy(portPosition); - return true; - } - - return false; - } - - private moveShipTowardDockingPort( - ship: ShipInstance, - host: DockingHost, - portIndex: number, - delta: number, - speed: number, - threshold: number, - ) { - const targetLocal = host.dockingPorts[portIndex].clone(); - const currentLocal = host.group.worldToLocal(ship.group.position.clone()); - currentLocal.y = gameBalance.yPlane; - targetLocal.y = gameBalance.yPlane; - - const toTargetLocal = targetLocal.clone().sub(currentLocal); - const distance = toTargetLocal.length(); - if (distance <= threshold) { - ship.group.position.copy(host.group.localToWorld(targetLocal.clone())); - ship.velocity.setScalar(0); - return true; - } - - const step = Math.min(distance, speed * delta); - const nextLocal = currentLocal.add(toTargetLocal.normalize().multiplyScalar(step)); - const worldPosition = host.group.localToWorld(nextLocal.clone()); - ship.target.copy(host.group.localToWorld(targetLocal.clone())); - ship.velocity.copy(worldPosition.clone().sub(ship.group.position).divideScalar(Math.max(delta, 0.0001))); - ship.group.position.copy(worldPosition.setY(gameBalance.yPlane)); - - if (ship.velocity.lengthSq() > 1) { - ship.group.lookAt(ship.group.position.clone().add(ship.velocity)); - } - return false; - } - - private beginUndock(ship: ShipInstance, host: DockingHost) { - if (ship.state === "undocking") { - return; - } - ship.state = "undocking"; - ship.actionTimer = gameBalance.dockingDuration * 0.75; - this.clearShipLandedDestination(ship); - const portIndex = ship.dockingPortIndex ?? 0; - const port = host.group.localToWorld(host.dockingPorts[portIndex].clone()); - const direction = port.clone().sub(host.group.position).setY(0).normalize(); - ship.target.copy(port.clone().add(direction.multiplyScalar(gameBalance.undockDistance)).setY(gameBalance.yPlane)); - this.releaseDockingPort(host, ship); - } - - private formatDockingClearance(host: DockingHost, clearance: DockingClearance) { - const hostLabel = this.isCarrierHost(host) ? `${host.definition.label} ${host.id}` : `${host.definition.label} ${host.id}`; - if (clearance.kind === "accepted") { - return `accepted ${hostLabel} bay-${clearance.portIndex}`; - } - return `rejected ${hostLabel} ${clearance.reason}`; - } - - private requestDockingClearance(host: DockingHost, ship: ShipInstance): DockingClearance { - if (this.getAssignedDockingHostId(ship) === host.id && ship.dockingPortIndex !== undefined) { - return { kind: "accepted", portIndex: ship.dockingPortIndex }; - } - if (!this.canShipDockAtHost(ship, host)) { - return { kind: "rejected", reason: "permission-denied" }; - } - const assignedShips = this.ships.filter( - (candidate) => - this.getAssignedDockingHostId(candidate) === host.id && - candidate.dockingPortIndex !== undefined, - ); - host.dockedShipIds.clear(); - assignedShips.forEach((candidate) => host.dockedShipIds.add(candidate.id)); - const usedPorts = new Set(assignedShips.map((candidate) => candidate.dockingPortIndex as number)); - const freePort = host.dockingPorts.findIndex((_, index) => !usedPorts.has(index)); - if (freePort >= 0) { - host.dockedShipIds.add(ship.id); - this.assignDockingHost(ship, host); - ship.dockingPortIndex = freePort; - return { kind: "accepted", portIndex: freePort }; - } - return { kind: "rejected", reason: "no-free-bay" }; - } - - private releaseDockingPort(host: DockingHost, ship: ShipInstance) { - host.dockedShipIds.delete(ship.id); - ship.dockedStationId = undefined; - ship.dockedCarrierId = undefined; - ship.dockingPortIndex = undefined; - } - - private getAssignedDockingHostId(ship: ShipInstance) { - return ship.dockedCarrierId ?? ship.dockedStationId; - } - - private getDockingHostForShip(ship: ShipInstance) { - if (ship.dockedCarrierId) { - return this.shipsById.get(ship.dockedCarrierId); - } - if (ship.dockedStationId) { - return this.stations.find((candidate) => candidate.id === ship.dockedStationId); - } - return undefined; - } - - private assignDockingHost(ship: ShipInstance, host: DockingHost) { - if (this.isCarrierHost(host)) { - ship.dockedCarrierId = host.id; - ship.dockedStationId = undefined; - ship.systemId = host.systemId; - return; - } - ship.dockedStationId = host.id; - ship.dockedCarrierId = undefined; - } - - private isCarrierHost(host: DockingHost): host is ShipInstance { - return "warpFx" in host; - } - - private canDockShipAtCarrier(ship: ShipInstance, carrier: ShipInstance) { - return ( - ship.id !== carrier.id && - carrier.definition.dockingCapacity !== undefined && - carrier.definition.dockingCapacity > 0 && - (carrier.definition.dockingClasses ?? []).includes(ship.definition.shipClass) - ); - } - - private canShipDockAtHost(ship: ShipInstance, host: DockingHost) { - if (this.isCarrierHost(host)) { - return this.canDockShipAtCarrier(ship, host); - } - return ( - host.factionId === ship.factionId || - host.factionId === "neutral" || - ship.factionId === "neutral" || - (ship.defaultBehavior.kind === "auto-mine" && ship.defaultBehavior.refineryId === host.id) || - (ship.order?.kind === "mine-this" && ship.order.refineryId === host.id) - ); - } - - private findNearestFriendlyCarrier(ship: ShipInstance) { - return this.ships - .filter( - (candidate) => - candidate.systemId === ship.systemId && - candidate.factionId === ship.factionId && - this.canDockShipAtCarrier(ship, candidate), - ) - .sort( - (left, right) => - ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position), - )[0]; - } - - private assignDockOrder(ship: ShipInstance, carrier: ShipInstance) { - if (!this.canDockShipAtCarrier(ship, carrier)) { - return; - } - ship.travelPlan = undefined; - this.setShipOrder(ship, { kind: "dock-at", status: "queued", hostKind: "ship", hostId: carrier.id }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - } - - private moveShipToward( - ship: ShipInstance, - destination: THREE.Vector3, - speed: number, - delta: number, - threshold: number, - directApproach = false, - ) { - const target = destination.clone().setY(gameBalance.yPlane); - ship.target.copy(target); - - const toTarget = target.clone().sub(ship.group.position); - const distance = toTarget.length(); - if (distance <= threshold) { - ship.velocity.multiplyScalar(0.65); - return true; - } - - let desiredDirection = toTarget.normalize(); - if (!directApproach && ship.state !== "warping" && ship.state !== "spooling-ftl" && ship.state !== "ftl") { - const systemCenter = this.getSystem(ship.systemId).center; - const radial = ship.group.position.clone().sub(systemCenter).setY(0); - const targetRadial = target.clone().sub(systemCenter).setY(0); - if (radial.lengthSq() > 1 && targetRadial.lengthSq() > 1) { - const tangential = new THREE.Vector3(-radial.z, 0, radial.x).normalize(); - const crossY = radial.clone().cross(targetRadial).y; - const sign = crossY >= 0 ? 1 : -1; - const curvature = THREE.MathUtils.clamp(distance / 650, 0, 0.9); - desiredDirection = desiredDirection.add(tangential.multiplyScalar(sign * curvature)).normalize(); - } - } - - const desiredVelocity = desiredDirection.multiplyScalar(speed); - const steering = ship.state === "warping" || ship.state === "ftl" ? delta * 4.2 : delta * 1.8; - ship.velocity.lerp(desiredVelocity, steering); - ship.group.position.addScaledVector(ship.velocity, delta); - - if (ship.velocity.lengthSq() > 1) { - ship.group.lookAt(ship.group.position.clone().add(ship.velocity)); - } - return false; - } - - private issueMoveOrder(ship: ShipInstance, destination: TravelDestination) { - const normalizedDestination = this.cloneTravelDestination(destination); - if (ship.order?.kind === "move-to" && this.sameTravelDestination(ship.order.destination, normalizedDestination)) { - return; - } - ship.travelPlan = undefined; - this.clearShipLandedDestination(ship); - this.setShipOrder(ship, { kind: "move-to", status: "queued", destination: normalizedDestination }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - } - - private assignMineOrder(ship: ShipInstance, node: ResourceNode | undefined, refinery: StationInstance | undefined) { - if (!node || !refinery) { - this.setShipOrder(ship, undefined); - this.setDefaultBehavior(ship, { kind: "idle" }); - this.setAssignment(ship, { kind: "unassigned" }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - this.clearShipLandedDestination(ship); - return; - } - if (ship.defaultBehavior.kind === "auto-mine" && ship.defaultBehavior.areaSystemId === node.systemId && ship.defaultBehavior.refineryId === refinery.id) { - return; - } - this.setShipOrder(ship, undefined); - this.setAssignment(ship, { kind: "mining-group", controllerId: refinery.id }); - this.setDefaultBehavior(ship, { - kind: "auto-mine", - areaSystemId: node.systemId, - refineryId: refinery.id, - nodeId: node.id, - phase: getShipCargoAmount(ship) >= ship.definition.cargoCapacity ? "travel-to-station" : "travel-to-node", - }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - this.clearShipLandedDestination(ship); - } - - private setPatrolOrder(ship: ShipInstance, points: TravelDestination[], startIndex: number, systemId = ship.systemId) { - const normalizedPoints = points.length > 0 ? points : [this.createSystemTravelDestination(systemId)]; - if (ship.defaultBehavior.kind === "patrol" && ship.defaultBehavior.systemId === systemId && this.sameTravelDestinationList(ship.defaultBehavior.points, normalizedPoints)) { - return; - } - this.setShipOrder(ship, undefined); - this.setAssignment(ship, { kind: "unassigned" }); - this.setDefaultBehavior(ship, { - kind: "patrol", - points: normalizedPoints.map((point) => this.cloneTravelDestination(point)), - systemId, - index: startIndex % normalizedPoints.length, - }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - this.clearShipLandedDestination(ship); - } - - private setEscortOrder(ship: ShipInstance, target: ShipInstance, offset = new THREE.Vector3()) { - const angle = (this.ships.indexOf(ship) % 6) * (Math.PI / 3); - const formationOffset = - offset.lengthSq() > 0 ? offset : new THREE.Vector3(Math.cos(angle) * 32, 0, Math.sin(angle) * 32); - if (ship.assignment.kind === "commander-subordinate" && ship.assignment.commanderId === target.id && ship.defaultBehavior.kind === "escort-assigned" && ship.defaultBehavior.offset.distanceToSquared(formationOffset) < 1) { - return; - } - this.setShipOrder(ship, undefined); - this.setAssignment(ship, { kind: "commander-subordinate", commanderId: target.id, role: "escort" }); - this.setDefaultBehavior(ship, { - kind: "escort-assigned", - offset: formationOffset, - }); - this.setControllerTask(ship, { kind: "idle" }); - ship.state = "idle"; - this.clearShipLandedDestination(ship); - } - - private findFactionStations(factionId: string) { - return this.stations.filter((station) => station.factionId === factionId); - } - - private getFactionItemAmount(factionId: string, itemId: string) { - return this.findFactionStations(factionId).reduce((total, station) => total + (station.itemStocks[itemId] ?? 0), 0); - } - - private canFactionAfford(factionId: string, costs: Array<{ itemId: string; amount: number }>) { - return costs.every((cost) => this.getFactionItemAmount(factionId, cost.itemId) >= cost.amount); - } - - private consumeFactionItems(factionId: string, costs: Array<{ itemId: string; amount: number }>) { - if (!this.canFactionAfford(factionId, costs)) { - return false; - } - costs.forEach((cost) => { - let remaining = cost.amount; - this.findFactionStations(factionId) - .sort((left, right) => (right.itemStocks[cost.itemId] ?? 0) - (left.itemStocks[cost.itemId] ?? 0)) - .forEach((station) => { - if (remaining <= 0) { - return; - } - const removed = this.removeStationItem(station, cost.itemId, remaining); - remaining -= removed; - }); - }); - return true; - } - - private findBestMiningNode(systemId: string) { - return this.nodes.filter((node) => node.systemId === systemId).sort((left, right) => right.oreRemaining - left.oreRemaining)[0]; - } - - private findRefinery(systemId: string, factionId?: string) { - return this.stations.find( - (station) => - station.systemId === systemId && - station.definition.category === "refining" && - (!factionId || station.factionId === factionId), - ); - } - - private findNearestFriendlyToEscort(ship: ShipInstance) { - return this.ships - .filter((candidate) => candidate.id !== ship.id && candidate.systemId === ship.systemId && candidate.factionId === ship.factionId) - .sort((left, right) => ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position))[0]; - } - - private pickCentralTargetSystem(faction: FactionInstance) { - return this.systems - .filter((system) => system.strategicValue === "central") - .sort((left, right) => { - const leftOwned = left.controllingFactionId === faction.definition.id ? 1 : 0; - const rightOwned = right.controllingFactionId === faction.definition.id ? 1 : 0; - if (leftOwned !== rightOwned) { - return leftOwned - rightOwned; - } - return left.controlProgress - right.controlProgress; - })[0]?.definition.id; - } - - private findThreatenedSystem(factionId: string) { - return this.systems.find((system) => { - const friendlyPresence = this.ships.some((ship) => ship.systemId === system.definition.id && ship.factionId === factionId && ship.definition.role === "military"); - const hostilePresence = this.ships.some((ship) => ship.systemId === system.definition.id && ship.factionId !== factionId && ship.definition.role === "military"); - const friendlyStation = this.stations.some((station) => station.systemId === system.definition.id && station.factionId === factionId); - return friendlyStation && hostilePresence && friendlyPresence; - })?.definition.id; - } - - private findCombatTarget(ship: ShipInstance) { - return this.ships - .filter((candidate) => candidate.id !== ship.id && candidate.systemId === ship.systemId && candidate.factionId !== ship.factionId && candidate.state !== "docked") - .sort((left, right) => { - const leftPriority = ship.factionId !== left.factionId && left.definition.role !== "military" ? -1 : 0; - const rightPriority = ship.factionId !== right.factionId && right.definition.role !== "military" ? -1 : 0; - if (leftPriority !== rightPriority) { - return leftPriority - rightPriority; - } - return ship.group.position.distanceTo(left.group.position) - ship.group.position.distanceTo(right.group.position); - }) - .find((candidate) => ship.group.position.distanceTo(candidate.group.position) <= ship.weaponRange); - } - - private destroyShip(ship: ShipInstance, killerFactionId?: string) { - this.selectionManager.removeShip(ship); - const faction = this.factionsById.get(ship.factionId); - if (faction) { - faction.shipsLost += 1; - } - if (killerFactionId && killerFactionId !== ship.factionId) { - const killerFaction = this.factionsById.get(killerFactionId); - if (killerFaction) { - killerFaction.enemyShipsDestroyed += 1; - } - } - this.scene.remove(ship.group); - this.ships.splice(this.ships.indexOf(ship), 1); - this.shipsById.delete(ship.id); - [...this.selectableTargets.entries()] - .filter(([, target]) => target.kind === "ship" && target.ship.id === ship.id) - .forEach(([object]) => this.selectableTargets.delete(object)); - } - - private tryBuildShipForFaction(faction: FactionInstance) { - const spawnStation = - this.findFactionStations(faction.definition.id).find((station) => station.definition.category === "shipyard") ?? - this.findFactionStations(faction.definition.id).find((station) => station.definition.category === "station"); - if (!spawnStation) { - return; - } - - const buildQueue = - faction.definition.kind === "empire" - ? [ - { shipId: "frigate", costs: [{ itemId: "hull-sections", amount: 10 }, { itemId: "naval-guns", amount: 2 }, { itemId: "ship-equipment", amount: 4 }, { itemId: "ammo-crates", amount: 6 }] }, - { shipId: "destroyer", costs: [{ itemId: "hull-sections", amount: 16 }, { itemId: "naval-guns", amount: 4 }, { itemId: "ship-equipment", amount: 6 }, { itemId: "ammo-crates", amount: 10 }] }, - { shipId: "miner", costs: [{ itemId: "refined-metals", amount: 18 }, { itemId: "ship-equipment", amount: 6 }, { itemId: "ship-parts", amount: 4 }] }, - { shipId: "hauler", costs: [{ itemId: "refined-metals", amount: 20 }, { itemId: "ship-equipment", amount: 6 }, { itemId: "ship-parts", amount: 5 }] }, - ] - : [ - { shipId: "frigate", costs: [{ itemId: "hull-sections", amount: 8 }, { itemId: "naval-guns", amount: 2 }, { itemId: "ammo-crates", amount: 6 }] }, - { shipId: "destroyer", costs: [{ itemId: "hull-sections", amount: 14 }, { itemId: "naval-guns", amount: 4 }, { itemId: "ammo-crates", amount: 10 }] }, - ]; - - const nextBuild = buildQueue.find((plan) => this.canFactionAfford(faction.definition.id, plan.costs)); - if (!nextBuild || !this.consumeFactionItems(faction.definition.id, nextBuild.costs)) { - return; - } - const ship = createShipInstance({ - id: `ship-${this.ships.length + 1}-${Date.now()}`, - definition: this.getShipDefinition(nextBuild.shipId), - systemId: spawnStation.systemId, - factionId: faction.definition.id, - factionColor: faction.definition.color, - selectableTargets: this.selectableTargets, - }); - ship.group.position.copy(spawnStation.group.position.clone().add(new THREE.Vector3(50 + Math.random() * 25, 0, 30 + Math.random() * 25))); - ship.target.copy(ship.group.position); - ship.idleOrbitRadius = ship.group.position.clone().setY(0).distanceTo(this.getSystem(spawnStation.systemId).center); - this.scene.add(ship.group); - this.ships.push(ship); - this.shipsById.set(ship.id, ship); - this.trackShipHistory(ship); - faction.shipsBuilt += 1; - faction.credits -= 60; - } - - private tryBuildOutpostForFaction(faction: FactionInstance) { - if (faction.definition.kind !== "empire") { - return; - } - const targetSystem = this.systems.find( - (system) => - system.strategicValue === "central" && - system.controllingFactionId === faction.definition.id && - !this.stations.some((station) => station.systemId === system.definition.id && station.factionId === faction.definition.id), - ); - if (!targetSystem) { - return; - } - const costs = [ - { itemId: "ship-parts", amount: 18 }, - { itemId: "naval-guns", amount: 10 }, - { itemId: "ammo-crates", amount: 14 }, - ]; - if (!this.consumeFactionItems(faction.definition.id, costs)) { - return; - } - const defense = constructibleDefinitions.find((definition) => definition.id === "defense-grid"); - if (!defense) { - return; - } - const station = createStationInstance({ - id: `station-${++this.stationIdCounter}`, - scene: this.scene, - definition: defense, - systemId: targetSystem.definition.id, - position: targetSystem.center.clone().add(new THREE.Vector3(180, 0, -120)), - factionId: faction.definition.id, - factionColor: faction.definition.color, - selectableTargets: this.selectableTargets, - }); - this.stations.push(station); - faction.stationsBuilt += 1; - } - - private getShipDefinition(shipId: string) { - const definition = shipDefinitionsById.get(shipId); - if (!definition) { - throw new Error(`Missing ship definition ${shipId}`); - } - return definition; - } - - private findNearestSystem(point: THREE.Vector3) { - return this.systems.reduce((best, system) => { - const bestDistance = best.center.distanceToSquared(point); - const distance = system.center.distanceToSquared(point); - return distance < bestDistance ? system : best; - }, this.systems[0]); - } - - private getSystem(systemId: string) { - const system = this.systems.find((candidate) => candidate.definition.id === systemId); - if (!system) { - throw new Error(`Missing solar system ${systemId}`); - } - return system; - } - - private focusSystem(systemId: string) { - const system = this.getSystem(systemId); - this.followShipId = undefined; - this.focusPoint(system.center, 1100); - this.updateHud(); - } - - private focusSelection() { - if (this.selection.length === 0 && !this.selectedStation && !this.selectedSystem && !this.selectedPlanet && !this.selectedNode) { - return; - } - if (this.selectedNode) { - this.followShipId = undefined; - this.focusPoint(this.selectedNode.position, 680); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedNode?.systemId); - this.updateHud(); - return; - } - if (this.selectedPlanet) { - this.followShipId = undefined; - const worldPosition = this.selectedPlanet.mesh.getWorldPosition(new THREE.Vector3()); - this.focusPoint(worldPosition, 760); - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedPlanet?.systemId); - this.updateHud(); - return; - } - if (this.selectedSystem) { - this.focusSystem(this.selectedSystem.definition.id); - return; - } - if (this.selectedStation) { - this.followShipId = undefined; - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === this.selectedStation?.systemId); - this.focusPoint(this.selectedStation.group.position, 640); - this.updateHud(); - return; - } - if (this.selection.length === 1) { - const ship = this.selection[0]; - this.followShipId = ship.id; - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === ship.systemId); - this.focusPoint(ship.group.position, 520); - this.updateHud(); - return; - } - - this.followShipId = undefined; - const center = new THREE.Vector3(); - this.selection.forEach((ship) => center.add(ship.group.position)); - center.multiplyScalar(1 / this.selection.length); - this.focusPoint(center, 840); - this.updateHud(); - } - - private focusPoint(point: THREE.Vector3, targetDistance: number) { - const focus = this.getCameraFocus(); - focus.copy(point); - const offset = this.camera.position.clone().sub(focus); - if (offset.lengthSq() === 0) { - offset.set(1, 0.75, 1); - } - offset.normalize().multiplyScalar(targetDistance); - if (offset.y < 140) { - offset.y = 140; - } - this.camera.position.copy(focus).add(offset); - this.camera.lookAt(focus); - this.applyViewLevel(); - } - - private handleWindowAction(action: string) { - if (action === "new-universe") { - this.generateNewUniverse(); - return; - } - if (action === "toggle-fleet-command") { - this.toggleWindow("fleet-command"); - return; - } - if (action === "toggle-debug") { - this.toggleWindow("debug"); - return; - } - if (action === "toggle-debug-autoscroll") { - this.debugAutoScroll = !this.debugAutoScroll; - this.updateHud(); - return; - } - if (action === "copy-debug-history") { - this.copyDebugHistory(); - return; - } - if (action === "toggle-ship-designer") { - this.toggleWindow("ship-designer"); - return; - } - if (action === "toggle-station-manager") { - this.toggleWindow("station-manager"); - } - } - - private handleWindowSelection(kind: string, id: string) { - if (kind === "faction" || kind === "focus-faction") { - const faction = this.factionsById.get(id); - if (!faction) { - return; - } - const system = this.getSystem(faction.definition.homeSystemId); - this.selectionManager.setSystem(system); - this.selectedSystemIndex = this.systems.findIndex((candidate) => candidate.definition.id === system.definition.id); - this.focusSystem(system.definition.id); - return; - } - - if (kind === "focus-ship") { - const ship = this.shipsById.get(id); - if (!ship) { - return; - } - this.selectionManager.replaceShips([ship]); - this.followShipId = ship.id; - this.selectedSystemIndex = this.systems.findIndex((system) => system.definition.id === ship.systemId); - this.focusPoint(ship.group.position, 520); - this.updateHud(); - return; - } - - if (kind === "ship") { - const ship = this.shipsById.get(id); - if (!ship) { - return; - } - this.selectionManager.replaceShips([ship]); - this.updateHud(); - } - } - - private toggleWindow(windowId: GameWindowId) { - this.windowState[windowId] = !this.windowState[windowId]; - this.updateHud(); - } - - private adjustZoom(multiplier: number) { - const focus = this.getCameraFocus(); - const direction = this.camera.position.clone().sub(focus).normalize(); - const distance = this.camera.position.distanceTo(focus); - const nextDistance = THREE.MathUtils.clamp(distance * multiplier, 180, 9000); - this.camera.position.copy(focus).add(direction.multiplyScalar(nextDistance)); - this.applyViewLevel(); - } - - private renderHudCanvases() { - drawMinimap({ - context: this.minimapContext, - width: this.minimapEl.width, - height: this.minimapEl.height, - systems: this.systems, - stations: this.stations, - ships: this.ships, - selection: this.selection, - selectedStation: this.selectedStation, - cameraFocus: this.getCameraFocus(), - }); - drawStrategicOverlay({ - context: this.strategicOverlayContext, - width: this.strategicOverlayEl.width, - height: this.strategicOverlayEl.height, - camera: this.camera, - systems: this.systems, - stations: this.stations, - ships: this.ships, - selection: this.selection, - selectedStation: this.selectedStation, - viewLevel: this.viewLevel, - }); - } - - private updateHud() { - const selectedDefinition = constructibleDefinitions[this.selectedConstructible]; - const system = this.systems[this.selectedSystemIndex] ?? this.systems[0]; - const selectedCount = - this.selection.length + (this.selectedStation ? 1 : 0) + (this.selectedSystem ? 1 : 0) + (this.selectedPlanet ? 1 : 0) + (this.selectedNode ? 1 : 0); - this.selectionTitleEl.textContent = getSelectionTitle( - this.selection, - this.selectedStation, - this.selectedSystem, - this.selectedPlanet, - this.selectedNode, - ); - this.selectionStripEl.innerHTML = getSelectionCardsMarkup( - this.selection, - this.selectedStation, - this.selectedSystem, - this.selectedPlanet, - this.selectedNode, - ); - const hasExplicitSelection = Boolean(this.selectedStation || this.selectedSystem || this.selectedPlanet || this.selectedNode || this.selection.length > 0); - this.detailsEl.textContent = hasExplicitSelection - ? "" - : getSelectionDetails( - this.selection, - this.selectedStation, - this.selectedSystem, - this.selectedPlanet, - this.selectedNode, - this.systems, - this.viewLevel, - this.ships, - this.factions, - ); - this.detailsEl.style.display = hasExplicitSelection ? "none" : "block"; - const sessionButton = this.sessionActionsEl.querySelector("button"); - if (sessionButton) { - sessionButton.textContent = `New Universe (${this.universe.systems.length})`; - } - this.sessionActionsEl.title = `${this.universe.label} • ${this.universe.systems.length} systems`; - this.statusEl.textContent = this.buildMode - ? `Observer Mode: ${selectedDefinition.label} preview in ${system.definition.label} • ${this.viewLevel} view • ${this.universe.systems.length} systems` - : `Game Master Mode: ${selectedCount} inspected • Camera ${system.definition.label} • ${this.viewLevel} view • ${this.universe.systems.length} systems${this.followShipId ? " • following ship" : ""}`; - this.ordersEl.dataset.mode = this.selectedStation ? "station" : this.selection.length > 0 ? "ships" : "none"; - this.fleetWindowEl.dataset.open = this.windowState["fleet-command"] ? "true" : "false"; - this.fleetWindowTitleEl.textContent = "Ships"; - const shipWindowMarkup = getShipWindowMarkup(this.ships, this.selection); - if (shipWindowMarkup !== this.lastShipWindowMarkup) { - this.fleetWindowBodyEl.innerHTML = shipWindowMarkup; - this.lastShipWindowMarkup = shipWindowMarkup; - } - this.debugWindowEl.dataset.open = this.windowState.debug ? "true" : "false"; - this.debugAutoScrollToggleEl.textContent = this.debugAutoScroll ? "Pause Scroll" : "Resume Scroll"; - const debugShip = this.getDebugShip(); - this.debugCopyHistoryEl.disabled = !debugShip; - const previousScrollTop = this.debugHistoryEl.scrollTop; - const previousScrollHeight = this.debugHistoryEl.scrollHeight; - const debugHistoryMarkup = getDebugHistoryMarkup(debugShip, this.shipHistoryById); - if (debugHistoryMarkup !== this.lastDebugHistoryMarkup) { - this.debugHistoryEl.innerHTML = debugHistoryMarkup; - this.lastDebugHistoryMarkup = debugHistoryMarkup; - if (this.debugAutoScroll) { - this.debugHistoryEl.scrollTop = 0; - } else { - this.debugHistoryEl.scrollTop = previousScrollTop + (this.debugHistoryEl.scrollHeight - previousScrollHeight); - } - } else if (this.debugAutoScroll) { - this.debugHistoryEl.scrollTop = 0; - } else { - this.debugHistoryEl.scrollTop = previousScrollTop; - } - } - - private updateMouse(clientX: number, clientY: number) { - this.mouse.x = (clientX / window.innerWidth) * 2 - 1; - this.mouse.y = -(clientY / window.innerHeight) * 2 + 1; - } - - private clearSelection() { - this.selectionManager.clear(); - } - - private updateMarqueeBox(clientX: number, clientY: number) { - if (!this.marqueeStart) { - return; - } - const left = Math.min(this.marqueeStart.x, clientX); - const top = Math.min(this.marqueeStart.y, clientY); - const width = Math.abs(clientX - this.marqueeStart.x); - const height = Math.abs(clientY - this.marqueeStart.y); - this.marqueeEl.style.display = this.marqueeActive ? "block" : "none"; - this.marqueeEl.style.left = `${left}px`; - this.marqueeEl.style.top = `${top}px`; - this.marqueeEl.style.width = `${width}px`; - this.marqueeEl.style.height = `${height}px`; - } - - private hideMarqueeBox() { - this.marqueeEl.style.display = "none"; - this.marqueeEl.style.width = "0"; - this.marqueeEl.style.height = "0"; - } - - private applyMarqueeSelection(clientX: number, clientY: number) { - if (!this.marqueeStart) { - return; - } - const left = Math.min(this.marqueeStart.x, clientX); - const right = Math.max(this.marqueeStart.x, clientX); - const top = Math.min(this.marqueeStart.y, clientY); - const bottom = Math.max(this.marqueeStart.y, clientY); - - if (!this.marqueeModifiers.shift && !this.marqueeModifiers.ctrl) { - this.clearSelection(); - } - - this.ships.forEach((ship) => { - if (ship.state === "docked" || !ship.group.visible) { - return; - } - const screen = ship.group.position.clone().project(this.camera); - if (screen.z < -1 || screen.z > 1) { - return; - } - const sx = ((screen.x + 1) * 0.5) * window.innerWidth; - const sy = ((-screen.y + 1) * 0.5) * window.innerHeight; - const inside = sx >= left && sx <= right && sy >= top && sy <= bottom; - if (!inside) { - return; - } - if (this.marqueeModifiers.ctrl && this.selection.includes(ship)) { - this.selectionManager.removeShip(ship); - } else { - this.selectionManager.addShip(ship); - } - }); - - this.updateHud(); - } - - private getCameraFocus() { - return this.cameraFocus; - } -} diff --git a/src/game/data/catalog.ts b/src/game/data/catalog.ts deleted file mode 100644 index 65d552c..0000000 --- a/src/game/data/catalog.ts +++ /dev/null @@ -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])); diff --git a/src/game/state/inventory.ts b/src/game/state/inventory.ts deleted file mode 100644 index df8b052..0000000 --- a/src/game/state/inventory.ts +++ /dev/null @@ -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; -} diff --git a/src/game/state/selectionManager.ts b/src/game/state/selectionManager.ts deleted file mode 100644 index e47e710..0000000 --- a/src/game/state/selectionManager.ts +++ /dev/null @@ -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; - } - } -} diff --git a/src/game/types.ts b/src/game/types.ts deleted file mode 100644 index 98a9212..0000000 --- a/src/game/types.ts +++ /dev/null @@ -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>; - 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>; -} - -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; - 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; - 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; - dockedShipIds: Set; - 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; -} diff --git a/src/game/ui/hud.ts b/src/game/ui/hud.ts deleted file mode 100644 index 0a2203e..0000000 --- a/src/game/ui/hud.ts +++ /dev/null @@ -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 = ` - -
-
-

No Selection

-
-
-
- - -
-
-
-
- -
-
-
-

Ships

-
-
-
- -
-
-
-
-

Debug

-

Simulation controls

-
- -
-
-
- - - -
-
-
- -
-
- `; - - container.append(root); - initializeWindowInteractions(root); - root.querySelectorAll("[data-window-action]").forEach((button) => { - button.addEventListener("click", () => handlers.onWindowAction(button.dataset.windowAction ?? "")); - }); - const fleetWindowBody = root.querySelector(".fleet-window-body"); - fleetWindowBody?.addEventListener("click", (event) => { - const mouseEvent = event as MouseEvent; - const target = event.target as HTMLElement; - const selectionNode = target.closest("[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(".minimap"); - const minimapContext = minimap?.getContext("2d"); - if (!minimap || !minimapContext) { - throw new Error("Unable to create minimap canvas"); - } - - const strategicOverlay = root.querySelector(".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(".app-window").forEach((windowEl) => { - initializeWindowPosition(windowEl); - - const header = windowEl.querySelector(".window-header"); - const resizeHandle = windowEl.querySelector(".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`; -} diff --git a/src/game/ui/presenters.ts b/src/game/ui/presenters.ts deleted file mode 100644 index d8ca55e..0000000 --- a/src/game/ui/presenters.ts +++ /dev/null @@ -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 `No active selection`; - } - 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 `
No ships online.
`; - } - - 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(); - sortedShips.forEach((ship) => { - const bucket = shipsByFaction.get(ship.factionId) ?? []; - bucket.push(ship); - shipsByFaction.set(ship.factionId, bucket); - }); - - return [...shipsByFaction.entries()] - .map( - ([factionId, factionShips]) => ` -
-

${factionId}

- ${factionShips - .map( - (ship) => ` -
- ${ship.definition.label} - ${describeShipNode(ship)} -
- `, - ) - .join("")} -
- `, - ) - .join(""); -} - -export function getDebugHistoryMarkup( - selectedShip: ShipInstance | undefined, - historyByShipId: Map, -) { - if (!selectedShip) { - return `
Select a ship to inspect its history.
`; - } - const entries = historyByShipId.get(selectedShip.id); - if (!entries || entries.length === 0) { - return ` -
-

${escapeHtml(selectedShip.definition.label)} • ${escapeHtml(selectedShip.id)}

-
No ship history recorded yet.
-
- `; - } - const destination = getControllerTaskDestinationLabel(selectedShip) ?? "none"; - const anchor = selectedShip.landedDestination - ? `${selectedShip.landedDestination.label} @ ${selectedShip.landedDestination.systemId}` - : "free"; - return ` -
-

${escapeHtml(selectedShip.definition.label)} • ${escapeHtml(selectedShip.id)}

-
-
Order: ${escapeHtml(getOrderSummary(selectedShip))}
-
Default behavior: ${escapeHtml(getBehaviorSummary(selectedShip))}
-
Assignment: ${escapeHtml(getAssignmentSummary(selectedShip))}
-
Controller task: ${escapeHtml(selectedShip.controllerTask.kind)}
-
Flight state: ${escapeHtml(selectedShip.state)}
-
Task target: ${escapeHtml(destination)}
-
Anchor: ${escapeHtml(anchor)}
-
- ${entries.map((entry) => `
${escapeHtml(entry)}
`).join("")} -
- `; -} - -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 ` -
- ${title} - ${lines.map((line) => `${line}`).join("")} -
- `; -} - -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; -} diff --git a/src/game/ui/strategicRenderer.ts b/src/game/ui/strategicRenderer.ts deleted file mode 100644 index fd9759e..0000000 --- a/src/game/ui/strategicRenderer.ts +++ /dev/null @@ -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(); - 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)"; -} diff --git a/src/game/world/universeGenerator.ts b/src/game/world/universeGenerator.ts deleted file mode 100644 index ee1f5ea..0000000 --- a/src/game/world/universeGenerator.ts +++ /dev/null @@ -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; - }; -} diff --git a/src/game/world/worldFactory.ts b/src/game/world/worldFactory.ts deleted file mode 100644 index 5851284..0000000 --- a/src/game/world/worldFactory.ts +++ /dev/null @@ -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; - strategicLinks: THREE.Group; - starfield?: THREE.Points; -} - -export function buildInitialWorld( - scene: THREE.Scene, - selectableTargets: Map, - systemsDefinition: SolarSystemDefinition[], - scenarioDefinition: ScenarioDefinition, -): BuildWorldResult { - const systems: SolarSystemInstance[] = []; - const nodes: ResourceNode[] = []; - const stations: StationInstance[] = []; - const ships: ShipInstance[] = []; - const shipsById = new Map(); - 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, -) { - 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, -) { - 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(); - - 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>; - selectableTargets: Map; -}) { - 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; -}) { - 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(); -} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index bd0a953..0000000 --- a/src/main.ts +++ /dev/null @@ -1,11 +0,0 @@ -import "./style.css"; -import { GameApp } from "./game/GameApp"; - -const appRoot = document.querySelector("#app"); - -if (!appRoot) { - throw new Error("Missing #app root element"); -} - -const game = new GameApp(appRoot); -game.start(); diff --git a/src/style.css b/src/style.css deleted file mode 100644 index c1174af..0000000 --- a/src/style.css +++ /dev/null @@ -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); - } -} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index da3941f..0000000 --- a/vite.config.ts +++ /dev/null @@ -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, - }, -});