680ebac919
* 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>
163 lines
5.4 KiB
TypeScript
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 };
|
|
}
|