Files
galaxy-game/ui/frontend/src/map/world.ts
T
Ilia Denisov f6e4a4f6bd
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Successful in 2m45s
feat(ui): map canvas follows light/dark theme; fix invisible gear control
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>
2026-05-24 08:49:37 +02:00

252 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Data model for the map renderer.
//
// World coordinates are TypeScript numbers (float64). The world is a
// rectangle [0, W) × [0, H). When wrap mode is 'torus', the world
// behaves toroidally — primitives near the right edge are visible at
// the left edge once the camera scrolls past, etc. When wrap mode is
// 'no-wrap', the world is a bounded plane and the camera is clamped
// at its edges.
//
// The algorithm specification for hit-test, torus wrap, and no-wrap
// camera behaviour lives in ui/docs/renderer.md. See that document
// before changing the contract of the types in this file.
export type PrimitiveID = number;
export type WrapMode = "torus" | "no-wrap";
// Style describes the visual appearance of a primitive. Any field may
// be omitted; missing fields fall back to the active theme defaults.
export interface Style {
fillColor?: number; // 0xRRGGBB
fillAlpha?: number; // 0..1
strokeColor?: number; // 0xRRGGBB
strokeAlpha?: number; // 0..1
strokeWidthPx?: number; // pixels at any zoom
pointRadiusPx?: number; // pixels at any zoom (for kind === 'point')
// strokeDashPx — when set on a `LinePrim`, the line is rendered as
// a dashed pattern whose dash and gap are both this length. When
// unset (or zero), the stroke is solid. Interpreted in the same
// world-unit space as `strokeWidthPx`, so the dash spacing scales
// with the camera. Phase 19 uses this for the IncomingGroup
// trajectory line; ignored on point and circle primitives.
strokeDashPx?: number;
}
// PrimitiveBase carries the fields shared by every primitive kind.
//
// priority is used for deterministic ordering during hit-test: higher
// priority wins ties. hitSlopPx is an optional per-primitive override
// of the kind default, in screen pixels.
export interface PrimitiveBase {
id: PrimitiveID;
priority: number;
style: Style;
hitSlopPx: number; // 0 = use kind default
}
export interface PointPrim extends PrimitiveBase {
kind: "point";
x: number;
y: number;
}
export interface CirclePrim extends PrimitiveBase {
kind: "circle";
x: number;
y: number;
radius: number; // world units
}
export interface LinePrim extends PrimitiveBase {
kind: "line";
x1: number;
y1: number;
x2: number;
y2: number;
}
export type Primitive = PointPrim | CirclePrim | LinePrim;
export type PrimitiveKind = Primitive["kind"];
// Default hit slop in screen pixels per primitive kind. Added on top
// of the visible footprint of each primitive — for points, the
// effective hit radius is `pointRadiusPx + slopPx`. Chosen for touch
// ergonomics; per-primitive `hitSlopPx` overrides the default.
export const DEFAULT_HIT_SLOP_PX: Record<PrimitiveKind, number> = {
point: 4,
circle: 6,
line: 6,
};
// Default world-unit radius drawn for a `PointPrim` when its
// `style.pointRadiusPx` is unset. Shared between the renderer
// (`render.ts.drawPoint`) and the hit-test
// (`hit-test.ts.matchPoint`) so the click target always covers the
// visible disc.
export const DEFAULT_POINT_RADIUS_PX = 3;
// kindOrder is the deterministic tie-break order used during hit-test
// when two primitives match a cursor at identical priority and
// distance. Smaller value wins.
export const KIND_ORDER: Record<PrimitiveKind, number> = {
point: 0,
line: 1,
circle: 2,
};
// Camera describes the world point at the centre of the viewport and
// the scale (pixels per world unit). Pan/zoom mutate this struct;
// `pixi-viewport` keeps its own internal state and we mirror it here
// for hit-test and for tests that read camera state directly.
export interface Camera {
centerX: number;
centerY: number;
scale: number;
}
export interface Viewport {
widthPx: number;
heightPx: number;
}
// World is the immutable container of primitives plus the toroidal
// dimensions. The renderer reindexes nothing — the brute-force
// hit-test walks all primitives on every pointer event, which is
// adequate for the ~1000-primitive Phase 9 budget.
export class World {
readonly width: number;
readonly height: number;
readonly primitives: Primitive[];
constructor(width: number, height: number, primitives: Primitive[] = []) {
if (!(width > 0) || !(height > 0)) {
throw new Error(`World: width and height must be positive, got ${width}×${height}`);
}
this.width = width;
this.height = height;
this.primitives = primitives;
}
}
// Theme is the renderer's colour palette. It carries both the generic
// fallbacks used when a primitive's `style` omits a colour and the
// 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 {
// Canvas background and the visibility-fog veil drawn over
// unscanned hyperspace.
background: number;
fog: number;
// Generic fallbacks for primitives whose `style` omits a colour.
pointFill: number;
circleStroke: 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 = {
background: 0x0a0e1a,
fog: 0x12162a,
pointFill: 0xe8eaf6,
circleStroke: 0x4fc3f7,
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,
};