ui/phase-18: ship-class calc bridge with live designer preview
Wires pkg/calc/ship.go into the WASM Core boundary as seven thin
wrappers (DriveEffective, EmptyMass, WeaponsBlockMass, FullMass,
Speed, CargoCapacity, CarryingMass). The ship-class designer reads
Core through a new CORE_CONTEXT_KEY populated by the in-game layout
and renders a five-row preview pane (mass, full-load mass, max
speed, range at full load, cargo capacity) that updates reactively
on every form edit and on the player's localPlayer{Drive,Weapons,
Shields,Cargo} tech levels — three of which are now decoded from
the report's Player block alongside the existing localPlayerDrive.
CarryingMass is the seventh wrapper added to the original six-function
list so that "full-load mass" composes through pkg/calc/ functions
without putting math in TypeScript.
This commit is contained in:
@@ -18,9 +18,12 @@ Phase 17 ship-class designer. Two modes driven by the optional
|
||||
referenced by active production / ship groups) and a Back
|
||||
button.
|
||||
|
||||
Phase 18 wires `pkg/calc/` into the form for live mass / speed /
|
||||
range / cargo previews; the markup keeps a placeholder slot near
|
||||
the value fields so the diff in Phase 18 stays minimal.
|
||||
Phase 18 wires `pkg/calc/` (via the `Core` WASM bridge) into the
|
||||
new-mode form: an `<aside class="preview">` block recomputes mass,
|
||||
full-load mass, max speed, range at full load, and cargo capacity
|
||||
on every form change, using the local player's tech levels off the
|
||||
rendered report. The preview hides itself until the form passes
|
||||
validation, so it never displays half-cooked numbers.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext, tick } from "svelte";
|
||||
@@ -41,6 +44,10 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
validateShipClass,
|
||||
type ShipClassInvalidReason,
|
||||
} from "$lib/util/ship-class-validation";
|
||||
import {
|
||||
CORE_CONTEXT_KEY,
|
||||
type CoreHandle,
|
||||
} from "$lib/core-context.svelte";
|
||||
|
||||
const rendered = getContext<RenderedReportSource | undefined>(
|
||||
RENDERED_REPORT_CONTEXT_KEY,
|
||||
@@ -48,6 +55,7 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
const draft = getContext<OrderDraftStore | undefined>(
|
||||
ORDER_DRAFT_CONTEXT_KEY,
|
||||
);
|
||||
const coreHandle = getContext<CoreHandle | undefined>(CORE_CONTEXT_KEY);
|
||||
|
||||
const gameId = $derived(page.params.id ?? "");
|
||||
const classId = $derived(page.params.classId ?? "");
|
||||
@@ -105,6 +113,54 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
);
|
||||
const canSave = $derived(validation.ok && draft !== undefined);
|
||||
|
||||
const driveTech = $derived(rendered?.report?.localPlayerDrive ?? 0);
|
||||
const cargoTech = $derived(rendered?.report?.localPlayerCargo ?? 0);
|
||||
|
||||
interface PreviewValues {
|
||||
mass: number;
|
||||
fullLoadMass: number;
|
||||
maxSpeed: number;
|
||||
rangeAtFull: number;
|
||||
cargoCapacity: number;
|
||||
}
|
||||
|
||||
const preview = $derived.by<PreviewValues | null>(() => {
|
||||
const core = coreHandle?.core;
|
||||
if (core === undefined || core === null) return null;
|
||||
if (!validation.ok) return null;
|
||||
const v = validation.value;
|
||||
const mass = core.emptyMass({
|
||||
drive: v.drive,
|
||||
weapons: v.weapons,
|
||||
armament: v.armament,
|
||||
shields: v.shields,
|
||||
cargo: v.cargo,
|
||||
});
|
||||
if (mass === null) return null;
|
||||
const cargoCapacity = core.cargoCapacity({
|
||||
cargo: v.cargo,
|
||||
cargoTech,
|
||||
});
|
||||
const carryAtFull =
|
||||
cargoTech > 0
|
||||
? core.carryingMass({ load: cargoCapacity, cargoTech })
|
||||
: 0;
|
||||
const fullLoadMass = core.fullMass({
|
||||
emptyMass: mass,
|
||||
carryingMass: carryAtFull,
|
||||
});
|
||||
const driveEffective = core.driveEffective({
|
||||
drive: v.drive,
|
||||
driveTech,
|
||||
});
|
||||
const maxSpeed = core.speed({ driveEffective, fullMass: mass });
|
||||
const rangeAtFull = core.speed({
|
||||
driveEffective,
|
||||
fullMass: fullLoadMass,
|
||||
});
|
||||
return { mass, fullLoadMass, maxSpeed, rangeAtFull, cargoCapacity };
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!isViewMode) {
|
||||
void tick().then(() => nameInputEl?.focus());
|
||||
@@ -309,6 +365,52 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
{invalidMessage}
|
||||
</p>
|
||||
{/if}
|
||||
{#if preview !== null}
|
||||
<aside
|
||||
class="preview"
|
||||
data-testid="designer-ship-class-preview"
|
||||
>
|
||||
<h3>{i18n.t("game.designer.ship_class.preview.title")}</h3>
|
||||
<dl>
|
||||
<div class="row">
|
||||
<dt>{i18n.t("game.designer.ship_class.preview.mass")}</dt>
|
||||
<dd data-testid="designer-ship-class-preview-mass">
|
||||
{formatNumber(preview.mass)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>
|
||||
{i18n.t("game.designer.ship_class.preview.full_load_mass")}
|
||||
</dt>
|
||||
<dd data-testid="designer-ship-class-preview-full-load-mass">
|
||||
{formatNumber(preview.fullLoadMass)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>
|
||||
{i18n.t("game.designer.ship_class.preview.max_speed")}
|
||||
</dt>
|
||||
<dd data-testid="designer-ship-class-preview-max-speed">
|
||||
{formatNumber(preview.maxSpeed)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>{i18n.t("game.designer.ship_class.preview.range")}</dt>
|
||||
<dd data-testid="designer-ship-class-preview-range">
|
||||
{formatNumber(preview.rangeAtFull)}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="row">
|
||||
<dt>
|
||||
{i18n.t("game.designer.ship_class.preview.cargo_capacity")}
|
||||
</dt>
|
||||
<dd data-testid="designer-ship-class-preview-cargo-capacity">
|
||||
{formatNumber(preview.cargoCapacity)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</aside>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -383,6 +485,42 @@ the value fields so the diff in Phase 18 stays minimal.
|
||||
font-size: 0.8rem;
|
||||
color: #d97a7a;
|
||||
}
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #0a0e1a;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 4px;
|
||||
max-width: 30rem;
|
||||
}
|
||||
.preview h3 {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: #aab;
|
||||
font-weight: 500;
|
||||
}
|
||||
.preview dl {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
row-gap: 0.2rem;
|
||||
column-gap: 0.75rem;
|
||||
}
|
||||
.preview .row {
|
||||
display: contents;
|
||||
}
|
||||
.preview dt {
|
||||
color: #aab;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.preview dd {
|
||||
margin: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.9rem;
|
||||
text-align: right;
|
||||
}
|
||||
.fields {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
|
||||
Reference in New Issue
Block a user