feat: improving ui
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -216,6 +216,7 @@ public sealed class ModuleDefinition
|
||||
[JsonPropertyName("product")]
|
||||
public List<string> ProductIds
|
||||
{
|
||||
get => Products;
|
||||
set => Products = value ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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("&", "&")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user