feat: improving ui

This commit is contained in:
2026-03-19 02:19:41 -04:00
parent aa4a6930ba
commit 710addf1f5
8 changed files with 137 additions and 69 deletions

View File

@@ -55,7 +55,13 @@ public sealed record StationDelta(
public sealed record StationActionProgressSnapshot(
string Lane,
string Label,
float Progress);
float Progress,
float TimeRemainingSeconds,
float CycleSeconds,
IReadOnlyList<RecipeEntrySnapshot> Inputs,
IReadOnlyList<RecipeEntrySnapshot> Outputs);
public sealed record RecipeEntrySnapshot(string ItemId, float Amount);
public sealed record StationStorageUsageSnapshot(
string StorageClass,

View File

@@ -216,6 +216,7 @@ public sealed class ModuleDefinition
[JsonPropertyName("product")]
public List<string> ProductIds
{
get => Products;
set => Products = value ?? [];
}
}

View File

@@ -542,12 +542,18 @@ public sealed partial class SimulationEngine
{
var recipe = SelectProductionRecipe(world, station, laneKey);
var timer = GetStationProductionTimer(station, laneKey);
var duration = MathF.Max(recipe?.Duration ?? 0.1f, 0.1f);
var progress = Math.Clamp(timer / duration, 0f, 1f);
return recipe is null || timer <= 0.01f
? null
: new StationActionProgressSnapshot(
laneKey,
recipe.Label,
Math.Clamp(timer / MathF.Max(recipe.Duration, 0.1f), 0f, 1f));
progress,
duration * (1f - progress),
duration,
recipe.Inputs.Select(i => new RecipeEntrySnapshot(i.ItemId, i.Amount)).ToList(),
recipe.Outputs.Select(o => new RecipeEntrySnapshot(o.ItemId, o.Amount)).ToList());
})
.Where(snapshot => snapshot is not null)
.Cast<StationActionProgressSnapshot>()

View File

@@ -131,14 +131,41 @@ public sealed partial class SimulationEngine
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
priority += recipe.Id switch
priority += GetStationRecipePriorityAdjustment(station, recipe, expansionPressure, fleetPressure);
return priority;
}
private static float GetStationRecipePriorityAdjustment(StationRuntime station, RecipeDefinition recipe, float expansionPressure, float fleetPressure)
{
var outputItemIds = recipe.Outputs
.Select(output => output.ItemId)
.ToHashSet(StringComparer.Ordinal);
if (outputItemIds.Contains("hullparts"))
{
"ship-parts-integration" => HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
return HasStationModules(station, "module_gen_prod_advancedelectronics_01", "module_gen_build_l_01")
? -140f * MathF.Max(expansionPressure, fleetPressure)
: 280f * MathF.Max(expansionPressure, fleetPressure),
"hull-fabrication" => 180f * expansionPressure,
"equipment-assembly" => 170f * expansionPressure,
"gun-assembly" => 160f * expansionPressure,
: 280f * MathF.Max(expansionPressure, fleetPressure);
}
if (outputItemIds.Contains("refinedmetals"))
{
return 180f * expansionPressure;
}
if (outputItemIds.Overlaps(["advancedelectronics", "dronecomponents", "engineparts", "fieldcoils", "missilecomponents", "shieldcomponents", "smartchips"]))
{
return 170f * expansionPressure;
}
if (outputItemIds.Overlaps(["turretcomponents", "weaponcomponents"]))
{
return 160f * expansionPressure;
}
return recipe.Id switch
{
"command-bridge-module-assembly" or "reactor-core-module-assembly" or "capacitor-bank-module-assembly" or "ion-drive-module-assembly" or "ftl-core-module-assembly" or "gun-turret-module-assembly"
=> 220f * MathF.Max(expansionPressure, fleetPressure),
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
@@ -149,8 +176,6 @@ public sealed partial class SimulationEngine
=> -120f * expansionPressure,
_ => 0f,
};
return priority;
}
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)

View File

@@ -1,9 +1,18 @@
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
export interface RecipeEntrySnapshot {
itemId: string;
amount: number;
}
export interface StationActionProgressSnapshot {
lane: string;
label: string;
progress: number;
timeRemainingSeconds: number;
cycleSeconds: number;
inputs: RecipeEntrySnapshot[];
outputs: RecipeEntrySnapshot[];
}
export interface StationStorageUsageSnapshot {

View File

@@ -284,7 +284,7 @@ canvas {
.detail-progress,
.ship-action-progress {
margin: 0 0 12px;
margin: 0 0 3px;
}
.detail-progress-label,
@@ -300,6 +300,15 @@ canvas {
line-height: 1;
}
.module-item {
display: block;
padding-left: 1em;
color: var(--muted);
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.72rem;
line-height: 1.5;
}
.detail-progress-track,
.ship-action-progress-track {
height: 6px;

View File

@@ -5,6 +5,15 @@ import {
formatSystemDistance,
inventoryAmount,
} from "./viewerMath";
import modulesData from "../../../shared/data/modules.json";
import itemsData from "../../../shared/data/items.json";
const moduleNameById = new Map<string, string>(
(modulesData as { id: string; name: string }[]).map((m) => [m.id, m.name]),
);
const itemTransportById = new Map<string, string>(
(itemsData as { id: string; transport: string }[]).map((item) => [item.id, item.transport]),
);
import { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
import type {
CameraMode,
@@ -37,72 +46,74 @@ interface SystemPanelParams {
cameraTargetShipId?: string;
}
function laneModuleId(lane: string): string | undefined {
switch (lane) {
case "refinery":
return "refinery-stack";
case "fabrication":
return "fabricator-array";
case "components":
return "component-factory";
case "shipyard":
return "ship-factory";
default:
return undefined;
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${Math.ceil(seconds)}s`;
const m = Math.floor(seconds / 60);
const s = Math.ceil(seconds % 60);
return s > 0 ? `${m}m ${s}s` : `${m}m`;
}
function escapeAttr(str: string): string {
return str
.replaceAll("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
function renderProgressBar(progress: number): string {
return `<div class="detail-progress"><div class="detail-progress-track"><div class="detail-progress-fill" style="width: ${(progress * 100).toFixed(1)}%"></div></div></div>`;
}
function formatModuleListWithConstruction(
world: WorldState,
stationId: string,
installedModules: string[],
currentProcesses: { lane: string; label: string; progress: number }[],
currentProcesses: { lane: string; label: string; progress: number; timeRemainingSeconds: number }[],
): string {
const processByModule = new Map<string, { label: string; progress: number }[]>();
const processByModule = new Map<string, { label: string; progress: number; timeRemainingSeconds: number; cycleSeconds: number; inputs: { itemId: string; amount: number }[]; outputs: { itemId: string; amount: number }[] }[]>();
for (const process of currentProcesses) {
const moduleId = laneModuleId(process.lane);
if (!moduleId) {
continue;
}
const existing = processByModule.get(moduleId) ?? [];
existing.push({ label: process.label, progress: process.progress });
processByModule.set(moduleId, existing);
const existing = processByModule.get(process.lane) ?? [];
existing.push({ label: process.label, progress: process.progress, timeRemainingSeconds: process.timeRemainingSeconds, cycleSeconds: process.cycleSeconds, inputs: process.inputs, outputs: process.outputs });
processByModule.set(process.lane, existing);
}
const renderedProcessCount = new Map<string, number>();
const moduleLines = installedModules.map((moduleId) => {
const moduleHtmlParts = installedModules.map((moduleId) => {
const processIndex = renderedProcessCount.get(moduleId) ?? 0;
const processes = processByModule.get(moduleId) ?? [];
const process = processes[processIndex];
renderedProcessCount.set(moduleId, processIndex + 1);
const moduleName = moduleNameById.get(moduleId) ?? moduleId;
if (!process) {
return moduleId;
return moduleName;
}
return `${moduleId} -> ${process.label} (${Math.round(process.progress * 100)}%)`;
const inputLines = process.inputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n");
const outputLines = process.outputs.map((e) => ` ${e.itemId}: ${e.amount.toFixed(0)}`).join("\n");
const tooltip = `Cycle: ${formatDuration(process.cycleSeconds)}\nInputs:\n${inputLines || " none"}\nOutputs:\n${outputLines || " none"}`;
const rightLabel = `${Math.round(process.progress * 100)}% · ${formatDuration(process.timeRemainingSeconds)}`;
return `<div class="detail-progress-label" title="${escapeAttr(tooltip)}"><span>${moduleName}</span><span>${rightLabel}</span></div>${renderProgressBar(process.progress)}`;
});
const activeSites = [...world.constructionSites.values()]
.filter((site) => site.stationId === stationId && site.state !== "completed")
.sort((left, right) => left.targetDefinitionId.localeCompare(right.targetDefinitionId));
for (const site of activeSites) {
const moduleId = site.blueprintId ?? site.targetDefinitionId;
const progress = Math.round(site.progress * 100);
const tooltip = site.requiredItems.length > 0
const moduleName = moduleNameById.get(moduleId) ?? moduleId;
const progress = site.progress;
const constructionTooltip = site.requiredItems.length > 0
? site.requiredItems
.map((entry) => `${entry.itemId}: ${entry.amount.toFixed(0)} required / ${inventoryAmount(site.stationId ? (world.stations.get(site.stationId)?.inventory ?? []) : site.deliveredItems, entry.itemId).toFixed(0)} available`)
.join("\n")
: "No material requirements";
const escapedTooltip = tooltip
.replaceAll("&", "&amp;")
.replaceAll("\"", "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
moduleLines.push(`<span title="${escapedTooltip}">${moduleId} (${progress}% constructing)</span>`);
moduleHtmlParts.push(`<div class="detail-progress-label" title="${escapeAttr(constructionTooltip)}"><span>${moduleName}</span><span>${Math.round(progress * 100)}% constructing</span></div>${renderProgressBar(progress)}`);
}
return moduleLines.length > 0 ? moduleLines.join("<br>") : "none";
return moduleHtmlParts.length > 0 ? moduleHtmlParts.join("<br>") : "none";
}
function formatStorageClassLabel(storageClass: string): string {
@@ -112,15 +123,31 @@ function formatStorageClassLabel(storageClass: string): string {
.join(" ");
}
function formatStorageUsage(storageUsage: { storageClass: string; used: number; capacity: number }[]): string {
function formatStorageWithInventory(
storageUsage: { storageClass: string; used: number; capacity: number }[],
inventory: { itemId: string; amount: number }[],
): string {
if (storageUsage.length === 0) {
return "none";
return inventory.length === 0 ? "none" : inventory.map((e) => `${e.itemId} ${e.amount.toFixed(0)}`).join("<br>");
}
const itemsByClass = new Map<string, { itemId: string; amount: number }[]>();
for (const entry of inventory) {
const cls = itemTransportById.get(entry.itemId) ?? "unknown";
const list = itemsByClass.get(cls) ?? [];
list.push(entry);
itemsByClass.set(cls, list);
}
return storageUsage
.map((entry) => {
const percentUsed = entry.capacity > 0 ? Math.round((entry.used / entry.capacity) * 100) : 0;
return `${formatStorageClassLabel(entry.storageClass)} ${percentUsed}% used (${entry.used.toFixed(0)} / ${entry.capacity.toFixed(0)})`;
const header = `${formatStorageClassLabel(entry.storageClass)} ${percentUsed}% (${entry.used.toFixed(0)} / ${entry.capacity.toFixed(0)})`;
const items = itemsByClass.get(entry.storageClass) ?? [];
if (items.length === 0) {
return `${header}<br><span style="padding-left:1em">empty</span>`;
}
return `${header}<br>${items.map((e) => `<span style="padding-left:1em">${e.itemId} ${e.amount.toFixed(0)}</span>`).join("<br>")}`;
})
.join("<br>");
}
@@ -236,33 +263,16 @@ export function updateDetailPanel(
const dockedShipLabels = station.dockedShipIds.length > 0
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
: "none";
const stationInventory = station.inventory;
const stationStorageUsage = formatStorageUsage(station.storageUsage);
const stationProcesses = station.currentProcesses;
const stationProcessingHtml = stationProcesses.length > 0
? stationProcesses.map((process) => `
<div class="detail-progress">
<div class="detail-progress-label">
<span>${process.label}</span>
<span>${Math.round(process.progress * 100)}%</span>
</div>
<div class="detail-progress-track">
<div class="detail-progress-fill" style="width: ${(process.progress * 100).toFixed(1)}%"></div>
</div>
</div>
`).join("")
: "";
const stationStorage = formatStorageWithInventory(station.storageUsage, station.inventory);
detailTitleEl.textContent = station.label;
detailBodyEl.innerHTML = `
<p>${station.category} · ${station.systemId}</p>
<p>Parent ${parent}</p>
${stationProcessingHtml}
<p>Docked ${station.dockedShips} / ${station.dockingPads}
<br>
${dockedShipLabels}</p>
<p>Modules ${moduleList}</p>
<p>Storage ${stationStorageUsage}</p>
<p>Inventory ${formatInventory(stationInventory)}</p>
<p>Storage ${stationStorage}</p>
`;
return;
}

View File

@@ -6,7 +6,9 @@
"module_arg_dock_m_01_lowtech",
"module_gen_prod_energycells_01",
"module_arg_stor_solid_m_01",
"module_arg_stor_liquid_m_01"
"module_arg_stor_liquid_m_01",
"module_arg_stor_container_m_01",
"module_gen_prod_refinedmetals_01"
],
"systemId": "helios",
"planetIndex": 2,