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;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Exposes the WASM `Core` instance through a Svelte context so views
|
||||
// that need its math bridge (Phase 18 ship-class preview, future
|
||||
// inspector calculators) can read it without re-booting the module.
|
||||
// The layout populates `core` after `loadCore()` resolves; consumers
|
||||
// observe `null` while the boot is in flight and the live `Core`
|
||||
// once the runtime is ready.
|
||||
|
||||
import type { Core } from "../platform/core/index";
|
||||
|
||||
/**
|
||||
* CORE_CONTEXT_KEY is the Svelte context key the in-game shell
|
||||
* layout uses to expose its booted `Core` to descendants such as
|
||||
* the ship-class designer preview pane.
|
||||
*/
|
||||
export const CORE_CONTEXT_KEY = Symbol("core");
|
||||
|
||||
export interface CoreHandle {
|
||||
readonly core: Core | null;
|
||||
}
|
||||
|
||||
export class CoreHolder implements CoreHandle {
|
||||
#core: Core | null = $state(null);
|
||||
|
||||
get core(): Core | null {
|
||||
return this.#core;
|
||||
}
|
||||
|
||||
set(core: Core | null): void {
|
||||
this.#core = core;
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,13 @@ const en = {
|
||||
"game.designer.ship_class.invalid.cargo_value": "cargo must be 0 or ≥ 1",
|
||||
"game.designer.ship_class.invalid.armament_weapons_pair": "armament and weapons must be both zero or both nonzero",
|
||||
"game.designer.ship_class.invalid.all_zero": "at least one value must be nonzero",
|
||||
"game.designer.ship_class.preview.title": "preview at your tech levels",
|
||||
"game.designer.ship_class.preview.mass": "mass",
|
||||
"game.designer.ship_class.preview.full_load_mass": "full-load mass",
|
||||
"game.designer.ship_class.preview.max_speed": "max speed (ly/turn)",
|
||||
"game.designer.ship_class.preview.range": "range at full load (ly/turn)",
|
||||
"game.designer.ship_class.preview.cargo_capacity": "cargo capacity per ship",
|
||||
"game.designer.ship_class.preview.unavailable": "—",
|
||||
} as const;
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -239,6 +239,13 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.designer.ship_class.invalid.cargo_value": "трюм должен быть 0 или ≥ 1",
|
||||
"game.designer.ship_class.invalid.armament_weapons_pair": "вооружённость и оружие должны быть оба нулевыми или оба ненулевыми",
|
||||
"game.designer.ship_class.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||
"game.designer.ship_class.preview.title": "превью при ваших технологиях",
|
||||
"game.designer.ship_class.preview.mass": "масса",
|
||||
"game.designer.ship_class.preview.full_load_mass": "масса с полной загрузкой",
|
||||
"game.designer.ship_class.preview.max_speed": "максимальная скорость (св.лет/ход)",
|
||||
"game.designer.ship_class.preview.range": "дальность при полной загрузке (св.лет/ход)",
|
||||
"game.designer.ship_class.preview.cargo_capacity": "грузоподъёмность одного корабля",
|
||||
"game.designer.ship_class.preview.unavailable": "—",
|
||||
};
|
||||
|
||||
export default ru;
|
||||
|
||||
Reference in New Issue
Block a user