9ae7b88b89
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>
271 lines
6.4 KiB
Svelte
271 lines
6.4 KiB
Svelte
<!--
|
|
Active-view picker that drives navigation between every in-game view.
|
|
The trigger swaps icon between desktop dropdown (▾) and mobile
|
|
hamburger (☰) at the 1024 px breakpoint via CSS only; the surface
|
|
itself is identical. The same component is reused for the mobile
|
|
"More" drawer entry of `bottom-tabs.svelte`.
|
|
|
|
Lists the seven IA destinations: map, tables (sub-list of six
|
|
entities), report, battle, mail, ship-class designer, science
|
|
designer. Closes on Escape, on outside click, and after a
|
|
navigation. Phase 26 introduces the history-mode entry; Phase 35
|
|
polishes microcopy.
|
|
-->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { goto } from "$app/navigation";
|
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
|
import { mailStore } from "$lib/mail-store.svelte";
|
|
|
|
type Props = { gameId: string };
|
|
let { gameId }: Props = $props();
|
|
|
|
const mailUnread = $derived(mailStore.unreadCount);
|
|
|
|
let open = $state(false);
|
|
let rootEl: HTMLDivElement | null = $state(null);
|
|
|
|
const tableEntities: ReadonlyArray<{ slug: string; key: TranslationKey }> = [
|
|
{ slug: "planets", key: "game.view.table.planets" },
|
|
{ slug: "ship-classes", key: "game.view.table.ship_classes" },
|
|
{ slug: "ship-groups", key: "game.view.table.ship_groups" },
|
|
{ slug: "fleets", key: "game.view.table.fleets" },
|
|
{ slug: "sciences", key: "game.view.table.sciences" },
|
|
{ slug: "races", key: "game.view.table.races" },
|
|
];
|
|
|
|
function toggleOpen(): void {
|
|
open = !open;
|
|
}
|
|
|
|
function go(path: string): void {
|
|
open = false;
|
|
void goto(path);
|
|
}
|
|
|
|
function onKeyDown(event: KeyboardEvent): void {
|
|
if (event.key === "Escape" && open) {
|
|
open = false;
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
const handleClick = (event: MouseEvent): void => {
|
|
if (!open || rootEl === null) return;
|
|
const target = event.target;
|
|
if (target instanceof Node && rootEl.contains(target)) return;
|
|
open = false;
|
|
};
|
|
document.addEventListener("click", handleClick, true);
|
|
document.addEventListener("keydown", onKeyDown);
|
|
return () => {
|
|
document.removeEventListener("click", handleClick, true);
|
|
document.removeEventListener("keydown", onKeyDown);
|
|
};
|
|
});
|
|
</script>
|
|
|
|
<div class="view-menu" bind:this={rootEl}>
|
|
<button
|
|
type="button"
|
|
class="trigger"
|
|
data-testid="view-menu-trigger"
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
aria-label={open
|
|
? i18n.t("game.shell.menu.close_views")
|
|
: i18n.t("game.shell.menu.open_views")}
|
|
onclick={toggleOpen}
|
|
>
|
|
<span class="icon-dropdown" aria-hidden="true">▾</span>
|
|
<span class="icon-hamburger" aria-hidden="true">☰</span>
|
|
</button>
|
|
{#if open}
|
|
<div class="surface" role="menu" data-testid="view-menu-list">
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="view-menu-item-map"
|
|
onclick={() => go(`/games/${gameId}/map`)}
|
|
>
|
|
{i18n.t("game.view.map")}
|
|
</button>
|
|
<details data-testid="view-menu-tables">
|
|
<summary>{i18n.t("game.view.table")}</summary>
|
|
<div class="sub">
|
|
{#each tableEntities as entry (entry.slug)}
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="view-menu-item-table-{entry.slug}"
|
|
onclick={() => go(`/games/${gameId}/table/${entry.slug}`)}
|
|
>
|
|
{i18n.t(entry.key)}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</details>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="view-menu-item-report"
|
|
onclick={() => go(`/games/${gameId}/report`)}
|
|
>
|
|
{i18n.t("game.view.report")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="view-menu-item-battle"
|
|
onclick={() => go(`/games/${gameId}/battle`)}
|
|
>
|
|
{i18n.t("game.view.battle")}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="view-menu-item-mail"
|
|
class="with-badge"
|
|
onclick={() => go(`/games/${gameId}/mail`)}
|
|
>
|
|
<span>{i18n.t("game.view.mail")}</span>
|
|
{#if mailUnread > 0}
|
|
<span class="badge" data-testid="view-menu-item-mail-badge">
|
|
{i18n.t("game.view.mail.badge", { count: String(mailUnread) })}
|
|
</span>
|
|
{/if}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
data-testid="view-menu-item-designer-science"
|
|
onclick={() => go(`/games/${gameId}/designer/science`)}
|
|
>
|
|
{i18n.t("game.view.designer.science")}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.view-menu {
|
|
position: relative;
|
|
}
|
|
.trigger {
|
|
font: inherit;
|
|
font-size: 1.1rem;
|
|
padding: 0.25rem 0.6rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 1px solid #2a3150;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
}
|
|
.trigger:hover {
|
|
background: #1c2238;
|
|
}
|
|
.icon-dropdown {
|
|
display: inline;
|
|
}
|
|
.icon-hamburger {
|
|
display: none;
|
|
}
|
|
@media (max-width: 767.98px) {
|
|
.icon-dropdown {
|
|
display: none;
|
|
}
|
|
.icon-hamburger {
|
|
display: inline;
|
|
}
|
|
}
|
|
.surface {
|
|
position: absolute;
|
|
top: calc(100% + 0.25rem);
|
|
right: 0;
|
|
min-width: 14rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #14182a;
|
|
border: 1px solid #2a3150;
|
|
border-radius: 6px;
|
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
|
|
z-index: 50;
|
|
}
|
|
.surface > button,
|
|
.surface > details > summary {
|
|
text-align: left;
|
|
font: inherit;
|
|
padding: 0.45rem 0.75rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.surface > button.with-badge {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.5rem;
|
|
}
|
|
.surface > button.with-badge .badge {
|
|
min-width: 1.5rem;
|
|
padding: 0 0.4rem;
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
border-radius: 999px;
|
|
background: #2a4d7d;
|
|
color: #fff;
|
|
}
|
|
.surface > button:hover,
|
|
.surface > details > summary:hover {
|
|
background: #1c2238;
|
|
}
|
|
.surface > details > summary {
|
|
list-style: none;
|
|
}
|
|
.surface > details > summary::-webkit-details-marker {
|
|
display: none;
|
|
}
|
|
.surface > details > summary::after {
|
|
content: "▸";
|
|
float: right;
|
|
opacity: 0.7;
|
|
}
|
|
.surface > details[open] > summary::after {
|
|
content: "▾";
|
|
}
|
|
.sub {
|
|
display: flex;
|
|
flex-direction: column;
|
|
padding-left: 0.5rem;
|
|
border-left: 1px solid #2a3150;
|
|
margin: 0 0.5rem 0.25rem;
|
|
}
|
|
.sub > button {
|
|
text-align: left;
|
|
font: inherit;
|
|
padding: 0.35rem 0.5rem;
|
|
background: transparent;
|
|
color: inherit;
|
|
border: 0;
|
|
cursor: pointer;
|
|
}
|
|
.sub > button:hover {
|
|
background: #1c2238;
|
|
}
|
|
@media (max-width: 767.98px) {
|
|
.surface {
|
|
position: fixed;
|
|
top: 3rem;
|
|
right: 0;
|
|
left: 0;
|
|
min-width: 0;
|
|
border-radius: 0;
|
|
border-left: 0;
|
|
border-right: 0;
|
|
max-height: calc(100vh - 3rem);
|
|
overflow-y: auto;
|
|
}
|
|
}
|
|
</style>
|