Add fuel logistics, modular construction, and pad docking

This commit is contained in:
2026-03-13 15:21:16 -04:00
parent 95dd550fdb
commit bf744ec43e
16 changed files with 1128 additions and 282 deletions

View File

@@ -1,101 +1,74 @@
# Next Steps # Next Steps
## Galaxy / Viewer Fit ## Immediate Backend Follow-Up
The world is now much larger and more varied: The simulation now has the beginnings of a self-sustaining bootstrap:
- roughly galaxy-scale system counts - ore miner
- elevated system height variance - gas miner
- procedural orbital metadata - station-side `gas -> fuel`
- moons, rings, binary stars, asteroid belts, gas clouds - constructor-led station module installation
- special-case `Sol` system content - pad-based docking with reservation
- timed mining, spool-up, docking, and undocking
The next step is not “make the map larger.” That is already done for the current runtime. The next step is to close the remaining logistics and simulation gaps around that bootstrap.
Recommended work: Recommended work:
- tune galaxy readability at scale - add constructor material logistics
- better starfield depth cues - allow the constructor to fetch module materials instead of assuming they are already on the target station
- stronger color/size differentiation for star classes - add pickup / load phases and delivery priorities
- improved system label decluttering - expose station installed modules and active construction to the viewer
- add galaxy navigation affordances - installed modules
- jump-to-system search - current build target
- constellation / region overlays - build progress
- bookmarks for notable systems such as `Sol` - harden dock management
- tighten viewer performance - make dock eligibility depend on dock type / ship class if needed
- reduce orbit/moon draw cost when zoomed out - surface pad occupancy in debug UI
- pool or simplify distant celestial meshes - add stronger action timing coverage
- profile high-system-count scenes - timed final approach / berthing if desired
- timed station-side processing queues if multiple modules run concurrently
- add recovery behavior for stranded ships
- rescue / tow / emergency refuel
- better handling after full fuel and power depletion
## Economic Growth ## Economy / Logistics
The current economy already supports: The economy now has a more explicit resource chain:
1. mining ore 1. mining ore
2. hauling to refining 2. harvesting gas
3. refining / fabricating goods 3. processing `gas -> fuel`
4. spending those goods on ships and outposts 4. refining ore
5. spending refined goods on station growth
The next step is not “invent a use for refined goods.” That use already exists. The next step is to make those logistics deliberate instead of bootstrap-scripted.
The next step is to make faction growth more intentional and legible.
Recommended work: Recommended work:
- make shipbuilding priorities reactive - split logistics roles more clearly
- build more miners / haulers when ore throughput is low - ore miner
- build escorts when industrial losses rise - gas miner
- build warships when frontier pressure rises - hauler / constructor transport
- make expansion logic consume the economy more visibly - make station build priorities responsive
- use industrial stock to claim and fortify central systems - fuel chain first when reserves are low
- expose production pressure in UI - ore refining when fuel is stable
- show ore throughput - docking expansion when traffic backs up
- show fabricated goods - make fuel scarcity visible in debug UI
- show queued faction priorities - fuel throughput
- make resource type differences matter - gas stock
- ore belts vs gas clouds - dock contention
- gas-aware logistics and production choices - move away from generic node selection
- let miners prefer nearby valid nodes
## Pirate Harassment - factor travel cost and dock turnaround into throughput planning
Pirates already exist and can raid, fight, and destroy ships.
What they are missing is sharper industrial harassment behavior.
Recommended work:
- prioritize miners, haulers, and refinery approaches as pirate targets
- add local threat weighting around:
- resource nodes
- refinery docking lanes
- undefended transport routes
- force empires to react by:
- escorting miners
- patrolling refinery systems
- building defensive stations sooner
This will make the industrial loop produce strategic tension instead of just passive growth.
## High-Value Gameplay Sequence
The most useful short-term gameplay loop to solidify is:
1. miners feed refining
2. refining feeds ship production
3. pirates harass industry
4. empires respond with escorts, patrols, and new outposts
5. stronger economies produce stronger military presence
6. system control shifts based on industrial strength and protection
That turns the simulation into a real strategy loop.
## Concrete Implementation Order ## Concrete Implementation Order
1. Add viewer-scale performance controls for the larger galaxy. 1. Add constructor pickup / delivery behavior for module materials.
2. Add faction production heuristics based on current economy and losses. 2. Expose station installed modules, dock pads, and active construction in the contracts and viewer.
3. Make pirate target selection explicitly prefer economic targets. 3. Add rescue / recovery behavior for power-starved ships.
4. Surface faction stocks, throughput, and build priorities in the HUD/debug views. 4. Add faction build priorities based on fuel, ore throughput, and dock saturation.
5. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`. 5. Improve pirate targeting so industrial ships and docking lanes are high-value harassment targets.
6. Break backend simulation responsibilities into smaller planning / faction / combat / logistics modules. 6. Break backend simulation responsibilities into smaller planning / faction / combat / logistics modules.
## Network / Multiplayer ## Network / Multiplayer
@@ -133,68 +106,31 @@ Recommended work:
- versioning - versioning
- reconnect / catch-up semantics - reconnect / catch-up semantics
## Viewer / Debugging
The viewer still works as an observer/debug client first.
Recommended work:
- fix the current `followedShipId` regression in `GameViewer.ts`
- show station module state and construction state
- show dock occupancy and waiting ships
- expose fuel-chain health
- gas stock
- fuel stock
- refinery / processor activity
- improve event typing for:
- dock request
- dock granted
- refuel
- construction started / completed
## Interest Management ## Interest Management
The current stream is world-wide. The stream is still world-wide.
That means every observer receives deltas for the full simulation, even when only looking at one part of space.
Recommended work: Recommended work:
- add observer/view-scoped subscriptions - add observer/view-scoped subscriptions
- visible systems
- nearby ships / stations / nodes
- faction-scoped or player-scoped channels later
- support subscribe / unsubscribe as camera focus changes
- send only relevant deltas per observer - send only relevant deltas per observer
- keep coarse strategic updates available for off-screen context - keep strategic summaries for off-screen context
- system ownership
- major combat
- economy summaries
This is the key step that makes many simultaneous observers practical without broadcasting the entire world to everyone.
## Replication Quality
The backend already sends:
- initial snapshot
- incremental deltas
- event records
Recommended work:
- add stronger event typing
- spawn
- destroy
- dock
- undock
- cargo transfer
- combat hit / kill
- improve interpolation and extrapolation policies per entity type
- add per-layer presentation tuning in the viewer
- smoother fade bands between local / system / universe
- better visual density control at galaxy scale
- moon/orbit LOD based on zoom level
- add resync handling when a client falls too far behind
- consider switching from SSE to websocket transport if bidirectional command traffic becomes heavy
## Celestial Depth
The current celestial layer is procedurally rich, but still mostly decorative outside of resource nodes.
Recommended work:
- add authored moon metadata when needed
- labels
- resource-bearing moons
- special landmarks
- support multiple belts / cloud bands per system explicitly
- add stellar gameplay hooks
- hazardous neutron-star systems
- high-value binary systems
- rich-gas outer systems
- expose notable-system summaries in the viewer
- star class
- resource profile
- moon count

View File

@@ -108,15 +108,27 @@ The backend simulation already includes:
- autonomous ships - autonomous ships
- orbital travel - orbital travel
- docking and undocking - pad-based docking and undocking
- stations expose docking capacity through installed dock-bay modules
- ships reserve an empty pad before docking
- ships wait in a holding pattern when no pad is available
- mining and refinery delivery - mining and refinery delivery
- module-gated ship and station capabilities - module-gated ship and station capabilities
- ships require fitted modules such as reactor, capacitor, and mining or gun turrets - ships require fitted modules such as reactor, capacitor, mining turret, gas extractor, or gun turrets
- stations require fitted modules such as power, refinery, and storage modules - stations require installed modules such as power, docking, refinery, fuel processing, and storage modules
- fuel-to-energy power simulation - fuel-to-energy power simulation
- ship reactors consume `gas` fuel to charge capacitors - gas clouds provide raw `gas`
- station power cores consume `gas` fuel to charge station energy buffers - station fuel processors convert `gas` into `fuel`
- ship reactors consume `fuel` to charge capacitors
- station power cores consume `fuel` to charge station energy buffers
- powered actions stop when fuel and energy are depleted - powered actions stop when fuel and energy are depleted
- constructor-led station module construction
- stations now track installed modules per instance instead of relying only on static constructible definitions
- module construction uses station inventory plus timed build progress
- explicit action timing in the control loop
- mining now runs on a fixed cycle
- warp and FTL travel require spool time
- docking and undocking have explicit durations
- refining / fabrication - refining / fabrication
- faction growth through ship and outpost production - faction growth through ship and outpost production
- pirate pressure and combat - pirate pressure and combat
@@ -129,7 +141,7 @@ The backend simulation already includes:
- systems in galaxy space - systems in galaxy space
- in-system entities in local space - in-system entities in local space
- item-based inventories for ships and stations - item-based inventories for ships and stations
- ore, refined metals, gas fuel, and cargo now flow through per-item inventories instead of ad hoc stock fields - ore, refined metals, raw gas, fuel, and cargo now flow through per-item inventories instead of ad hoc stock fields
The runtime model still follows the intended layered control architecture: The runtime model still follows the intended layered control architecture:
@@ -164,13 +176,20 @@ The runtime model still follows the intended layered control architecture:
- one modular station - one modular station
- one constructor ship - one constructor ship
- one mining ship - one mining ship
- added a gas mining ship for bootstrap fuel logistics
- moved the starting ships close to the `helios` star and next to each other - moved the starting ships close to the `helios` star and next to each other
- added modular ship and station data - added modular ship and station data
- new station power and storage modules - new station power and storage modules
- new ship reactor, capacitor, mining turret, and gun turret modules - new fuel processing and docking bay modules
- new ship reactor, capacitor, mining turret, gas extractor, and gun turret modules
- refactored simulation inventories to per-item storage - refactored simulation inventories to per-item storage
- stations and ships now replicate inventories instead of specialized ore/refined/cargo counters - stations and ships now replicate inventories instead of specialized ore/refined/cargo counters
- added fuel-driven power generation and energy consumption in the simulation loop - split raw `gas` from burnable `fuel` in the simulation loop
- added module recipe data and per-station installed-module runtime state
- added constructor-led station module construction for the bootstrap station
- added gas harvesting, gas-to-fuel processing, and explicit ship refueling behavior
- reworked docking into pad reservation with visible stand-off positions instead of snapping ships into station centers
- added action timing for mining cycles, warp / FTL spool-up, and undocking
- replaced the viewer bottom faction strip with a horizontal ship-card debugging rail - replaced the viewer bottom faction strip with a horizontal ship-card debugging rail
- added movable, resizable, multi-window history panels in the viewer - added movable, resizable, multi-window history panels in the viewer
- fixed the auto-miner undock controller transition - fixed the auto-miner undock controller transition
@@ -190,9 +209,14 @@ The runtime model still follows the intended layered control architecture:
- the galaxy is much larger now, so viewer performance and visual density need active tuning - the galaxy is much larger now, so viewer performance and visual density need active tuning
- moon rendering is procedural from counts, not authored moon-by-moon data - moon rendering is procedural from counts, not authored moon-by-moon data
- resource extraction behavior still treats all resource nodes generically - resource extraction behavior still treats all resource nodes generically
- item inventories exist, but storage enforcement and module-slot restrictions are still lightweight - item inventories exist, but storage/module restrictions are still partial
- cargo/storage compatibility is mostly data-convention driven - station storage capacity is now enforced by storage class and installed module
- ship cargo compatibility is still mostly data-convention driven
- hull-specific module restrictions are not enforced yet - hull-specific module restrictions are not enforced yet
- constructor logic only builds from station-local inventory
- it does not yet fetch module materials from other stations or ships
- station installed modules and active construction are not yet exposed in the viewer contract
- some viewer follow-camera code is currently broken by a pre-existing missing `followedShipId` property
- piracy and faction growth are still functional rather than strategically deep - piracy and faction growth are still functional rather than strategically deep
- no persistence for saves, seeds, or reconnect state - no persistence for saves, seeds, or reconnect state
@@ -205,26 +229,31 @@ The runtime model still follows the intended layered control architecture:
- [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs) - [apps/backend/Simulation/SimulationEngine.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/SimulationEngine.cs)
- simulation advancement - simulation advancement
- snapshot / delta composition for galaxy-space systems and local-space entities - snapshot / delta composition for galaxy-space systems and local-space entities
- inventory, fuel, and energy processing - inventory, gas/fuel, and energy processing
- auto-miner undock state transition fix - station module construction
- gas harvesting, refueling, and pad-based docking
- action timing for mining, spool-up, docking, and undocking
- [apps/backend/Simulation/ScenarioLoader.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs) - [apps/backend/Simulation/ScenarioLoader.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/ScenarioLoader.cs)
- faction bootstrap - faction bootstrap
- galaxy generation - galaxy generation
- special systems - special systems
- procedural celestial/resource content - procedural celestial/resource content
- normalization of authored placements into system-local space - normalization of authored placements into system-local space
- minimal startup seeding for ships, station, and fuel - minimal startup seeding for ships, station, fuel, and module materials
- [apps/backend/Contracts/WorldContracts.cs](/home/jbourdon/repos/space-game/apps/backend/Contracts/WorldContracts.cs) - [apps/backend/Contracts/WorldContracts.cs](/home/jbourdon/repos/space-game/apps/backend/Contracts/WorldContracts.cs)
- snapshot and delta contract shape - snapshot and delta contract shape
- station dock-pad count
- [apps/backend/Simulation/RuntimeModels.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs) - [apps/backend/Simulation/RuntimeModels.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs)
- runtime vector math and world model - runtime vector math and world model
- per-item inventories on ships and stations - per-item inventories on ships and stations
- per-station installed modules and docking pad assignments
- [apps/viewer/src/GameViewer.ts](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts) - [apps/viewer/src/GameViewer.ts](/home/jbourdon/repos/space-game/apps/viewer/src/GameViewer.ts)
- camera, selection, streaming integration, and presentation - camera, selection, streaming integration, and presentation
- layered local/remote system presentation - layered local/remote system presentation
- orbital reconstruction and moon rendering - orbital reconstruction and moon rendering
- projected shell markers and hover labels - projected shell markers and hover labels
- ship card list and multi-window history debugging UI - ship card list and multi-window history debugging UI
- station HUD docked/pad count readout
- [apps/viewer/src/style.css](/home/jbourdon/repos/space-game/apps/viewer/src/style.css) - [apps/viewer/src/style.css](/home/jbourdon/repos/space-game/apps/viewer/src/style.css)
- HUD layout - HUD layout
- ship-card rail - ship-card rail
@@ -235,14 +264,19 @@ The runtime model still follows the intended layered control architecture:
- viewer-side snapshot contract for galaxy-space systems and local-space entities - viewer-side snapshot contract for galaxy-space systems and local-space entities
- [shared/data](/home/jbourdon/repos/space-game/shared/data) - [shared/data](/home/jbourdon/repos/space-game/shared/data)
- scenario and world data definitions - scenario and world data definitions
- `module-recipes.json` now defines timed module construction costs
## Validation ## Validation
Validation passing at the end of this session: Validation passing at the end of this session:
- `dotnet build apps/backend/SpaceGame.Simulation.Api.csproj` - `dotnet build apps/backend/SpaceGame.Simulation.Api.csproj`
Validation currently failing / blocked:
- `cd apps/viewer && npm run build` - `cd apps/viewer && npm run build`
- fails because `apps/viewer/src/GameViewer.ts` references a missing `followedShipId` property
## Last Commit ## Last Commit
- `1747d84` - `ef62577`

View File

@@ -86,6 +86,7 @@ public sealed record StationSnapshot(
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string Color, string Color,
int DockedShips, int DockedShips,
int DockingPads,
float EnergyStored, float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
string FactionId); string FactionId);
@@ -98,6 +99,7 @@ public sealed record StationDelta(
Vector3Dto LocalPosition, Vector3Dto LocalPosition,
string Color, string Color,
int DockedShips, int DockedShips,
int DockingPads,
float EnergyStored, float EnergyStored,
IReadOnlyList<InventoryEntry> Inventory, IReadOnlyList<InventoryEntry> Inventory,
string FactionId); string FactionId);

View File

@@ -5,8 +5,10 @@ public sealed class BalanceDefinition
public float YPlane { get; set; } public float YPlane { get; set; }
public float ArrivalThreshold { get; set; } public float ArrivalThreshold { get; set; }
public float MiningRate { get; set; } public float MiningRate { get; set; }
public float MiningCycleSeconds { get; set; }
public float TransferRate { get; set; } public float TransferRate { get; set; }
public float DockingDuration { get; set; } public float DockingDuration { get; set; }
public float UndockingDuration { get; set; }
public float UndockDistance { get; set; } public float UndockDistance { get; set; }
public EnergyBalanceDefinition Energy { get; set; } = new(); public EnergyBalanceDefinition Energy { get; set; } = new();
public FuelBalanceDefinition Fuel { get; set; } = new(); public FuelBalanceDefinition Fuel { get; set; } = new();
@@ -68,6 +70,19 @@ public sealed class ItemDefinition
public string Summary { get; set; } = string.Empty; public string Summary { get; set; } = string.Empty;
} }
public sealed class RecipeInputDefinition
{
public required string ItemId { get; set; }
public float Amount { get; set; }
}
public sealed class ModuleRecipeDefinition
{
public required string ModuleId { get; set; }
public float Duration { get; set; }
public required List<RecipeInputDefinition> Inputs { get; set; }
}
public sealed class PlanetDefinition public sealed class PlanetDefinition
{ {
public required string Label { get; set; } public required string Label { get; set; }

View File

@@ -14,6 +14,7 @@ public sealed class SimulationWorld
public required List<FactionRuntime> Factions { get; init; } public required List<FactionRuntime> Factions { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; } public required Dictionary<string, ShipDefinition> ShipDefinitions { get; init; }
public required Dictionary<string, ItemDefinition> ItemDefinitions { get; init; } public required Dictionary<string, ItemDefinition> ItemDefinitions { get; init; }
public required Dictionary<string, ModuleRecipeDefinition> ModuleRecipes { get; init; }
public int TickIntervalMs { get; init; } = 200; public int TickIntervalMs { get; init; } = 200;
public DateTimeOffset GeneratedAtUtc { get; set; } public DateTimeOffset GeneratedAtUtc { get; set; }
} }
@@ -43,13 +44,24 @@ public sealed class StationRuntime
public required ConstructibleDefinition Definition { get; init; } public required ConstructibleDefinition Definition { get; init; }
public required Vector3 Position { get; init; } public required Vector3 Position { get; init; }
public required string FactionId { get; init; } public required string FactionId { get; init; }
public HashSet<string> InstalledModules { get; } = new(StringComparer.Ordinal);
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal); public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public Dictionary<int, string> DockingPadAssignments { get; } = new();
public float EnergyStored { get; set; } public float EnergyStored { get; set; }
public float ProcessTimer { get; set; } public float ProcessTimer { get; set; }
public HashSet<string> DockedShipIds { get; } = []; public HashSet<string> DockedShipIds { get; } = [];
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
public string LastDeltaSignature { get; set; } = string.Empty; public string LastDeltaSignature { get; set; } = string.Empty;
} }
public sealed class ModuleConstructionRuntime
{
public required string ModuleId { get; init; }
public float ProgressSeconds { get; set; }
public float RequiredSeconds { get; init; }
public string AssignedConstructorShipId { get; set; } = string.Empty;
}
public sealed class ShipRuntime public sealed class ShipRuntime
{ {
public required string Id { get; init; } public required string Id { get; init; }
@@ -67,6 +79,7 @@ public sealed class ShipRuntime
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal); public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public float EnergyStored { get; set; } public float EnergyStored { get; set; }
public string? DockedStationId { get; set; } public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public float Health { get; set; } public float Health { get; set; }
public List<string> History { get; } = []; public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty; public string LastSignature { get; set; } = string.Empty;
@@ -98,8 +111,10 @@ public sealed class DefaultBehaviorRuntime
{ {
public required string Kind { get; set; } public required string Kind { get; set; }
public string? AreaSystemId { get; set; } public string? AreaSystemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; } public string? RefineryId { get; set; }
public string? NodeId { get; set; } public string? NodeId { get; set; }
public string? ModuleId { get; set; }
public string? Phase { get; set; } public string? Phase { get; set; }
public List<Vector3> PatrolPoints { get; set; } = []; public List<Vector3> PatrolPoints { get; set; } = [];
public int PatrolIndex { get; set; } public int PatrolIndex { get; set; }

View File

@@ -88,11 +88,13 @@ public sealed class ScenarioLoader
var ships = Read<List<ShipDefinition>>("ships.json"); var ships = Read<List<ShipDefinition>>("ships.json");
var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json"); var constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
var items = Read<List<ItemDefinition>>("items.json"); var items = Read<List<ItemDefinition>>("items.json");
var moduleRecipes = Read<List<ModuleRecipeDefinition>>("module-recipes.json");
var balance = Read<BalanceDefinition>("balance.json"); var balance = Read<BalanceDefinition>("balance.json");
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal); var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var moduleRecipeDefinitions = moduleRecipes.ToDictionary((definition) => definition.ModuleId, StringComparer.Ordinal);
var systemRuntimes = systems var systemRuntimes = systems
.Select((definition) => new SystemRuntime .Select((definition) => new SystemRuntime
{ {
@@ -141,17 +143,23 @@ public sealed class ScenarioLoader
Position = ResolveStationPosition(system, plan, balance), Position = ResolveStationPosition(system, plan, balance),
FactionId = plan.FactionId ?? DefaultFactionId, FactionId = plan.FactionId ?? DefaultFactionId,
}); });
foreach (var moduleId in definition.Modules)
{
stations[^1].InstalledModules.Add(moduleId);
}
} }
foreach (var station in stations) foreach (var station in stations)
{ {
station.Inventory["gas"] = 320f; station.Inventory["fuel"] = 240f;
station.Inventory["refined-metals"] = 120f;
} }
var refinery = stations.FirstOrDefault((station) => var refinery = stations.FirstOrDefault((station) =>
HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank") && HasInstalledModules(station, "power-core", "liquid-tank") &&
station.SystemId == scenario.MiningDefaults.RefinerySystemId) station.SystemId == scenario.MiningDefaults.RefinerySystemId)
?? stations.FirstOrDefault((station) => HasModules(station.Definition, "refinery-stack", "power-core", "liquid-tank", "gas-tank")); ?? stations.FirstOrDefault((station) => HasInstalledModules(station, "power-core", "liquid-tank"));
var patrolRoutes = scenario.PatrolRoutes.ToDictionary( var patrolRoutes = scenario.PatrolRoutes.ToDictionary(
(route) => route.SystemId, (route) => route.SystemId,
@@ -185,9 +193,15 @@ public sealed class ScenarioLoader
}); });
shipsRuntime[^1].Inventory["gas"] = definition.Id switch shipsRuntime[^1].Inventory["gas"] = definition.Id switch
{
_ => 0f,
};
shipsRuntime[^1].Inventory.Remove("gas");
shipsRuntime[^1].Inventory["fuel"] = definition.Id switch
{ {
"constructor" => 90f, "constructor" => 90f,
"miner" => 90f, "miner" => 90f,
"gas-miner" => 90f,
_ => 120f, _ => 120f,
}; };
} }
@@ -208,6 +222,7 @@ public sealed class ScenarioLoader
Factions = factions, Factions = factions,
ShipDefinitions = shipDefinitions, ShipDefinitions = shipDefinitions,
ItemDefinitions = itemDefinitions, ItemDefinitions = itemDefinitions,
ModuleRecipes = moduleRecipeDefinitions,
GeneratedAtUtc = DateTimeOffset.UtcNow, GeneratedAtUtc = DateTimeOffset.UtcNow,
}; };
} }
@@ -818,13 +833,33 @@ public sealed class ScenarioLoader
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes, IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
StationRuntime? refinery) StationRuntime? refinery)
{ {
if (HasModules(definition, "fabricator-array", "docking-clamps") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "construct-station",
StationId = refinery.Id,
Phase = "travel-to-station",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "gas-extractor") && refinery is not null)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-harvest-gas",
StationId = refinery.Id,
Phase = "travel-to-node",
};
}
if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null) if (HasModules(definition, "reactor-core", "capacitor-bank", "mining-turret") && refinery is not null)
{ {
return new DefaultBehaviorRuntime return new DefaultBehaviorRuntime
{ {
Kind = "auto-mine", Kind = "auto-mine",
AreaSystemId = scenario.MiningDefaults.NodeSystemId, AreaSystemId = scenario.MiningDefaults.NodeSystemId,
RefineryId = refinery.Id, StationId = refinery.Id,
Phase = "travel-to-node", Phase = "travel-to-node",
}; };
} }
@@ -883,6 +918,9 @@ public sealed class ScenarioLoader
private static bool HasModules(ConstructibleDefinition definition, params string[] modules) => private static bool HasModules(ConstructibleDefinition definition, params string[] modules) =>
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));
private static bool HasInstalledModules(StationRuntime station, params string[] modules) =>
modules.All((moduleId) => station.InstalledModules.Contains(moduleId, StringComparer.Ordinal));
private static bool HasModules(ShipDefinition definition, params string[] modules) => private static bool HasModules(ShipDefinition definition, params string[] modules) =>
modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal)); modules.All((moduleId) => definition.Modules.Contains(moduleId, StringComparer.Ordinal));

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ import type {
type ZoomLevel = "local" | "system" | "universe"; type ZoomLevel = "local" | "system" | "universe";
type SelectionGroup = "ships" | "structures" | "celestials"; type SelectionGroup = "ships" | "structures" | "celestials";
type DragMode = "orbit" | "marquee"; type DragMode = "orbit" | "marquee";
type CameraMode = "tactical" | "follow";
type Selectable = type Selectable =
| { kind: "ship"; id: string } | { kind: "ship"; id: string }
| { kind: "station"; id: string } | { kind: "station"; id: string }
@@ -245,6 +246,7 @@ export class GameViewer {
private desiredDistance = ZOOM_DISTANCE.system; private desiredDistance = ZOOM_DISTANCE.system;
private orbitYaw = -2.3; private orbitYaw = -2.3;
private orbitPitch = 0.62; private orbitPitch = 0.62;
private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode; private dragMode?: DragMode;
private dragPointerId?: number; private dragPointerId?: number;
private dragStart = new THREE.Vector2(); private dragStart = new THREE.Vector2();
@@ -252,7 +254,12 @@ export class GameViewer {
private marqueeActive = false; private marqueeActive = false;
private suppressClickSelection = false; private suppressClickSelection = false;
private activeSystemId?: string; private activeSystemId?: string;
private followedShipId?: string; private cameraTargetShipId?: string;
private readonly followCameraPosition = new THREE.Vector3();
private readonly followCameraFocus = new THREE.Vector3();
private readonly followCameraDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraDesiredDirection = new THREE.Vector3(0, 0.16, 1);
private readonly followCameraOffset = new THREE.Vector3();
private readonly historyWindows: HistoryWindowState[] = []; private readonly historyWindows: HistoryWindowState[] = [];
private historyWindowCounter = 0; private historyWindowCounter = 0;
private historyWindowZCounter = 10; private historyWindowZCounter = 10;
@@ -334,6 +341,7 @@ export class GameViewer {
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick); this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false }); this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
this.factionStripEl.addEventListener("click", this.onShipStripClick); this.factionStripEl.addEventListener("click", this.onShipStripClick);
this.factionStripEl.addEventListener("dblclick", this.onShipStripDoubleClick);
this.historyLayerEl.addEventListener("click", this.onHistoryLayerClick); this.historyLayerEl.addEventListener("click", this.onHistoryLayerClick);
this.historyLayerEl.addEventListener("pointerdown", this.onHistoryLayerPointerDown); this.historyLayerEl.addEventListener("pointerdown", this.onHistoryLayerPointerDown);
window.addEventListener("pointermove", this.onHistoryWindowPointerMove); window.addEventListener("pointermove", this.onHistoryWindowPointerMove);
@@ -709,18 +717,30 @@ export class GameViewer {
.map((ship) => { .map((ship) => {
const fuel = this.inventoryAmount(ship.inventory, "gas"); const fuel = this.inventoryAmount(ship.inventory, "gas");
const isSelected = this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship" && this.selectedItems[0].id === ship.id; const isSelected = this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship" && this.selectedItems[0].id === ship.id;
const isFollowed = this.followedShipId === ship.id; const isFollowed = this.cameraMode === "follow" && this.cameraTargetShipId === ship.id;
return ` return `
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}"> <article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
<div class="ship-card-header"> <div class="ship-card-header">
<h3>${ship.label}</h3> <h3>${ship.label}</h3>
<span class="ship-card-badge">${ship.shipClass}</span> <div class="ship-card-meta">
<span class="ship-card-badge">${ship.shipClass}</span>
<button
type="button"
class="ship-card-history-button"
data-history-ship-id="${ship.id}"
aria-label="Open history for ${ship.label}"
title="Open history"
>&#128340;</button>
</div>
</div> </div>
<p>${ship.systemId}</p> <p>${ship.systemId}</p>
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p> <p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
<p>State ${ship.state}</p> <p>State ${ship.state}</p>
<p>Order ${ship.orderKind ?? "none"}</p> <div class="ship-card-ai">
<button type="button" class="ship-card-history-button" data-history-ship-id="${ship.id}">Open History</button> <p>Order ${ship.orderKind ?? "none"}</p>
<p>Behavior ${ship.defaultBehaviorKind}</p>
<p>Task ${ship.controllerTaskKind}</p>
</div>
</article> </article>
`; `;
}) })
@@ -767,14 +787,12 @@ export class GameViewer {
this.detailTitleEl.textContent = ship.label; this.detailTitleEl.textContent = ship.label;
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0); const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
this.detailBodyEl.innerHTML = ` this.detailBodyEl.innerHTML = `
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</p>
<p>Parent ${parent}</p> <p>Parent ${parent}</p>
<p>State ${ship.state}<br>Behavior ${ship.defaultBehaviorKind}<br>Task ${ship.controllerTaskKind}</p> <p>State ${ship.state}</p>
<p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p> <p>Energy ${ship.energyStored.toFixed(0)}<br>Cargo ${cargoUsed.toFixed(0)} / ${ship.cargoCapacity.toFixed(0)}</p>
<p>Inventory ${this.formatInventory(ship.inventory)}</p> <p>Inventory ${this.formatInventory(ship.inventory)}</p>
<p>Velocity ${this.formatVector(ship.localVelocity)}</p> <p>Velocity ${this.formatVector(ship.localVelocity)}</p>
<p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p> <p>Camera ${this.cameraMode === "follow" && this.cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
<p>History available from the ship card list.</p>
`; `;
return; return;
} }
@@ -789,7 +807,7 @@ export class GameViewer {
this.detailBodyEl.innerHTML = ` this.detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p> <p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p> <p>Parent ${parent}</p>
<p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips}</p> <p>Energy ${station.energyStored.toFixed(0)}<br>Docked ${station.dockedShips} / ${station.dockingPads}</p>
<p>Inventory ${this.formatInventory(station.inventory)}</p> <p>Inventory ${this.formatInventory(station.inventory)}</p>
<p>History available in the separate history window.</p> <p>History available in the separate history window.</p>
`; `;
@@ -879,7 +897,10 @@ export class GameViewer {
this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta); this.currentDistance = THREE.MathUtils.damp(this.currentDistance, this.desiredDistance, 7.5, delta);
this.zoomLevel = this.classifyZoomLevel(this.currentDistance); this.zoomLevel = this.classifyZoomLevel(this.currentDistance);
this.updateActiveSystem(); this.updateActiveSystem();
this.updateFollowCamera(delta); if (this.cameraMode === "follow" && this.updateFollowCamera(delta)) {
return;
}
this.updatePanFromKeyboard(delta); this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3); this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
@@ -895,10 +916,6 @@ export class GameViewer {
} }
private updatePanFromKeyboard(delta: number) { private updatePanFromKeyboard(delta: number) {
if (this.followedShipId) {
return;
}
const move = new THREE.Vector3(); const move = new THREE.Vector3();
if (this.keyState.has("w")) { if (this.keyState.has("w")) {
move.z -= 1; move.z -= 1;
@@ -944,8 +961,8 @@ export class GameViewer {
const iconAlpha = isProjectedSystemIcon const iconAlpha = isProjectedSystemIcon
? 0 ? 0
: entry.hideIconInUniverse : entry.hideIconInUniverse
? blend.systemWeight * (isActiveDetail ? 1 : 0) ? blend.systemWeight * (isActiveDetail ? 1 : 0)
: Math.max(blend.systemWeight, blend.universeWeight); : Math.max(blend.systemWeight, blend.universeWeight);
this.setObjectOpacity(entry.detail, detailAlpha); this.setObjectOpacity(entry.detail, detailAlpha);
this.setObjectOpacity(entry.icon, iconAlpha); this.setObjectOpacity(entry.icon, iconAlpha);
@@ -1064,21 +1081,14 @@ export class GameViewer {
const now = performance.now(); const now = performance.now();
const worldTimeSeconds = this.currentWorldTimeSeconds(); const worldTimeSeconds = this.currentWorldTimeSeconds();
for (const visual of this.shipVisuals.values()) { for (const visual of this.shipVisuals.values()) {
const elapsedMs = now - visual.receivedAtMs; const worldPosition = this.getAnimatedShipLocalPosition(visual, now);
const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1);
const worldPosition = new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT);
if (blendT >= 1) {
const extrapolationSeconds = Math.min((elapsedMs - visual.blendDurationMs) / 1000, 0.35);
worldPosition.copy(visual.authoritativePosition).addScaledVector(visual.velocity, extrapolationSeconds);
}
visual.mesh.position.copy(this.toDisplayLocalPosition(worldPosition, visual.systemId)); visual.mesh.position.copy(this.toDisplayLocalPosition(worldPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.position.copy(visual.mesh.position);
const shipVisible = visual.systemId === this.activeSystemId; const shipVisible = visual.systemId === this.activeSystemId;
visual.mesh.visible = shipVisible; visual.mesh.visible = shipVisible;
visual.icon.visible = shipVisible && visual.icon.visible; visual.icon.visible = shipVisible && visual.icon.visible;
const desiredHeading = visual.targetPosition.clone().sub(worldPosition); const desiredHeading = this.resolveShipHeading(visual, worldPosition);
if (desiredHeading.lengthSq() > 0.01) { if (desiredHeading.lengthSq() > 0.01) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading)); visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
} }
@@ -1091,8 +1101,7 @@ export class GameViewer {
visual.mesh.visible = visual.systemId === this.activeSystemId; visual.mesh.visible = visual.systemId === this.activeSystemId;
} }
for (const visual of this.stationVisuals.values()) { for (const visual of this.stationVisuals.values()) {
const animatedLocalPosition = this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09); visual.mesh.position.copy(this.toDisplayLocalPosition(visual.localPosition, visual.systemId));
visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position); visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === this.activeSystemId; visual.mesh.visible = visual.systemId === this.activeSystemId;
} }
@@ -1102,6 +1111,25 @@ export class GameViewer {
this.updateSystemSummaryPresentation(); this.updateSystemSummaryPresentation();
} }
private getAnimatedShipLocalPosition(visual: ShipVisual, now = performance.now()) {
const elapsedMs = now - visual.receivedAtMs;
const blendT = THREE.MathUtils.clamp(elapsedMs / visual.blendDurationMs, 0, 1);
return new THREE.Vector3().lerpVectors(visual.startPosition, visual.authoritativePosition, blendT);
}
private resolveShipHeading(visual: ShipVisual, worldPosition: THREE.Vector3) {
const desiredHeading = visual.targetPosition.clone().sub(worldPosition);
if (desiredHeading.lengthSq() > 0.01) {
return desiredHeading;
}
if (visual.velocity.lengthSq() > 0.01) {
return visual.velocity.clone();
}
return new THREE.Vector3(Math.cos(this.orbitYaw), 0, Math.sin(this.orbitYaw));
}
private updatePlanetPresentation() { private updatePlanetPresentation() {
const nowSeconds = this.currentWorldTimeSeconds(); const nowSeconds = this.currentWorldTimeSeconds();
for (const visual of this.planetVisuals) { for (const visual of this.planetVisuals) {
@@ -1652,8 +1680,10 @@ export class GameViewer {
? new Date(this.world.generatedAtUtc).toLocaleTimeString() ? new Date(this.world.generatedAtUtc).toLocaleTimeString()
: "n/a"; : "n/a";
const activeSystem = this.activeSystemId ?? "deep-space"; const activeSystem = this.activeSystemId ?? "deep-space";
const cameraModeLabel = this.cameraMode === "follow" ? "camera-follow" : "tactical";
this.statusEl.textContent = [ this.statusEl.textContent = [
`mode: ${mode}`, `mode: ${mode}`,
`camera: ${cameraModeLabel}`,
`zoom: ${this.zoomLevel}`, `zoom: ${this.zoomLevel}`,
`system: ${activeSystem}`, `system: ${activeSystem}`,
`sequence: ${sequence}`, `sequence: ${sequence}`,
@@ -2009,6 +2039,30 @@ export class GameViewer {
this.updatePanels(); this.updatePanels();
}; };
private onShipStripDoubleClick = (event: MouseEvent) => {
const target = event.target;
if (!(target instanceof HTMLElement)) {
return;
}
if (target.closest("[data-history-ship-id]")) {
return;
}
const card = target.closest<HTMLElement>("[data-ship-id]");
const shipId = card?.dataset.shipId;
if (!shipId) {
return;
}
this.selectedItems = [{ kind: "ship", id: shipId }];
this.syncFollowStateFromSelection();
this.focusOnSelection(this.selectedItems[0]);
this.toggleCameraMode("follow");
this.updatePanels();
this.updateGamePanel("Live");
};
private openHistoryWindow(target: Selectable) { private openHistoryWindow(target: Selectable) {
const existing = this.historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target)); const existing = this.historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target));
if (existing) { if (existing) {
@@ -2286,14 +2340,7 @@ export class GameViewer {
if (this.selectedItems.length !== 1) { if (this.selectedItems.length !== 1) {
return; return;
} }
const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]); this.focusOnSelection(this.selectedItems[0]);
if (nextFocus) {
if (this.activeSystemId && this.isSelectionInActiveSystem(this.selectedItems[0])) {
this.systemFocusLocal.copy(nextFocus);
} else {
this.galaxyFocus.copy(nextFocus);
}
}
this.syncFollowStateFromSelection(); this.syncFollowStateFromSelection();
}; };
@@ -2312,7 +2359,10 @@ export class GameViewer {
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
this.keyState.add(key); this.keyState.add(key);
if (["w", "a", "s", "d"].includes(key)) { if (["w", "a", "s", "d"].includes(key)) {
this.followedShipId = undefined; this.cameraMode = "tactical";
}
if (key === "c") {
this.toggleCameraMode();
} }
if (key === "1") { if (key === "1") {
this.desiredDistance = ZOOM_DISTANCE.local; this.desiredDistance = ZOOM_DISTANCE.local;
@@ -2328,6 +2378,51 @@ export class GameViewer {
this.keyState.delete(event.key.toLowerCase()); this.keyState.delete(event.key.toLowerCase());
}; };
private toggleCameraMode(forceMode?: CameraMode) {
const nextMode = forceMode ?? (this.cameraMode === "follow" ? "tactical" : "follow");
if (nextMode === "tactical") {
this.cameraMode = "tactical";
return;
}
if (!this.cameraTargetShipId && this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") {
this.cameraTargetShipId = this.selectedItems[0].id;
}
if (!this.cameraTargetShipId) {
return;
}
this.cameraMode = "follow";
this.desiredDistance = Math.min(this.desiredDistance, 1800);
this.followCameraPosition.set(0, 0, 0);
this.followCameraFocus.set(0, 0, 0);
}
private focusOnSelection(selection: Selectable) {
const nextFocus = this.resolveSelectionPosition(selection);
if (!nextFocus) {
return;
}
const selectionSystemId = this.resolveSelectableSystemId(selection);
if (selectionSystemId && selection.kind !== "system" && this.world) {
const system = this.world.systems.get(selectionSystemId);
if (system) {
this.galaxyFocus.copy(this.toThreeVector(system.galaxyPosition));
this.systemFocusLocal.copy(nextFocus);
return;
}
}
if (this.activeSystemId && this.isSelectionInActiveSystem(selection)) {
this.systemFocusLocal.copy(nextFocus);
return;
}
this.galaxyFocus.copy(nextFocus);
}
private resolveSelectionPosition(selection: Selectable) { private resolveSelectionPosition(selection: Selectable) {
if (!this.world) { if (!this.world) {
return undefined; return undefined;
@@ -2339,10 +2434,7 @@ export class GameViewer {
} }
if (selection.kind === "station") { if (selection.kind === "station") {
const station = this.world.stations.get(selection.id); const station = this.world.stations.get(selection.id);
const visual = station ? this.stationVisuals.get(station.id) : undefined; return station ? this.toThreeVector(station.localPosition) : undefined;
return visual
? this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09)
: (station ? this.toThreeVector(station.localPosition) : undefined);
} }
if (selection.kind === "node") { if (selection.kind === "node") {
const node = this.world.nodes.get(selection.id); const node = this.world.nodes.get(selection.id);
@@ -2473,7 +2565,15 @@ export class GameViewer {
} }
private determineActiveSystemId() { private determineActiveSystemId() {
if (!this.world || this.currentDistance >= 12000) { if (!this.world) {
return undefined;
}
if (this.cameraMode === "follow" && this.cameraTargetShipId) {
return this.world.ships.get(this.cameraTargetShipId)?.systemId;
}
if (this.currentDistance >= 12000) {
return undefined; return undefined;
} }
@@ -2491,10 +2591,6 @@ export class GameViewer {
} }
} }
if (this.followedShipId) {
return this.world.ships.get(this.followedShipId)?.systemId;
}
let nearestSystemId: string | undefined; let nearestSystemId: string | undefined;
let nearestDistance = Number.POSITIVE_INFINITY; let nearestDistance = Number.POSITIVE_INFINITY;
for (const system of this.world.systems.values()) { for (const system of this.world.systems.values()) {
@@ -2512,28 +2608,62 @@ export class GameViewer {
} }
private updateFollowCamera(delta: number) { private updateFollowCamera(delta: number) {
if (!this.followedShipId || !this.world) { if (!this.cameraTargetShipId || !this.world) {
return; this.cameraMode = "tactical";
return false;
} }
const ship = this.world.ships.get(this.followedShipId); const ship = this.world.ships.get(this.cameraTargetShipId);
if (!ship) { const visual = this.shipVisuals.get(this.cameraTargetShipId);
this.followedShipId = undefined; if (!ship || !visual) {
return; this.cameraTargetShipId = undefined;
this.cameraMode = "tactical";
return false;
} }
const target = this.toThreeVector(ship.localPosition); const shipLocalPosition = this.getAnimatedShipLocalPosition(visual);
this.systemFocusLocal.lerp(target, 1 - Math.exp(-delta * 8)); const shipWorldPosition = this.toDisplayLocalPosition(shipLocalPosition, ship.systemId);
this.systemFocusLocal.lerp(shipLocalPosition, 1 - Math.exp(-delta * 8));
this.followCameraDesiredDirection.copy(this.resolveShipHeading(visual, shipLocalPosition)).normalize();
this.followCameraDirection.lerp(this.followCameraDesiredDirection, 1 - Math.exp(-delta * 5));
this.followCameraDirection.normalize();
const distance = THREE.MathUtils.clamp(this.currentDistance * 0.72, 320, 6800);
const height = THREE.MathUtils.clamp(distance * 0.18, 70, 1100);
const lookAhead = THREE.MathUtils.clamp(distance * 0.9, 220, 2400);
this.followCameraOffset.copy(this.followCameraDirection).multiplyScalar(-distance);
this.followCameraOffset.y += height;
const desiredPosition = shipWorldPosition.clone().add(this.followCameraOffset);
const desiredFocus = shipWorldPosition.clone().addScaledVector(this.followCameraDirection, lookAhead);
desiredFocus.y += height * 0.28;
const positionLerp = 1 - Math.exp(-delta * 6);
const focusLerp = 1 - Math.exp(-delta * 8);
if (this.followCameraPosition.lengthSq() === 0) {
this.followCameraPosition.copy(desiredPosition);
this.followCameraFocus.copy(desiredFocus);
} else {
this.followCameraPosition.lerp(desiredPosition, positionLerp);
this.followCameraFocus.lerp(desiredFocus, focusLerp);
}
this.camera.position.copy(this.followCameraPosition);
this.camera.lookAt(this.followCameraFocus);
return true;
} }
private syncFollowStateFromSelection() { private syncFollowStateFromSelection() {
if (this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") { if (this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") {
this.followedShipId = this.selectedItems[0].id; this.cameraTargetShipId = this.selectedItems[0].id;
this.desiredDistance = Math.min(this.desiredDistance, 1600);
return; return;
} }
this.followedShipId = undefined; this.cameraTargetShipId = undefined;
if (this.cameraMode === "follow") {
this.cameraMode = "tactical";
}
} }
private updateSystemDetailVisibility() { private updateSystemDetailVisibility() {
@@ -2694,8 +2824,8 @@ export class GameViewer {
return; return;
} }
if (this.followedShipId) { if (this.cameraMode === "follow" && this.cameraTargetShipId) {
const followedShip = this.world.ships.get(this.followedShipId); const followedShip = this.world.ships.get(this.cameraTargetShipId);
if (followedShip?.systemId === systemId) { if (followedShip?.systemId === systemId) {
this.systemFocusLocal.copy(this.toThreeVector(followedShip.localPosition)); this.systemFocusLocal.copy(this.toThreeVector(followedShip.localPosition));
return; return;
@@ -2761,8 +2891,8 @@ export class GameViewer {
moonCount += planet.moonCount; moonCount += planet.moonCount;
} }
const followText = activeContext && this.followedShipId const followText = activeContext && this.cameraMode === "follow" && this.cameraTargetShipId
? `<p>Camera locked to ${this.world.ships.get(this.followedShipId)?.label ?? this.followedShipId}</p>` ? `<p>Camera locked to ${this.world.ships.get(this.cameraTargetShipId)?.label ?? this.cameraTargetShipId}</p>`
: ""; : "";
return ` return `

View File

@@ -90,6 +90,7 @@ export interface StationSnapshot {
localPosition: Vector3Dto; localPosition: Vector3Dto;
color: string; color: string;
dockedShips: number; dockedShips: number;
dockingPads: number;
energyStored: number; energyStored: number;
inventory: InventoryEntry[]; inventory: InventoryEntry[];
factionId: string; factionId: string;

View File

@@ -1,9 +1,11 @@
{ {
"yPlane": 4, "yPlane": 4,
"arrivalThreshold": 16, "arrivalThreshold": 16,
"miningRate": 28, "miningRate": 10,
"miningCycleSeconds": 10,
"transferRate": 56, "transferRate": 56,
"dockingDuration": 1.2, "dockingDuration": 1.2,
"undockingDuration": 1.2,
"undockDistance": 42, "undockDistance": 42,
"energy": { "energy": {
"idleDrain": 0.7, "idleDrain": 0.7,

View File

@@ -12,7 +12,7 @@
"bulk-liquid": 600, "bulk-liquid": 600,
"bulk-gas": 600 "bulk-gas": 600
}, },
"modules": ["docking-clamps", "refinery-stack", "fabricator-array", "power-core", "bulk-bay", "liquid-tank", "gas-tank"] "modules": ["docking-clamps", "dock-bay-small", "power-core", "bulk-bay", "liquid-tank"]
}, },
{ {
"id": "trade-hub", "id": "trade-hub",
@@ -32,7 +32,7 @@
"radius": 24, "radius": 24,
"dockingCapacity": 3, "dockingCapacity": 3,
"storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 }, "storage": { "bulk-solid": 2000, "manufactured": 1000, "bulk-liquid": 400, "bulk-gas": 400 },
"modules": ["docking-clamps", "refinery-stack", "bulk-bay", "fabricator-array", "power-core", "liquid-tank", "gas-tank"] "modules": ["docking-clamps", "power-core", "bulk-bay", "liquid-tank", "gas-tank", "refinery-stack", "fuel-processor"]
}, },
{ {
"id": "farm-ring", "id": "farm-ring",

View File

@@ -47,6 +47,12 @@
"storage": "bulk-gas", "storage": "bulk-gas",
"summary": "Compressed gas reserves for future chemical and fuel chains." "summary": "Compressed gas reserves for future chemical and fuel chains."
}, },
{
"id": "fuel",
"label": "Reactor Fuel",
"storage": "bulk-liquid",
"summary": "Processed liquid fuel consumed by ships and station power systems."
},
{ {
"id": "water", "id": "water",
"label": "Water", "label": "Water",

View File

@@ -0,0 +1,30 @@
[
{
"moduleId": "dock-bay-small",
"duration": 12,
"inputs": [
{ "itemId": "refined-metals", "amount": 34 }
]
},
{
"moduleId": "gas-tank",
"duration": 10,
"inputs": [
{ "itemId": "refined-metals", "amount": 30 }
]
},
{
"moduleId": "fuel-processor",
"duration": 14,
"inputs": [
{ "itemId": "refined-metals", "amount": 42 }
]
},
{
"moduleId": "refinery-stack",
"duration": 14,
"inputs": [
{ "itemId": "refined-metals", "amount": 38 }
]
}
]

View File

@@ -41,6 +41,12 @@
"category": "mining", "category": "mining",
"summary": "Articulated mining head for shipborne extraction." "summary": "Articulated mining head for shipborne extraction."
}, },
{
"id": "gas-extractor",
"label": "Gas Extractor",
"category": "mining",
"summary": "Cryogenic intake and compression rig for harvesting gas clouds."
},
{ {
"id": "gun-turret", "id": "gun-turret",
"label": "Gun Turret", "label": "Gun Turret",
@@ -65,6 +71,12 @@
"category": "dock", "category": "dock",
"summary": "Docking collar and transfer arms." "summary": "Docking collar and transfer arms."
}, },
{
"id": "dock-bay-small",
"label": "Small Dock Bay",
"category": "dock",
"summary": "External docking truss with two independent ship pads."
},
{ {
"id": "carrier-bay", "id": "carrier-bay",
"label": "Carrier Bay", "label": "Carrier Bay",
@@ -77,6 +89,12 @@
"category": "refinery", "category": "refinery",
"summary": "Ore cracking and metal separation." "summary": "Ore cracking and metal separation."
}, },
{
"id": "fuel-processor",
"label": "Fuel Processor",
"category": "refinery",
"summary": "Gas cracking and catalytic processing for reactor fuel."
},
{ {
"id": "turret-grid", "id": "turret-grid",
"label": "Turret Grid", "label": "Turret Grid",

View File

@@ -4,7 +4,8 @@
], ],
"shipFormations": [ "shipFormations": [
{ "shipId": "constructor", "count": 1, "center": [45, 0, 20], "systemId": "helios" }, { "shipId": "constructor", "count": 1, "center": [45, 0, 20], "systemId": "helios" },
{ "shipId": "miner", "count": 1, "center": [52, 0, 24], "systemId": "helios" } { "shipId": "miner", "count": 1, "center": [52, 0, 24], "systemId": "helios" },
{ "shipId": "gas-miner", "count": 1, "center": [60, 0, 28], "systemId": "helios" }
], ],
"patrolRoutes": [], "patrolRoutes": [],
"miningDefaults": { "miningDefaults": {

View File

@@ -111,5 +111,22 @@
"size": 6, "size": 6,
"maxHealth": 150, "maxHealth": 150,
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay", "docking-clamps"] "modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "mining-turret", "bulk-bay", "docking-clamps"]
},
{
"id": "gas-miner",
"label": "Nimbus Gas Harvester",
"role": "mining",
"shipClass": "industrial",
"speed": 24,
"ftlSpeed": 2350,
"spoolTime": 3.2,
"cargoCapacity": 120,
"cargoKind": "bulk-gas",
"cargoItemId": "gas",
"color": "#8ce5ff",
"hullColor": "#2a5668",
"size": 6,
"maxHealth": 150,
"modules": ["command-bridge", "reactor-core", "capacitor-bank", "ion-drive", "ftl-core", "gas-extractor", "gas-tank", "docking-clamps"]
} }
] ]