Files
galaxy-game/ui/frontend/src/map/world.ts
T
Ilia Denisov eb5018342e
Tests · UI / test (push) Has been cancelled
Tests · UI / test (pull_request) Successful in 3m18s
feat(ui): F8-12 — owner feedback round 2 (#55)
* Bug fix: theme flip no longer leaves planets oversized. The
  camera-preserving remount now calls a new
  `RendererHandle.refreshCameraDerivedDraws` explicitly after the
  manual moveCenter/setZoom pair so the post-mount geometry tracks
  `viewport.scaled` even if pixi-viewport's `'zoomed'` listener
  races the next Ticker tick.
* Doc #3: clicks on a planet label route through the same hit-test
  path as a click on the disc. The label `Container` now has a
  pointer hit area sized to the text + frame padding; pointertap
  simulates a click at the planet centre, so selection and
  pick-mode resolution behave identically.
* Doc #4: battle X-crosses + cargo arrowhead wings grow
  sub-linearly with zoom (PLANET_SIZE_ZOOM_ALPHA). New
  `Style.softLengthAnchor` ('center' / 'start') makes the renderer
  treat the recorded endpoints as the geometry "at the reference
  scale" and rescale around the midpoint (X-cross) or the start
  endpoint (arrow wings). Arrowhead base length is halved from 6
  to 3 world units to match the owner's "in half" request.
* Doc #5: picker overlay loses the anchor ring at the source, the
  cursor line drops to a cargo-route-thin 0.6 px stroke, and the
  hover ring around the destination is replaced by a planet-style
  outline (visible disc + 1 px padding) in the `pickHighlight`
  accent — so candidate destinations read like selection in warm
  yellow.
* Doc #6: regression test pins the in-disc hit zone.
* Perf #1: camera-driven redraws are throttled onto the next
  Ticker tick. A rapid wheel / pinch burst now coalesces into at
  most one `clear() + redraw` pass per painted frame, which keeps
  the 500-planet map responsive on zoom and toggle flips.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:40:20 +02:00

420 lines
15 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.
//
// `strokeWidthPx` / `pointRadiusPx` are honest screen-pixel sizes
// since F8-12 (#28): the renderer divides them by the current camera
// scale before drawing, and rebuilds the affected `Graphics` whenever
// the camera zooms. This keeps thin lines crisp and small markers
// readable across the whole zoom range — the camera-relative
// thickening that the old contract promised but never delivered is
// gone.
//
// `pointRadiusBasePx` is the opposite intent: a planet's known
// `size` produces a base on-screen pixel radius at the "whole world
// fits" reference zoom, and the renderer grows it sub-linearly with
// the camera scale through `PLANET_SIZE_ZOOM_ALPHA` (F8-12 / #31).
// When `pointRadiusBasePx` is set on a `PointPrim`, `pointRadiusPx`
// is ignored.
export interface Style {
fillColor?: number; // 0xRRGGBB
fillAlpha?: number; // 0..1
strokeColor?: number; // 0xRRGGBB
strokeAlpha?: number; // 0..1
strokeWidthPx?: number; // screen pixels at any zoom
pointRadiusPx?: number; // screen pixels at any zoom (for kind === 'point')
pointRadiusBasePx?: number; // screen pixels at scaleRef, softened by PLANET_SIZE_ZOOM_ALPHA
// 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 world-unit
// space — the dash spacing scales with the camera. Phase 19 uses
// this for the IncomingGroup trajectory line; ignored on point
// and circle primitives.
strokeDashPx?: number;
// softLengthAnchor — when set on a `LinePrim`, the renderer treats
// the world-coord endpoints as the line length "at the reference
// scale" and grows / shrinks them with `PLANET_SIZE_ZOOM_ALPHA`
// the same way planet discs do. `'center'` scales both endpoints
// around the segment midpoint (used by battle X-crosses anchored
// on the planet centre); `'start'` keeps `(x1, y1)` fixed and
// only scales `(x2, y2)` along the original direction (used by
// cargo-route arrowhead wings anchored at the destination).
softLengthAnchor?: "center" | "start";
}
// 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 the bombing accent (damaged vs wiped). The
// bombing accent is now drawn as the planet's outline rather than a
// separate ring (F8-12 / issue #55, п.30).
battleMarker: number;
bombingDamaged: number;
bombingWiped: number;
// Reach rings, the selection accent (planet outline + label frame),
// and pending-Send tracks. `selectionRing` is kept around for the
// soon-to-be-removed `selection-ring.ts` and the test that locks
// the colour; both lines disappear once the label-driven selection
// lands.
reachCircle: number;
selectionRing: number;
selectionAccent: number;
pendingSend: number;
// Planet label colours. `labelText` paints the primary line
// (planet name when the toggle is on), `labelMuted` paints the
// `#N` companion line. The inverse pair fills the rounded frame
// drawn around the selected planet's label (background = the
// selection accent, text = the canvas background colour).
labelText: number;
labelMuted: number;
labelInverseText: number;
labelInverseBackground: number;
// Pick-mode overlay: the anchor / cursor-line / hover highlight
// colour and the multiply tint applied to non-reachable primitives.
pickHighlight: number;
pickDimTint: number;
}
/**
* PLANET_SIZE_ZOOM_ALPHA is the exponent that softens the on-screen
* growth of known-size planets with the camera scale (F8-12 / п.31).
* `α = 1` keeps the historical linear-with-zoom behaviour; `α = 0`
* would make planets fully zoom-invariant. 0.33 (cube-root soft scaling
* relative to `scale_ref`) is the owner-approved starting point — it
* gives a noticeable, but moderated, growth as the user zooms in. The
* constant lives next to the themes so the tuning knob is in one
* obvious place.
*/
export const PLANET_SIZE_ZOOM_ALPHA = 0.33;
/**
* displayPointRadiusWorld returns the world-space radius the renderer
* should draw a `PointPrim` with at the current camera scale. When
* `style.pointRadiusBasePx` is set (known-size planets), the radius
* is the base pixel size at `scaleRef`, grown by
* `(scale / scaleRef)^α` and converted back into world units —
* `α = PLANET_SIZE_ZOOM_ALPHA`. At `scale = scaleRef` the visible
* pixel size equals the base; a 10× zoom-in only grows it ~2.15×.
* Otherwise the radius collapses to `pointRadiusPx / cameraScale` so
* the on-screen disc stays the same pixel size regardless of zoom.
*
* Used by both the renderer (`render.ts:drawPoint`) and the hit-test
* (`hit-test.ts:matchPoint`) so the visible disc and the click zone
* always agree.
*/
export function displayPointRadiusWorld(
style: Style,
cameraScale: number,
scaleRef: number,
): number {
if (cameraScale <= 0) {
return style.pointRadiusBasePx ?? style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
}
if (style.pointRadiusBasePx !== undefined) {
const refScale = scaleRef > 0 ? scaleRef : cameraScale;
const screenPx =
style.pointRadiusBasePx *
Math.pow(cameraScale / refScale, PLANET_SIZE_ZOOM_ALPHA);
return screenPx / cameraScale;
}
const px = style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX;
return px / cameraScale;
}
/**
* displayStrokeWidthWorld converts `style.strokeWidthPx` (a screen-pixel
* thickness, F8-12 / #28) into the world-space width the renderer
* passes to `g.stroke({...})`. The renderer redraws strokes on every
* `viewport.zoomed` so the on-screen thickness stays constant.
*/
export function displayStrokeWidthWorld(
style: Style,
cameraScale: number,
): number {
const px = style.strokeWidthPx ?? 1;
if (cameraScale <= 0) return px;
return px / cameraScale;
}
/**
* softLengthFactor returns the multiplier that scales a line's
* length when `style.softLengthAnchor` is set. The factor matches
* the planet-radius softening rule: at `scale = scaleRef` it equals
* `1` (the recorded geometry is the reference length); zooming in
* shrinks the world-space length so the on-screen length grows by
* `(scale / scaleRef)^α`. `displayLineEndpoints` is the convenience
* wrapper that applies it to a line's `(x1, y1)(x2, y2)` pair
* given the configured anchor.
*/
export function softLengthFactor(
cameraScale: number,
scaleRef: number,
): number {
if (cameraScale <= 0 || scaleRef <= 0) return 1;
return Math.pow(cameraScale / scaleRef, PLANET_SIZE_ZOOM_ALPHA - 1);
}
/**
* displayLineEndpoints returns the world-space endpoints the
* renderer should draw a `LinePrim` between, honouring
* `style.softLengthAnchor` if set. Used by both the renderer and
* the hit-test so the click zone always matches the visible stroke.
*/
export function displayLineEndpoints(
style: Style,
x1: number,
y1: number,
x2: number,
y2: number,
cameraScale: number,
scaleRef: number,
): { x1: number; y1: number; x2: number; y2: number } {
if (style.softLengthAnchor === undefined) {
return { x1, y1, x2, y2 };
}
const factor = softLengthFactor(cameraScale, scaleRef);
if (factor === 1) return { x1, y1, x2, y2 };
if (style.softLengthAnchor === "start") {
return {
x1,
y1,
x2: x1 + (x2 - x1) * factor,
y2: y1 + (y2 - y1) * factor,
};
}
const cx = (x1 + x2) / 2;
const cy = (y1 + y2) / 2;
return {
x1: cx + (x1 - cx) * factor,
y1: cy + (y1 - cy) * factor,
x2: cx + (x2 - cx) * factor,
y2: cy + (y2 - cy) * factor,
};
}
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,
selectionAccent: 0x6d8cff,
pendingSend: 0x66bb6a,
labelText: 0xc7d2e0,
labelMuted: 0x90a4ae,
labelInverseText: 0x0a0e1a,
labelInverseBackground: 0x6d8cff,
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,
selectionAccent: 0x3949ab,
pendingSend: 0x388e3c,
labelText: 0x263240,
labelMuted: 0x5a6d8a,
labelInverseText: 0xf3f5fb,
labelInverseBackground: 0x3949ab,
pickHighlight: 0xef6c00,
pickDimTint: 0xaeb6c4,
};