feat(ui): Phase 29 map visibility toggles
Adds the gear-icon popover on the map view with per-game persistence of every category toggle plus the wrap-mode radio. Hide-by-id and visibility-fog facilities land on the renderer so every flip applies within one frame without a Pixi remount; the wrap-mode toggle keeps its existing remount + camera-preserve path. A new server-side turn force-resets every flag to defaults so a hidden category never makes the player miss the next turn's news. Also fixes the FligthDistance → FlightDistance typo in pkg/calc/race.go (plus the single Go caller); the TS side keeps duplicating the formula until a race-level WASM bridge lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -155,6 +155,36 @@ export interface RendererHandle {
|
||||
* for unknown ids.
|
||||
*/
|
||||
getPrimitiveAlpha(id: PrimitiveID): number;
|
||||
/**
|
||||
* setHiddenPrimitiveIds replaces the set of primitives the
|
||||
* renderer should hide. Hidden primitives have their per-copy
|
||||
* `Graphics.visible` flipped to `false` and are skipped by
|
||||
* `hitAt`, so a click on the area they used to cover falls
|
||||
* through to the next primitive. Empty input clears the hide
|
||||
* set. Called every effect run by the Phase 29 map view to
|
||||
* materialise the `MapToggles` flags + planet-cascade rule
|
||||
* without a Pixi remount.
|
||||
*/
|
||||
setHiddenPrimitiveIds(ids: ReadonlySet<PrimitiveID>): void;
|
||||
/**
|
||||
* isPrimitiveHidden reports whether the supplied primitive id is
|
||||
* in the current hide set. Used by the debug surface so e2e
|
||||
* specs can assert toggle behaviour without poking at Pixi
|
||||
* internals.
|
||||
*/
|
||||
isPrimitiveHidden(id: PrimitiveID): boolean;
|
||||
/**
|
||||
* setVisibilityFog draws (or removes) the Phase 29 visibility
|
||||
* fog overlay. Each entry describes a circle around a LOCAL
|
||||
* planet that the player has scanner / visibility coverage on;
|
||||
* the overlay fills the world rectangle with a slightly lighter
|
||||
* fog colour and "punches" each circle out, leaving the
|
||||
* intelligence-covered area in the regular background. Empty
|
||||
* input destroys the existing fog Graphics.
|
||||
*/
|
||||
setVisibilityFog(
|
||||
circles: ReadonlyArray<{ x: number; y: number; radius: number }>,
|
||||
): void;
|
||||
resize(widthPx: number, heightPx: number): void;
|
||||
dispose(): void;
|
||||
}
|
||||
@@ -173,6 +203,18 @@ const TORUS_OFFSETS: ReadonlyArray<readonly [number, number]> = [
|
||||
|
||||
const ORIGIN_COPY_INDEX = 4; // (0, 0) entry of TORUS_OFFSETS
|
||||
|
||||
// EMPTY_HIDDEN_IDS is the default state of the Phase 29 hide set
|
||||
// (no primitive is hidden). Shared by every renderer instance so a
|
||||
// frequent `setHiddenPrimitiveIds(EMPTY_HIDDEN_IDS)` call from the
|
||||
// debug surface stays allocation-free.
|
||||
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.
|
||||
const FOG_COLOR = 0x12162a;
|
||||
|
||||
export async function createRenderer(opts: RendererOptions): Promise<RendererHandle> {
|
||||
const theme = opts.theme ?? DARK_THEME;
|
||||
const preference = opts.preference ?? ["webgpu", "webgl"];
|
||||
@@ -225,6 +267,22 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
const allPrimitiveIds: PrimitiveID[] = [];
|
||||
const extraPrimitiveIds = new Set<PrimitiveID>();
|
||||
let currentWorld: World = opts.world;
|
||||
// hiddenIds is the Phase 29 hide-by-id snapshot. Empty by default;
|
||||
// every map-view effect run replaces it with the current
|
||||
// MapToggles-derived set via `setHiddenPrimitiveIds`. Both
|
||||
// renderer-internal hit-test sites (pointer-move, clicked) and the
|
||||
// external `handle.hitAt` thread it through `hitTest`.
|
||||
let hiddenIds: ReadonlySet<PrimitiveID> = EMPTY_HIDDEN_IDS;
|
||||
// Per-copy fog Graphics for the Phase 29 visibility fog overlay.
|
||||
// Created lazily when `setVisibilityFog` first receives a
|
||||
// non-empty list; cleared (and destroyed) when the list goes
|
||||
// empty again. Each fog Graphics is inserted at index 0 of its
|
||||
// torus copy so primitives paint on top.
|
||||
let fogGraphics: Graphics[] = [];
|
||||
const applyHiddenStateTo = (id: PrimitiveID, list: Graphics[]): void => {
|
||||
const visible = !hiddenIds.has(id);
|
||||
for (const g of list) g.visible = visible;
|
||||
};
|
||||
const populatePrimitives = (prim: Primitive, isExtra: boolean): void => {
|
||||
for (const c of copies) {
|
||||
const g = buildGraphics(prim, theme);
|
||||
@@ -239,6 +297,11 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
allPrimitiveIds.push(prim.id);
|
||||
if (prim.kind === "point") pointPrimitivesById.set(prim.id, prim);
|
||||
if (isExtra) extraPrimitiveIds.add(prim.id);
|
||||
// Fresh primitives honour the current hide set so cargo-route
|
||||
// or pending-Send extras pushed after `setHiddenPrimitiveIds`
|
||||
// inherit the right visibility.
|
||||
const list = primitiveGraphics.get(prim.id);
|
||||
if (list !== undefined) applyHiddenStateTo(prim.id, list);
|
||||
};
|
||||
for (const p of opts.world.primitives) {
|
||||
populatePrimitives(p, false);
|
||||
@@ -347,6 +410,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
handle.getViewport(),
|
||||
cursorPx,
|
||||
mode,
|
||||
hiddenIds,
|
||||
);
|
||||
const hoveredId = hit?.primitive.id ?? null;
|
||||
if (hoveredId === lastHoveredId) return;
|
||||
@@ -552,6 +616,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
handle.getViewport(),
|
||||
cursorPx,
|
||||
mode,
|
||||
hiddenIds,
|
||||
),
|
||||
setExtraPrimitives: (prims) => {
|
||||
// Drop the previous extras layer.
|
||||
@@ -629,6 +694,49 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
// torus tile), so the central-tile entry is representative.
|
||||
return list[Math.min(ORIGIN_COPY_INDEX, list.length - 1)]!.alpha;
|
||||
},
|
||||
setHiddenPrimitiveIds: (ids) => {
|
||||
// Snapshot the input so a later mutation by the caller does
|
||||
// not silently un-hide primitives on the next hit-test.
|
||||
hiddenIds = new Set(ids);
|
||||
for (const [id, list] of primitiveGraphics) {
|
||||
applyHiddenStateTo(id, list);
|
||||
}
|
||||
},
|
||||
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||
setVisibilityFog: (circles) => {
|
||||
if (circles.length === 0) {
|
||||
for (const g of fogGraphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
fogGraphics = [];
|
||||
return;
|
||||
}
|
||||
// Recreate the fog Graphics on every call. Pixi v8's
|
||||
// `Graphics.clear()` exists but reusing the same instance
|
||||
// with multiple `.cut()` operations across calls can
|
||||
// accumulate stale path state in our experience; a fresh
|
||||
// Graphics keeps the contract simple.
|
||||
for (const g of fogGraphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
fogGraphics = [];
|
||||
for (const copy of copies) {
|
||||
const g = new Graphics();
|
||||
g.rect(0, 0, opts.world.width, opts.world.height);
|
||||
g.fill({ color: FOG_COLOR, alpha: 1 });
|
||||
for (const c of circles) {
|
||||
g.circle(c.x, c.y, c.radius);
|
||||
g.cut();
|
||||
}
|
||||
// Fog sits below every primitive on the same copy so
|
||||
// planet glyphs paint on top. `addChildAt(g, 0)` keeps
|
||||
// the rest of the children's order intact.
|
||||
copy.addChildAt(g, 0);
|
||||
fogGraphics.push(g);
|
||||
}
|
||||
},
|
||||
resize: (w, h) => {
|
||||
app.renderer.resize(w, h);
|
||||
viewport.resize(w, h, opts.world.width, opts.world.height);
|
||||
@@ -651,6 +759,15 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
teardownPickMode();
|
||||
previous?.onPick(null);
|
||||
}
|
||||
// `app.destroy({...children: true})` below would also walk
|
||||
// fog graphics, but we drop them eagerly so the closure
|
||||
// reference clears even if a future caller queries the
|
||||
// renderer mid-dispose.
|
||||
for (const g of fogGraphics) {
|
||||
g.parent?.removeChild(g);
|
||||
g.destroy();
|
||||
}
|
||||
fogGraphics = [];
|
||||
viewport.off("moved", enforceCentreWhenLarger);
|
||||
viewport.off("moved", wrapTorusCamera);
|
||||
viewport.off("clicked", handleViewportClicked);
|
||||
|
||||
Reference in New Issue
Block a user