ui/phase-19: torus-aware incoming track + on-planet groups in inspector

Two follow-up fixes after the initial Phase 19 landing:

  1. The IncomingGroup dashed trajectory was drawn between raw
     (x1, y1) and (x2, y2) world coordinates. On torus wrap mode
     this took the long way around when origin and destination
     sat near opposite seams. The line now picks endpoints via
     `torusShortestDelta` so the segment crosses the seam when
     that's the shorter visual path. The interpolated clickable
     point follows the same unwrapped vector. The same helper
     fixes the in-hyperspace position for local / foreign groups.
  2. On-planet local and foreign groups previously rendered as
     small offset points next to every populated planet, which
     turned the canvas into noise as soon as a player held more
     than a handful of planets. The map no longer renders any
     in-orbit group; the planet inspector grows a compact
     "stationed ship groups" subsection
     (`lib/inspectors/planet/ship-groups.svelte`) that lists
     each in-orbit group as a row of `<race> · <class> · <count>
     ships · <mass>`. Race attribution: LocalGroup → the player's
     race, OtherGroup on a foreign-owned planet → the planet's
     owner, OtherGroup elsewhere → "foreign" placeholder. Rows
     are non-interactive in Phase 19; Phase 21+ will deep-link
     into the ship-groups table view with a (planet, race) filter.

Tests:
  - `state-binding-groups.test.ts` swaps the on-planet rendering
    expectation for the new "no map primitive" rule, and adds a
    regression that asserts the incoming line crosses the torus
    seam via `torusShortestDelta`.
  - new `inspector-planet-ship-groups.test.ts` covers row
    composition, the destination-mismatch filter, the
    in-hyperspace exclusion, the foreign-planet owner fallback,
    and the empty-state collapse.
  - `inspector-planet.test.ts` and `inspector-ship-group.spec.ts`
    pick up the new prop chain (`localShipGroups`,
    `otherShipGroups`, `localRace`).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-10 15:08:41 +02:00
parent d63fe44618
commit f7109af55c
12 changed files with 511 additions and 77 deletions
+6
View File
@@ -275,6 +275,12 @@ const en = {
"game.inspector.ship_group.location.in_hyperspace": "in hyperspace",
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "no data — only the radar blip is known",
"game.inspector.planet.ship_groups.title": "stationed ship groups",
"game.inspector.planet.ship_groups.row.count": "{count} ships",
"game.inspector.planet.ship_groups.row.mass": "mass {mass}",
"game.inspector.planet.ship_groups.race.unknown": "unknown",
"game.inspector.planet.ship_groups.race.foreign": "foreign",
} as const;
export default en;
+6
View File
@@ -276,6 +276,12 @@ const ru: Record<keyof typeof en, string> = {
"game.inspector.ship_group.location.in_hyperspace": "в гиперпространстве",
"game.inspector.ship_group.fleet.none": "—",
"game.inspector.ship_group.unidentified_no_data": "данных нет — известны только координаты",
"game.inspector.planet.ship_groups.title": "корабли на орбите",
"game.inspector.planet.ship_groups.row.count": "{count} кораблей",
"game.inspector.planet.ship_groups.row.mass": "масса {mass}",
"game.inspector.planet.ship_groups.race.unknown": "неизвестно",
"game.inspector.planet.ship_groups.race.foreign": "чужие",
};
export default ru;
@@ -12,6 +12,8 @@ dismiss from the IA section §6 land in Phase 35 polish.
-->
<script lang="ts">
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportRoute,
ShipClassSummary,
@@ -27,6 +29,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
onMap: boolean;
onClose: () => void;
};
@@ -38,6 +43,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
mapWidth,
mapHeight,
localPlayerDrive,
localShipGroups,
otherShipGroups,
localRace,
onMap,
onClose,
}: Props = $props();
@@ -66,6 +74,9 @@ dismiss from the IA section §6 land in Phase 35 polish.
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localShipGroups}
{otherShipGroups}
{localRace}
/>
</section>
{/if}
@@ -15,6 +15,8 @@ field with five buttons.
<script lang="ts">
import { getContext, tick } from "svelte";
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
ReportRoute,
ShipClassSummary,
@@ -30,6 +32,7 @@ field with five buttons.
} from "$lib/util/entity-name";
import CargoRoutes from "./planet/cargo-routes.svelte";
import Production from "./planet/production.svelte";
import ShipGroups from "./planet/ship-groups.svelte";
type Props = {
planet: ReportPlanet;
@@ -39,6 +42,9 @@ field with five buttons.
mapWidth: number;
mapHeight: number;
localPlayerDrive: number;
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
};
let {
planet,
@@ -48,6 +54,9 @@ field with five buttons.
mapWidth,
mapHeight,
localPlayerDrive,
localShipGroups,
otherShipGroups,
localRace,
}: Props = $props();
const kindKeyMap: Record<ReportPlanet["kind"], TranslationKey> = {
@@ -223,6 +232,13 @@ field with five buttons.
/>
{/if}
<ShipGroups
{planet}
{localShipGroups}
{otherShipGroups}
{localRace}
/>
<dl class="fields">
{#if planet.kind === "other" && planet.owner !== null}
<div class="field" data-testid="inspector-planet-field-owner">
@@ -0,0 +1,144 @@
<!--
Phase 19 read-only "ship groups stationed here" subsection of the
planet inspector. Phase 19 originally rendered every in-orbit group
as an offset point near its planet on the map, which crowded the
canvas to the point of unreadability. The map now hides on-planet
groups and the planet inspector lists them instead — one row per
group, showing its race, class, ship count, and mass.
Race attribution is best-effort:
- LocalGroup → the player's own race (`localRace` prop).
- OtherGroup on an `other`-kind planet → the planet's owner.
- OtherGroup elsewhere → "foreign" placeholder; the engine's
typed contract does not carry per-group ownership outside
battle rosters.
Rows are intentionally non-interactive in Phase 19. Phase 21+ will
deliver a full ship-groups table view; clicking a row will then
deep-link into that table with a `(planet, race)` filter pre-applied.
-->
<script lang="ts">
import type {
ReportLocalShipGroup,
ReportOtherShipGroup,
ReportPlanet,
} from "../../../api/game-state";
import { i18n } from "$lib/i18n/index.svelte";
type Props = {
planet: ReportPlanet;
localShipGroups: ReportLocalShipGroup[];
otherShipGroups: ReportOtherShipGroup[];
localRace: string;
};
let { planet, localShipGroups, otherShipGroups, localRace }: Props = $props();
interface StationedRow {
key: string;
race: string;
class: string;
count: number;
mass: number;
}
const stationedRows: StationedRow[] = $derived.by(() => {
const rows: StationedRow[] = [];
for (const g of localShipGroups) {
if (g.destination !== planet.number) continue;
if (g.origin !== null || g.range !== null) continue;
rows.push({
key: `local:${g.id}`,
race: localRace || i18n.t("game.inspector.planet.ship_groups.race.unknown"),
class: g.class,
count: g.count,
mass: g.mass,
});
}
const foreignRace =
planet.owner ??
i18n.t("game.inspector.planet.ship_groups.race.foreign");
for (let i = 0; i < otherShipGroups.length; i++) {
const g = otherShipGroups[i]!;
if (g.destination !== planet.number) continue;
if (g.origin !== null || g.range !== null) continue;
rows.push({
key: `other:${i}`,
race: foreignRace,
class: g.class,
count: g.count,
mass: g.mass,
});
}
return rows;
});
function formatNumber(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}
</script>
{#if stationedRows.length > 0}
<section class="ship-groups" data-testid="inspector-planet-ship-groups">
<h4>{i18n.t("game.inspector.planet.ship_groups.title")}</h4>
<ul class="rows">
{#each stationedRows as row (row.key)}
<li class="row" data-testid="inspector-planet-ship-groups-row">
<span class="race" data-testid="inspector-planet-ship-groups-race">
{row.race}
</span>
<span class="class">{row.class}</span>
<span class="count">
{i18n.t("game.inspector.planet.ship_groups.row.count", {
count: String(row.count),
})}
</span>
<span class="mass">
{i18n.t("game.inspector.planet.ship_groups.row.mass", {
mass: formatNumber(row.mass),
})}
</span>
</li>
{/each}
</ul>
</section>
{/if}
<style>
.ship-groups {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
h4 {
margin: 0;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #aab;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 0.5rem;
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
}
.race {
font-weight: 600;
}
.class {
color: #cdd;
}
.count,
.mass {
color: #aab;
}
</style>
@@ -89,6 +89,13 @@ from the Phase 10 stub.
const localPlayerDrive = $derived(
renderedReport?.report?.localPlayerDrive ?? 0,
);
const localShipGroups = $derived(
renderedReport?.report?.localShipGroups ?? [],
);
const otherShipGroups = $derived(
renderedReport?.report?.otherShipGroups ?? [],
);
const localRace = $derived(renderedReport?.report?.race ?? "");
</script>
<section class="tool" data-testid="sidebar-tool-inspector">
@@ -101,6 +108,9 @@ from the Phase 10 stub.
{mapWidth}
{mapHeight}
{localPlayerDrive}
{localShipGroups}
{otherShipGroups}
{localRace}
/>
{:else if selectedShipGroup !== null}
<ShipGroup selection={selectedShipGroup} planets={allPlanets} />
+57 -46
View File
@@ -4,22 +4,28 @@
// incoming-trajectory lines) lives here.
//
// Position rules:
// - On-planet local / other groups (origin === null) — drawn next
// to the destination planet, slightly offset so the group has its
// own hit-target distinct from the planet pixel. Multiple groups
// stationed at the same planet share the offset (Phase 19
// limitation; a future phase fans them out or lists them in the
// planet inspector).
// - On-planet local / other groups (origin === null, range === null)
// are NOT rendered on the map. Stationed groups would otherwise
// pile up next to every populated planet and turn the canvas
// into noise; the planet inspector lists them instead
// (see `lib/inspectors/planet/ship-groups.svelte`).
// - In-hyperspace local / other groups (origin / range set) —
// interpolated along the origin → destination line at `range`
// world units from the destination.
// world units from the destination. The line is the wrap-aware
// shortest path on a torus.
// - Incoming groups — origin and destination are always present;
// emit a dashed red trajectory line between the two and a
// clickable point at the interpolated position (range = the
// `distance` field).
// emit a dashed red trajectory line from origin to a wrap-aware
// destination plus a clickable point at the interpolated
// position (range = the `distance` field).
// - Unidentified groups — drawn at the absolute (x, y) the radar
// reports.
//
// Torus-shortest deltas come from `map/math.torusShortestDelta`. The
// canonical Go-side equivalent is `pkg/calc.ShortestDelta`; the TS
// helper duplicates the formula because the renderer's hot path
// avoids the WASM boundary cost. Both implementations agree on the
// half-circumference tie-break.
//
// PrimitiveIDs are partitioned via large per-variant offsets so they
// never collide with planet ids (which run in `[0, planetCount)`).
@@ -32,6 +38,7 @@ import type {
ReportUnidentifiedShipGroup,
} from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte";
import { torusShortestDelta } from "./math";
import type { LinePrim, PointPrim, PrimitiveID, Style } from "./world";
/**
@@ -49,12 +56,6 @@ export const SHIP_GROUP_ID_OFFSETS = {
unidentified: 400_000_000,
} as const;
/** ON_PLANET_OFFSET is the (dx, dy) world-unit shift applied to a
* group point that sits on a planet, so the group has a distinct
* click target from the planet itself. The offset is small enough
* that the visual association with the planet stays clear. */
const ON_PLANET_OFFSET = { dx: 6, dy: -6 };
const STYLE_LOCAL_GROUP: Style = {
fillColor: 0xfff176,
fillAlpha: 0.95,
@@ -88,9 +89,9 @@ const STYLE_UNIDENTIFIED_GROUP: Style = {
// Priority order inside `hit-test`: ship groups outrank planets so a
// hyperspace group landing on top of an unidentified planet is
// selectable. On-planet groups stay below the planet so clicks on a
// planet still resolve to the planet itself (the offset gives the
// group its own un-overlapped hit area).
// selectable. The trajectory line itself is given the lowest priority
// so a click on the dashed segment never "wins" over the clickable
// point at the interpolated position.
const PRIORITY_LOCAL = 5;
const PRIORITY_OTHER = 5;
const PRIORITY_INCOMING_POINT = 6;
@@ -109,10 +110,12 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
for (const planet of report.planets) {
planetIndex.set(planet.number, planet);
}
const w = report.mapWidth;
const h = report.mapHeight;
for (let i = 0; i < report.localShipGroups.length; i++) {
const group = report.localShipGroups[i]!;
const pos = computeGroupPosition(group, planetIndex);
const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.local + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP));
@@ -121,7 +124,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
for (let i = 0; i < report.otherShipGroups.length; i++) {
const group = report.otherShipGroups[i]!;
const pos = computeGroupPosition(group, planetIndex);
const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.other + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, STYLE_OTHER_GROUP));
@@ -133,6 +136,15 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
const origin = planetIndex.get(group.origin);
const destination = planetIndex.get(group.destination);
if (origin === undefined || destination === undefined) continue;
// Unwrap the destination relative to origin so the line crosses
// the torus seam when that is the shorter path. Renderer-side
// we draw the segment in a single tile; in torus mode PixiJS
// repeats the world so the line still appears continuous on
// the visible side of the seam.
const dx = torusShortestDelta(origin.x, destination.x, w);
const dy = torusShortestDelta(origin.y, destination.y, h);
const destX = origin.x + dx;
const destY = origin.y + dy;
const lineId = SHIP_GROUP_ID_OFFSETS.incomingLine + i;
primitives.push({
kind: "line",
@@ -142,16 +154,10 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
hitSlopPx: 0,
x1: origin.x,
y1: origin.y,
x2: destination.x,
y2: destination.y,
x2: destX,
y2: destY,
});
const pos = interpolateAlongLine(
destination.x,
destination.y,
origin.x,
origin.y,
group.distance,
);
const pos = interpolateAlongLine(destX, destY, origin.x, origin.y, group.distance);
const pointId = SHIP_GROUP_ID_OFFSETS.incoming + i;
primitives.push(
makePoint(
@@ -185,29 +191,34 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
return { primitives, lookup };
}
function computeGroupPosition(
/**
* computeInSpacePosition returns the renderer-side (x, y) of a local
* or foreign group that is currently in hyperspace. On-planet groups
* (origin === null || range === null) are intentionally skipped so the
* map does not pile dozens of primitives onto every populated planet
* — the planet inspector lists them instead. Returns null when either
* the group is on-planet, or the origin / destination planets are
* not visible to the local player.
*/
function computeInSpacePosition(
group: ReportLocalShipGroup | ReportOtherShipGroup,
planetIndex: Map<number, ReportPlanet>,
mapWidth: number,
mapHeight: number,
): { x: number; y: number } | null {
if (group.origin === null || group.range === null) return null;
const destination = planetIndex.get(group.destination);
if (destination === undefined) return null;
if (group.origin === null || group.range === null) {
// Stationed on the destination planet; offset slightly so the
// group is distinct from the planet's own hit target.
return {
x: destination.x + ON_PLANET_OFFSET.dx,
y: destination.y + ON_PLANET_OFFSET.dy,
};
}
const origin = planetIndex.get(group.origin);
if (origin === undefined) return null;
return interpolateAlongLine(
destination.x,
destination.y,
origin.x,
origin.y,
group.range,
);
const dx = torusShortestDelta(destination.x, origin.x, mapWidth);
const dy = torusShortestDelta(destination.y, origin.y, mapHeight);
const total = Math.hypot(dx, dy);
if (total === 0 || group.range <= 0) {
return { x: destination.x, y: destination.y };
}
const t = Math.min(1, group.range / total);
return { x: destination.x + t * dx, y: destination.y + t * dy };
}
/**
@@ -180,6 +180,13 @@ fresh.
const inspectorLocalDrive = $derived(
renderedReport.report?.localPlayerDrive ?? 0,
);
const inspectorLocalShipGroups = $derived(
renderedReport.report?.localShipGroups ?? [],
);
const inspectorOtherShipGroups = $derived(
renderedReport.report?.otherShipGroups ?? [],
);
const inspectorLocalRace = $derived(renderedReport.report?.race ?? "");
// Reveal the inspector whenever a new planet selection lands.
// Reading `selection.selected` once outside the effect keeps the
@@ -324,6 +331,9 @@ fresh.
mapWidth={inspectorMapWidth}
mapHeight={inspectorMapHeight}
localPlayerDrive={inspectorLocalDrive}
localShipGroups={inspectorLocalShipGroups}
otherShipGroups={inspectorOtherShipGroups}
localRace={inspectorLocalRace}
onMap={effectiveTool === "map"}
onClose={() => selection.clear()}
/>