feat: improved ops-strip with faction and stations
This commit is contained in:
@@ -124,7 +124,7 @@ export class ViewerAppController {
|
||||
private readonly systemBodyEl: HTMLDivElement;
|
||||
private readonly detailTitleEl: HTMLHeadingElement;
|
||||
private readonly detailBodyEl: HTMLDivElement;
|
||||
private readonly factionStripEl: HTMLDivElement;
|
||||
private readonly opsStripEl: HTMLDivElement;
|
||||
private readonly networkSectionEl: HTMLDivElement;
|
||||
private readonly networkSummaryEl: HTMLSpanElement;
|
||||
private readonly networkPanelEl: HTMLDivElement;
|
||||
@@ -208,7 +208,7 @@ export class ViewerAppController {
|
||||
this.systemBodyEl = hud.systemBodyEl;
|
||||
this.detailTitleEl = hud.detailTitleEl;
|
||||
this.detailBodyEl = hud.detailBodyEl;
|
||||
this.factionStripEl = hud.factionStripEl;
|
||||
this.opsStripEl = hud.opsStripEl;
|
||||
this.networkSummaryEl = hud.networkSummaryEl;
|
||||
this.networkPanelEl = hud.networkPanelEl;
|
||||
this.performanceSectionEl = hud.performanceSectionEl;
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
export interface FactionGoapState {
|
||||
militaryShipCount: number;
|
||||
minerShipCount: number;
|
||||
transportShipCount: number;
|
||||
constructorShipCount: number;
|
||||
controlledSystemCount: number;
|
||||
targetSystemCount: number;
|
||||
hasShipFactory: boolean;
|
||||
oreStockpile: number;
|
||||
refinedMetalsStockpile: number;
|
||||
}
|
||||
|
||||
export interface FactionGoapPriority {
|
||||
goalName: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface FactionSnapshot {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -9,6 +26,8 @@ export interface FactionSnapshot {
|
||||
shipsBuilt: number;
|
||||
shipsLost: number;
|
||||
defaultPolicySetId?: string | null;
|
||||
goapState?: FactionGoapState | null;
|
||||
goapPriorities?: FactionGoapPriority[] | null;
|
||||
}
|
||||
|
||||
export interface FactionDelta extends FactionSnapshot {}
|
||||
|
||||
@@ -87,7 +87,7 @@ canvas {
|
||||
.info-panel,
|
||||
.network-panel,
|
||||
.performance-panel,
|
||||
.ship-strip {
|
||||
.ops-strip {
|
||||
backdrop-filter: blur(18px);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--panel-border);
|
||||
@@ -413,7 +413,7 @@ canvas {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ship-strip {
|
||||
.ops-strip {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
@@ -536,6 +536,24 @@ canvas {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.faction-card {
|
||||
border-top-color: rgba(180, 130, 255, 0.3);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.faction-card:hover {
|
||||
transform: none;
|
||||
border-color: rgba(180, 130, 255, 0.5);
|
||||
}
|
||||
|
||||
.station-card {
|
||||
border-top-color: rgba(127, 255, 180, 0.22);
|
||||
}
|
||||
|
||||
.station-card:hover {
|
||||
border-color: rgba(127, 255, 180, 0.5);
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 14px;
|
||||
height: 48px;
|
||||
@@ -544,7 +562,7 @@ canvas {
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.ship-strip {
|
||||
.ops-strip {
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
@@ -584,7 +602,7 @@ canvas {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ship-strip {
|
||||
.ops-strip {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
|
||||
@@ -136,7 +136,7 @@ export function createViewerControllers(host: any) {
|
||||
getNetworkStats: () => host.networkStats,
|
||||
getSystemSummaryVisuals: () => host.systemSummaryVisuals,
|
||||
errorEl: host.errorEl,
|
||||
factionStripEl: host.factionStripEl,
|
||||
opsStripEl: host.opsStripEl,
|
||||
detailTitleEl: host.detailTitleEl,
|
||||
detailBodyEl: host.detailBodyEl,
|
||||
worldLabel: () => host.world?.label ?? "",
|
||||
@@ -267,8 +267,8 @@ export function wireViewerEvents(host: any) {
|
||||
host.renderer.domElement.addEventListener("click", host.interactionController.onClick);
|
||||
host.renderer.domElement.addEventListener("dblclick", host.interactionController.onDoubleClick);
|
||||
host.renderer.domElement.addEventListener("wheel", host.interactionController.onWheel, { passive: false });
|
||||
host.factionStripEl.addEventListener("click", host.interactionController.onShipStripClick);
|
||||
host.factionStripEl.addEventListener("dblclick", host.interactionController.onShipStripDoubleClick);
|
||||
host.opsStripEl.addEventListener("click", host.interactionController.onOpsStripClick);
|
||||
host.opsStripEl.addEventListener("dblclick", host.interactionController.onOpsStripDoubleClick);
|
||||
host.historyLayerEl.addEventListener("click", host.interactionController.onHistoryLayerClick);
|
||||
host.historyLayerEl.addEventListener("pointerdown", host.interactionController.onHistoryLayerPointerDown);
|
||||
window.addEventListener("pointermove", host.interactionController.onHistoryWindowPointerMove);
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
import { inventoryAmount } from "./viewerMath";
|
||||
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
|
||||
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes";
|
||||
|
||||
export function renderFactionStrip(
|
||||
world: WorldState | undefined,
|
||||
selectedItems: Selectable[],
|
||||
cameraMode: CameraMode,
|
||||
cameraTargetShipId?: string,
|
||||
zoomLevel?: ZoomLevel,
|
||||
activeSystemId?: string,
|
||||
) {
|
||||
if (!world) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const ships = [...world.ships.values()]
|
||||
.filter((ship) => {
|
||||
if (zoomLevel === "universe" || !activeSystemId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ship.systemId === activeSystemId;
|
||||
})
|
||||
.sort((left, right) => left.label.localeCompare(right.label));
|
||||
|
||||
return ships
|
||||
.map((ship) => {
|
||||
const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
|
||||
const shipLocation = describeShipLocation(world, ship);
|
||||
const shipState = describeShipState(world, ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
const isSelected = selectedItems.length === 1
|
||||
&& selectedItems[0].kind === "ship"
|
||||
&& selectedItems[0].id === ship.id;
|
||||
const isFollowed = cameraMode === "follow" && 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.class}</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>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
|
||||
<p>Cargo ${cargo.toFixed(0)}</p>
|
||||
<p>State ${shipState}</p>
|
||||
${shipAction ? `
|
||||
<div class="ship-action-progress">
|
||||
<div class="ship-action-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="ship-action-progress-track">
|
||||
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div class="ship-card-ai">
|
||||
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
|
||||
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
|
||||
<p>Task ${ship.controllerTaskKind}</p>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
@@ -9,7 +9,7 @@ export interface ViewerHudElements {
|
||||
systemBodyEl: HTMLDivElement;
|
||||
detailTitleEl: HTMLHeadingElement;
|
||||
detailBodyEl: HTMLDivElement;
|
||||
factionStripEl: HTMLDivElement;
|
||||
opsStripEl: HTMLDivElement;
|
||||
networkSummaryEl: HTMLSpanElement;
|
||||
networkPanelEl: HTMLDivElement;
|
||||
performanceSectionEl: HTMLDivElement;
|
||||
@@ -71,7 +71,7 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
|
||||
<div class="error-strip" hidden></div>
|
||||
</div>
|
||||
<div class="history-layer"></div>
|
||||
<section class="ship-strip"></section>
|
||||
<section class="ops-strip"></section>
|
||||
<div class="marquee-box"></div>
|
||||
<div class="hover-label" hidden></div>
|
||||
`;
|
||||
@@ -87,7 +87,7 @@ export function createViewerHud(documentRef: Document): ViewerHudElements {
|
||||
systemBodyEl: root.querySelector(".system-body") as HTMLDivElement,
|
||||
detailTitleEl: root.querySelector(".detail-title") as HTMLHeadingElement,
|
||||
detailBodyEl: root.querySelector(".detail-body") as HTMLDivElement,
|
||||
factionStripEl: root.querySelector(".ship-strip") as HTMLDivElement,
|
||||
opsStripEl: root.querySelector(".ops-strip") as HTMLDivElement,
|
||||
networkSummaryEl: root.querySelector(".network-summary") as HTMLSpanElement,
|
||||
networkPanelEl: root.querySelector(".network-body") as HTMLDivElement,
|
||||
performanceSectionEl: root.querySelector(".performance-panel") as HTMLDivElement,
|
||||
|
||||
@@ -150,7 +150,7 @@ export class ViewerInteractionController {
|
||||
this.context.updatePanels();
|
||||
};
|
||||
|
||||
readonly onShipStripClick = (event: MouseEvent) => {
|
||||
readonly onOpsStripClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
@@ -163,18 +163,25 @@ export class ViewerInteractionController {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = target.closest<HTMLElement>("[data-ship-id]");
|
||||
const shipId = card?.dataset.shipId;
|
||||
if (!shipId) {
|
||||
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
|
||||
const shipId = shipCard?.dataset.shipId;
|
||||
if (shipId) {
|
||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
const stationCard = target.closest<HTMLElement>("[data-station-id]");
|
||||
const stationId = stationCard?.dataset.stationId;
|
||||
if (stationId) {
|
||||
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.updatePanels();
|
||||
}
|
||||
};
|
||||
|
||||
readonly onShipStripDoubleClick = (event: MouseEvent) => {
|
||||
readonly onOpsStripDoubleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
@@ -184,18 +191,28 @@ export class ViewerInteractionController {
|
||||
return;
|
||||
}
|
||||
|
||||
const card = target.closest<HTMLElement>("[data-ship-id]");
|
||||
const shipId = card?.dataset.shipId;
|
||||
if (!shipId) {
|
||||
const shipCard = target.closest<HTMLElement>("[data-ship-id]");
|
||||
const shipId = shipCard?.dataset.shipId;
|
||||
if (shipId) {
|
||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
||||
this.toggleCameraMode("follow");
|
||||
this.context.updatePanels();
|
||||
this.context.updateGamePanel("Live");
|
||||
return;
|
||||
}
|
||||
|
||||
this.context.setSelectedItems([{ kind: "ship", id: shipId }]);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.context.focusOnSelection({ kind: "ship", id: shipId });
|
||||
this.toggleCameraMode("follow");
|
||||
this.context.updatePanels();
|
||||
this.context.updateGamePanel("Live");
|
||||
const stationCard = target.closest<HTMLElement>("[data-station-id]");
|
||||
const stationId = stationCard?.dataset.stationId;
|
||||
if (stationId) {
|
||||
this.context.setSelectedItems([{ kind: "station", id: stationId }]);
|
||||
this.context.syncFollowStateFromSelection();
|
||||
this.toggleCameraMode("tactical");
|
||||
this.context.focusOnSelection({ kind: "station", id: stationId });
|
||||
this.context.updatePanels();
|
||||
this.context.updateGamePanel("Live");
|
||||
}
|
||||
};
|
||||
|
||||
readonly onHistoryLayerClick = (event: MouseEvent) => this.context.historyController.onHistoryLayerClick(event);
|
||||
|
||||
154
apps/viewer/src/viewerOpsStrip.ts
Normal file
154
apps/viewer/src/viewerOpsStrip.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { StationSnapshot } from "./contractsInfrastructure";
|
||||
import type { FactionSnapshot } from "./contractsFactions";
|
||||
import { inventoryAmount } from "./viewerMath";
|
||||
import { describeShipCurrentAction, describeShipLocation, describeShipObjective, describeShipState } from "./viewerSelection";
|
||||
import type { CameraMode, Selectable, WorldState, ZoomLevel } from "./viewerTypes";
|
||||
|
||||
function renderFactionCard(faction: FactionSnapshot): string {
|
||||
const state = faction.goapState;
|
||||
const priorities = faction.goapPriorities;
|
||||
|
||||
return `
|
||||
<article class="ship-card faction-card" data-faction-id="${faction.id}">
|
||||
<div class="ship-card-header">
|
||||
<h3>${faction.label}</h3>
|
||||
<span class="ship-card-badge">faction</span>
|
||||
</div>
|
||||
${state ? `
|
||||
<div class="ship-card-ai">
|
||||
<p class="ship-card-section-title">GOAP State</p>
|
||||
<p>Military ${state.militaryShipCount} · Miners ${state.minerShipCount}</p>
|
||||
<p>Transport ${state.transportShipCount} · Constructors ${state.constructorShipCount}</p>
|
||||
<p>Systems ${state.controlledSystemCount} / ${state.targetSystemCount}</p>
|
||||
<p>Factory ${state.hasShipFactory ? "yes" : "no"} · Ore ${state.oreStockpile.toFixed(0)}</p>
|
||||
</div>
|
||||
` : ""}
|
||||
${priorities && priorities.length > 0 ? `
|
||||
<div class="ship-card-ai">
|
||||
<p class="ship-card-section-title">Priorities</p>
|
||||
${priorities.map(p => `<p>${p.goalName} <span style="float:right">${p.priority.toFixed(0)}</span></p>`).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderStationCard(station: StationSnapshot, isSelected: boolean): string {
|
||||
const cargo = station.inventory.reduce((sum, e) => sum + e.amount, 0);
|
||||
const processes = station.currentProcesses;
|
||||
|
||||
return `
|
||||
<article class="ship-card station-card${isSelected ? " is-selected" : ""}" data-station-id="${station.id}">
|
||||
<div class="ship-card-header">
|
||||
<h3>${station.label}</h3>
|
||||
<span class="ship-card-badge">${station.category}</span>
|
||||
</div>
|
||||
<p>${station.systemId}</p>
|
||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}</p>
|
||||
<p>Cargo ${cargo.toFixed(0)} · Pop ${station.population.toFixed(0)}</p>
|
||||
<p>Modules ${station.installedModules.length}</p>
|
||||
${processes.length > 0 ? `
|
||||
<div class="ship-card-ai">
|
||||
${processes.map(p => `
|
||||
<div class="ship-action-progress">
|
||||
<div class="ship-action-progress-label">
|
||||
<span>${p.label}</span>
|
||||
<span>${Math.round(p.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="ship-action-progress-track">
|
||||
<div class="ship-action-progress-fill" style="width: ${(p.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
` : ""}
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderOpsStrip(
|
||||
world: WorldState | undefined,
|
||||
selectedItems: Selectable[],
|
||||
cameraMode: CameraMode,
|
||||
cameraTargetShipId?: string,
|
||||
zoomLevel?: ZoomLevel,
|
||||
activeSystemId?: string,
|
||||
) {
|
||||
if (!world) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const isSystemFiltered = zoomLevel !== "universe" && activeSystemId != null;
|
||||
|
||||
const factionCards = [...world.factions.values()]
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map(renderFactionCard)
|
||||
.join("");
|
||||
|
||||
const stationCards = [...world.stations.values()]
|
||||
.filter((station) => !isSystemFiltered || station.systemId === activeSystemId)
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.map((station) => {
|
||||
const isSelected = selectedItems.length === 1
|
||||
&& selectedItems[0].kind === "station"
|
||||
&& selectedItems[0].id === station.id;
|
||||
return renderStationCard(station, isSelected);
|
||||
})
|
||||
.join("");
|
||||
|
||||
const ships = [...world.ships.values()]
|
||||
.filter((ship) => !isSystemFiltered || ship.systemId === activeSystemId)
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const shipCards = ships
|
||||
.map((ship) => {
|
||||
const cargo = ship.inventory.reduce((sum, e) => sum + e.amount, 0);
|
||||
const shipLocation = describeShipLocation(world, ship);
|
||||
const shipState = describeShipState(world, ship);
|
||||
const shipAction = describeShipCurrentAction(ship);
|
||||
const isSelected = selectedItems.length === 1
|
||||
&& selectedItems[0].kind === "ship"
|
||||
&& selectedItems[0].id === ship.id;
|
||||
const isFollowed = cameraMode === "follow" && 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.class}</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>${shipLocation.system}${shipLocation.local ? `<br>${shipLocation.local}` : ""}</p>
|
||||
<p>Cargo ${cargo.toFixed(0)}</p>
|
||||
<p>State ${shipState}</p>
|
||||
${shipAction ? `
|
||||
<div class="ship-action-progress">
|
||||
<div class="ship-action-progress-label">
|
||||
<span>${shipAction.label}</span>
|
||||
<span>${Math.round(shipAction.progress * 100)}%</span>
|
||||
</div>
|
||||
<div class="ship-action-progress-track">
|
||||
<div class="ship-action-progress-fill" style="width: ${(shipAction.progress * 100).toFixed(1)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
` : ""}
|
||||
<div class="ship-card-ai">
|
||||
${ship.commanderObjective ? `<p>Objective ${describeShipObjective(ship.commanderObjective)}</p>` : ""}
|
||||
<p>Behavior ${ship.defaultBehaviorKind}${ship.behaviorPhase ? ` · ${ship.behaviorPhase}` : ""}</p>
|
||||
<p>Task ${ship.controllerTaskKind}</p>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
return factionCards + stationCards + shipCards;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fetchWorldSnapshot, openWorldStream } from "./api";
|
||||
import { renderFactionStrip } from "./viewerFactionStrip";
|
||||
import { renderOpsStrip } from "./viewerOpsStrip";
|
||||
import { updateDetailPanel } from "./viewerPanels";
|
||||
import { applyDeltaToWorld, cloneFactions, createWorldState, recordDeltaStats } from "./viewerState";
|
||||
import type {
|
||||
@@ -49,7 +49,7 @@ export interface ViewerWorldLifecycleContext {
|
||||
getNetworkStats: () => NetworkStats;
|
||||
getSystemSummaryVisuals: () => Map<string, unknown>;
|
||||
errorEl: HTMLDivElement;
|
||||
factionStripEl: HTMLDivElement;
|
||||
opsStripEl: HTMLDivElement;
|
||||
detailTitleEl: HTMLHeadingElement;
|
||||
detailBodyEl: HTMLDivElement;
|
||||
worldLabel: () => string;
|
||||
@@ -194,7 +194,7 @@ export class ViewerWorldLifecycle {
|
||||
}
|
||||
|
||||
rebuildFactions(_factions: FactionSnapshot[]) {
|
||||
this.context.factionStripEl.innerHTML = renderFactionStrip(
|
||||
this.context.opsStripEl.innerHTML = renderOpsStrip(
|
||||
this.context.getWorld(),
|
||||
this.context.getSelectedItems(),
|
||||
this.context.getCameraMode(),
|
||||
|
||||
Reference in New Issue
Block a user