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

@@ -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>
<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>
<p>${ship.systemId}</p>
<p>Fuel ${fuel.toFixed(0)} · Energy ${ship.energyStored.toFixed(0)}</p>
<p>State ${ship.state}</p>
<p>Order ${ship.orderKind ?? "none"}</p>
<button type="button" class="ship-card-history-button" data-history-ship-id="${ship.id}">Open History</button>
<div class="ship-card-ai">
<p>Order ${ship.orderKind ?? "none"}</p>
<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;
@@ -944,8 +961,8 @@ export class GameViewer {
const iconAlpha = isProjectedSystemIcon
? 0
: entry.hideIconInUniverse
? blend.systemWeight * (isActiveDetail ? 1 : 0)
: Math.max(blend.systemWeight, blend.universeWeight);
? blend.systemWeight * (isActiveDetail ? 1 : 0)
: Math.max(blend.systemWeight, blend.universeWeight);
this.setObjectOpacity(entry.detail, detailAlpha);
this.setObjectOpacity(entry.icon, iconAlpha);
@@ -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;