feat: improving ui
This commit is contained in:
@@ -55,7 +55,13 @@ public sealed record StationDelta(
|
|||||||
public sealed record StationActionProgressSnapshot(
|
public sealed record StationActionProgressSnapshot(
|
||||||
string Lane,
|
string Lane,
|
||||||
string Label,
|
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(
|
public sealed record StationStorageUsageSnapshot(
|
||||||
string StorageClass,
|
string StorageClass,
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ public sealed class ModuleDefinition
|
|||||||
[JsonPropertyName("product")]
|
[JsonPropertyName("product")]
|
||||||
public List<string> ProductIds
|
public List<string> ProductIds
|
||||||
{
|
{
|
||||||
|
get => Products;
|
||||||
set => Products = value ?? [];
|
set => Products = value ?? [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -542,12 +542,18 @@ public sealed partial class SimulationEngine
|
|||||||
{
|
{
|
||||||
var recipe = SelectProductionRecipe(world, station, laneKey);
|
var recipe = SelectProductionRecipe(world, station, laneKey);
|
||||||
var timer = GetStationProductionTimer(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
|
return recipe is null || timer <= 0.01f
|
||||||
? null
|
? null
|
||||||
: new StationActionProgressSnapshot(
|
: new StationActionProgressSnapshot(
|
||||||
laneKey,
|
laneKey,
|
||||||
recipe.Label,
|
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)
|
.Where(snapshot => snapshot is not null)
|
||||||
.Cast<StationActionProgressSnapshot>()
|
.Cast<StationActionProgressSnapshot>()
|
||||||
|
|||||||
@@ -131,14 +131,41 @@ public sealed partial class SimulationEngine
|
|||||||
|
|
||||||
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
var expansionPressure = GetFactionExpansionPressure(world, station.FactionId);
|
||||||
var fleetPressure = FactionCommanderHasDirective(world, station.FactionId, "produce-military-ships") ? 1f : 0f;
|
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)
|
? -140f * MathF.Max(expansionPressure, fleetPressure)
|
||||||
: 280f * MathF.Max(expansionPressure, fleetPressure),
|
: 280f * MathF.Max(expansionPressure, fleetPressure);
|
||||||
"hull-fabrication" => 180f * expansionPressure,
|
}
|
||||||
"equipment-assembly" => 170f * expansionPressure,
|
|
||||||
"gun-assembly" => 160f * expansionPressure,
|
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"
|
"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),
|
=> 220f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
"frigate-construction" => 320f * MathF.Max(expansionPressure, fleetPressure),
|
||||||
@@ -149,8 +176,6 @@ public sealed partial class SimulationEngine
|
|||||||
=> -120f * expansionPressure,
|
=> -120f * expansionPressure,
|
||||||
_ => 0f,
|
_ => 0f,
|
||||||
};
|
};
|
||||||
|
|
||||||
return priority;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
private static bool RecipeAppliesToStation(StationRuntime station, RecipeDefinition recipe)
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
|
import type { InventoryEntry, Vector3Dto } from "./contractsCommon";
|
||||||
|
|
||||||
|
export interface RecipeEntrySnapshot {
|
||||||
|
itemId: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StationActionProgressSnapshot {
|
export interface StationActionProgressSnapshot {
|
||||||
lane: string;
|
lane: string;
|
||||||
label: string;
|
label: string;
|
||||||
progress: number;
|
progress: number;
|
||||||
|
timeRemainingSeconds: number;
|
||||||
|
cycleSeconds: number;
|
||||||
|
inputs: RecipeEntrySnapshot[];
|
||||||
|
outputs: RecipeEntrySnapshot[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StationStorageUsageSnapshot {
|
export interface StationStorageUsageSnapshot {
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ canvas {
|
|||||||
|
|
||||||
.detail-progress,
|
.detail-progress,
|
||||||
.ship-action-progress {
|
.ship-action-progress {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-progress-label,
|
.detail-progress-label,
|
||||||
@@ -300,6 +300,15 @@ canvas {
|
|||||||
line-height: 1;
|
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,
|
.detail-progress-track,
|
||||||
.ship-action-progress-track {
|
.ship-action-progress-track {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
formatSystemDistance,
|
formatSystemDistance,
|
||||||
inventoryAmount,
|
inventoryAmount,
|
||||||
} from "./viewerMath";
|
} 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 { describeCelestialPathWithinSystem, describeOrbitalParent, describeSelectable, describeShipBehavior, describeShipCurrentAction, describeShipOrder, describeShipState, getSelectionGroup, renderSystemDetails } from "./viewerSelection";
|
||||||
import type {
|
import type {
|
||||||
CameraMode,
|
CameraMode,
|
||||||
@@ -37,72 +46,74 @@ interface SystemPanelParams {
|
|||||||
cameraTargetShipId?: string;
|
cameraTargetShipId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function laneModuleId(lane: string): string | undefined {
|
function formatDuration(seconds: number): string {
|
||||||
switch (lane) {
|
if (seconds < 60) return `${Math.ceil(seconds)}s`;
|
||||||
case "refinery":
|
const m = Math.floor(seconds / 60);
|
||||||
return "refinery-stack";
|
const s = Math.ceil(seconds % 60);
|
||||||
case "fabrication":
|
return s > 0 ? `${m}m ${s}s` : `${m}m`;
|
||||||
return "fabricator-array";
|
}
|
||||||
case "components":
|
|
||||||
return "component-factory";
|
|
||||||
case "shipyard":
|
function escapeAttr(str: string): string {
|
||||||
return "ship-factory";
|
return str
|
||||||
default:
|
.replaceAll("&", "&")
|
||||||
return undefined;
|
.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(
|
function formatModuleListWithConstruction(
|
||||||
world: WorldState,
|
world: WorldState,
|
||||||
stationId: string,
|
stationId: string,
|
||||||
installedModules: string[],
|
installedModules: string[],
|
||||||
currentProcesses: { lane: string; label: string; progress: number }[],
|
currentProcesses: { lane: string; label: string; progress: number; timeRemainingSeconds: number }[],
|
||||||
): string {
|
): 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) {
|
for (const process of currentProcesses) {
|
||||||
const moduleId = laneModuleId(process.lane);
|
const existing = processByModule.get(process.lane) ?? [];
|
||||||
if (!moduleId) {
|
existing.push({ label: process.label, progress: process.progress, timeRemainingSeconds: process.timeRemainingSeconds, cycleSeconds: process.cycleSeconds, inputs: process.inputs, outputs: process.outputs });
|
||||||
continue;
|
processByModule.set(process.lane, existing);
|
||||||
}
|
|
||||||
|
|
||||||
const existing = processByModule.get(moduleId) ?? [];
|
|
||||||
existing.push({ label: process.label, progress: process.progress });
|
|
||||||
processByModule.set(moduleId, existing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderedProcessCount = new Map<string, number>();
|
const renderedProcessCount = new Map<string, number>();
|
||||||
const moduleLines = installedModules.map((moduleId) => {
|
const moduleHtmlParts = installedModules.map((moduleId) => {
|
||||||
const processIndex = renderedProcessCount.get(moduleId) ?? 0;
|
const processIndex = renderedProcessCount.get(moduleId) ?? 0;
|
||||||
const processes = processByModule.get(moduleId) ?? [];
|
const processes = processByModule.get(moduleId) ?? [];
|
||||||
const process = processes[processIndex];
|
const process = processes[processIndex];
|
||||||
renderedProcessCount.set(moduleId, processIndex + 1);
|
renderedProcessCount.set(moduleId, processIndex + 1);
|
||||||
|
const moduleName = moduleNameById.get(moduleId) ?? moduleId;
|
||||||
if (!process) {
|
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()]
|
const activeSites = [...world.constructionSites.values()]
|
||||||
.filter((site) => site.stationId === stationId && site.state !== "completed")
|
.filter((site) => site.stationId === stationId && site.state !== "completed")
|
||||||
.sort((left, right) => left.targetDefinitionId.localeCompare(right.targetDefinitionId));
|
.sort((left, right) => left.targetDefinitionId.localeCompare(right.targetDefinitionId));
|
||||||
|
|
||||||
for (const site of activeSites) {
|
for (const site of activeSites) {
|
||||||
const moduleId = site.blueprintId ?? site.targetDefinitionId;
|
const moduleId = site.blueprintId ?? site.targetDefinitionId;
|
||||||
const progress = Math.round(site.progress * 100);
|
const moduleName = moduleNameById.get(moduleId) ?? moduleId;
|
||||||
const tooltip = site.requiredItems.length > 0
|
const progress = site.progress;
|
||||||
|
const constructionTooltip = site.requiredItems.length > 0
|
||||||
? site.requiredItems
|
? 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`)
|
.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")
|
.join("\n")
|
||||||
: "No material requirements";
|
: "No material requirements";
|
||||||
const escapedTooltip = tooltip
|
moduleHtmlParts.push(`<div class="detail-progress-label" title="${escapeAttr(constructionTooltip)}"><span>${moduleName}</span><span>${Math.round(progress * 100)}% constructing</span></div>${renderProgressBar(progress)}`);
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("\"", """)
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">");
|
|
||||||
moduleLines.push(`<span title="${escapedTooltip}">${moduleId} (${progress}% constructing)</span>`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return moduleLines.length > 0 ? moduleLines.join("<br>") : "none";
|
return moduleHtmlParts.length > 0 ? moduleHtmlParts.join("<br>") : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatStorageClassLabel(storageClass: string): string {
|
function formatStorageClassLabel(storageClass: string): string {
|
||||||
@@ -112,15 +123,31 @@ function formatStorageClassLabel(storageClass: string): string {
|
|||||||
.join(" ");
|
.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) {
|
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
|
return storageUsage
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const percentUsed = entry.capacity > 0 ? Math.round((entry.used / entry.capacity) * 100) : 0;
|
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>");
|
.join("<br>");
|
||||||
}
|
}
|
||||||
@@ -236,33 +263,16 @@ export function updateDetailPanel(
|
|||||||
const dockedShipLabels = station.dockedShipIds.length > 0
|
const dockedShipLabels = station.dockedShipIds.length > 0
|
||||||
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
|
? station.dockedShipIds.map((shipId) => world.ships.get(shipId)?.label ?? shipId).join("<br>")
|
||||||
: "none";
|
: "none";
|
||||||
const stationInventory = station.inventory;
|
const stationStorage = formatStorageWithInventory(station.storageUsage, 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("")
|
|
||||||
: "";
|
|
||||||
detailTitleEl.textContent = station.label;
|
detailTitleEl.textContent = station.label;
|
||||||
detailBodyEl.innerHTML = `
|
detailBodyEl.innerHTML = `
|
||||||
<p>${station.category} · ${station.systemId}</p>
|
<p>${station.category} · ${station.systemId}</p>
|
||||||
<p>Parent ${parent}</p>
|
<p>Parent ${parent}</p>
|
||||||
${stationProcessingHtml}
|
|
||||||
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
<p>Docked ${station.dockedShips} / ${station.dockingPads}
|
||||||
<br>
|
<br>
|
||||||
${dockedShipLabels}</p>
|
${dockedShipLabels}</p>
|
||||||
<p>Modules ${moduleList}</p>
|
<p>Modules ${moduleList}</p>
|
||||||
<p>Storage ${stationStorageUsage}</p>
|
<p>Storage ${stationStorage}</p>
|
||||||
<p>Inventory ${formatInventory(stationInventory)}</p>
|
|
||||||
`;
|
`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
"module_arg_dock_m_01_lowtech",
|
"module_arg_dock_m_01_lowtech",
|
||||||
"module_gen_prod_energycells_01",
|
"module_gen_prod_energycells_01",
|
||||||
"module_arg_stor_solid_m_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",
|
"systemId": "helios",
|
||||||
"planetIndex": 2,
|
"planetIndex": 2,
|
||||||
|
|||||||
Reference in New Issue
Block a user