feat(ui): map canvas follows light/dark theme; fix invisible gear control
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s

The map view now selects a DARK_THEME or LIGHT_THEME palette from the
resolved app theme and threads it through every primitive builder, so
the canvas, planets, ship groups, cargo routes, battle/bombing markers,
fog, reach + selection rings, pending-Send tracks, and the pick overlay
all switch with the rest of the chrome. A theme flip remounts the
renderer preserving the camera — Pixi bakes the background at init and
every primitive bakes its colour at build, so a live re-tint is not
possible on the same instance.

This also fixes the reported bug: the gear-popover trigger and the
loading overlay hardcoded a dark navy background, so in light theme the
gear was invisible (dark icon on dark chip) until hover flipped it to a
white chip. Both now use the --color-surface-overlay token and read
correctly in both themes.

The light palette mirrors the dark one role-for-role, darkened /
saturated for contrast on a light background while keeping the incoming,
battle, and bombing accents vivid. The values are a first pass meant to
be refined during the F8 manual-QA loop.

Removes the now-dead "Phase 35" references from the code and lifts the
map-recoloring prohibition from the design-system / renderer docs; the
battle scene stays a fixed-palette data-viz surface.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-24 08:49:37 +02:00
parent d44ad9b6eb
commit f6e4a4f6bd
27 changed files with 631 additions and 230 deletions
+3 -2
View File
@@ -802,8 +802,9 @@ every change applies within one frame (no Pixi remount):
off, hides every non-LOCAL planet that sits beyond off, hides every non-LOCAL planet that sits beyond
`FlightDistance(localPlayerDrive)` of every LOCAL planet `FlightDistance(localPlayerDrive)` of every LOCAL planet
(torus-aware metric). (torus-aware metric).
- **View** — "visible hyperspace" toggle (slightly lighter - **View** — "visible hyperspace" toggle (a faint overlay,
overlay outside the union of tinted to contrast with the active theme's map background,
outside the union of
`VisibilityDistance(localPlayerDrive)` circles around LOCAL `VisibilityDistance(localPlayerDrive)` circles around LOCAL
planets; LOCAL planets are always exempt — the toggle is planets; LOCAL planets are always exempt — the toggle is
named after the visible part of the map rather than the named after the visible part of the map rather than the
+3 -2
View File
@@ -823,8 +823,9 @@ Directory-промоушен ([Раздел 5](#5-реестр-названий-
не-LOCAL планету, отстоящую дальше не-LOCAL планету, отстоящую дальше
`FlightDistance(localPlayerDrive)` от любой LOCAL-планеты `FlightDistance(localPlayerDrive)` от любой LOCAL-планеты
(метрика учитывает торическую развёртку). (метрика учитывает торическую развёртку).
- **Вид** — переключатель «видимое гиперпространство» (чуть - **Вид** — переключатель «видимое гиперпространство» (лёгкая
более светлая заливка вне объединения окружностей заливка, подобранная под фон карты активной темы, вне
объединения окружностей
`VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет; `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет;
LOCAL-планеты всегда вне фильтра — тоггл назван по видимой LOCAL-планеты всегда вне фильтра — тоггл назван по видимой
области карты, а не по затемнённой) плюс радиогруппа области карты, а не по затемнённой) плюс радиогруппа
+16 -7
View File
@@ -94,12 +94,20 @@ them in sync.
stay literal `rgba(…)`. They sit over arbitrary content, not a themed stay literal `rgba(…)`. They sit over arbitrary content, not a themed
surface, so a surface token would be wrong; there is no `--color-scrim` surface, so a surface token would be wrong; there is no `--color-scrim`
until a third caller justifies one. until a third caller justifies one.
- Data-visualisation surfaces keep a fixed palette. The battle scene - Data-visualisation surfaces define their palette in code, not via CSS
tokens, because they paint to a canvas / SVG instead of themed DOM.
The WebGL map canvas ships two palettes — `DARK_THEME` and
`LIGHT_THEME` in [`src/map/world.ts`](../frontend/src/map/world.ts) —
and follows the resolved app theme like the rest of the chrome:
`active-view/map.svelte` selects the palette from `theme.resolved` and
remounts the renderer on a theme flip (Pixi bakes the background and
every primitive colour at build time, so a live re-tint is not
possible; the remount preserves the camera). The battle scene
(`battle-player/battle-scene.svelte`) is a self-contained SVG (`battle-player/battle-scene.svelte`) is a self-contained SVG
visualisation — like the WebGL map canvas — and stays dark in both visualisation that still keeps a single fixed dark palette in both
themes; its only themed neighbours are the surrounding chrome themes — re-theming it for light is a separate design task — and its
(`battle-viewer.svelte`). Re-theming a viz surface for light is a only themed neighbours are the surrounding chrome
dedicated design task, not a token swap. (`battle-viewer.svelte`).
- Spacing-scale adoption is gradual — colour tokens are the priority; - Spacing-scale adoption is gradual — colour tokens are the priority;
existing one-off paddings are migrated opportunistically, not churned existing one-off paddings are migrated opportunistically, not churned
en masse. en masse.
@@ -113,8 +121,9 @@ battle, mail, toasts). The whole app switches coherently between light
and dark from a single token change. and dark from a single token change.
The only remaining literal colours are the documented exceptions above: The only remaining literal colours are the documented exceptions above:
the battle-scene data-viz palette, the overlay scrims, and the the canvas data-viz palettes (the theme-aware map palette and the fixed
directional / deliberate drop shadows. battle-scene palette, both defined in code rather than as tokens), the
overlay scrims, and the directional / deliberate drop shadows.
The default theme is **`system`** — it follows the OS light/dark The default theme is **`system`** — it follows the OS light/dark
preference; users can pin light or dark via the account-menu picker. preference; users can pin light or dark via the account-menu picker.
+20 -8
View File
@@ -75,11 +75,23 @@ overrides them.
## Theme ## Theme
A single dark theme is implemented. The theme is a record of default A `Theme` is the renderer's full colour palette: the canvas background
colours; primitives whose `style` omits a colour fall back to the and fog veil, the generic fallbacks for primitives whose `style` omits
theme. Runtime theme switching is not implemented — light/dark and a colour, and the semantic colours every primitive builder paints with
the materialise-on-theme-change cycle are deferred to the (planets, ship groups, cargo routes, battle / bombing markers, reach +
finalization plan ([../PLAN-finalize.md](../PLAN-finalize.md)). selection rings, pending-Send tracks, and the pick-mode overlay). Two
palettes ship in `src/map/world.ts``DARK_THEME` and `LIGHT_THEME`
and the builders take the active palette so the whole canvas follows
the user's light / dark choice.
`active-view/map.svelte` selects the palette from `theme.resolved`
(`$lib/theme/theme.svelte.ts`) and threads it into `reportToWorld`, the
overlay builders, and `createRenderer({ theme })`. A theme flip is
handled by a remount that preserves the camera: Pixi bakes the
background at `Application.init` and every primitive bakes its colour
at build time, so the palette cannot be swapped live on an existing
instance. The debug playground (`routes/__debug/map`) omits the option
and keeps the `DARK_THEME` default.
## Hit-test ## Hit-test
@@ -335,9 +347,9 @@ scanner / visibility coverage:
- An empty list destroys the existing fog rectangles and mask. - An empty list destroys the existing fog rectangles and mask.
- A non-empty list rebuilds a single viewport-level `fogLayer` (a - A non-empty list rebuilds a single viewport-level `fogLayer` (a
sibling below the nine torus copies). `fogPaintOps` returns an sibling below the nine torus copies). `fogPaintOps` returns an
ordered op list — one world-sized rectangle filled with `FOG_COLOR` ordered op list — one world-sized rectangle filled with the active
(two shades lighter than the dark theme background) plus one circle palette's `fog` colour (a faint shade off the theme background) plus
per visibility circle. The renderer draws the rectangle ops into one circle per visibility circle. The renderer draws the rectangle ops into
`fogLayer` and collects the circle ops into a single `Graphics` set `fogLayer` and collects the circle ops into a single `Graphics` set
as `fogLayer`'s **inverse stencil mask** as `fogLayer`'s **inverse stencil mask**
(`setMask({ mask, inverse: true })`), so the fog shows everywhere (`setMask({ mask, inverse: true })`), so the fog shows everywhere
@@ -243,7 +243,7 @@ bottom-tabs bar.
font: inherit; font: inherit;
font-size: 1.4rem; font-size: 1.4rem;
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
background: rgba(20, 24, 42, 0.85); background: var(--color-surface-overlay);
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
+43 -6
View File
@@ -33,6 +33,7 @@ preference the store already manages.
import { computeReachCircles } from "../../map/reach-circles"; import { computeReachCircles } from "../../map/reach-circles";
import { computeSelectionRing } from "../../map/selection-ring"; import { computeSelectionRing } from "../../map/selection-ring";
import { reachStore } from "$lib/calculator/reach.svelte"; import { reachStore } from "$lib/calculator/reach.svelte";
import { theme as themeStore } from "$lib/theme/theme.svelte";
import { import {
reportToWorld, reportToWorld,
type HitTarget, type HitTarget,
@@ -44,7 +45,12 @@ preference the store already manages.
computeHiddenPlanetNumbers, computeHiddenPlanetNumbers,
fingerprintHiddenPlanets, fingerprintHiddenPlanets,
} from "../../map/visibility"; } from "../../map/visibility";
import type { PrimitiveID } from "../../map/world"; import {
DARK_THEME,
LIGHT_THEME,
type PrimitiveID,
type Theme,
} from "../../map/world";
import { import {
ORDER_DRAFT_CONTEXT_KEY, ORDER_DRAFT_CONTEXT_KEY,
OrderDraftStore, OrderDraftStore,
@@ -132,6 +138,13 @@ preference the store already manages.
}> = []; }> = [];
let mountedTurn: number | null = null; let mountedTurn: number | null = null;
let mountedGameId: string | null = null; let mountedGameId: string | null = null;
// The palette the current renderer was mounted with (DARK_THEME or
// LIGHT_THEME). A theme flip changes the singleton reference, which
// the effect detects to drive a camera-preserving remount — Pixi
// bakes the background colour at init and every primitive bakes its
// colour at build, so a live re-tint is not possible on the same
// instance.
let mountedPalette: Theme | null = null;
let onResize: (() => void) | null = null; let onResize: (() => void) | null = null;
let detachClick: (() => void) | null = null; let detachClick: (() => void) | null = null;
let detachDebugProviders: (() => void) | null = null; let detachDebugProviders: (() => void) | null = null;
@@ -177,6 +190,11 @@ preference the store already manages.
// extras filter all derive from this rune. // extras filter all derive from this rune.
const toggles = store?.mapToggles; const toggles = store?.mapToggles;
const gameId = store?.gameId ?? ""; const gameId = store?.gameId ?? "";
// Track the resolved app theme so the canvas follows the user's
// light / dark choice. A flip re-keys the snapshot below and
// triggers a camera-preserving remount with the new palette.
const palette: Theme =
themeStore.resolved === "light" ? LIGHT_THEME : DARK_THEME;
if (!mounted || canvasEl === null || containerEl === null) return; if (!mounted || canvasEl === null || containerEl === null) return;
if (status !== "ready" || !report || toggles === undefined) return; if (status !== "ready" || !report || toggles === undefined) return;
@@ -245,6 +263,7 @@ preference the store already manages.
const sameSnapshot = const sameSnapshot =
mountedTurn === report.turn && mountedTurn === report.turn &&
mountedGameId === gameId && mountedGameId === gameId &&
mountedPalette === palette &&
handle !== null; handle !== null;
if (sameSnapshot) { if (sameSnapshot) {
// Apply wrap-mode flips in-place via the renderer's own // Apply wrap-mode flips in-place via the renderer's own
@@ -274,6 +293,7 @@ preference the store already manages.
toggles, toggles,
hiddenPlanetNumbers, hiddenPlanetNumbers,
mode, mode,
palette,
), ),
); );
}); });
@@ -297,6 +317,7 @@ preference the store already manages.
extrasFingerprint, extrasFingerprint,
draftCommands, draftCommands,
draftStatuses, draftStatuses,
palette,
); );
}); });
}); });
@@ -308,16 +329,22 @@ preference the store already manages.
toggles: MapToggles, toggles: MapToggles,
hiddenPlanetNumbers: ReadonlySet<number>, hiddenPlanetNumbers: ReadonlySet<number>,
mode: "torus" | "no-wrap", mode: "torus" | "no-wrap",
palette: Theme,
): import("../../map/world").Primitive[] { ): import("../../map/world").Primitive[] {
const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined; const skip = hiddenPlanetNumbers.size > 0 ? hiddenPlanetNumbers : undefined;
const cargo = toggles.cargoRoutes const cargo = toggles.cargoRoutes
? buildCargoRouteLines(report, skip ? { skipPlanets: skip } : undefined) ? buildCargoRouteLines(
report,
skip ? { skipPlanets: skip } : undefined,
palette,
)
: []; : [];
const pending = buildPendingSendLines( const pending = buildPendingSendLines(
report, report,
draftCommands, draftCommands,
draftStatuses, draftStatuses,
skip ? { skipPlanets: skip } : undefined, skip ? { skipPlanets: skip } : undefined,
palette,
); );
// Reach circles published by the ship-class calculator. Empty // Reach circles published by the ship-class calculator. Empty
// when no own planet is selected or the design is invalid, so // when no own planet is selected or the design is invalid, so
@@ -331,11 +358,16 @@ preference the store already manages.
report.mapWidth, report.mapWidth,
report.mapHeight, report.mapHeight,
mode, mode,
palette,
) )
: []; : [];
const selectedPlanetId = const selectedPlanetId =
selection?.selected?.kind === "planet" ? selection.selected.id : null; selection?.selected?.kind === "planet" ? selection.selected.id : null;
const selectionRing = computeSelectionRing(report.planets, selectedPlanetId); const selectionRing = computeSelectionRing(
report.planets,
selectedPlanetId,
palette,
);
return [ return [
...cargo, ...cargo,
...pending, ...pending,
@@ -370,10 +402,11 @@ preference the store already manages.
extrasFingerprint: string, extrasFingerprint: string,
draftCommands: readonly OrderCommand[], draftCommands: readonly OrderCommand[],
draftStatuses: Readonly<Record<string, string>>, draftStatuses: Readonly<Record<string, string>>,
palette: Theme,
): Promise<void> { ): Promise<void> {
mountInProgress = true; mountInProgress = true;
try { try {
await mountRenderer(report, mode); await mountRenderer(report, mode, palette);
if (handle === null) return; if (handle === null) return;
applyVisibilityState(report, toggles, hiddenPlanetNumbers); applyVisibilityState(report, toggles, hiddenPlanetNumbers);
handle.setExtraPrimitives( handle.setExtraPrimitives(
@@ -384,6 +417,7 @@ preference the store already manages.
toggles, toggles,
hiddenPlanetNumbers, hiddenPlanetNumbers,
mode, mode,
palette,
), ),
); );
lastExtrasFingerprint = extrasFingerprint; lastExtrasFingerprint = extrasFingerprint;
@@ -426,6 +460,7 @@ preference the store already manages.
async function mountRenderer( async function mountRenderer(
report: NonNullable<GameStateStore["report"]>, report: NonNullable<GameStateStore["report"]>,
mode: "torus" | "no-wrap", mode: "torus" | "no-wrap",
palette: Theme,
): Promise<void> { ): Promise<void> {
if (canvasEl === null || containerEl === null) return; if (canvasEl === null || containerEl === null) return;
// Capture camera state before disposing so a remount inside // Capture camera state before disposing so a remount inside
@@ -462,7 +497,7 @@ preference the store already manages.
hitLookup: nextHitLookup, hitLookup: nextHitLookup,
categories, categories,
planetDependents, planetDependents,
} = reportToWorld(report); } = reportToWorld(report, palette);
hitLookup = nextHitLookup; hitLookup = nextHitLookup;
currentCategories = categories; currentCategories = categories;
currentPlanetDependents = planetDependents; currentPlanetDependents = planetDependents;
@@ -471,6 +506,7 @@ preference the store already manages.
world, world,
mode, mode,
preference: ["webgpu", "webgl"], preference: ["webgpu", "webgl"],
theme: palette,
}); });
const minScale = minScaleNoWrap( const minScale = minScaleNoWrap(
{ {
@@ -592,6 +628,7 @@ preference the store already manages.
}; };
mountedTurn = report.turn; mountedTurn = report.turn;
mountedGameId = targetGameId; mountedGameId = targetGameId;
mountedPalette = palette;
// runSerializedMount immediately pushes the visibility // runSerializedMount immediately pushes the visibility
// state + extras after this resolves; clearing the // state + extras after this resolves; clearing the
// fingerprint here is defensive in case the post-mount // fingerprint here is defensive in case the post-mount
@@ -773,7 +810,7 @@ preference the store already manages.
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
background: rgba(20, 24, 42, 0.85); background: var(--color-surface-overlay);
color: var(--color-text); color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
+1 -1
View File
@@ -10,7 +10,7 @@ entities), report, battle, mail, ship-class designer, science
designer. Each entry mutates `activeView` (the single-URL app-shell designer. Each entry mutates `activeView` (the single-URL app-shell
has no per-view routes) and closes the menu. Closes on Escape, on has no per-view routes) and closes the menu. Closes on Escape, on
outside click, and after a selection. Phase 26 introduces the outside click, and after a selection. Phase 26 introduces the
history-mode entry; Phase 35 polishes microcopy. history-mode entry; microcopy is refined in a later polish pass.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
+1 -1
View File
@@ -14,7 +14,7 @@
// The locale state is exposed through a Svelte 5 runes singleton // The locale state is exposed through a Svelte 5 runes singleton
// (`i18n`) so components stay reactive without ceremony: // (`i18n`) so components stay reactive without ceremony:
// `<p>{i18n.t('login.title')}</p>` re-renders whenever // `<p>{i18n.t('login.title')}</p>` re-renders whenever
// `i18n.locale` changes. Phase 35 will swap this primitive for a // `i18n.locale` changes. A later pass can swap this primitive for a
// fuller solution once message-format pluralisation and lazy // fuller solution once message-format pluralisation and lazy
// loading become necessary. // loading become necessary.
@@ -8,7 +8,7 @@ does not stack on top of the calc / order overlays).
Phase 13 ships the minimal dismissal surface: a close button (`✕`) Phase 13 ships the minimal dismissal surface: a close button (`✕`)
that clears the selection. Swipe-to-dismiss and tap-outside-to- that clears the selection. Swipe-to-dismiss and tap-outside-to-
dismiss from the IA section §6 land in Phase 35 polish. dismiss from the IA section §6 are deferred to a later polish pass.
--> -->
<script lang="ts"> <script lang="ts">
import type { import type {
@@ -8,10 +8,10 @@ or the header view-menu naturally drops the overlay.
More opens a drawer with the same destination list as the header More opens a drawer with the same destination list as the header
view-menu, each entry mutating `activeView` directly (the single-URL view-menu, each entry mutating `activeView` directly (the single-URL
app-shell has no per-view routes). Phase 35 polish narrows it to the app-shell has no per-view routes). A later polish pass narrows it to
IA-spec subset (Mail, Battle log, Tables, History, Settings, Logout) the IA-spec subset (Mail, Battle log, Tables, History, Settings,
once History exists; until then the convenience of one source of Logout) once History exists; until then the convenience of one source
truth for destinations beats the duplication. of truth for destinations beats the duplication.
--> -->
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
+11 -14
View File
@@ -17,18 +17,16 @@
// same `world` / `hitLookup` plumbing as planets and ship groups. // same `world` / `hitLookup` plumbing as planets and ship groups.
import type { GameReport, ReportPlanet } from "../api/game-state"; import type { GameReport, ReportPlanet } from "../api/game-state";
import type { import {
CirclePrim, DARK_THEME,
LinePrim, type CirclePrim,
Primitive, type LinePrim,
PrimitiveID, type Primitive,
Style, type PrimitiveID,
type Style,
type Theme,
} from "./world"; } from "./world";
export const BATTLE_MARKER_COLOR = 0xffd400;
export const BOMBING_MARKER_COLOR_DAMAGED = 0xffd400;
export const BOMBING_MARKER_COLOR_WIPED = 0xff3030;
/** Battle and bombing marker primitive ids use a high-bit prefix to /** Battle and bombing marker primitive ids use a high-bit prefix to
* avoid colliding with planet numbers or cargo-route line ids. */ * avoid colliding with planet numbers or cargo-route line ids. */
export const BATTLE_MARKER_ID_PREFIX = 0xa0000000; export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
@@ -102,6 +100,7 @@ export function battleMarkerStrokeWidth(shots: number): number {
*/ */
export function buildBattleAndBombingMarkers( export function buildBattleAndBombingMarkers(
report: GameReport, report: GameReport,
theme: Theme = DARK_THEME,
): BuildMarkersResult { ): BuildMarkersResult {
const planetByNumber = new Map<number, ReportPlanet>(); const planetByNumber = new Map<number, ReportPlanet>();
for (const planet of report.planets) { for (const planet of report.planets) {
@@ -127,7 +126,7 @@ export function buildBattleAndBombingMarkers(
if (planet === undefined) continue; if (planet === undefined) continue;
const strokeWidthPx = battleMarkerStrokeWidth(battle.shots); const strokeWidthPx = battleMarkerStrokeWidth(battle.shots);
const style: Style = { const style: Style = {
strokeColor: BATTLE_MARKER_COLOR, strokeColor: theme.battleMarker,
strokeAlpha: 0.95, strokeAlpha: 0.95,
strokeWidthPx, strokeWidthPx,
}; };
@@ -172,9 +171,7 @@ export function buildBattleAndBombingMarkers(
const bombing = report.bombings[i]; const bombing = report.bombings[i];
const planet = planetByNumber.get(bombing.planetNumber); const planet = planetByNumber.get(bombing.planetNumber);
if (planet === undefined) continue; if (planet === undefined) continue;
const color = bombing.wiped const color = bombing.wiped ? theme.bombingWiped : theme.bombingDamaged;
? BOMBING_MARKER_COLOR_WIPED
: BOMBING_MARKER_COLOR_DAMAGED;
const style: Style = { const style: Style = {
strokeColor: color, strokeColor: color,
strokeAlpha: 0.9, strokeAlpha: 0.9,
+21 -30
View File
@@ -2,8 +2,8 @@
// short arrow from the source planet to its destination, drawn as // short arrow from the source planet to its destination, drawn as
// three `LinePrim` segments — one shaft and two arrowhead wings — // three `LinePrim` segments — one shaft and two arrowhead wings —
// styled per load type so the four cargo kinds are // styled per load type so the four cargo kinds are
// distinguishable at a glance. Phase 16 ships placeholder // distinguishable at a glance. The stroke colours come from the
// colours; Phase 35 polish picks final values. // active `Theme` (dark or light); the alpha and width are fixed.
// //
// Geometry uses `torusShortestDelta` so an arrow that crosses the // Geometry uses `torusShortestDelta` so an arrow that crosses the
// torus seam takes the wrap, not the long way round, matching the // torus seam takes the wrap, not the long way round, matching the
@@ -13,35 +13,21 @@
import type { GameReport, ReportPlanet } from "../api/game-state"; import type { GameReport, ReportPlanet } from "../api/game-state";
import type { CargoLoadType } from "../sync/order-types"; import type { CargoLoadType } from "../sync/order-types";
import { torusShortestDelta } from "./math"; import { torusShortestDelta } from "./math";
import type { LinePrim, PrimitiveID, Style } from "./world"; import { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } from "./world";
export const STYLE_ROUTE_COL: Style = { /**
strokeColor: 0x4fc3f7, * routeStylesByLoadType builds the per-load-type stroke styles for the
strokeAlpha: 0.95, * active theme. A single `Style` object is shared by every line of a
strokeWidthPx: 2, * given load type within one call so the renderer can dedupe them.
}; */
export const STYLE_ROUTE_CAP: Style = { function routeStylesByLoadType(theme: Theme): Record<CargoLoadType, Style> {
strokeColor: 0xffb74d, return {
strokeAlpha: 0.95, COL: { strokeColor: theme.routeCol, strokeAlpha: 0.95, strokeWidthPx: 2 },
strokeWidthPx: 2, CAP: { strokeColor: theme.routeCap, strokeAlpha: 0.95, strokeWidthPx: 2 },
}; MAT: { strokeColor: theme.routeMat, strokeAlpha: 0.95, strokeWidthPx: 2 },
export const STYLE_ROUTE_MAT: Style = { EMP: { strokeColor: theme.routeEmp, strokeAlpha: 0.85, strokeWidthPx: 1 },
strokeColor: 0x81c784,
strokeAlpha: 0.95,
strokeWidthPx: 2,
};
export const STYLE_ROUTE_EMP: Style = {
strokeColor: 0x90a4ae,
strokeAlpha: 0.85,
strokeWidthPx: 1,
};
const STYLE_BY_LOAD_TYPE: Record<CargoLoadType, Style> = {
COL: STYLE_ROUTE_COL,
CAP: STYLE_ROUTE_CAP,
MAT: STYLE_ROUTE_MAT,
EMP: STYLE_ROUTE_EMP,
}; };
}
/** Per-load-type priority. Higher wins hit-test ties; planets sit /** Per-load-type priority. Higher wins hit-test ties; planets sit
* at 1..4 (`state-binding.ts.priorityFor`), so route arrows always * at 1..4 (`state-binding.ts.priorityFor`), so route arrows always
@@ -91,13 +77,18 @@ const HEAD_HALF_ANGLE = (25 * Math.PI) / 180;
* whose routes — outgoing or incoming — should be filtered out so the * whose routes — outgoing or incoming — should be filtered out so the
* arrows do not point at hidden glyphs. Empty / undefined means no * arrows do not point at hidden glyphs. Empty / undefined means no
* extra filtering, preserving the pre-Phase-29 contract. * extra filtering, preserving the pre-Phase-29 contract.
*
* `theme` supplies the per-load-type stroke colours and defaults to
* `DARK_THEME`.
*/ */
export function buildCargoRouteLines( export function buildCargoRouteLines(
report: GameReport, report: GameReport,
opts?: { skipPlanets?: ReadonlySet<number> }, opts?: { skipPlanets?: ReadonlySet<number> },
theme: Theme = DARK_THEME,
): LinePrim[] { ): LinePrim[] {
if (report.routes.length === 0) return []; if (report.routes.length === 0) return [];
const skip = opts?.skipPlanets; const skip = opts?.skipPlanets;
const styleByLoadType = routeStylesByLoadType(theme);
const planetById = new Map<number, ReportPlanet>(); const planetById = new Map<number, ReportPlanet>();
for (const planet of report.planets) { for (const planet of report.planets) {
planetById.set(planet.number, planet); planetById.set(planet.number, planet);
@@ -131,7 +122,7 @@ export function buildCargoRouteLines(
route.sourcePlanetNumber, route.sourcePlanetNumber,
entry.loadType, entry.loadType,
); );
const style = STYLE_BY_LOAD_TYPE[entry.loadType]; const style = styleByLoadType[entry.loadType];
const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType]; const priority = PRIORITY_BY_LOAD_TYPE[entry.loadType];
lines.push({ lines.push({
kind: "line", kind: "line",
+1
View File
@@ -4,6 +4,7 @@ export {
DEFAULT_HIT_SLOP_PX, DEFAULT_HIT_SLOP_PX,
KIND_ORDER, KIND_ORDER,
DARK_THEME, DARK_THEME,
LIGHT_THEME,
World, World,
type Camera, type Camera,
type CirclePrim, type CirclePrim,
+11 -9
View File
@@ -15,14 +15,7 @@
import type { GameReport, ReportPlanet } from "../api/game-state"; import type { GameReport, ReportPlanet } from "../api/game-state";
import type { OrderCommand } from "../sync/order-types"; import type { OrderCommand } from "../sync/order-types";
import { torusShortestDelta } from "./math"; import { torusShortestDelta } from "./math";
import type { LinePrim, PrimitiveID, Style } from "./world"; import { DARK_THEME, type LinePrim, type PrimitiveID, type Style, type Theme } from "./world";
const STYLE_PENDING_SEND_LINE: Style = {
strokeColor: 0x66bb6a,
strokeAlpha: 0.85,
strokeWidthPx: 1,
strokeDashPx: 4,
};
// Sit between cargo-route arrows (5..8) and ship-group points (5..) // Sit between cargo-route arrows (5..8) and ship-group points (5..)
// in priority. The line never participates in hit-test (hitSlopPx=0) // in priority. The line never participates in hit-test (hitSlopPx=0)
@@ -50,15 +43,24 @@ export const PENDING_SEND_LINE_ID_PREFIX = 0xa0000000;
* The function is pure — it walks the supplied arrays and returns * The function is pure — it walks the supplied arrays and returns
* a new primitive list. Callers combine the result with cargo- * a new primitive list. Callers combine the result with cargo-
* route lines and feed both into `handle.setExtraPrimitives`. * route lines and feed both into `handle.setExtraPrimitives`.
*
* `theme` supplies the dashed-line colour and defaults to `DARK_THEME`.
*/ */
export function buildPendingSendLines( export function buildPendingSendLines(
report: GameReport, report: GameReport,
commands: readonly OrderCommand[], commands: readonly OrderCommand[],
statuses: Readonly<Record<string, string>>, statuses: Readonly<Record<string, string>>,
opts?: { skipPlanets?: ReadonlySet<number> }, opts?: { skipPlanets?: ReadonlySet<number> },
theme: Theme = DARK_THEME,
): LinePrim[] { ): LinePrim[] {
if (commands.length === 0) return []; if (commands.length === 0) return [];
const skip = opts?.skipPlanets; const skip = opts?.skipPlanets;
const style: Style = {
strokeColor: theme.pendingSend,
strokeAlpha: 0.85,
strokeWidthPx: 1,
strokeDashPx: 4,
};
const planetById = new Map<number, ReportPlanet>(); const planetById = new Map<number, ReportPlanet>();
for (const planet of report.planets) { for (const planet of report.planets) {
planetById.set(planet.number, planet); planetById.set(planet.number, planet);
@@ -93,7 +95,7 @@ export function buildPendingSendLines(
kind: "line", kind: "line",
id: pendingSendLineId(serial), id: pendingSendLineId(serial),
priority: PRIORITY_PENDING_SEND_LINE, priority: PRIORITY_PENDING_SEND_LINE,
style: STYLE_PENDING_SEND_LINE, style,
hitSlopPx: 0, hitSlopPx: 0,
x1: source.x, x1: source.x,
y1: source.y, y1: source.y,
+14 -15
View File
@@ -148,23 +148,22 @@ export function computePickOverlay(
} }
/** /**
* PICK_OVERLAY_STYLE captures the colours / widths the renderer * PICK_OVERLAY_STYLE captures the per-channel alphas and widths the
* applies to each spec channel. Exported so tests and future themes * renderer applies to the pick overlay, plus the dim alpha for
* can read the same values. * non-reachable primitives. The colours themselves — the highlight
* colour shared by the anchor / line / hover channels and the dim
* multiply tint — come from the active `Theme` (`pickHighlight`,
* `pickDimTint`) so the overlay tracks the light / dark palette.
* *
* `dimAlpha` and `dimTint` are applied together to non-reachable * `dimAlpha` and `Theme.pickDimTint` are applied together to
* primitives during a pick session: the alpha drops their * non-reachable primitives during a pick session: the alpha drops
* brightness, and the tint multiplies their fill colour toward dark * their brightness while the tint collapses the colour identity
* gray so the colour identity (planet kind) collapses into a * (planet kind) into a single muted shade, so the disabled set reads
* single muted shade. The combination has to read as "obviously * as obviously inert against the map background.
* disabled" against the dark theme — bright planets such as
* `STYLE_LOCAL` (`0x6dd2ff`) survive a 0.3 alpha alone too
* comfortably, so the tint pulls them down too.
*/ */
export const PICK_OVERLAY_STYLE = { export const PICK_OVERLAY_STYLE = {
anchor: { color: 0xffe082, alpha: 0.9, width: 2 }, anchor: { alpha: 0.9, width: 2 },
line: { color: 0xffe082, alpha: 0.5, width: 1 }, line: { alpha: 0.5, width: 1 },
hover: { color: 0xffe082, alpha: 1, width: 2 }, hover: { alpha: 1, width: 2 },
dimAlpha: 0.35, dimAlpha: 0.35,
dimTint: 0x303841,
} as const; } as const;
+5 -4
View File
@@ -11,9 +11,8 @@
// clears the map in one turn therefore shows a single ring; a slow ship // clears the map in one turn therefore shows a single ring; a slow ship
// shows all three. // shows all three.
import type { CirclePrim } from "./world"; import { DARK_THEME, type CirclePrim, type Theme } from "./world";
export const REACH_CIRCLE_COLOR = 0x6d8cff;
/** High-bit prefix so reach-circle ids never collide with planet /** High-bit prefix so reach-circle ids never collide with planet
* numbers, cargo-route lines, or battle/bombing markers. */ * numbers, cargo-route lines, or battle/bombing markers. */
export const REACH_CIRCLE_ID_PREFIX = 0xb0000000; export const REACH_CIRCLE_ID_PREFIX = 0xb0000000;
@@ -47,7 +46,8 @@ export function reachBound(
* centred on `origin`, with radii speedPerTurn × {1, 2, 3}. A ring for * centred on `origin`, with radii speedPerTurn × {1, 2, 3}. A ring for
* turn `t` is included only when the previous ring still fits inside the * turn `t` is included only when the previous ring still fits inside the
* map's reach bound, so the count shrinks as the per-turn speed grows. * map's reach bound, so the count shrinks as the per-turn speed grows.
* Returns an empty list when the speed is non-positive. * Returns an empty list when the speed is non-positive. `theme`
* supplies the ring colour and defaults to `DARK_THEME`.
*/ */
export function computeReachCircles( export function computeReachCircles(
origin: { x: number; y: number }, origin: { x: number; y: number },
@@ -55,6 +55,7 @@ export function computeReachCircles(
mapWidth: number, mapWidth: number,
mapHeight: number, mapHeight: number,
mode: "torus" | "no-wrap", mode: "torus" | "no-wrap",
theme: Theme = DARK_THEME,
): CirclePrim[] { ): CirclePrim[] {
if (speedPerTurn <= 0) return []; if (speedPerTurn <= 0) return [];
const bound = reachBound(origin, mapWidth, mapHeight, mode); const bound = reachBound(origin, mapWidth, mapHeight, mode);
@@ -71,7 +72,7 @@ export function computeReachCircles(
y: origin.y, y: origin.y,
radius: speedPerTurn * turn, radius: speedPerTurn * turn,
style: { style: {
strokeColor: REACH_CIRCLE_COLOR, strokeColor: theme.reachCircle,
strokeAlpha: 0.55 - (turn - 1) * 0.12, strokeAlpha: 0.55 - (turn - 1) * 0.12,
strokeWidthPx: 0.5, strokeWidthPx: 0.5,
}, },
+5 -11
View File
@@ -226,12 +226,6 @@ const ORIGIN_COPY_INDEX = 4; // (0, 0) entry of TORUS_OFFSETS
// debug surface stays allocation-free. // debug surface stays allocation-free.
const EMPTY_HIDDEN_IDS: ReadonlySet<PrimitiveID> = new Set(); const EMPTY_HIDDEN_IDS: ReadonlySet<PrimitiveID> = new Set();
// FOG_COLOR is the Phase 29 visibility-fog colour. Two shades
// lighter than the dark theme background (`0x0a0e1a`) so it reads
// as a faint fog without contrasting against the rest of the map.
// The colour is tunable in Phase 35 polish.
export const FOG_COLOR = 0x12162a;
/** /**
* FogPaintOp is one item in the ordered draw sequence produced by * FogPaintOp is one item in the ordered draw sequence produced by
* `fogPaintOps`. The renderer dispatches each op directly onto a * `fogPaintOps`. The renderer dispatches each op directly onto a
@@ -657,7 +651,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
g.clear(); g.clear();
g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius); g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius);
g.stroke({ g.stroke({
color: PICK_OVERLAY_STYLE.anchor.color, color: theme.pickHighlight,
alpha: PICK_OVERLAY_STYLE.anchor.alpha, alpha: PICK_OVERLAY_STYLE.anchor.alpha,
width: PICK_OVERLAY_STYLE.anchor.width, width: PICK_OVERLAY_STYLE.anchor.width,
}); });
@@ -665,7 +659,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
g.moveTo(spec.line.x1, spec.line.y1); g.moveTo(spec.line.x1, spec.line.y1);
g.lineTo(spec.line.x2, spec.line.y2); g.lineTo(spec.line.x2, spec.line.y2);
g.stroke({ g.stroke({
color: PICK_OVERLAY_STYLE.line.color, color: theme.pickHighlight,
alpha: PICK_OVERLAY_STYLE.line.alpha, alpha: PICK_OVERLAY_STYLE.line.alpha,
width: PICK_OVERLAY_STYLE.line.width, width: PICK_OVERLAY_STYLE.line.width,
}); });
@@ -677,7 +671,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
spec.hoverOutline.radius, spec.hoverOutline.radius,
); );
g.stroke({ g.stroke({
color: PICK_OVERLAY_STYLE.hover.color, color: theme.pickHighlight,
alpha: PICK_OVERLAY_STYLE.hover.alpha, alpha: PICK_OVERLAY_STYLE.hover.alpha,
width: PICK_OVERLAY_STYLE.hover.width, width: PICK_OVERLAY_STYLE.hover.width,
}); });
@@ -720,7 +714,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
dimmedAlphaBackup.set(g, g.alpha); dimmedAlphaBackup.set(g, g.alpha);
dimmedTintBackup.set(g, g.tint as number); dimmedTintBackup.set(g, g.tint as number);
g.alpha = PICK_OVERLAY_STYLE.dimAlpha; g.alpha = PICK_OVERLAY_STYLE.dimAlpha;
g.tint = PICK_OVERLAY_STYLE.dimTint; g.tint = theme.pickDimTint;
} }
} }
// Overlay graphic. Lives in the origin copy so the central // Overlay graphic. Lives in the origin copy so the central
@@ -891,7 +885,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
const ops = fogPaintOps( const ops = fogPaintOps(
opts.world, opts.world,
circles, circles,
FOG_COLOR, theme.fog,
theme.background, theme.background,
mode, mode,
); );
+5 -4
View File
@@ -4,14 +4,13 @@
// Ship-group selection is intentionally not ringed here — groups are // Ship-group selection is intentionally not ringed here — groups are
// addressed by report index and have no single stable map coordinate. // addressed by report index and have no single stable map coordinate.
import type { CirclePrim } from "./world"; import { DARK_THEME, type CirclePrim, type Theme } from "./world";
/** Planet marker radius in world units; mirrors `battle-markers.ts`. */ /** Planet marker radius in world units; mirrors `battle-markers.ts`. */
const PLANET_RADIUS_WORLD = 6; const PLANET_RADIUS_WORLD = 6;
/** The ring sits just outside the marker (and the bombing ring at +3). */ /** The ring sits just outside the marker (and the bombing ring at +3). */
const SELECTION_RING_RADIUS = PLANET_RADIUS_WORLD + 4; const SELECTION_RING_RADIUS = PLANET_RADIUS_WORLD + 4;
export const SELECTION_RING_COLOR = 0x6d8cff;
/** High-bit prefix so the ring id never collides with planet numbers, /** High-bit prefix so the ring id never collides with planet numbers,
* route lines, reach rings (`0xb…`), or battle markers. */ * route lines, reach rings (`0xb…`), or battle markers. */
export const SELECTION_RING_ID = 0xc0000000; export const SELECTION_RING_ID = 0xc0000000;
@@ -21,11 +20,13 @@ const SELECTION_RING_PRIORITY = 0;
/** /**
* computeSelectionRing returns one ring primitive centred on the selected * computeSelectionRing returns one ring primitive centred on the selected
* planet, or `null` when nothing (or a non-planet) is selected or the * planet, or `null` when nothing (or a non-planet) is selected or the
* planet is absent from the current report. * planet is absent from the current report. `theme` supplies the ring
* colour and defaults to `DARK_THEME`.
*/ */
export function computeSelectionRing( export function computeSelectionRing(
planets: ReadonlyArray<{ number: number; x: number; y: number }>, planets: ReadonlyArray<{ number: number; x: number; y: number }>,
selectedPlanetId: number | null, selectedPlanetId: number | null,
theme: Theme = DARK_THEME,
): CirclePrim | null { ): CirclePrim | null {
if (selectedPlanetId === null) return null; if (selectedPlanetId === null) return null;
const planet = planets.find((p) => p.number === selectedPlanetId); const planet = planets.find((p) => p.number === selectedPlanetId);
@@ -39,7 +40,7 @@ export function computeSelectionRing(
y: planet.y, y: planet.y,
radius: SELECTION_RING_RADIUS, radius: SELECTION_RING_RADIUS,
style: { style: {
strokeColor: SELECTION_RING_COLOR, strokeColor: theme.selectionRing,
strokeAlpha: 0.95, strokeAlpha: 0.95,
strokeWidthPx: 1.5, strokeWidthPx: 1.5,
}, },
+48 -34
View File
@@ -38,7 +38,14 @@ import type {
} from "../api/game-state"; } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte"; import type { ShipGroupRef } from "../lib/selection.svelte";
import { torusShortestDelta } from "./math"; import { torusShortestDelta } from "./math";
import type { LinePrim, PointPrim, PrimitiveID, Style } from "./world"; import {
DARK_THEME,
type LinePrim,
type PointPrim,
type PrimitiveID,
type Style,
type Theme,
} from "./world";
/** /**
* SHIP_GROUP_ID_OFFSETS partitions the primitive-id namespace so a * SHIP_GROUP_ID_OFFSETS partitions the primitive-id namespace so a
@@ -56,43 +63,46 @@ export const SHIP_GROUP_ID_OFFSETS = {
unidentified: 400_000_000, unidentified: 400_000_000,
} as const; } as const;
const STYLE_LOCAL_GROUP: Style = { // shipGroupStyles builds the per-variant `Style` objects for the
fillColor: 0xfff176, // active theme. Only the colours are theme-driven; the alpha, radius,
fillAlpha: 0.95, // and dash spacing are fixed emphasis values. The in-space track
pointRadiusPx: 3, // reuses the own-group colour and the incoming trajectory line reuses
}; // the incoming colour so each pair reads as one entity.
function shipGroupStyles(theme: Theme): {
const STYLE_LOCAL_INSPACE_LINE: Style = { local: Style;
strokeColor: 0xfff176, localLine: Style;
other: Style;
incoming: Style;
incomingLine: Style;
unidentified: Style;
} {
return {
local: { fillColor: theme.shipLocal, fillAlpha: 0.95, pointRadiusPx: 3 },
localLine: {
strokeColor: theme.shipLocal,
strokeAlpha: 0.7, strokeAlpha: 0.7,
strokeWidthPx: 1, strokeWidthPx: 1,
strokeDashPx: 4, strokeDashPx: 4,
}; },
other: { fillColor: theme.shipOther, fillAlpha: 0.9, pointRadiusPx: 3 },
const STYLE_OTHER_GROUP: Style = { incoming: {
fillColor: 0xff6f40, fillColor: theme.shipIncoming,
fillAlpha: 0.9,
pointRadiusPx: 3,
};
const STYLE_INCOMING_GROUP: Style = {
fillColor: 0xff5252,
fillAlpha: 1, fillAlpha: 1,
pointRadiusPx: 4, pointRadiusPx: 4,
}; },
incomingLine: {
const STYLE_INCOMING_LINE: Style = { strokeColor: theme.shipIncoming,
strokeColor: 0xff5252,
strokeAlpha: 0.85, strokeAlpha: 0.85,
strokeWidthPx: 1, strokeWidthPx: 1,
strokeDashPx: 4, strokeDashPx: 4,
}; },
unidentified: {
const STYLE_UNIDENTIFIED_GROUP: Style = { fillColor: theme.shipUnidentified,
fillColor: 0x9aa3a8,
fillAlpha: 0.65, fillAlpha: 0.65,
pointRadiusPx: 3, pointRadiusPx: 3,
},
}; };
}
// Priority order inside `hit-test`: ship groups outrank planets so a // Priority order inside `hit-test`: ship groups outrank planets so a
// hyperspace group landing on top of an unidentified planet is // hyperspace group landing on top of an unidentified planet is
@@ -146,7 +156,11 @@ function addDependent(
set.add(primitiveId); set.add(primitiveId);
} }
export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives { export function shipGroupsToPrimitives(
report: GameReport,
theme: Theme = DARK_THEME,
): ShipGroupPrimitives {
const styles = shipGroupStyles(theme);
const primitives: (PointPrim | LinePrim)[] = []; const primitives: (PointPrim | LinePrim)[] = [];
const lookup = new Map<PrimitiveID, ShipGroupRef>(); const lookup = new Map<PrimitiveID, ShipGroupRef>();
const categories = new Map<PrimitiveID, ShipGroupCategory>(); const categories = new Map<PrimitiveID, ShipGroupCategory>();
@@ -163,7 +177,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
const pos = computeInSpacePosition(group, planetIndex, w, h); const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue; if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.local + i; const id = SHIP_GROUP_ID_OFFSETS.local + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, STYLE_LOCAL_GROUP)); primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_LOCAL, styles.local));
lookup.set(id, { variant: "local", id: group.id }); lookup.set(id, { variant: "local", id: group.id });
categories.set(id, "hyperspaceGroup"); categories.set(id, "hyperspaceGroup");
addDependent(planetDependents, group.destination, id); addDependent(planetDependents, group.destination, id);
@@ -183,7 +197,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
kind: "line", kind: "line",
id: lineId, id: lineId,
priority: PRIORITY_LOCAL_LINE, priority: PRIORITY_LOCAL_LINE,
style: STYLE_LOCAL_INSPACE_LINE, style: styles.localLine,
hitSlopPx: 0, hitSlopPx: 0,
x1: origin.x, x1: origin.x,
y1: origin.y, y1: origin.y,
@@ -200,7 +214,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
const pos = computeInSpacePosition(group, planetIndex, w, h); const pos = computeInSpacePosition(group, planetIndex, w, h);
if (pos === null) continue; if (pos === null) continue;
const id = SHIP_GROUP_ID_OFFSETS.other + i; const id = SHIP_GROUP_ID_OFFSETS.other + i;
primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, STYLE_OTHER_GROUP)); primitives.push(makePoint(id, pos.x, pos.y, PRIORITY_OTHER, styles.other));
lookup.set(id, { variant: "other", index: i }); lookup.set(id, { variant: "other", index: i });
categories.set(id, "hyperspaceGroup"); categories.set(id, "hyperspaceGroup");
addDependent(planetDependents, group.destination, id); addDependent(planetDependents, group.destination, id);
@@ -225,7 +239,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
kind: "line", kind: "line",
id: lineId, id: lineId,
priority: PRIORITY_INCOMING_LINE, priority: PRIORITY_INCOMING_LINE,
style: STYLE_INCOMING_LINE, style: styles.incomingLine,
hitSlopPx: 0, hitSlopPx: 0,
x1: origin.x, x1: origin.x,
y1: origin.y, y1: origin.y,
@@ -242,7 +256,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
pos.x, pos.x,
pos.y, pos.y,
PRIORITY_INCOMING_POINT, PRIORITY_INCOMING_POINT,
STYLE_INCOMING_GROUP, styles.incoming,
/*hitSlopPx*/ 4, /*hitSlopPx*/ 4,
), ),
); );
@@ -261,7 +275,7 @@ export function shipGroupsToPrimitives(report: GameReport): ShipGroupPrimitives
group.x, group.x,
group.y, group.y,
PRIORITY_UNIDENTIFIED, PRIORITY_UNIDENTIFIED,
STYLE_UNIDENTIFIED_GROUP, styles.unidentified,
), ),
); );
lookup.set(id, { variant: "unidentified", index: i }); lookup.set(id, { variant: "unidentified", index: i });
+34 -37
View File
@@ -9,9 +9,8 @@
// //
// The four planet kinds in the report each map to a distinct style so // The four planet kinds in the report each map to a distinct style so
// the user can tell own / other-race / uninhabited / unidentified // the user can tell own / other-race / uninhabited / unidentified
// planets apart at a glance. The exact colours are Phase 11 defaults // planets apart at a glance. The colours come from the active `Theme`
// chosen against the dark theme; Phase 35 polish picks final // (dark or light); only the per-kind alpha and radius are fixed here.
// colours and adds theme switching.
import type { GameReport, ReportPlanet } from "../api/game-state"; import type { GameReport, ReportPlanet } from "../api/game-state";
import type { ShipGroupRef } from "../lib/selection.svelte"; import type { ShipGroupRef } from "../lib/selection.svelte";
@@ -23,31 +22,14 @@ import {
shipGroupsToPrimitives, shipGroupsToPrimitives,
type ShipGroupCategory, type ShipGroupCategory,
} from "./ship-groups"; } from "./ship-groups";
import { World, type Primitive, type PrimitiveID, type Style } from "./world"; import {
DARK_THEME,
const STYLE_LOCAL: Style = { World,
fillColor: 0x6dd2ff, type Primitive,
fillAlpha: 1, type PrimitiveID,
pointRadiusPx: 6, type Style,
}; type Theme,
} from "./world";
const STYLE_OTHER: Style = {
fillColor: 0xff8a65,
fillAlpha: 1,
pointRadiusPx: 5,
};
const STYLE_UNINHABITED: Style = {
fillColor: 0xb0bec5,
fillAlpha: 0.85,
pointRadiusPx: 4,
};
const STYLE_UNIDENTIFIED: Style = {
fillColor: 0x546e7a,
fillAlpha: 0.7,
pointRadiusPx: 3,
};
// PlanetIDs occupy the [0, 4_000_000_000) range — well below // PlanetIDs occupy the [0, 4_000_000_000) range — well below
// JavaScript's `Number.MAX_SAFE_INTEGER` — so the engine `number` // JavaScript's `Number.MAX_SAFE_INTEGER` — so the engine `number`
@@ -55,16 +37,24 @@ const STYLE_UNIDENTIFIED: Style = {
// binding uses the engine number directly as the primitive id so the // binding uses the engine number directly as the primitive id so the
// click handler can recover a planet by hit-test result without an // click handler can recover a planet by hit-test result without an
// extra lookup. // extra lookup.
function styleFor(kind: ReportPlanet["kind"]): Style { function styleFor(kind: ReportPlanet["kind"], theme: Theme): Style {
switch (kind) { switch (kind) {
case "local": case "local":
return STYLE_LOCAL; return { fillColor: theme.planetLocal, fillAlpha: 1, pointRadiusPx: 6 };
case "other": case "other":
return STYLE_OTHER; return { fillColor: theme.planetOther, fillAlpha: 1, pointRadiusPx: 5 };
case "uninhabited": case "uninhabited":
return STYLE_UNINHABITED; return {
fillColor: theme.planetUninhabited,
fillAlpha: 0.85,
pointRadiusPx: 4,
};
case "unidentified": case "unidentified":
return STYLE_UNIDENTIFIED; return {
fillColor: theme.planetUnidentified,
fillAlpha: 0.7,
pointRadiusPx: 3,
};
} }
} }
@@ -146,8 +136,15 @@ export interface ReportToWorldResult {
* If the report carries zero planets (turn-zero edge cases or seeded * If the report carries zero planets (turn-zero edge cases or seeded
* tests), the World is still well-formed: the renderer mounts on an * tests), the World is still well-formed: the renderer mounts on an
* empty primitive list without errors. * empty primitive list without errors.
*
* `theme` supplies the planet / ship-group / marker colours; it
* defaults to `DARK_THEME` so callers that do not care about theming
* (tests, the debug playground) keep the original palette.
*/ */
export function reportToWorld(report: GameReport): ReportToWorldResult { export function reportToWorld(
report: GameReport,
theme: Theme = DARK_THEME,
): ReportToWorldResult {
const primitives: Primitive[] = []; const primitives: Primitive[] = [];
const hitLookup = new Map<PrimitiveID, HitTarget>(); const hitLookup = new Map<PrimitiveID, HitTarget>();
const categories = new Map<PrimitiveID, MapCategory>(); const categories = new Map<PrimitiveID, MapCategory>();
@@ -158,7 +155,7 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
kind: "point", kind: "point",
id: planet.number, id: planet.number,
priority: priorityFor(planet.kind), priority: priorityFor(planet.kind),
style: styleFor(planet.kind), style: styleFor(planet.kind, theme),
hitSlopPx: 0, hitSlopPx: 0,
x: planet.x, x: planet.x,
y: planet.y, y: planet.y,
@@ -174,7 +171,7 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
planetDependents.set(planet.number, own); planetDependents.set(planet.number, own);
} }
const groups = shipGroupsToPrimitives(report); const groups = shipGroupsToPrimitives(report, theme);
for (const prim of groups.primitives) { for (const prim of groups.primitives) {
primitives.push(prim); primitives.push(prim);
} }
@@ -186,7 +183,7 @@ export function reportToWorld(report: GameReport): ReportToWorldResult {
} }
mergeDependents(planetDependents, groups.planetDependents); mergeDependents(planetDependents, groups.planetDependents);
const markers = buildBattleAndBombingMarkers(report); const markers = buildBattleAndBombingMarkers(report, theme);
for (const prim of markers.primitives) { for (const prim of markers.primitives) {
primitives.push(prim); primitives.push(prim);
} }
+106 -3
View File
@@ -130,19 +130,122 @@ export class World {
} }
} }
// Theme carries the default colours used when a primitive's `style` // Theme is the renderer's colour palette. It carries both the generic
// leaves a colour unset. Phase 9 ships a single dark theme; runtime // fallbacks used when a primitive's `style` omits a colour and the
// theme switching is deferred to Phase 35. // semantic colours every primitive builder paints with (planets, ship
// groups, cargo routes, battle / bombing markers, reach + selection
// rings, pending-Send tracks, and the pick-mode overlay). Two concrete
// palettes are shipped — `DARK_THEME` and `LIGHT_THEME` — and the map
// view selects between them from the resolved app theme
// (`$lib/theme/theme.svelte.ts`), so the canvas follows the user's
// light / dark choice like the rest of the chrome.
//
// Only colours live here: per-primitive alphas, widths, and radii are
// emphasis / geometry, not theme, and stay as constants in the builder
// modules. The light palette mirrors the dark one role-for-role but
// darkens / saturates each hue so it reads against a light background;
// the incoming-group, battle, and bombing accents stay deliberately
// vivid in both palettes.
export interface Theme { export interface Theme {
// Canvas background and the visibility-fog veil drawn over
// unscanned hyperspace.
background: number; background: number;
fog: number;
// Generic fallbacks for primitives whose `style` omits a colour.
pointFill: number; pointFill: number;
circleStroke: number; circleStroke: number;
lineStroke: number; lineStroke: number;
// Planet glyphs, one colour per `ReportPlanet.kind`.
planetLocal: number;
planetOther: number;
planetUninhabited: number;
planetUnidentified: number;
// Ship groups. The in-space track reuses `shipLocal` and the
// incoming trajectory line reuses `shipIncoming`.
shipLocal: number;
shipOther: number;
shipIncoming: number;
shipUnidentified: number;
// Cargo-route arrows, one colour per load type.
routeCol: number;
routeCap: number;
routeMat: number;
routeEmp: number;
// Battle X-crosses and bombing rings (damaged vs wiped).
battleMarker: number;
bombingDamaged: number;
bombingWiped: number;
// Reach rings, the selected-planet ring, and pending-Send tracks.
reachCircle: number;
selectionRing: number;
pendingSend: number;
// Pick-mode overlay: the anchor / cursor-line / hover highlight
// colour and the multiply tint applied to non-reachable primitives.
pickHighlight: number;
pickDimTint: number;
} }
export const DARK_THEME: Theme = { export const DARK_THEME: Theme = {
background: 0x0a0e1a, background: 0x0a0e1a,
fog: 0x12162a,
pointFill: 0xe8eaf6, pointFill: 0xe8eaf6,
circleStroke: 0x4fc3f7, circleStroke: 0x4fc3f7,
lineStroke: 0xa5d6a7, lineStroke: 0xa5d6a7,
planetLocal: 0x6dd2ff,
planetOther: 0xff8a65,
planetUninhabited: 0xb0bec5,
planetUnidentified: 0x546e7a,
shipLocal: 0xfff176,
shipOther: 0xff6f40,
shipIncoming: 0xff5252,
shipUnidentified: 0x9aa3a8,
routeCol: 0x4fc3f7,
routeCap: 0xffb74d,
routeMat: 0x81c784,
routeEmp: 0x90a4ae,
battleMarker: 0xffd400,
bombingDamaged: 0xffd400,
bombingWiped: 0xff3030,
reachCircle: 0x6d8cff,
selectionRing: 0x6d8cff,
pendingSend: 0x66bb6a,
pickHighlight: 0xffe082,
pickDimTint: 0x303841,
};
// LIGHT_THEME mirrors DARK_THEME role-for-role. The background matches
// the app's light shell background (`--color-bg` in `tokens.css`) so
// the canvas blends into the surrounding chrome instead of reading as a
// dark rectangle; the fog is a faint darkening over the lighter base.
// Hues are darkened / saturated relative to the dark palette so small
// glyphs and thin strokes stay legible on a light surface, while the
// incoming (red), battle (amber), and bombing (amber / red) accents are
// kept vivid. Values are a first pass meant to be refined during the
// owner's F8 manual-QA loop.
export const LIGHT_THEME: Theme = {
background: 0xf3f5fb,
fog: 0xe2e7f1,
pointFill: 0x1a2138,
circleStroke: 0x1565c0,
lineStroke: 0x2e7d32,
planetLocal: 0x1565c0,
planetOther: 0xe64a19,
planetUninhabited: 0x78909c,
planetUnidentified: 0x90a4ae,
shipLocal: 0xc79100,
shipOther: 0xd84315,
shipIncoming: 0xd50000,
shipUnidentified: 0x607d8b,
routeCol: 0x0288d1,
routeCap: 0xef6c00,
routeMat: 0x2e7d32,
routeEmp: 0x607d8b,
battleMarker: 0xf57f17,
bombingDamaged: 0xf57f17,
bombingWiped: 0xc62828,
reachCircle: 0x3949ab,
selectionRing: 0x3949ab,
pendingSend: 0x388e3c,
pickHighlight: 0xef6c00,
pickDimTint: 0xaeb6c4,
}; };
+60 -7
View File
@@ -5,11 +5,9 @@ import { describe, expect, it } from "vitest";
import type { GameReport } from "../src/api/game-state"; import type { GameReport } from "../src/api/game-state";
import { import {
battleMarkerStrokeWidth, battleMarkerStrokeWidth,
BATTLE_MARKER_COLOR,
BOMBING_MARKER_COLOR_DAMAGED,
BOMBING_MARKER_COLOR_WIPED,
buildBattleAndBombingMarkers, buildBattleAndBombingMarkers,
} from "../src/map/battle-markers"; } from "../src/map/battle-markers";
import { DARK_THEME, LIGHT_THEME } from "../src/map/world";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
describe("battleMarkerStrokeWidth", () => { describe("battleMarkerStrokeWidth", () => {
@@ -87,9 +85,10 @@ describe("buildBattleAndBombingMarkers", () => {
const out = buildBattleAndBombingMarkers(report); const out = buildBattleAndBombingMarkers(report);
const lines = out.primitives.filter((p) => p.kind === "line"); const lines = out.primitives.filter((p) => p.kind === "line");
expect(lines).toHaveLength(2); expect(lines).toHaveLength(2);
// Same yellow colour, 5 px wide for a 100-shot battle. // Same colour (dark-palette default), 5 px wide for a 100-shot
// battle.
for (const l of lines) { for (const l of lines) {
expect(l.style.strokeColor).toBe(BATTLE_MARKER_COLOR); expect(l.style.strokeColor).toBe(DARK_THEME.battleMarker);
expect(l.style.strokeWidthPx).toBe(5); expect(l.style.strokeWidthPx).toBe(5);
} }
// First line: top-left → bottom-right corner of the planet square. // First line: top-left → bottom-right corner of the planet square.
@@ -184,7 +183,61 @@ describe("buildBattleAndBombingMarkers", () => {
const out = buildBattleAndBombingMarkers(report); const out = buildBattleAndBombingMarkers(report);
const rings = out.primitives.filter((p) => p.kind === "circle"); const rings = out.primitives.filter((p) => p.kind === "circle");
expect(rings).toHaveLength(2); expect(rings).toHaveLength(2);
expect(rings[0].style.strokeColor).toBe(BOMBING_MARKER_COLOR_DAMAGED); expect(rings[0].style.strokeColor).toBe(DARK_THEME.bombingDamaged);
expect(rings[1].style.strokeColor).toBe(BOMBING_MARKER_COLOR_WIPED); expect(rings[1].style.strokeColor).toBe(DARK_THEME.bombingWiped);
});
it("paints markers with the supplied palette's colours", () => {
const report = makeReport({
planets: [
{
number: 4,
name: "Test",
kind: "local",
x: 10,
y: 20,
size: 50,
resources: 0,
industryStockpile: 0,
materialsStockpile: 0,
population: 0,
colonists: 0,
industry: 0,
freeIndustry: 0,
production: "MAT",
owner: null,
},
],
battles: [
{ id: "11111111-1111-1111-1111-111111111111", planet: 4, shots: 3 },
],
bombings: [
{
planetNumber: 4,
planet: "Test",
owner: "X",
attacker: "Y",
production: "MAT",
industry: 0,
population: 0,
colonists: 0,
industryStockpile: 0,
materialsStockpile: 0,
attackPower: 1,
wiped: true,
},
],
});
const out = buildBattleAndBombingMarkers(report, LIGHT_THEME);
const lines = out.primitives.filter((p) => p.kind === "line");
const rings = out.primitives.filter((p) => p.kind === "circle");
for (const l of lines) {
expect(l.style.strokeColor).toBe(LIGHT_THEME.battleMarker);
}
expect(rings[0].style.strokeColor).toBe(LIGHT_THEME.bombingWiped);
// The accents are deliberately distinct between the palettes.
expect(LIGHT_THEME.battleMarker).not.toBe(DARK_THEME.battleMarker);
expect(LIGHT_THEME.bombingWiped).not.toBe(DARK_THEME.bombingWiped);
}); });
}); });
+7 -2
View File
@@ -15,9 +15,14 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { FOG_COLOR, fogPaintOps } from "../src/map/render"; import { fogPaintOps } from "../src/map/render";
import { DARK_THEME } from "../src/map/world";
const BG_COLOR = 0x0a0e1a; // The fog colour now lives on the theme; the renderer passes
// `theme.fog` to `fogPaintOps`. These ops tests pin the pure
// projection, so they reference the dark palette's value directly.
const FOG_COLOR = DARK_THEME.fog;
const BG_COLOR = DARK_THEME.background;
const WORLD = { width: 1000, height: 800 }; const WORLD = { width: 1000, height: 800 };
describe("fogPaintOps — no-wrap mode", () => { describe("fogPaintOps — no-wrap mode", () => {
+20 -8
View File
@@ -13,12 +13,9 @@ import type {
} from "../src/api/game-state"; } from "../src/api/game-state";
import { import {
ROUTE_LINE_ID_PREFIX, ROUTE_LINE_ID_PREFIX,
STYLE_ROUTE_CAP,
STYLE_ROUTE_COL,
STYLE_ROUTE_EMP,
STYLE_ROUTE_MAT,
buildCargoRouteLines, buildCargoRouteLines,
} from "../src/map/cargo-routes"; } from "../src/map/cargo-routes";
import { DARK_THEME, LIGHT_THEME } from "../src/map/world";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups"; import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet { function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
@@ -146,10 +143,25 @@ describe("buildCargoRouteLines", () => {
if (existing === undefined) styleByPriority.set(line.priority, line.style); if (existing === undefined) styleByPriority.set(line.priority, line.style);
else expect(existing).toBe(line.style); else expect(existing).toBe(line.style);
} }
expect(styleByPriority.get(8)).toBe(STYLE_ROUTE_COL); // Default (dark) palette colours, one per load type.
expect(styleByPriority.get(7)).toBe(STYLE_ROUTE_CAP); expect(styleByPriority.get(8)?.strokeColor).toBe(DARK_THEME.routeCol);
expect(styleByPriority.get(6)).toBe(STYLE_ROUTE_MAT); expect(styleByPriority.get(7)?.strokeColor).toBe(DARK_THEME.routeCap);
expect(styleByPriority.get(5)).toBe(STYLE_ROUTE_EMP); expect(styleByPriority.get(6)?.strokeColor).toBe(DARK_THEME.routeMat);
expect(styleByPriority.get(5)?.strokeColor).toBe(DARK_THEME.routeEmp);
});
test("uses the supplied palette's stroke colours", () => {
const report = makeReport(
[
makePlanet({ number: 1, x: 100, y: 100 }),
makePlanet({ number: 2, x: 200, y: 100 }),
],
1,
[{ loadType: "COL", destinationPlanetNumber: 2 }],
);
const [shaft] = buildCargoRouteLines(report, undefined, LIGHT_THEME);
expect(shaft.style.strokeColor).toBe(LIGHT_THEME.routeCol);
expect(LIGHT_THEME.routeCol).not.toBe(DARK_THEME.routeCol);
}); });
test("line ids carry the ROUTE_LINE_ID_PREFIX high bit", () => { test("line ids carry the ROUTE_LINE_ID_PREFIX high bit", () => {
+144
View File
@@ -0,0 +1,144 @@
// Coverage for the map's light / dark palette threading. The map
// renderer follows the resolved app theme: `map.svelte` selects
// `DARK_THEME` or `LIGHT_THEME` and threads it through every primitive
// builder so the canvas, planets, ship groups, routes, markers, and
// overlays all switch with the rest of the chrome. These tests pin the
// palette plumbing — that the builders honour the supplied palette and
// that the two palettes actually differ role-for-role — without booting
// Pixi. The per-builder colour tests (battle-markers, cargo-routes,
// selection-ring) cover their own surfaces; this spec covers the
// planet, ship-group, and reach-ring paths plus the palette invariants.
import { describe, expect, test } from "vitest";
import type { GameReport, ReportPlanet } from "../src/api/game-state";
import { computeReachCircles } from "../src/map/reach-circles";
import { reportToWorld } from "../src/map/state-binding";
import { DARK_THEME, LIGHT_THEME, type Theme } from "../src/map/world";
import { EMPTY_SHIP_GROUPS } from "./helpers/empty-ship-groups";
function makeReport(overrides: Partial<GameReport> = {}): GameReport {
return {
turn: 1,
mapWidth: 4000,
mapHeight: 4000,
planetCount: 0,
planets: [],
race: "",
localShipClass: [],
routes: [],
localPlayerDrive: 0,
localPlayerWeapons: 0,
localPlayerShields: 0,
localPlayerCargo: 0,
...EMPTY_SHIP_GROUPS,
...overrides,
};
}
function makePlanet(overrides: Partial<ReportPlanet>): ReportPlanet {
return {
number: 0,
name: "",
x: 0,
y: 0,
kind: "local",
owner: null,
size: null,
resources: null,
industryStockpile: null,
materialsStockpile: null,
industry: null,
population: null,
colonists: null,
production: null,
freeIndustry: null,
...overrides,
};
}
function planetFill(
kind: ReportPlanet["kind"],
theme?: Theme,
): number | undefined {
const { world } = reportToWorld(
makeReport({ planets: [makePlanet({ number: 1, kind })] }),
theme,
);
return world.primitives[0]?.style.fillColor;
}
describe("map palette threading", () => {
test("planet glyphs default to the dark palette", () => {
expect(planetFill("local")).toBe(DARK_THEME.planetLocal);
});
test("planet glyphs follow the supplied palette per kind", () => {
expect(planetFill("local", LIGHT_THEME)).toBe(LIGHT_THEME.planetLocal);
expect(planetFill("other", LIGHT_THEME)).toBe(LIGHT_THEME.planetOther);
expect(planetFill("uninhabited", LIGHT_THEME)).toBe(
LIGHT_THEME.planetUninhabited,
);
expect(planetFill("unidentified", LIGHT_THEME)).toBe(
LIGHT_THEME.planetUnidentified,
);
});
test("incoming-group accent follows the palette", () => {
const report = makeReport({
planets: [
makePlanet({ number: 1, x: 0, y: 0, kind: "local" }),
makePlanet({ number: 2, x: 100, y: 0, kind: "local" }),
],
incomingShipGroups: [
{ origin: 1, destination: 2, distance: 10, speed: 5, mass: 1 },
],
});
for (const theme of [DARK_THEME, LIGHT_THEME]) {
const { world, hitLookup } = reportToWorld(report, theme);
// Locate the clickable incoming point via the hit-lookup.
let incomingId: number | null = null;
for (const [id, target] of hitLookup) {
if (
target.kind === "shipGroup" &&
target.ref.variant === "incoming"
) {
incomingId = id;
break;
}
}
expect(incomingId).not.toBeNull();
const point = world.primitives.find((p) => p.id === incomingId);
expect(point?.style.fillColor).toBe(theme.shipIncoming);
}
});
test("reach rings follow the supplied palette", () => {
const dark = computeReachCircles({ x: 0, y: 0 }, 100, 1000, 1000, "torus");
expect(dark[0]?.style.strokeColor).toBe(DARK_THEME.reachCircle);
const light = computeReachCircles(
{ x: 0, y: 0 },
100,
1000,
1000,
"torus",
LIGHT_THEME,
);
expect(light[0]?.style.strokeColor).toBe(LIGHT_THEME.reachCircle);
});
});
describe("palette invariants", () => {
test("the two palettes define the same fields", () => {
expect(Object.keys(LIGHT_THEME).sort()).toEqual(
Object.keys(DARK_THEME).sort(),
);
});
test("the canvas background and accents differ between palettes", () => {
expect(LIGHT_THEME.background).not.toBe(DARK_THEME.background);
expect(LIGHT_THEME.shipIncoming).not.toBe(DARK_THEME.shipIncoming);
expect(LIGHT_THEME.battleMarker).not.toBe(DARK_THEME.battleMarker);
expect(LIGHT_THEME.bombingWiped).not.toBe(DARK_THEME.bombingWiped);
});
});
+24 -1
View File
@@ -12,6 +12,7 @@ import type {
} from "../src/api/game-state"; } from "../src/api/game-state";
import type { OrderCommand } from "../src/sync/order-types"; import type { OrderCommand } from "../src/sync/order-types";
import { buildPendingSendLines } from "../src/map/pending-send-routes"; import { buildPendingSendLines } from "../src/map/pending-send-routes";
import { DARK_THEME, LIGHT_THEME } from "../src/map/world";
function planet(overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number" | "x" | "y">): ReportPlanet { function planet(overrides: Partial<ReportPlanet> & Pick<ReportPlanet, "number" | "x" | "y">): ReportPlanet {
return { return {
@@ -108,7 +109,29 @@ describe("buildPendingSendLines", () => {
expect(line.x2).toBe(110); expect(line.x2).toBe(110);
expect(line.y2).toBe(100); expect(line.y2).toBe(100);
expect(line.style.strokeDashPx).toBeGreaterThan(0); expect(line.style.strokeDashPx).toBeGreaterThan(0);
expect(line.style.strokeColor).toBe(0x66bb6a); expect(line.style.strokeColor).toBe(DARK_THEME.pendingSend);
});
test("uses the supplied palette's dashed-line colour", () => {
const report = makeReport({
planets: [SOURCE_PLANET, DEST_PLANET],
localShipGroups: [localGroup({ id: GROUP_ID, destination: 1 })],
});
const cmd: OrderCommand = {
kind: "sendShipGroup",
id: "cmd-1",
groupId: GROUP_ID,
destinationPlanetNumber: 2,
};
const lines = buildPendingSendLines(
report,
[cmd],
{ "cmd-1": "valid" },
undefined,
LIGHT_THEME,
);
expect(lines[0]?.style.strokeColor).toBe(LIGHT_THEME.pendingSend);
expect(LIGHT_THEME.pendingSend).not.toBe(DARK_THEME.pendingSend);
}); });
test("uses the torus-shortest path across the seam", () => { test("uses the torus-shortest path across the seam", () => {
+10 -6
View File
@@ -1,10 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { computeSelectionRing, SELECTION_RING_ID } from "../src/map/selection-ring";
computeSelectionRing, import { DARK_THEME, LIGHT_THEME } from "../src/map/world";
SELECTION_RING_COLOR,
SELECTION_RING_ID,
} from "../src/map/selection-ring";
const planets = [ const planets = [
{ number: 1, x: 10, y: 20 }, { number: 1, x: 10, y: 20 },
@@ -29,8 +26,15 @@ describe("computeSelectionRing", () => {
y: 40, y: 40,
hitSlopPx: 0, hitSlopPx: 0,
}); });
expect(ring?.style.strokeColor).toBe(SELECTION_RING_COLOR); // Defaults to the dark palette.
expect(ring?.style.strokeColor).toBe(DARK_THEME.selectionRing);
// Sits outside the planet marker (radius 6 world units). // Sits outside the planet marker (radius 6 world units).
expect(ring?.radius ?? 0).toBeGreaterThan(6); expect(ring?.radius ?? 0).toBeGreaterThan(6);
}); });
it("uses the supplied palette's ring colour", () => {
const ring = computeSelectionRing(planets, 2, LIGHT_THEME);
expect(ring?.style.strokeColor).toBe(LIGHT_THEME.selectionRing);
expect(LIGHT_THEME.selectionRing).not.toBe(DARK_THEME.selectionRing);
});
}); });