Files
galaxy-game/ui/frontend/src/map/battle-markers.ts
T
Ilia Denisov 680ebac919
Tests · UI / test (push) Waiting to run
Tests · UI / test (pull_request) Failing after 5m16s
feat(ui): F8-12 — map polish (zoom invariance, labels, selection, soft radius) (#55)
* Honest pixel-space sizing for `pointRadiusPx` / `strokeWidthPx`: the
  renderer divides by the current camera scale on every
  `viewport.zoomed` so thin lines / small markers stay the same on-screen
  size at any zoom.
* Known-size planets switch to `pointRadiusWorld`, softened against the
  reference scale by `PLANET_SIZE_ZOOM_ALPHA = 0.33`; unidentified
  planets pin to a 3-px disc.
* New planet label layer renders a two-line `name / #N` legend under
  each planet (`#N` only for unidentified or when the new `planetNames`
  toggle is off). Selection now paints an inverse-fill frame around the
  selected planet's label plus an outline on the disc; the old
  selection-ring primitive is retired.
* Bombing markers swap the separate CirclePrim for a planet-outline
  overlay (damaged / wiped colour); the report deep-link moves to a
  "view bombing report" link in the planet inspector.
* Docs + tests follow: `renderer.md` reflects the new sizing contract +
  label / outline layers, vitest covers the sizing math, label
  formatting, and the new toggle, and the map-toggles e2e adds a
  persistence case for `planetNames`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 23:51:16 +02:00

163 lines
5.4 KiB
TypeScript

// Phase 27 battle markers on the map. Bombing markers used to live
// here as a separate ring primitive but F8-12 / #30 turned them into
// a planet-outline overlay drawn by `render.ts.setPlanetOutlines`,
// driven from `map.svelte`. The remaining surface here is the battle
// X-cross:
//
// * Battle marker — an X cross drawn through the corners of the
// square that circumscribes the planet circle. Two yellow
// LinePrim, stroke width scales linearly with the number of
// shots: 1 shot → 1px, 100+ shots → 5px (capped). Clicking
// either line opens the Battle Viewer for the corresponding
// UUID.
import type { GameReport, ReportPlanet } from "../api/game-state";
import {
DARK_THEME,
type LinePrim,
type Primitive,
type PrimitiveID,
type Style,
type Theme,
} from "./world";
/** Battle marker primitive ids use a high-bit prefix to avoid
* colliding with planet numbers or cargo-route line ids. */
export const BATTLE_MARKER_ID_PREFIX = 0xa0000000;
const PLANET_RADIUS_WORLD = 6;
const BATTLE_CROSS_HALF = PLANET_RADIUS_WORLD + 2;
/** Battle marker priority sits between planets (1..4) and cargo
* routes; the cross is over the planet but loses clicks against the
* planet glyph itself. */
const BATTLE_MARKER_PRIORITY = 9;
const BATTLE_LINE_INDEX_A = 0;
const BATTLE_LINE_INDEX_B = 1;
export interface BattleMarkerTarget {
kind: "battle";
battleId: string;
planet: number;
}
export type MarkerTarget = BattleMarkerTarget;
/**
* MarkerCategory tags every emitted primitive with the toggleable
* surface it belongs to so the Phase 29 hide-set machinery can flip
* each independently. Battle markers are the only category left here;
* the `bombingMarker` toggle now hides the planet-outline overlay
* built in `map.svelte.applyPlanetOutlines` (F8-12 / #30).
*/
export type MarkerCategory = "battleMarker";
export interface BuildMarkersResult {
primitives: Primitive[];
lookup: Map<PrimitiveID, MarkerTarget>;
categories: Map<PrimitiveID, MarkerCategory>;
/**
* planetDependents maps the anchor planet number to the ids of
* markers drawn on it; the Phase 29 cascade hides the markers
* together with the planet when the planet itself is filtered out
* (kind toggle off or unreachable filter on).
*/
planetDependents: Map<number, Set<PrimitiveID>>;
}
/**
* battleMarkerStrokeWidth maps a battle's `shots` count to a stroke
* width in pixels. 1 shot → 1 px (the thinnest visible), 100+ shots
* → 5 px (the cap). Linearly interpolated between those bounds.
*/
export function battleMarkerStrokeWidth(shots: number): number {
if (shots <= 1) return 1;
if (shots >= 100) return 5;
return 1 + ((shots - 1) * 4) / 99;
}
/**
* buildBattleAndBombingMarkers emits battle X-cross primitives plus a
* hit-lookup mapping for the current-turn report. Battles whose
* planet is not visible (e.g. observer-only without a report.planets
* entry) are skipped — they have no on-map location to anchor
* against. Bombing visuals are no longer produced here (F8-12 / #30);
* the renderer paints them as a planet-outline overlay driven from
* `map.svelte.applyPlanetOutlines`.
*/
export function buildBattleAndBombingMarkers(
report: GameReport,
theme: Theme = DARK_THEME,
): BuildMarkersResult {
const planetByNumber = new Map<number, ReportPlanet>();
for (const planet of report.planets) {
planetByNumber.set(planet.number, planet);
}
const primitives: Primitive[] = [];
const lookup = new Map<PrimitiveID, MarkerTarget>();
const categories = new Map<PrimitiveID, MarkerCategory>();
const planetDependents = new Map<number, Set<PrimitiveID>>();
const addDependent = (planetNumber: number, id: PrimitiveID): void => {
let set = planetDependents.get(planetNumber);
if (set === undefined) {
set = new Set();
planetDependents.set(planetNumber, set);
}
set.add(id);
};
for (let i = 0; i < report.battles.length; i++) {
const battle = report.battles[i];
const planet = planetByNumber.get(battle.planet);
if (planet === undefined) continue;
const strokeWidthPx = battleMarkerStrokeWidth(battle.shots);
const style: Style = {
strokeColor: theme.battleMarker,
strokeAlpha: 0.95,
strokeWidthPx,
};
const baseId = BATTLE_MARKER_ID_PREFIX | (i << 4);
const lineA: LinePrim = {
kind: "line",
id: baseId | BATTLE_LINE_INDEX_A,
priority: BATTLE_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x1: planet.x - BATTLE_CROSS_HALF,
y1: planet.y - BATTLE_CROSS_HALF,
x2: planet.x + BATTLE_CROSS_HALF,
y2: planet.y + BATTLE_CROSS_HALF,
};
const lineB: LinePrim = {
kind: "line",
id: baseId | BATTLE_LINE_INDEX_B,
priority: BATTLE_MARKER_PRIORITY,
style,
hitSlopPx: 0,
x1: planet.x - BATTLE_CROSS_HALF,
y1: planet.y + BATTLE_CROSS_HALF,
x2: planet.x + BATTLE_CROSS_HALF,
y2: planet.y - BATTLE_CROSS_HALF,
};
const target: BattleMarkerTarget = {
kind: "battle",
battleId: battle.id,
planet: battle.planet,
};
primitives.push(lineA, lineB);
lookup.set(lineA.id, target);
lookup.set(lineB.id, target);
categories.set(lineA.id, "battleMarker");
categories.set(lineB.id, "battleMarker");
addDependent(battle.planet, lineA.id);
addDependent(battle.planet, lineB.id);
}
// Bombing visuals are produced by `setPlanetOutlines` in the
// renderer (F8-12 / #30); the data still lives on
// `report.bombings`, but no primitive is emitted here.
return { primitives, lookup, categories, planetDependents };
}