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
## 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
- elevated system height variance
- procedural orbital metadata
- moons, rings, binary stars, asteroid belts, gas clouds
- special-case `Sol` system content
- ore miner
- gas miner
- station-side `gas -> fuel`
- constructor-led station module installation
- 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:
- tune galaxy readability at scale
- better starfield depth cues
- stronger color/size differentiation for star classes
- improved system label decluttering
- add galaxy navigation affordances
- jump-to-system search
- constellation / region overlays
- bookmarks for notable systems such as `Sol`
- tighten viewer performance
- reduce orbit/moon draw cost when zoomed out
- pool or simplify distant celestial meshes
- profile high-system-count scenes
- add constructor material logistics
- allow the constructor to fetch module materials instead of assuming they are already on the target station
- add pickup / load phases and delivery priorities
- expose station installed modules and active construction to the viewer
- installed modules
- current build target
- build progress
- harden dock management
- make dock eligibility depend on dock type / ship class if needed
- surface pad occupancy in debug UI
- add stronger action timing coverage
- 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
2. hauling to refining
3. refining / fabricating goods
4. spending those goods on ships and outposts
2. harvesting gas
3. processing `gas -> fuel`
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 faction growth more intentional and legible.
The next step is to make those logistics deliberate instead of bootstrap-scripted.
Recommended work:
- make shipbuilding priorities reactive
- build more miners / haulers when ore throughput is low
- build escorts when industrial losses rise
- build warships when frontier pressure rises
- make expansion logic consume the economy more visibly
- use industrial stock to claim and fortify central systems
- expose production pressure in UI
- show ore throughput
- show fabricated goods
- show queued faction priorities
- make resource type differences matter
- ore belts vs gas clouds
- gas-aware logistics and production choices
## Pirate Harassment
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.
- split logistics roles more clearly
- ore miner
- gas miner
- hauler / constructor transport
- make station build priorities responsive
- fuel chain first when reserves are low
- ore refining when fuel is stable
- docking expansion when traffic backs up
- make fuel scarcity visible in debug UI
- fuel throughput
- gas stock
- dock contention
- move away from generic node selection
- let miners prefer nearby valid nodes
- factor travel cost and dock turnaround into throughput planning
## Concrete Implementation Order
1. Add viewer-scale performance controls for the larger galaxy.
2. Add faction production heuristics based on current economy and losses.
3. Make pirate target selection explicitly prefer economic targets.
4. Surface faction stocks, throughput, and build priorities in the HUD/debug views.
5. Expand the order/behavior set with higher-value RTS actions like `hold-here`, `attack`, and `defend-area`.
1. Add constructor pickup / delivery behavior for module materials.
2. Expose station installed modules, dock pads, and active construction in the contracts and viewer.
3. Add rescue / recovery behavior for power-starved ships.
4. Add faction build priorities based on fuel, ore throughput, and dock saturation.
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.
## Network / Multiplayer
@@ -133,68 +106,31 @@ Recommended work:
- versioning
- 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
The current stream is world-wide.
That means every observer receives deltas for the full simulation, even when only looking at one part of space.
The stream is still world-wide.
Recommended work:
- 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
- keep coarse strategic updates available 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
- keep strategic summaries for off-screen context

View File

@@ -108,15 +108,27 @@ The backend simulation already includes:
- autonomous ships
- 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
- module-gated ship and station capabilities
- ships require fitted modules such as reactor, capacitor, and mining or gun turrets
- stations require fitted modules such as power, refinery, and storage modules
- ships require fitted modules such as reactor, capacitor, mining turret, gas extractor, or gun turrets
- stations require installed modules such as power, docking, refinery, fuel processing, and storage modules
- fuel-to-energy power simulation
- ship reactors consume `gas` fuel to charge capacitors
- station power cores consume `gas` fuel to charge station energy buffers
- gas clouds provide raw `gas`
- 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
- 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
- faction growth through ship and outpost production
- pirate pressure and combat
@@ -129,7 +141,7 @@ The backend simulation already includes:
- systems in galaxy space
- in-system entities in local space
- 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:
@@ -164,13 +176,20 @@ The runtime model still follows the intended layered control architecture:
- one modular station
- one constructor 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
- added modular ship and station data
- 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
- 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
- added movable, resizable, multi-window history panels in the viewer
- 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
- moon rendering is procedural from counts, not authored moon-by-moon data
- resource extraction behavior still treats all resource nodes generically
- item inventories exist, but storage enforcement and module-slot restrictions are still lightweight
- cargo/storage compatibility is mostly data-convention driven
- item inventories exist, but storage/module restrictions are still partial
- 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
- 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
- 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)
- simulation advancement
- snapshot / delta composition for galaxy-space systems and local-space entities
- inventory, fuel, and energy processing
- auto-miner undock state transition fix
- inventory, gas/fuel, and energy processing
- 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)
- faction bootstrap
- galaxy generation
- special systems
- procedural celestial/resource content
- 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)
- snapshot and delta contract shape
- station dock-pad count
- [apps/backend/Simulation/RuntimeModels.cs](/home/jbourdon/repos/space-game/apps/backend/Simulation/RuntimeModels.cs)
- runtime vector math and world model
- 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)
- camera, selection, streaming integration, and presentation
- layered local/remote system presentation
- orbital reconstruction and moon rendering
- projected shell markers and hover labels
- 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)
- HUD layout
- 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
- [shared/data](/home/jbourdon/repos/space-game/shared/data)
- scenario and world data definitions
- `module-recipes.json` now defines timed module construction costs
## Validation
Validation passing at the end of this session:
- `dotnet build apps/backend/SpaceGame.Simulation.Api.csproj`
Validation currently failing / blocked:
- `cd apps/viewer && npm run build`
- fails because `apps/viewer/src/GameViewer.ts` references a missing `followedShipId` property
## Last Commit
- `1747d84`
- `ef62577`

View File

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

View File

@@ -5,8 +5,10 @@ public sealed class BalanceDefinition
public float YPlane { get; set; }
public float ArrivalThreshold { get; set; }
public float MiningRate { get; set; }
public float MiningCycleSeconds { get; set; }
public float TransferRate { get; set; }
public float DockingDuration { get; set; }
public float UndockingDuration { get; set; }
public float UndockDistance { get; set; }
public EnergyBalanceDefinition Energy { 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 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 required string Label { get; set; }

View File

@@ -14,6 +14,7 @@ public sealed class SimulationWorld
public required List<FactionRuntime> Factions { get; init; }
public required Dictionary<string, ShipDefinition> ShipDefinitions { 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 DateTimeOffset GeneratedAtUtc { get; set; }
}
@@ -43,13 +44,24 @@ public sealed class StationRuntime
public required ConstructibleDefinition Definition { get; init; }
public required Vector3 Position { 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<int, string> DockingPadAssignments { get; } = new();
public float EnergyStored { get; set; }
public float ProcessTimer { get; set; }
public HashSet<string> DockedShipIds { get; } = [];
public ModuleConstructionRuntime? ActiveConstruction { get; set; }
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 required string Id { get; init; }
@@ -67,6 +79,7 @@ public sealed class ShipRuntime
public Dictionary<string, float> Inventory { get; } = new(StringComparer.Ordinal);
public float EnergyStored { get; set; }
public string? DockedStationId { get; set; }
public int? AssignedDockingPadIndex { get; set; }
public float Health { get; set; }
public List<string> History { get; } = [];
public string LastSignature { get; set; } = string.Empty;
@@ -98,8 +111,10 @@ public sealed class DefaultBehaviorRuntime
{
public required string Kind { get; set; }
public string? AreaSystemId { get; set; }
public string? StationId { get; set; }
public string? RefineryId { get; set; }
public string? NodeId { get; set; }
public string? ModuleId { get; set; }
public string? Phase { get; set; }
public List<Vector3> PatrolPoints { 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 constructibles = Read<List<ConstructibleDefinition>>("constructibles.json");
var items = Read<List<ItemDefinition>>("items.json");
var moduleRecipes = Read<List<ModuleRecipeDefinition>>("module-recipes.json");
var balance = Read<BalanceDefinition>("balance.json");
var shipDefinitions = ships.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var constructibleDefinitions = constructibles.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var itemDefinitions = items.ToDictionary((definition) => definition.Id, StringComparer.Ordinal);
var moduleRecipeDefinitions = moduleRecipes.ToDictionary((definition) => definition.ModuleId, StringComparer.Ordinal);
var systemRuntimes = systems
.Select((definition) => new SystemRuntime
{
@@ -141,17 +143,23 @@ public sealed class ScenarioLoader
Position = ResolveStationPosition(system, plan, balance),
FactionId = plan.FactionId ?? DefaultFactionId,
});
foreach (var moduleId in definition.Modules)
{
stations[^1].InstalledModules.Add(moduleId);
}
}
foreach (var station in stations)
{
station.Inventory["gas"] = 320f;
station.Inventory["fuel"] = 240f;
station.Inventory["refined-metals"] = 120f;
}
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)
?? 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(
(route) => route.SystemId,
@@ -185,9 +193,15 @@ public sealed class ScenarioLoader
});
shipsRuntime[^1].Inventory["gas"] = definition.Id switch
{
_ => 0f,
};
shipsRuntime[^1].Inventory.Remove("gas");
shipsRuntime[^1].Inventory["fuel"] = definition.Id switch
{
"constructor" => 90f,
"miner" => 90f,
"gas-miner" => 90f,
_ => 120f,
};
}
@@ -208,6 +222,7 @@ public sealed class ScenarioLoader
Factions = factions,
ShipDefinitions = shipDefinitions,
ItemDefinitions = itemDefinitions,
ModuleRecipes = moduleRecipeDefinitions,
GeneratedAtUtc = DateTimeOffset.UtcNow,
};
}
@@ -818,13 +833,33 @@ public sealed class ScenarioLoader
IReadOnlyDictionary<string, List<Vector3>> patrolRoutes,
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)
{
return new DefaultBehaviorRuntime
{
Kind = "auto-mine",
AreaSystemId = scenario.MiningDefaults.NodeSystemId,
RefineryId = refinery.Id,
StationId = refinery.Id,
Phase = "travel-to-node",
};
}
@@ -883,6 +918,9 @@ public sealed class ScenarioLoader
private static bool HasModules(ConstructibleDefinition definition, params string[] modules) =>
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) =>
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 SelectionGroup = "ships" | "structures" | "celestials";
type DragMode = "orbit" | "marquee";
type CameraMode = "tactical" | "follow";
type Selectable =
| { kind: "ship"; id: string }
| { kind: "station"; id: string }
@@ -245,6 +246,7 @@ export class GameViewer {
private desiredDistance = ZOOM_DISTANCE.system;
private orbitYaw = -2.3;
private orbitPitch = 0.62;
private cameraMode: CameraMode = "tactical";
private dragMode?: DragMode;
private dragPointerId?: number;
private dragStart = new THREE.Vector2();
@@ -252,7 +254,12 @@ export class GameViewer {
private marqueeActive = false;
private suppressClickSelection = false;
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 historyWindowCounter = 0;
private historyWindowZCounter = 10;
@@ -334,6 +341,7 @@ export class GameViewer {
this.renderer.domElement.addEventListener("dblclick", this.onDoubleClick);
this.renderer.domElement.addEventListener("wheel", this.onWheel, { passive: false });
this.factionStripEl.addEventListener("click", this.onShipStripClick);
this.factionStripEl.addEventListener("dblclick", this.onShipStripDoubleClick);
this.historyLayerEl.addEventListener("click", this.onHistoryLayerClick);
this.historyLayerEl.addEventListener("pointerdown", this.onHistoryLayerPointerDown);
window.addEventListener("pointermove", this.onHistoryWindowPointerMove);
@@ -709,18 +717,30 @@ export class GameViewer {
.map((ship) => {
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 isFollowed = this.followedShipId === ship.id;
const isFollowed = this.cameraMode === "follow" && this.cameraTargetShipId === ship.id;
return `
<article class="ship-card${isSelected ? " is-selected" : ""}${isFollowed ? " is-followed" : ""}" data-ship-id="${ship.id}">
<div class="ship-card-header">
<h3>${ship.label}</h3>
<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>
<p>${ship.systemId}</p>
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
<p>State ${ship.state}</p>
<div class="ship-card-ai">
<p>Order ${ship.orderKind ?? "none"}</p>
<button type="button" class="ship-card-history-button" data-history-ship-id="${ship.id}">Open History</button>
<p>Behavior ${ship.defaultBehaviorKind}</p>
<p>Task ${ship.controllerTaskKind}</p>
</div>
</article>
`;
})
@@ -767,14 +787,12 @@ export class GameViewer {
this.detailTitleEl.textContent = ship.label;
const cargoUsed = ship.inventory.reduce((sum, entry) => sum + entry.amount, 0);
this.detailBodyEl.innerHTML = `
<p>${ship.shipClass} · ${ship.role} · ${ship.systemId}</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>Inventory ${this.formatInventory(ship.inventory)}</p>
<p>Velocity ${this.formatVector(ship.localVelocity)}</p>
<p>${this.followedShipId === ship.id ? "Camera follow engaged" : "Camera follow idle"}</p>
<p>History available from the ship card list.</p>
<p>Camera ${this.cameraMode === "follow" && this.cameraTargetShipId === ship.id ? "camera-follow" : "tactical"}<br>Press C to toggle follow</p>
`;
return;
}
@@ -789,7 +807,7 @@ export class GameViewer {
this.detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</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>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.zoomLevel = this.classifyZoomLevel(this.currentDistance);
this.updateActiveSystem();
this.updateFollowCamera(delta);
if (this.cameraMode === "follow" && this.updateFollowCamera(delta)) {
return;
}
this.updatePanFromKeyboard(delta);
this.orbitPitch = THREE.MathUtils.clamp(this.orbitPitch, 0.18, 1.3);
@@ -895,10 +916,6 @@ export class GameViewer {
}
private updatePanFromKeyboard(delta: number) {
if (this.followedShipId) {
return;
}
const move = new THREE.Vector3();
if (this.keyState.has("w")) {
move.z -= 1;
@@ -1064,21 +1081,14 @@ export class GameViewer {
const now = performance.now();
const worldTimeSeconds = this.currentWorldTimeSeconds();
for (const visual of this.shipVisuals.values()) {
const elapsedMs = now - visual.receivedAtMs;
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);
}
const worldPosition = this.getAnimatedShipLocalPosition(visual, now);
visual.mesh.position.copy(this.toDisplayLocalPosition(worldPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
const shipVisible = visual.systemId === this.activeSystemId;
visual.mesh.visible = shipVisible;
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) {
visual.mesh.lookAt(visual.mesh.position.clone().add(desiredHeading));
}
@@ -1091,8 +1101,7 @@ export class GameViewer {
visual.mesh.visible = visual.systemId === this.activeSystemId;
}
for (const visual of this.stationVisuals.values()) {
const animatedLocalPosition = this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09);
visual.mesh.position.copy(this.toDisplayLocalPosition(animatedLocalPosition, visual.systemId));
visual.mesh.position.copy(this.toDisplayLocalPosition(visual.localPosition, visual.systemId));
visual.icon.position.copy(visual.mesh.position);
visual.mesh.visible = visual.systemId === this.activeSystemId;
}
@@ -1102,6 +1111,25 @@ export class GameViewer {
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() {
const nowSeconds = this.currentWorldTimeSeconds();
for (const visual of this.planetVisuals) {
@@ -1652,8 +1680,10 @@ export class GameViewer {
? new Date(this.world.generatedAtUtc).toLocaleTimeString()
: "n/a";
const activeSystem = this.activeSystemId ?? "deep-space";
const cameraModeLabel = this.cameraMode === "follow" ? "camera-follow" : "tactical";
this.statusEl.textContent = [
`mode: ${mode}`,
`camera: ${cameraModeLabel}`,
`zoom: ${this.zoomLevel}`,
`system: ${activeSystem}`,
`sequence: ${sequence}`,
@@ -2009,6 +2039,30 @@ export class GameViewer {
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) {
const existing = this.historyWindows.find((windowState) => JSON.stringify(windowState.target) === JSON.stringify(target));
if (existing) {
@@ -2286,14 +2340,7 @@ export class GameViewer {
if (this.selectedItems.length !== 1) {
return;
}
const nextFocus = this.resolveSelectionPosition(this.selectedItems[0]);
if (nextFocus) {
if (this.activeSystemId && this.isSelectionInActiveSystem(this.selectedItems[0])) {
this.systemFocusLocal.copy(nextFocus);
} else {
this.galaxyFocus.copy(nextFocus);
}
}
this.focusOnSelection(this.selectedItems[0]);
this.syncFollowStateFromSelection();
};
@@ -2312,7 +2359,10 @@ export class GameViewer {
const key = event.key.toLowerCase();
this.keyState.add(key);
if (["w", "a", "s", "d"].includes(key)) {
this.followedShipId = undefined;
this.cameraMode = "tactical";
}
if (key === "c") {
this.toggleCameraMode();
}
if (key === "1") {
this.desiredDistance = ZOOM_DISTANCE.local;
@@ -2328,6 +2378,51 @@ export class GameViewer {
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) {
if (!this.world) {
return undefined;
@@ -2339,10 +2434,7 @@ export class GameViewer {
}
if (selection.kind === "station") {
const station = this.world.stations.get(selection.id);
const visual = station ? this.stationVisuals.get(station.id) : undefined;
return visual
? this.computeStructureLocalPosition(visual, this.currentWorldTimeSeconds(), 0.09)
: (station ? this.toThreeVector(station.localPosition) : undefined);
return station ? this.toThreeVector(station.localPosition) : undefined;
}
if (selection.kind === "node") {
const node = this.world.nodes.get(selection.id);
@@ -2473,7 +2565,15 @@ export class GameViewer {
}
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;
}
@@ -2491,10 +2591,6 @@ export class GameViewer {
}
}
if (this.followedShipId) {
return this.world.ships.get(this.followedShipId)?.systemId;
}
let nearestSystemId: string | undefined;
let nearestDistance = Number.POSITIVE_INFINITY;
for (const system of this.world.systems.values()) {
@@ -2512,28 +2608,62 @@ export class GameViewer {
}
private updateFollowCamera(delta: number) {
if (!this.followedShipId || !this.world) {
return;
if (!this.cameraTargetShipId || !this.world) {
this.cameraMode = "tactical";
return false;
}
const ship = this.world.ships.get(this.followedShipId);
if (!ship) {
this.followedShipId = undefined;
return;
const ship = this.world.ships.get(this.cameraTargetShipId);
const visual = this.shipVisuals.get(this.cameraTargetShipId);
if (!ship || !visual) {
this.cameraTargetShipId = undefined;
this.cameraMode = "tactical";
return false;
}
const target = this.toThreeVector(ship.localPosition);
this.systemFocusLocal.lerp(target, 1 - Math.exp(-delta * 8));
const shipLocalPosition = this.getAnimatedShipLocalPosition(visual);
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() {
if (this.selectedItems.length === 1 && this.selectedItems[0].kind === "ship") {
this.followedShipId = this.selectedItems[0].id;
this.desiredDistance = Math.min(this.desiredDistance, 1600);
this.cameraTargetShipId = this.selectedItems[0].id;
return;
}
this.followedShipId = undefined;
this.cameraTargetShipId = undefined;
if (this.cameraMode === "follow") {
this.cameraMode = "tactical";
}
}
private updateSystemDetailVisibility() {
@@ -2694,8 +2824,8 @@ export class GameViewer {
return;
}
if (this.followedShipId) {
const followedShip = this.world.ships.get(this.followedShipId);
if (this.cameraMode === "follow" && this.cameraTargetShipId) {
const followedShip = this.world.ships.get(this.cameraTargetShipId);
if (followedShip?.systemId === systemId) {
this.systemFocusLocal.copy(this.toThreeVector(followedShip.localPosition));
return;
@@ -2761,8 +2891,8 @@ export class GameViewer {
moonCount += planet.moonCount;
}
const followText = activeContext && this.followedShipId
? `<p>Camera locked to ${this.world.ships.get(this.followedShipId)?.label ?? this.followedShipId}</p>`
const followText = activeContext && this.cameraMode === "follow" && this.cameraTargetShipId
? `<p>Camera locked to ${this.world.ships.get(this.cameraTargetShipId)?.label ?? this.cameraTargetShipId}</p>`
: "";
return `

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
"bulk-liquid": 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",
@@ -32,7 +32,7 @@
"radius": 24,
"dockingCapacity": 3,
"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",

View File

@@ -47,6 +47,12 @@
"storage": "bulk-gas",
"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",
"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",
"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",
"label": "Gun Turret",
@@ -65,6 +71,12 @@
"category": "dock",
"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",
"label": "Carrier Bay",
@@ -77,6 +89,12 @@
"category": "refinery",
"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",
"label": "Turret Grid",

View File

@@ -4,7 +4,8 @@
],
"shipFormations": [
{ "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": [],
"miningDefaults": {

View File

@@ -111,5 +111,22 @@
"size": 6,
"maxHealth": 150,
"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"]
}
]