Add fuel logistics, modular construction, and pad docking
This commit is contained in:
@@ -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"
|
||||
>🕔</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 `
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface StationSnapshot {
|
||||
localPosition: Vector3Dto;
|
||||
color: string;
|
||||
dockedShips: number;
|
||||
dockingPads: number;
|
||||
energyStored: number;
|
||||
inventory: InventoryEntry[];
|
||||
factionId: string;
|
||||
|
||||
Reference in New Issue
Block a user