feat(ui): Phase 30 ship-class calculator with goal-seek and reach circles
Fuse the standalone ship-class designer (Phases 17/18) into a sidebar calculator: live mass/speed/attack/defence/bombing results, a planet build-rate readout, single-target goal-seek, a modernization-cost mode, and auto reach circles on the map for the selected planet. pkg/calc becomes the single source for the new math (no mirroring): extract BombingPower from the engine model and the per-turn ship-production loop from controller.ProduceShip into pkg/calc (engine now delegates), and add inverse goal-seek solvers in pkg/calc/solve.go. Thin-bridge the combat, planet-build, and solver functions through ui/core/calc + ui/wasm and rebuild core.wasm. Remove the standalone designer view/route; the ship-classes table and the view/bottom menus open the calculator via a shared request store. Docs: rewrite ui/PLAN.md Phase 30, adjust Phase 34 (realistic forecast + CAP/COL ownership), add ui/docs/calculator-ux.md, extend calc-bridge.md, fix navigation.md; remove ui/CALCULATOR.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
<!--
|
||||
Reusable "Ship Class design area": the five design blocks (drive,
|
||||
armament, weapons, shields, cargo) plus the four tech levels they are
|
||||
built with. Each tech defaults to the player's current level and shows a
|
||||
lock icon once overridden; clicking the lock resets it. A block claimed
|
||||
by an active goal-seek lock renders read-only with its own lock marker.
|
||||
The component is presentational — the parent owns the state and the
|
||||
calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n/index.svelte";
|
||||
import type { ClaimedInput } from "./calc-model";
|
||||
|
||||
export interface DesignBlocksState {
|
||||
drive: number;
|
||||
armament: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
export interface TechState {
|
||||
drive: number;
|
||||
weapons: number;
|
||||
shields: number;
|
||||
cargo: number;
|
||||
}
|
||||
export type TechKey = keyof TechState;
|
||||
|
||||
type Props = {
|
||||
blocks: DesignBlocksState;
|
||||
// Blocks after goal-seek: the claimed block carries its solved
|
||||
// value, which is what the read-only computed cell displays.
|
||||
resolved: DesignBlocksState;
|
||||
techs: TechState;
|
||||
techOverridden: Record<TechKey, boolean>;
|
||||
computedInput?: ClaimedInput | null;
|
||||
blocksReadonly?: boolean;
|
||||
onTechInput: (key: TechKey) => void;
|
||||
onResetTech: (key: TechKey) => void;
|
||||
};
|
||||
let {
|
||||
blocks = $bindable(),
|
||||
resolved,
|
||||
techs = $bindable(),
|
||||
techOverridden,
|
||||
computedInput = null,
|
||||
blocksReadonly = false,
|
||||
onTechInput,
|
||||
onResetTech,
|
||||
}: Props = $props();
|
||||
|
||||
const BLOCK_ROWS: {
|
||||
key: keyof DesignBlocksState;
|
||||
label: () => string;
|
||||
step: string;
|
||||
tech: TechKey | null;
|
||||
}[] = [
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="design" data-testid="calculator-design-area">
|
||||
<div class="cols">
|
||||
<span></span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.ship")}</span>
|
||||
<span class="col-head">{i18n.t("game.calculator.col.tech")}</span>
|
||||
</div>
|
||||
{#each BLOCK_ROWS as row (row.key)}
|
||||
{@const isComputed = computedInput === row.key}
|
||||
<div class="row">
|
||||
<span class="label">{row.label()}</span>
|
||||
{#if isComputed}
|
||||
<input
|
||||
class="ship"
|
||||
type="number"
|
||||
step={row.step}
|
||||
readonly
|
||||
value={resolved[row.key]}
|
||||
data-computed="true"
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
title={i18n.t("game.calculator.lock.reset")}
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
class="ship"
|
||||
type="number"
|
||||
step={row.step}
|
||||
min="0"
|
||||
bind:value={blocks[row.key]}
|
||||
readonly={blocksReadonly}
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
/>
|
||||
{/if}
|
||||
{#if row.tech !== null}
|
||||
{@const techKey = row.tech}
|
||||
<span class="tech-cell">
|
||||
<input
|
||||
class="tech"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
bind:value={techs[techKey]}
|
||||
oninput={() => onTechInput(techKey)}
|
||||
data-testid={`calculator-tech-${techKey}`}
|
||||
/>
|
||||
{#if techOverridden[techKey]}
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
title={i18n.t("game.calculator.tech.reset")}
|
||||
aria-label={i18n.t("game.calculator.tech.reset")}
|
||||
data-testid={`calculator-tech-reset-${techKey}`}
|
||||
onclick={() => onResetTech(techKey)}
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span></span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.design {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.cols,
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.5rem 1fr 1fr;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.col-head {
|
||||
color: #8890b0;
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.label {
|
||||
color: #aab;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
input {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.2rem 0.35rem;
|
||||
background: #0a0e1a;
|
||||
color: #e8eaf6;
|
||||
border: 1px solid #2a3150;
|
||||
border-radius: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
input[data-computed="true"],
|
||||
input[readonly] {
|
||||
color: #9fb0ff;
|
||||
background: #11162a;
|
||||
}
|
||||
.tech-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.lock {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user