// PixiJS map renderer with pan/zoom, torus and no-wrap modes. // // Owns the Pixi `Application` lifecycle and a `pixi-viewport` instance // configured for the active wrap mode. Torus mode renders nine // container copies at offsets {-W, 0, W} × {-H, 0, H}, giving the // user a seamless toroidal world while panning past the edge — and // keeps the camera centre snapped into the central tile via a // `moved` listener so the fixed 3×3 layout is sufficient for any // distance of pan. No-wrap mode hides eight of the nine copies and // pins the camera with `pixi-viewport`'s `clamp` plugin plus a // `moved` listener that recentres the camera when the visible // viewport exceeds the world along an axis. Both modes share a // `clampZoom({ minScale })` so the world (origin copy) always fills // at least the viewport — without it torus mode would expose all // nine copies at once. // // Hit-test is owned by ./hit-test.ts; this file only exposes the // current camera and viewport so callers can run hits. import { Application, Container, Graphics, Ticker, UPDATE_PRIORITY, type Renderer, type RendererType, } from "pixi.js"; import { Viewport as PixiViewport } from "pixi-viewport"; import { hitTest, type Hit } from "./hit-test"; import { screenToWorld } from "./math"; import { minScaleNoWrap } from "./no-wrap"; import { computePickOverlay, PICK_OVERLAY_STYLE, type PickModeHandle, type PickModeOptions, } from "./pick-mode"; import { wrapCameraTorus } from "./torus"; import { DARK_THEME, DEFAULT_POINT_RADIUS_PX, World, type Camera, type CirclePrim, type LinePrim, type PointPrim, type Primitive, type PrimitiveID, type Theme, type Viewport, type WrapMode, } from "./world"; // RendererPreference matches Pixi's accepted values for backend // selection. The map renderer always restricts to webgpu/webgl. export type RendererPreference = "webgpu" | "webgl"; export interface RendererOptions { canvas: HTMLCanvasElement; world: World; mode: WrapMode; preference?: RendererPreference | RendererPreference[]; theme?: Theme; resolution?: number; // device pixel ratio override; defaults to window.devicePixelRatio } export interface RendererHandle { app: Application; viewport: PixiViewport; getMode(): WrapMode; setMode(mode: WrapMode): void; getCamera(): Camera; getViewport(): Viewport; getBackend(): "webgl" | "webgpu" | "canvas"; /** * getRenderCount returns how many frames the renderer has actually * painted since creation. The renderer runs render-on-demand (the * Pixi auto-render loop is stopped), so this counter only advances * when the camera moved or a content mutation requested a repaint — * never on idle frames. Exposed for the debug surface so e2e specs * can assert that an idle map does not keep repainting. */ getRenderCount(): number; hitAt(cursorPx: { x: number; y: number }): Hit | null; /** * setExtraPrimitives replaces the current overlay primitive layer * with `prims`. The base world (passed to `createRenderer`) is * preserved; only the extras layer changes. Used by the in-game * shell to project order-overlay-driven artefacts (Phase 16 * cargo-route arrows) onto the live renderer without disposing * and recreating the Pixi `Application` — which Pixi 8 does not * reliably support on the same canvas. * * Hit-test, `getPrimitives`, and pick mode all see the union of * base + extras after the call returns. Repeated calls * remount-replace the extras atomically. */ setExtraPrimitives(prims: readonly Primitive[]): void; /** * getPrimitives returns the live union of base + extras. The * order is base-first, extras-last (mirroring the draw order). * Reads stay in sync with `setExtraPrimitives`. */ getPrimitives(): readonly Primitive[]; /** * onClick subscribes `cb` to a click on the map (a pointer-down / * pointer-up pair without enough drag to trigger pan). The cursor * is reported in canvas pixel coordinates so callers can hand it * straight to `hitAt`. Returns a function that detaches the * listener; the returned disposer is idempotent. * * Built on `pixi-viewport`'s `clicked` event, which already * applies the same drag threshold the pan plugin uses, so a * click here will not race a pan gesture. */ onClick(cb: (cursorPx: { x: number; y: number }) => void): () => void; /** * onPointerMove subscribes `cb` to every pointer-move event on * the canvas. The callback receives the cursor in canvas-local * pixel coordinates so callers can hand it straight to `hitAt`. * Touch drags also emit pointer-move while a finger is pressed. * The returned function detaches the listener; idempotent. */ onPointerMove(cb: (cursorPx: { x: number; y: number }) => void): () => void; /** * onHoverChange subscribes `cb` to changes in the primitive * currently under the cursor. The callback fires only when the * id transitions (deduped) and is invoked with `null` when the * cursor moves into empty space. Driven by the same pointer-move * stream as `onPointerMove`, so subscribing to both does not * double-cost the pointer event. */ onHoverChange(cb: (id: PrimitiveID | null) => void): () => void; /** * setPickMode opens (or, with `null`, closes) a map-driven * destination pick. While a session is active the renderer dims * primitives outside `reachableIds`, mounts an overlay drawing * the source-anchor ring, the cursor line, and the * hover-highlight ring, suppresses regular `onClick` consumers, * and listens for Escape on `document`. The session resolves via * `opts.onPick(id)` on a click hitting a reachable planet, or * `opts.onPick(null)` on Escape / handle.cancel(). * * Returns the imperative cancel handle when a session was opened * (i.e. `opts !== null`), otherwise `null`. Calling the function * again with `null` closes any active session and is idempotent. */ setPickMode(opts: PickModeOptions | null): PickModeHandle | null; /** * isPickModeActive reports whether a `setPickMode` session is * currently open. The standard `onClick` path is suppressed * while this returns `true`. */ isPickModeActive(): boolean; /** * getPickState returns a defensive snapshot of the pick-mode * session for debugging surfaces. `sourcePrimitiveId` and * `reachableIds` are `null` while no session is open. */ getPickState(): { active: boolean; sourcePrimitiveId: PrimitiveID | null; reachableIds: ReadonlySet | null; hoveredId: PrimitiveID | null; }; /** * getPrimitiveAlpha returns the current rendered alpha of the * primitive `id` (in the central tile). Used by the debug * surface to report dimmed-state for e2e assertions. Returns 1 * 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): 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; } const TORUS_OFFSETS: ReadonlyArray = [ [-1, -1], [0, -1], [1, -1], [-1, 0], [0, 0], [1, 0], [-1, 1], [0, 1], [1, 1], ]; 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 = 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. export const FOG_COLOR = 0x12162a; /** * FogPaintOp is one item in the ordered draw sequence produced by * `fogPaintOps`. The renderer dispatches each op directly onto a * Pixi `Graphics`; the indirection exists so the Phase 29 layered * overpaint (fog rect then background-coloured circles on top) can * be unit-tested without a Pixi context. */ export type FogPaintOp = | { readonly kind: "fillRect"; readonly x: number; readonly y: number; readonly width: number; readonly height: number; readonly color: number; readonly alpha: number; } | { readonly kind: "fillCircle"; readonly x: number; readonly y: number; readonly radius: number; readonly color: number; readonly alpha: number; }; /** * fogPaintOps returns the ordered sequence of paint operations that * draw the Phase 29 visible-hyperspace overlay. The renderer * dispatches each op onto its own Pixi `Graphics` inside a single * `fogLayer` that sits below every primitive copy, so the natural * rendering order paints fog underneath the world. * * Coordinates are in world space (the `fogLayer` has no transform), * which means the wrap offsets are baked directly into the * positions — there is no per-tile dispatch on the renderer side. * * `mode` controls the torus-wrap behaviour: * * - `"torus"`: every fog rect AND every visibility circle is * emitted at the nine offsets (`(dx, dy) ∈ {-1, 0, 1}²`), so * the fog covers all nine torus tiles and a planet near a seam * keeps a continuous visibility hole across it. * - `"no-wrap"`: only the central tile is emitted. The user can * never pan past the boundary in no-wrap mode, so the * additional wraps would just be wasted paint — worse, a * wrapped circle from a planet near an edge would leak into * the visible world rectangle as an unwanted hole. * * Empty `circles` returns an empty list — the caller skips fog * rendering entirely. Width/height ≤ 0 also returns empty so a * degenerate world cannot produce a non-empty op set. */ export function fogPaintOps( world: { width: number; height: number }, circles: ReadonlyArray<{ x: number; y: number; radius: number }>, fogColor: number, bgColor: number, mode: WrapMode, ): FogPaintOp[] { if (circles.length === 0) return []; if (world.width <= 0 || world.height <= 0) return []; const offsets: ReadonlyArray = mode === "torus" ? TORUS_OFFSETS : ORIGIN_ONLY_OFFSET; const ops: FogPaintOp[] = []; for (const [dx, dy] of offsets) { ops.push({ kind: "fillRect", x: dx * world.width, y: dy * world.height, width: world.width, height: world.height, color: fogColor, alpha: 1, }); } for (const c of circles) { for (const [dx, dy] of offsets) { ops.push({ kind: "fillCircle", x: c.x + dx * world.width, y: c.y + dy * world.height, radius: c.radius, color: bgColor, alpha: 1, }); } } return ops; } const ORIGIN_ONLY_OFFSET: ReadonlyArray = [[0, 0]]; export async function createRenderer(opts: RendererOptions): Promise { const theme = opts.theme ?? DARK_THEME; const preference = opts.preference ?? ["webgpu", "webgl"]; const resolution = opts.resolution ?? globalThis.devicePixelRatio ?? 1; const canvas = opts.canvas; const widthPx = canvas.clientWidth || canvas.width || 800; const heightPx = canvas.clientHeight || canvas.height || 600; const app = new Application(); await app.init({ canvas, width: widthPx, height: heightPx, preference, backgroundColor: theme.background, backgroundAlpha: 1, antialias: true, autoDensity: true, resolution, }); // Render-on-demand: stop Pixi's continuous auto-render loop. Frames // are painted explicitly by `renderFlush` below, only when the // camera moved or a content mutation requested a repaint. This is // what stops the heavy visibility-fog overlay from re-rasterising // every frame and freezing the whole UI on large reports. app.stop(); const viewport = new PixiViewport({ screenWidth: widthPx, screenHeight: heightPx, worldWidth: opts.world.width, worldHeight: opts.world.height, events: app.renderer.events, }); // No `.decelerate()`: panning stops the instant the drag is // released instead of coasting. Besides matching the requested feel, // it means the viewport stops mutating its transform as soon as the // pointer is up, so render-on-demand goes idle immediately after a // drag rather than repainting through an inertia tail. viewport.drag().wheel({ smooth: 5 }).pinch(); // Render-on-demand wiring. `viewport.dirty` is maintained by // pixi-viewport's own `Ticker.shared` update and flips true on any // camera move (drag / wheel / pinch / programmatic `moveCenter` / // the torus + no-wrap `moved` listeners). `contentDirty` is flipped // by `requestRender` from every scene-graph mutation that does not // move the camera (fog, hide-set, extras, wrap mode, resize, pick // overlay). The flush runs at LOW priority so it observes the // viewport's freshly updated `dirty` flag within the same shared // tick. Plain hover mutates no Graphics, so it never repaints. let contentDirty = true; // force the first paint after mount let renderCount = 0; const requestRender = (): void => { contentDirty = true; }; const renderFlush = (): void => { if (!viewport.dirty && !contentDirty) return; app.render(); viewport.dirty = false; contentDirty = false; renderCount++; }; Ticker.shared.add(renderFlush, undefined, UPDATE_PRIORITY.LOW); app.stage.addChild(viewport); // Phase 29 fog layer: a single Container sharing the viewport's // coordinate space, populated by `setVisibilityFog`. Added to // the viewport BEFORE the nine torus copies so the layered // repaint (fog rectangles + background-coloured circles) always // renders underneath every primitive. An earlier per-copy // approach with `copy.addChildAt(fog, 0)` ended up with fog on // top in practice — moving the fog to a sibling of the copies // avoids any reorder ambiguity. const fogLayer = new Container(); viewport.addChild(fogLayer); // Create nine torus copies, each holding its own primitive // graphics. Origin copy is always visible; the other eight // follow the active wrap mode. const copies: Container[] = TORUS_OFFSETS.map(([dx, dy]) => { const c = new Container(); c.x = dx * opts.world.width; c.y = dy * opts.world.height; viewport.addChild(c); return c; }); // Per-id `Graphics` lookup. Each primitive lives in nine copies // (one per torus tile); pick-mode dims them by id, so the lookup // indexes the full set of `Graphics` instances per primitive id. const primitiveGraphics = new Map(); const pointPrimitivesById = new Map(); const allPrimitiveIds: PrimitiveID[] = []; const extraPrimitiveIds = new Set(); 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 = EMPTY_HIDDEN_IDS; // `fogLayer` (declared above) is repopulated every time // `setVisibilityFog` runs. We track the dispatched ops only // implicitly via the layer's children; on every flip we drop // the previous children and rebuild from the new op list. 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); c.addChild(g); let list = primitiveGraphics.get(prim.id); if (list === undefined) { list = []; primitiveGraphics.set(prim.id, list); } list.push(g); } 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); } let mode: WrapMode = opts.mode; const enforceCentreWhenLarger = (): void => { const halfW = viewport.screenWidth / (2 * viewport.scaled); const halfH = viewport.screenHeight / (2 * viewport.scaled); const overX = halfW * 2 >= opts.world.width; const overY = halfH * 2 >= opts.world.height; if (!overX && !overY) return; viewport.moveCenter( overX ? opts.world.width / 2 : viewport.center.x, overY ? opts.world.height / 2 : viewport.center.y, ); }; // Reentry guard for the torus wrap handler: `viewport.moveCenter` // fires the same `'moved'` event that triggered the wrap, so a // naive callback would loop forever. let wrappingCamera = false; const wrapTorusCamera = (): void => { if (mode !== "torus" || wrappingCamera) return; const wrapped = wrapCameraTorus( { centerX: viewport.center.x, centerY: viewport.center.y, scale: viewport.scaled, }, opts.world, ); if (wrapped.centerX === viewport.center.x && wrapped.centerY === viewport.center.y) { return; } wrappingCamera = true; try { viewport.moveCenter(wrapped.centerX, wrapped.centerY); } finally { wrappingCamera = false; } }; const applyMode = (newMode: WrapMode): void => { mode = newMode; for (let i = 0; i < copies.length; i++) { copies[i].visible = newMode === "torus" || i === ORIGIN_COPY_INDEX; } // Always reset clamp plugins; reattach per mode. viewport.plugins.remove("clamp"); viewport.plugins.remove("clamp-zoom"); viewport.off("moved", enforceCentreWhenLarger); viewport.off("moved", wrapTorusCamera); const minScale = minScaleNoWrap( { widthPx: viewport.screenWidth, heightPx: viewport.screenHeight }, opts.world, ); // Both modes enforce minScale on zoom-out: the world (origin // copy) always fills at least the viewport. Without this, in // torus mode the user would zoom out far enough to see the // 3×3 grid of wrap copies at once; the copies are there to // fill the partial slack near a panned edge, not to be // visible simultaneously. viewport.clampZoom({ minScale }); if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (newMode === "no-wrap") { viewport.clamp({ direction: "all" }); viewport.on("moved", enforceCentreWhenLarger); enforceCentreWhenLarger(); } else { // Torus mode keeps free pan: the user can drag in any // direction indefinitely. To keep the fixed 3×3 wrap // layout sufficient, snap the camera back into the // `[0, W) × [0, H)` central tile whenever it walks past // the edge — toroidal coordinates are equivalent modulo // world dimensions, so the user sees no jump. viewport.on("moved", wrapTorusCamera); wrapTorusCamera(); } // Toggling `copy.visible` does not move the camera, so request a // repaint explicitly; any camera change above also sets // `viewport.dirty`, which is harmless to request twice. requestRender(); }; applyMode(mode); // Pointer-move + hover plumbing. Listening on the underlying // canvas keeps the renderer agnostic of pixi-viewport's plugin // chain (drag/pinch can swallow Pixi-level pointer events while // a gesture is in progress; the DOM event still fires). const pointerMoveCallbacks = new Set< (cursorPx: { x: number; y: number }) => void >(); const hoverChangeCallbacks = new Set<(id: PrimitiveID | null) => void>(); let lastHoveredId: PrimitiveID | null = null; let lastCursorPx: { x: number; y: number } | null = null; const handlePointerMove = (event: PointerEvent): void => { const rect = canvas.getBoundingClientRect(); const cursorPx = { x: event.clientX - rect.left, y: event.clientY - rect.top, }; lastCursorPx = cursorPx; for (const cb of pointerMoveCallbacks) cb(cursorPx); const hit = hitTest( currentWorld, handle.getCamera(), handle.getViewport(), cursorPx, mode, hiddenIds, ); const hoveredId = hit?.primitive.id ?? null; if (hoveredId === lastHoveredId) return; lastHoveredId = hoveredId; for (const cb of hoverChangeCallbacks) cb(hoveredId); }; const handlePointerLeave = (): void => { lastCursorPx = null; if (hoverChangeCallbacks.size === 0 || lastHoveredId === null) return; lastHoveredId = null; for (const cb of hoverChangeCallbacks) cb(null); }; canvas.addEventListener("pointermove", handlePointerMove); canvas.addEventListener("pointerleave", handlePointerLeave); // Click dispatch. The renderer owns one `viewport.clicked` // listener and fans the event out to either the pick-mode // resolver (when a session is open) or the standard `onClick` // subscribers — never both. Routing through one listener makes // the gating race-proof: a pick-mode resolution + teardown runs // in the same tick as the click, and the standard subscribers // do not see the post-teardown state. const clickSubscribers = new Set< (cursorPx: { x: number; y: number }) => void >(); // Pick-mode state. Owned by the renderer so all callers funnel // through `setPickMode`; tests for the pure overlay math live in // `pick-mode.ts`. let pickModeActive = false; let pickOptions: PickModeOptions | null = null; let pickOverlay: Graphics | null = null; const dimmedAlphaBackup = new Map(); const dimmedTintBackup = new Map(); const detachPickListeners: Array<() => void> = []; const handleViewportClicked = (e: { screen: { x: number; y: number }; }): void => { const cursorPx = { x: e.screen.x, y: e.screen.y }; if (pickModeActive) { const session = pickOptions; if (session === null) return; const hit = hitTest( currentWorld, handle.getCamera(), handle.getViewport(), cursorPx, mode, ); const hitId = hit?.primitive.id ?? null; if (hitId === null) return; if (hitId === session.sourcePrimitiveId) return; if (!session.reachableIds.has(hitId)) return; const cb = session.onPick; teardownPickMode(); cb(hitId); return; } for (const cb of clickSubscribers) cb(cursorPx); }; viewport.on("clicked", handleViewportClicked); const redrawPickOverlay = (): void => { if (pickOverlay === null || pickOptions === null) return; const cursorWorld = lastCursorPx === null ? null : screenToWorld( lastCursorPx, handle.getCamera(), handle.getViewport(), ); const spec = computePickOverlay( pickOptions, cursorWorld, lastHoveredId, pointPrimitivesById, allPrimitiveIds, ); const g = pickOverlay; g.clear(); g.circle(spec.anchor.x, spec.anchor.y, spec.anchor.radius); g.stroke({ color: PICK_OVERLAY_STYLE.anchor.color, alpha: PICK_OVERLAY_STYLE.anchor.alpha, width: PICK_OVERLAY_STYLE.anchor.width, }); if (spec.line !== null) { g.moveTo(spec.line.x1, spec.line.y1); g.lineTo(spec.line.x2, spec.line.y2); g.stroke({ color: PICK_OVERLAY_STYLE.line.color, alpha: PICK_OVERLAY_STYLE.line.alpha, width: PICK_OVERLAY_STYLE.line.width, }); } if (spec.hoverOutline !== null) { g.circle( spec.hoverOutline.x, spec.hoverOutline.y, spec.hoverOutline.radius, ); g.stroke({ color: PICK_OVERLAY_STYLE.hover.color, alpha: PICK_OVERLAY_STYLE.hover.alpha, width: PICK_OVERLAY_STYLE.hover.width, }); } requestRender(); }; const teardownPickMode = (): void => { if (!pickModeActive) return; pickModeActive = false; for (const detach of detachPickListeners) detach(); detachPickListeners.length = 0; for (const [g, alpha] of dimmedAlphaBackup) g.alpha = alpha; dimmedAlphaBackup.clear(); for (const [g, tint] of dimmedTintBackup) g.tint = tint; dimmedTintBackup.clear(); if (pickOverlay !== null) { pickOverlay.destroy(); pickOverlay = null; } pickOptions = null; // Un-dimming primitives and removing the overlay are scene // changes that do not move the camera. requestRender(); }; const openPickMode = (options: PickModeOptions): PickModeHandle => { // An existing session is cancelled first so the previous // `onPick(null)` is delivered before the new one starts. if (pickModeActive) { const previous = pickOptions; teardownPickMode(); previous?.onPick(null); } pickOptions = options; pickModeActive = true; // Dim every primitive that's not the source and not reachable. for (const [id, list] of primitiveGraphics) { if (id === options.sourcePrimitiveId) continue; if (options.reachableIds.has(id)) continue; for (const g of list) { dimmedAlphaBackup.set(g, g.alpha); dimmedTintBackup.set(g, g.tint as number); g.alpha = PICK_OVERLAY_STYLE.dimAlpha; g.tint = PICK_OVERLAY_STYLE.dimTint; } } // Overlay graphic. Lives in the origin copy so the central // tile owns it; the camera always wraps back into this tile // (`wrapTorusCamera`), so the user sees the overlay // regardless of how far they have panned. pickOverlay = new Graphics(); copies[ORIGIN_COPY_INDEX]!.addChild(pickOverlay); redrawPickOverlay(); // Pointer-move drives the cursor line; hover changes drive // the outline. Both go through the renderer's existing // callback registries. detachPickListeners.push(handle.onPointerMove(redrawPickOverlay)); detachPickListeners.push(handle.onHoverChange(redrawPickOverlay)); // Click resolution is handled by the shared // `handleViewportClicked` dispatcher above; pick mode does // not subscribe its own `clicked` listener — see the // rationale in the dispatcher's comment. const keyHandler = (event: KeyboardEvent): void => { if (event.key !== "Escape") return; if (pickOptions === null) return; event.preventDefault(); const cb = pickOptions.onPick; teardownPickMode(); cb(null); }; document.addEventListener("keydown", keyHandler); detachPickListeners.push(() => document.removeEventListener("keydown", keyHandler), ); return { cancel: (): void => { if (pickOptions === null) return; const cb = pickOptions.onPick; teardownPickMode(); cb(null); }, }; }; const handle: RendererHandle = { app, viewport, getMode: () => mode, setMode: applyMode, getCamera: () => ({ centerX: viewport.center.x, centerY: viewport.center.y, scale: viewport.scaled, }), getViewport: () => ({ widthPx: viewport.screenWidth, heightPx: viewport.screenHeight, }), getBackend: () => rendererBackendName(app.renderer), getRenderCount: () => renderCount, hitAt: (cursorPx) => hitTest( currentWorld, handle.getCamera(), handle.getViewport(), cursorPx, mode, hiddenIds, ), setExtraPrimitives: (prims) => { // Drop the previous extras layer. for (const id of extraPrimitiveIds) { const list = primitiveGraphics.get(id); if (list !== undefined) { for (const g of list) { g.parent?.removeChild(g); g.destroy(); } primitiveGraphics.delete(id); } pointPrimitivesById.delete(id); const idx = allPrimitiveIds.indexOf(id); if (idx >= 0) allPrimitiveIds.splice(idx, 1); } extraPrimitiveIds.clear(); // Add the new extras. for (const p of prims) { populatePrimitives(p, true); } // Rebuild the snapshot World hit-test reads from. The // renderer keeps `currentWorld` mutable so the live // extras participate in click/hover tests on the same // frame they're drawn. currentWorld = new World(opts.world.width, opts.world.height, [ ...opts.world.primitives, ...prims, ]); requestRender(); }, getPrimitives: () => currentWorld.primitives, onClick: (cb) => { clickSubscribers.add(cb); return () => { clickSubscribers.delete(cb); }; }, onPointerMove: (cb) => { pointerMoveCallbacks.add(cb); return () => { pointerMoveCallbacks.delete(cb); }; }, onHoverChange: (cb) => { hoverChangeCallbacks.add(cb); // Fire the current state once so subscribers do not have to // wait for the next pointer movement to learn what's under // the cursor. cb(lastHoveredId); return () => { hoverChangeCallbacks.delete(cb); }; }, setPickMode: (options) => { if (options === null) { if (!pickModeActive) return null; const previous = pickOptions; teardownPickMode(); previous?.onPick(null); return null; } return openPickMode(options); }, isPickModeActive: () => pickModeActive, getPickState: () => ({ active: pickModeActive, sourcePrimitiveId: pickOptions?.sourcePrimitiveId ?? null, reachableIds: pickOptions?.reachableIds ?? null, hoveredId: lastHoveredId, }), getPrimitiveAlpha: (id) => { const list = primitiveGraphics.get(id); if (list === undefined || list.length === 0) return 1; // All copies share the same alpha (dim is applied to every // 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); } requestRender(); }, isPrimitiveHidden: (id) => hiddenIds.has(id), setVisibilityFog: (circles) => { // Drop the previous fog children — every flip rebuilds // from scratch instead of mutating in place. Pixi v8's // `Container.removeChildren()` returns the detached // children so we can destroy each one explicitly. for (const old of fogLayer.removeChildren()) { old.destroy({ children: true }); } // Repaint whether or not new fog is added: clearing the layer // (toggling the fog off) is itself a scene change. requestRender(); const ops = fogPaintOps( opts.world, circles, FOG_COLOR, theme.background, mode, ); if (ops.length === 0) return; // Each op gets its own Graphics so any multi-shape Pixi // quirks cannot drop a layer (an earlier all-in-one // implementation surfaced exactly that symptom in DEV — // only the last planet's glyph stayed visible inside the // bg holes). The ops carry world-space positions; the // `fogLayer` has no transform. for (const op of ops) { const g = new Graphics(); if (op.kind === "fillRect") { g.rect(op.x, op.y, op.width, op.height); } else { g.circle(op.x, op.y, op.radius); } g.fill({ color: op.color, alpha: op.alpha }); fogLayer.addChild(g); } }, resize: (w, h) => { app.renderer.resize(w, h); viewport.resize(w, h, opts.world.width, opts.world.height); const minScale = minScaleNoWrap({ widthPx: w, heightPx: h }, opts.world); viewport.plugins.remove("clamp-zoom"); viewport.clampZoom({ minScale }); if (viewport.scaled < minScale) viewport.setZoom(minScale, true); if (mode === "no-wrap") { enforceCentreWhenLarger(); } // The drawing buffer was resized; repaint at the new size. requestRender(); }, dispose: () => { // Detach the render-on-demand flush first so nothing tries // to paint a half-destroyed scene on the next shared tick. Ticker.shared.remove(renderFlush); // Tear down any open pick session before destroying the // app — the resolution callback might reference Svelte // stores that disappear next tick on dispose, but // `onPick(null)` here is a synchronous notification the // caller is responsible for handling. if (pickModeActive) { const previous = pickOptions; teardownPickMode(); previous?.onPick(null); } // `app.destroy({...children: true})` below recursively // destroys every container in the scene graph, fogLayer // included. The explicit removeChildren()/destroy here // drops the fog children eagerly so a future caller // querying the renderer mid-dispose does not see stale // fog instances still parented under the layer. for (const old of fogLayer.removeChildren()) { old.destroy({ children: true }); } viewport.off("moved", enforceCentreWhenLarger); viewport.off("moved", wrapTorusCamera); viewport.off("clicked", handleViewportClicked); canvas.removeEventListener("pointermove", handlePointerMove); canvas.removeEventListener("pointerleave", handlePointerLeave); pointerMoveCallbacks.clear(); hoverChangeCallbacks.clear(); clickSubscribers.clear(); app.destroy({ removeView: false }, { children: true }); }, }; return handle; } function rendererBackendName(r: Renderer): "webgl" | "webgpu" | "canvas" { const t = r.type as RendererType; // 1=WEBGL, 2=WEBGPU, 4=CANVAS per RendererType enum. if (t === 2) return "webgpu"; if (t === 4) return "canvas"; return "webgl"; } function buildGraphics(p: Primitive, theme: Theme): Graphics { const g = new Graphics(); if (p.kind === "point") drawPoint(g, p, theme); else if (p.kind === "circle") drawCircle(g, p, theme); else drawLine(g, p, theme); return g; } function drawPoint(g: Graphics, p: PointPrim, theme: Theme): void { const color = p.style.fillColor ?? theme.pointFill; const alpha = p.style.fillAlpha ?? 1; const radiusPx = p.style.pointRadiusPx ?? DEFAULT_POINT_RADIUS_PX; g.circle(p.x, p.y, radiusPx); g.fill({ color, alpha }); } function drawCircle(g: Graphics, p: CirclePrim, theme: Theme): void { g.circle(p.x, p.y, p.radius); if (p.style.fillColor !== undefined) { g.fill({ color: p.style.fillColor, alpha: p.style.fillAlpha ?? 1 }); } const strokeColor = p.style.strokeColor ?? theme.circleStroke; const strokeAlpha = p.style.strokeAlpha ?? 1; const strokeWidth = p.style.strokeWidthPx ?? 1; g.stroke({ color: strokeColor, alpha: strokeAlpha, width: strokeWidth }); } function drawLine(g: Graphics, p: LinePrim, theme: Theme): void { const color = p.style.strokeColor ?? theme.lineStroke; const alpha = p.style.strokeAlpha ?? 1; const width = p.style.strokeWidthPx ?? 1; const dash = p.style.strokeDashPx; if (dash === undefined || dash <= 0) { g.moveTo(p.x1, p.y1); g.lineTo(p.x2, p.y2); g.stroke({ color, alpha, width }); return; } // PixiJS v8 has no native dashed-line API; segment the path into // equal-length dashes (dash and gap both `dash` units). const dx = p.x2 - p.x1; const dy = p.y2 - p.y1; const length = Math.hypot(dx, dy); if (length === 0) return; const ux = dx / length; const uy = dy / length; const step = dash * 2; for (let t = 0; t < length; t += step) { const segEnd = Math.min(t + dash, length); g.moveTo(p.x1 + ux * t, p.y1 + uy * t); g.lineTo(p.x1 + ux * segEnd, p.y1 + uy * segEnd); } g.stroke({ color, alpha, width }); }