fix(ui): calculator polish — smart input steps, unified tech/MAT lock idiom, tech floor, speed-lock ceiling fix
- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only case (every positive drive solves it), so locking the displayed speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible". - ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide the native spinner so it cannot produce invalid (0, 1) values; armament keeps its native step 1. - Tech and planet MAT cells follow the same lock idiom as goal-seek locks: open padlock (🔓) over the inherited value → click to open an input with a closed padlock (🔒). The padlock slot is always reserved, so the column width is stable. - Tech overrides (design area and modernization target) are floored at the player's current tech on this turn — a lower value is flagged as invalid.
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
<!--
|
||||
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
|
||||
built with. Tech and MAT locks follow the same idiom as goal-seek
|
||||
locks below the design area — by default the value renders as plain
|
||||
text with an open padlock; clicking it overrides (input + closed
|
||||
padlock). Reserved space for the padlock keeps the column width
|
||||
stable as the lock state toggles. 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 { tick } from "svelte";
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
import {
|
||||
shipClassFieldErrors,
|
||||
@@ -37,6 +41,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
resolved: DesignBlocksState;
|
||||
techs: TechState;
|
||||
techOverridden: Record<TechKey, boolean>;
|
||||
// Lower bound for the tech inputs: the player's current tech on
|
||||
// this turn. A design cannot be built with tech below the player's
|
||||
// own level, so we surface that as a per-field validation error.
|
||||
techFloor: TechState;
|
||||
computedInput?: ClaimedInput | null;
|
||||
blocksReadonly?: boolean;
|
||||
onTechInput: (key: TechKey) => void;
|
||||
@@ -47,6 +55,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
resolved,
|
||||
techs = $bindable(),
|
||||
techOverridden,
|
||||
techFloor,
|
||||
computedInput = null,
|
||||
blocksReadonly = false,
|
||||
onTechInput,
|
||||
@@ -73,7 +82,38 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
|
||||
}
|
||||
function techError(key: TechKey): string {
|
||||
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : "";
|
||||
const value = techs[key];
|
||||
if (value < 0) return i18n.t("game.calculator.invalid.tech_value");
|
||||
if (value < techFloor[key]) {
|
||||
return i18n.t("game.calculator.invalid.tech_below_current");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Smart step on the four ship-class blocks (drive, weapons, shields,
|
||||
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
|
||||
// native 0.01 step would produce invalid intermediates like 0.01.
|
||||
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
|
||||
// otherwise −0.1 down to 1, clamped at 0. Armament keeps native step 1.
|
||||
function bumpBlock(value: number, dir: 1 | -1): number {
|
||||
if (dir === 1) {
|
||||
if (value < 1) return 1;
|
||||
return Math.round((value + 0.1) * 10) / 10;
|
||||
}
|
||||
if (value <= 1) return 0;
|
||||
return Math.round((value - 0.1) * 10) / 10;
|
||||
}
|
||||
function onBlockKey(
|
||||
event: KeyboardEvent,
|
||||
key: keyof DesignBlocksState,
|
||||
): void {
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
blocks[key] = bumpBlock(blocks[key], 1);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
blocks[key] = bumpBlock(blocks[key], -1);
|
||||
}
|
||||
}
|
||||
|
||||
const BLOCK_ROWS: {
|
||||
@@ -81,13 +121,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
label: () => string;
|
||||
step: string;
|
||||
tech: TechKey | null;
|
||||
smartStep: boolean;
|
||||
}[] = [
|
||||
{ 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" },
|
||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.1", tech: "drive", smartStep: true },
|
||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null, smartStep: false },
|
||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.1", tech: "weapons", smartStep: true },
|
||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.1", tech: "shields", smartStep: true },
|
||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.1", tech: "cargo", smartStep: true },
|
||||
];
|
||||
|
||||
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
|
||||
|
||||
async function activateTechOverride(key: TechKey): Promise<void> {
|
||||
onTechInput(key);
|
||||
await tick();
|
||||
techInputEls[key]?.focus();
|
||||
techInputEls[key]?.select();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="design" data-testid="calculator-design-area">
|
||||
@@ -103,6 +153,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
{#if isComputed}
|
||||
<input
|
||||
class="ship"
|
||||
class:smart-step={row.smartStep}
|
||||
type="number"
|
||||
step={row.step}
|
||||
readonly
|
||||
@@ -114,6 +165,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
{:else}
|
||||
<input
|
||||
class="ship"
|
||||
class:smart-step={row.smartStep}
|
||||
type="number"
|
||||
step={row.step}
|
||||
min="0"
|
||||
@@ -122,26 +174,29 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
||||
title={blockError(row.key)}
|
||||
data-testid={`calculator-block-${row.key}`}
|
||||
onkeydown={row.smartStep
|
||||
? (e) => onBlockKey(e, row.key)
|
||||
: null}
|
||||
/>
|
||||
{/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)}
|
||||
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||
title={techError(techKey)}
|
||||
data-testid={`calculator-tech-${techKey}`}
|
||||
/>
|
||||
{#if techOverridden[techKey]}
|
||||
<input
|
||||
bind:this={techInputEls[techKey]}
|
||||
class="tech"
|
||||
type="number"
|
||||
step="0.001"
|
||||
min={techFloor[techKey]}
|
||||
bind:value={techs[techKey]}
|
||||
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||
title={techError(techKey)}
|
||||
data-testid={`calculator-tech-${techKey}`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
class="lock active"
|
||||
title={i18n.t("game.calculator.tech.reset")}
|
||||
aria-label={i18n.t("game.calculator.tech.reset")}
|
||||
data-testid={`calculator-tech-reset-${techKey}`}
|
||||
@@ -149,6 +204,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="tech-val"
|
||||
data-testid={`calculator-tech-value-${techKey}`}
|
||||
>
|
||||
{techs[techKey]}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
title={i18n.t("game.calculator.tech.override")}
|
||||
aria-label={i18n.t("game.calculator.tech.override")}
|
||||
data-testid={`calculator-tech-override-${techKey}`}
|
||||
onclick={() => void activateTechOverride(techKey)}
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
@@ -193,6 +265,18 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
border-radius: 3px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Drive/weapons/shields/cargo use the JS-driven smart step (0→1 jump
|
||||
then 0.1 increments) for keyboard arrows; hide the native spinner
|
||||
on those inputs so it cannot produce invalid 0.01 intermediates. */
|
||||
input.smart-step::-webkit-inner-spin-button,
|
||||
input.smart-step::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input.smart-step {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
input[data-computed="true"],
|
||||
input[readonly] {
|
||||
color: var(--color-accent);
|
||||
@@ -206,6 +290,14 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.tech-val {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.8rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
padding: 0.2rem 0.35rem;
|
||||
}
|
||||
.lock {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
@@ -214,5 +306,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.lock.active,
|
||||
.lock:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -393,7 +393,9 @@ const en = {
|
||||
"game.calculator.lock.reset": "locked — click to release to the computed value",
|
||||
"game.calculator.lock.infeasible": "this target cannot be reached with the current design",
|
||||
"game.calculator.lock.max": "release the locked result first — one result at a time",
|
||||
"game.calculator.tech.override": "click to override your current tech",
|
||||
"game.calculator.tech.reset": "overridden — click to reset to your current tech",
|
||||
"game.calculator.mat.override": "click to override the planet value",
|
||||
"game.calculator.mat.reset": "overridden — click to reset to the planet value",
|
||||
"game.calculator.modern.current": "current",
|
||||
"game.calculator.modern.target": "target",
|
||||
@@ -418,6 +420,7 @@ const en = {
|
||||
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
|
||||
"game.calculator.invalid.negative": "value cannot be negative",
|
||||
"game.calculator.invalid.tech_value": "tech level cannot be negative",
|
||||
"game.calculator.invalid.tech_below_current": "tech level cannot be below your current tech this turn",
|
||||
"game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity",
|
||||
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
|
||||
|
||||
|
||||
@@ -394,7 +394,9 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
|
||||
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
|
||||
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
|
||||
"game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень",
|
||||
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
|
||||
"game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT",
|
||||
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
|
||||
"game.calculator.modern.current": "текущий",
|
||||
"game.calculator.modern.target": "целевой",
|
||||
@@ -419,6 +421,7 @@ const ru: Record<keyof typeof en, string> = {
|
||||
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||
"game.calculator.invalid.negative": "значение не может быть отрицательным",
|
||||
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
|
||||
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
|
||||
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
||||
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ switch (the inspector auto-opens on a planet click) — the calculator is a
|
||||
long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { getContext, tick } from "svelte";
|
||||
import { appScreen } from "$lib/app-nav.svelte";
|
||||
|
||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||
@@ -273,6 +273,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
|
||||
);
|
||||
|
||||
// Modernization target tech mirrors the design-area floor: a target
|
||||
// below the player's current tech on this turn is meaningless (no
|
||||
// upgrade), so flag it the same way.
|
||||
function targetTechError(key: TechKey): string {
|
||||
const value = cs.targetTech[key];
|
||||
if (value < 0) return i18n.t("game.calculator.invalid.negative");
|
||||
if (value < playerTech[key]) {
|
||||
return i18n.t("game.calculator.invalid.tech_below_current");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Locking a speed back-solves the drive block; with a zero drive the
|
||||
// ship is deliberately immobile, so disallow it.
|
||||
function lockDisabledReason(output: LockableOutputId): string {
|
||||
@@ -291,8 +303,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
function onResetTech(key: TechKey): void {
|
||||
cs.techOverridden[key] = false;
|
||||
}
|
||||
function onMatInput(): void {
|
||||
const matInputRef: { el?: HTMLInputElement } = {};
|
||||
async function activateMatOverride(): Promise<void> {
|
||||
cs.matOverridden = true;
|
||||
await tick();
|
||||
matInputRef.el?.focus();
|
||||
matInputRef.el?.select();
|
||||
}
|
||||
function resetMat(): void {
|
||||
cs.matOverridden = false;
|
||||
@@ -485,6 +501,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
resolved={resolvedCeil}
|
||||
bind:techs={cs.techValues}
|
||||
techOverridden={cs.techOverridden}
|
||||
techFloor={playerTech}
|
||||
computedInput={result.computedInput}
|
||||
{onTechInput}
|
||||
{onResetTech}
|
||||
@@ -589,17 +606,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={cs.matValue}
|
||||
oninput={onMatInput}
|
||||
aria-invalid={matError !== "" ? "true" : "false"}
|
||||
title={matError}
|
||||
data-testid="calculator-planet-mat"
|
||||
/>
|
||||
{#if cs.matOverridden}
|
||||
<input
|
||||
bind:this={matInputRef.el}
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
bind:value={cs.matValue}
|
||||
aria-invalid={matError !== "" ? "true" : "false"}
|
||||
title={matError}
|
||||
data-testid="calculator-planet-mat"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="lock active"
|
||||
@@ -610,6 +627,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
>
|
||||
🔒
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="mat-val"
|
||||
data-testid="calculator-planet-mat-value"
|
||||
>
|
||||
{cs.matValue}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="lock"
|
||||
title={i18n.t("game.calculator.mat.override")}
|
||||
aria-label={i18n.t("game.calculator.mat.override")}
|
||||
data-testid="calculator-mat-override"
|
||||
onclick={() => void activateMatOverride()}
|
||||
>
|
||||
🔓
|
||||
</button>
|
||||
{/if}
|
||||
</span>
|
||||
<span></span>
|
||||
@@ -638,18 +672,17 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
|
||||
</div>
|
||||
{#each modernCosts?.perBlock ?? [] as row (row.key)}
|
||||
{@const targetError = targetTechError(row.key)}
|
||||
<div class="rrow">
|
||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||
<span class="cell">
|
||||
<input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
min={playerTech[row.key]}
|
||||
bind:value={cs.targetTech[row.key]}
|
||||
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
|
||||
title={cs.targetTech[row.key] < 0
|
||||
? i18n.t("game.calculator.invalid.negative")
|
||||
: ""}
|
||||
aria-invalid={targetError !== "" ? "true" : "false"}
|
||||
title={targetError}
|
||||
data-testid={`calculator-target-${row.key}`}
|
||||
/>
|
||||
</span>
|
||||
@@ -899,4 +932,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
|
||||
design area) so the cell width stays the same whether the value is
|
||||
the inherited planet number or the player's override. */
|
||||
.mat-val {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.85rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
padding: 0.15rem 0.3rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user