Merge pull request 'fix(ui-map): render-on-demand + drop pan inertia (Safari fog freeze, stage 1)' (#21) from feature/ui-map-render-on-demand into development
This commit was merged in pull request #21.
This commit is contained in:
+20
-2
@@ -3235,8 +3235,9 @@ Targeted tests:
|
||||
- `tests/map-hit-test.test.ts` — `hitTest` honours the
|
||||
`hiddenIds` parameter;
|
||||
- `tests/e2e/map-toggles.spec.ts` — cascade, fog, wrap-mode
|
||||
camera preservation, reload persistence across the four
|
||||
Playwright projects.
|
||||
camera preservation, reload persistence, plus render-on-demand
|
||||
(an idle map does not repaint; a released drag does not coast)
|
||||
across the four Playwright projects.
|
||||
|
||||
Decisions:
|
||||
|
||||
@@ -3278,6 +3279,23 @@ Decisions:
|
||||
`FligthDistance`; the Phase 29 work renamed it to
|
||||
`FlightDistance` (and the only TS call site duplicates the
|
||||
formula directly, awaiting a future race-level WASM bridge).
|
||||
8. **Render-on-demand + no pan inertia (fog perf, stage 1).** The
|
||||
renderer originally kept Pixi's continuous auto-render loop, so
|
||||
the visibility fog's layered overpaint re-rasterised every frame
|
||||
and froze the whole UI on large reports in Safari (Pixi's WebGPU
|
||||
backend) — even while idle. The renderer now stops the auto-render
|
||||
loop (`app.stop()`) and paints on demand: a single `Ticker.shared`
|
||||
flush renders only when `viewport.dirty` (camera moved) or an
|
||||
internal `requestRender()` fires from a content mutation
|
||||
(`setVisibilityFog` / `setHiddenPrimitiveIds` /
|
||||
`setExtraPrimitives` / `applyMode` / `resize` / pick overlay);
|
||||
plain hover paints nothing. The `decelerate` (drag-inertia) plugin
|
||||
is removed so a released drag stops instantly and the viewport
|
||||
goes idle immediately. `RendererHandle.getRenderCount()` (mirrored
|
||||
on `__galaxyDebug` as `getMapRenderCount`) backs the e2e
|
||||
assertions. If Safari pan is still heavy after this, stage 2 cuts
|
||||
the overpaint itself (an inverse stencil mask of the circle union,
|
||||
kept vector so the map stays crisp at any zoom).
|
||||
|
||||
## Phase 30. Calculator Tab
|
||||
|
||||
|
||||
+72
-24
@@ -186,10 +186,12 @@ zoom. The math is symmetric and tested in
|
||||
cascades through the array and falls back to whichever backend
|
||||
initialises successfully.
|
||||
- **`pixi-viewport@^6`** — pan/zoom/pinch plugin layer over a
|
||||
Pixi `Container`. Provides drag inertia, mobile gestures, and
|
||||
the `clamp`/`clampZoom` plugins out of the box. We disable the
|
||||
Pixi `Container`. Provides drag, mobile gestures, and the
|
||||
`clamp`/`clampZoom` plugins out of the box. We disable the
|
||||
plugins we do not need (`bounce`, `snap`, `follow`,
|
||||
`mouse-edges`).
|
||||
`mouse-edges`) and deliberately omit `decelerate`: a released
|
||||
drag stops immediately instead of coasting, which also lets
|
||||
render-on-demand (below) go idle the moment the pointer is up.
|
||||
|
||||
No additional dependencies are necessary. The deprecated
|
||||
`pixi.js`-v7 era `pixi-viewport` v5 contracts have been replaced
|
||||
@@ -212,13 +214,45 @@ The selected backend is exposed via `[data-backend]` on the
|
||||
playground page header so the e2e spec can assert it without
|
||||
poking Pixi internals.
|
||||
|
||||
## Render-on-demand
|
||||
|
||||
Pixi's continuous auto-render loop is stopped right after
|
||||
`Application.init` (`app.stop()`). Frames are painted explicitly by
|
||||
a single gated flush added to `Ticker.shared` — the same ticker
|
||||
pixi-viewport already drives, so no second timer is created:
|
||||
|
||||
```ts
|
||||
if (viewport.dirty || contentDirty) { app.render(); /* reset both */ }
|
||||
```
|
||||
|
||||
- `viewport.dirty` is maintained by pixi-viewport's own update and
|
||||
covers every camera change (drag / wheel / pinch, the torus and
|
||||
no-wrap `moved` listeners, programmatic `moveCenter`).
|
||||
- `contentDirty` is set by an internal `requestRender()` from every
|
||||
scene-graph mutation that does not move the camera:
|
||||
`setVisibilityFog`, `setHiddenPrimitiveIds`, `setExtraPrimitives`,
|
||||
`applyMode`, `resize`, and the pick-mode overlay redraw.
|
||||
- Plain hover mutates no `Graphics`, so moving the cursor over the
|
||||
map paints nothing.
|
||||
|
||||
An idle map therefore does zero GPU work per frame. This matters
|
||||
for the visibility fog: its layered overpaint is fill-heavy, and a
|
||||
continuously re-rendered fog froze the whole UI on large reports in
|
||||
Safari (Pixi's WebGPU backend). `RendererHandle.getRenderCount()`
|
||||
exposes the painted-frame count; the `map-toggles` e2e spec asserts
|
||||
with it that an idle map does not repaint and that a released drag
|
||||
does not coast.
|
||||
|
||||
## Performance acceptance
|
||||
|
||||
The "60 fps with 1000 primitives" criterion is documented but
|
||||
manually verified, not asserted in CI. CI runners vary too much
|
||||
in CPU/GPU to make wall-clock fps reliable. Manual gate: open
|
||||
`/__debug/map`, drag continuously for 5 seconds, observe Pixi's
|
||||
ticker FPS in DevTools (Pixi exposes `app.ticker.FPS`).
|
||||
`/__debug/map`, drag continuously for 5 seconds, and watch the
|
||||
frame rate in the browser DevTools rendering meter (the app ticker
|
||||
is stopped under render-on-demand, so `app.ticker.FPS` no longer
|
||||
tracks paints — frames land via the `Ticker.shared` flush only
|
||||
while the camera is moving).
|
||||
|
||||
If a future regression requires a programmatic perf gate, the
|
||||
right place is a Tier 2 (release-line) Playwright trace measuring
|
||||
@@ -299,34 +333,40 @@ Phase 29 fog overlay used to highlight the player's visible
|
||||
hyperspace. Each entry describes a circle around a LOCAL planet
|
||||
where the player has scanner / visibility coverage:
|
||||
|
||||
- An empty list destroys the existing fog Graphics.
|
||||
- A non-empty list creates one fog `Graphics` per torus copy.
|
||||
Each draws a world-sized rectangle filled with `FOG_COLOR` (two
|
||||
shades lighter than the dark theme background), then paints an
|
||||
opaque background-coloured circle on top for every visibility
|
||||
circle. The overpaint order naturally unions overlapping circles
|
||||
— earlier iterations used Pixi v8's `Graphics.cut()` to subtract
|
||||
holes, but `cut()` produces incorrect unions for multiple
|
||||
overlapping holes; layered repainting trades one extra fill per
|
||||
circle for a predictable, geometry-free union.
|
||||
- The fog is inserted at the bottom of each copy's z-order so
|
||||
- An empty list destroys the existing fog `Graphics`.
|
||||
- A non-empty list rebuilds a single viewport-level `fogLayer` (a
|
||||
sibling that sits below the nine torus copies, not a child of
|
||||
them). `fogPaintOps` returns an ordered op list — one world-sized
|
||||
rectangle filled with `FOG_COLOR` (two shades lighter than the
|
||||
dark theme background), then an opaque background-coloured circle
|
||||
for every visibility circle — and the renderer dispatches each op
|
||||
onto its own `Graphics`. The overpaint order naturally unions
|
||||
overlapping circles — earlier iterations used Pixi v8's
|
||||
`Graphics.cut()` to subtract holes, but `cut()` produces incorrect
|
||||
unions for multiple overlapping holes; layered repainting trades
|
||||
one extra fill per circle for a predictable, geometry-free union.
|
||||
- The ops carry world-space positions, so wrap mode is baked into
|
||||
the op list rather than into copy visibility: `torus` emits the
|
||||
rectangle and every circle at the nine `{-1,0,1}²` tile offsets;
|
||||
`no-wrap` emits only the central tile. `fogLayer` has no transform.
|
||||
- The fog layer sits below every primitive copy in z-order, so
|
||||
primitives paint on top.
|
||||
- The fog never participates in hit-test. Planet glyphs sit on
|
||||
top of fog, so clicks on visible planets work unchanged.
|
||||
- Wrap mode is honoured for free — `applyMode` hides every
|
||||
non-origin copy in `no-wrap`, so the fog inherits the same
|
||||
behaviour because the fog Graphics is a child of each copy.
|
||||
|
||||
The map view recomputes the fog input only when the report or the
|
||||
`visibleHyperspace` toggle changes — per-frame cost stays at zero.
|
||||
`visibleHyperspace` toggle changes, and under render-on-demand a
|
||||
static fog paints no frames at all — the layered overpaint cost is
|
||||
only paid on the frames where the camera is actually moving.
|
||||
|
||||
## Debug surface
|
||||
|
||||
The DEV-only `__galaxyDebug` object (defined in
|
||||
`routes/__debug/store/+page.svelte`) exposes
|
||||
`getMapPrimitives()`, `getMapPickState()`, `getMapCamera()`, and
|
||||
`getMapFog()` so e2e specs can assert the renderer's current
|
||||
state without scraping pixels:
|
||||
`getMapPrimitives()`, `getMapPickState()`, `getMapCamera()`,
|
||||
`getMapFog()`, `getMapMode()`, and `getMapRenderCount()` so e2e
|
||||
specs can assert the renderer's current state without scraping
|
||||
pixels:
|
||||
|
||||
- `getMapPrimitives()` returns a snapshot of every primitive in
|
||||
the active world: id, kind, priority, current alpha
|
||||
@@ -342,10 +382,18 @@ state without scraping pixels:
|
||||
- `getMapFog()` returns the most recent fog input
|
||||
(the list of circles last passed to `setVisibilityFog`).
|
||||
Empty when the `visibleHyperspace` toggle is off.
|
||||
- `getMapMode()` returns the renderer's current `WrapMode`
|
||||
(`'torus'` or `'no-wrap'`), used to await the remount after a
|
||||
wrap-mode flip.
|
||||
- `getMapRenderCount()` returns the painted-frame count. Under
|
||||
render-on-demand it stays flat while the map is idle and advances
|
||||
only on camera moves or content mutations, so e2e specs can prove
|
||||
the idle map is not repainting.
|
||||
|
||||
The active map view registers providers on mount via
|
||||
`registerMapPrimitivesProvider` / `registerMapPickStateProvider`
|
||||
/ `registerMapCameraProvider` / `registerMapFogProvider` in
|
||||
/ `registerMapCameraProvider` / `registerMapFogProvider` /
|
||||
`registerMapModeProvider` / `registerMapRenderCountProvider` in
|
||||
`src/lib/debug-surface.svelte.ts`, deregisters on dispose, and
|
||||
the surface invokes them lazily on every read.
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ preference the store already manages.
|
||||
registerMapModeProvider,
|
||||
registerMapPickStateProvider,
|
||||
registerMapPrimitivesProvider,
|
||||
registerMapRenderCountProvider,
|
||||
type MapCameraSnapshot,
|
||||
type MapFogSnapshot,
|
||||
type MapPickStateSnapshot,
|
||||
@@ -536,12 +537,16 @@ preference the store already manages.
|
||||
const detachMode = registerMapModeProvider(() =>
|
||||
handle === null ? null : handle.getMode(),
|
||||
);
|
||||
const detachRenderCount = registerMapRenderCountProvider(() =>
|
||||
handle === null ? null : handle.getRenderCount(),
|
||||
);
|
||||
detachDebugProviders = (): void => {
|
||||
detachPrim();
|
||||
detachPick();
|
||||
detachCamera();
|
||||
detachFog();
|
||||
detachMode();
|
||||
detachRenderCount();
|
||||
};
|
||||
mountedTurn = report.turn;
|
||||
mountedGameId = targetGameId;
|
||||
|
||||
@@ -74,12 +74,14 @@ type PickStateProvider = () => MapPickStateSnapshot;
|
||||
type CameraProvider = () => MapCameraSnapshot | null;
|
||||
type FogProvider = () => MapFogSnapshot;
|
||||
type ModeProvider = () => WrapMode | null;
|
||||
type RenderCountProvider = () => number | null;
|
||||
|
||||
let primitivesProvider: PrimitivesProvider | null = null;
|
||||
let pickStateProvider: PickStateProvider | null = null;
|
||||
let cameraProvider: CameraProvider | null = null;
|
||||
let fogProvider: FogProvider | null = null;
|
||||
let modeProvider: ModeProvider | null = null;
|
||||
let renderCountProvider: RenderCountProvider | null = null;
|
||||
|
||||
/**
|
||||
* registerMapPrimitivesProvider attaches a provider that yields the
|
||||
@@ -152,6 +154,23 @@ export function registerMapModeProvider(provider: ModeProvider): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* registerMapRenderCountProvider attaches a provider that yields the
|
||||
* renderer's actual painted-frame count. Because the renderer runs
|
||||
* render-on-demand, the count stays flat while the map is idle and
|
||||
* only advances on camera moves or content mutations. e2e specs use
|
||||
* it to assert the idle map does not keep repainting. Same idempotent
|
||||
* semantics as the other providers.
|
||||
*/
|
||||
export function registerMapRenderCountProvider(
|
||||
provider: RenderCountProvider,
|
||||
): () => void {
|
||||
renderCountProvider = provider;
|
||||
return () => {
|
||||
if (renderCountProvider === provider) renderCountProvider = null;
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_PICK_STATE: MapPickStateSnapshot = {
|
||||
active: false,
|
||||
sourcePlanetNumber: null,
|
||||
@@ -191,6 +210,13 @@ export function getMapMode(): WrapMode | null {
|
||||
return modeProvider?.() ?? null;
|
||||
}
|
||||
|
||||
/** Pulls the renderer's painted-frame count. Returns `null` when no
|
||||
* map view is mounted. Stays constant on idle frames (render-on-demand)
|
||||
* and advances only on camera moves or content mutations. */
|
||||
export function getMapRenderCount(): number | null {
|
||||
return renderCountProvider?.() ?? null;
|
||||
}
|
||||
|
||||
interface RendererDebugWindow {
|
||||
__galaxyDebug?: {
|
||||
getMapPrimitives?: () => readonly MapPrimitiveSnapshot[];
|
||||
@@ -198,6 +224,7 @@ interface RendererDebugWindow {
|
||||
getMapCamera?: () => MapCameraSnapshot | null;
|
||||
getMapFog?: () => MapFogSnapshot;
|
||||
getMapMode?: () => WrapMode | null;
|
||||
getMapRenderCount?: () => number | null;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
@@ -222,6 +249,7 @@ export function installRendererDebugSurface(): () => void {
|
||||
getMapCamera,
|
||||
getMapFog,
|
||||
getMapMode,
|
||||
getMapRenderCount,
|
||||
};
|
||||
win.__galaxyDebug = surface;
|
||||
return (): void => {
|
||||
@@ -245,5 +273,8 @@ export function installRendererDebugSurface(): () => void {
|
||||
if (current.getMapMode === getMapMode) {
|
||||
delete current.getMapMode;
|
||||
}
|
||||
if (current.getMapRenderCount === getMapRenderCount) {
|
||||
delete current.getMapRenderCount;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,15 @@
|
||||
// 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, type Renderer, type RendererType } from "pixi.js";
|
||||
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";
|
||||
@@ -66,6 +74,15 @@ export interface RendererHandle {
|
||||
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
|
||||
@@ -329,6 +346,12 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
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,
|
||||
@@ -337,7 +360,35 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
worldHeight: opts.world.height,
|
||||
events: app.renderer.events,
|
||||
});
|
||||
viewport.drag().wheel({ smooth: 5 }).pinch().decelerate();
|
||||
// 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);
|
||||
|
||||
@@ -484,6 +535,10 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
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);
|
||||
@@ -621,6 +676,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
width: PICK_OVERLAY_STYLE.hover.width,
|
||||
});
|
||||
}
|
||||
requestRender();
|
||||
};
|
||||
const teardownPickMode = (): void => {
|
||||
if (!pickModeActive) return;
|
||||
@@ -636,6 +692,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
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
|
||||
@@ -711,6 +770,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
heightPx: viewport.screenHeight,
|
||||
}),
|
||||
getBackend: () => rendererBackendName(app.renderer),
|
||||
getRenderCount: () => renderCount,
|
||||
hitAt: (cursorPx) =>
|
||||
hitTest(
|
||||
currentWorld,
|
||||
@@ -748,6 +808,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
...opts.world.primitives,
|
||||
...prims,
|
||||
]);
|
||||
requestRender();
|
||||
},
|
||||
getPrimitives: () => currentWorld.primitives,
|
||||
onClick: (cb) => {
|
||||
@@ -803,6 +864,7 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
for (const [id, list] of primitiveGraphics) {
|
||||
applyHiddenStateTo(id, list);
|
||||
}
|
||||
requestRender();
|
||||
},
|
||||
isPrimitiveHidden: (id) => hiddenIds.has(id),
|
||||
setVisibilityFog: (circles) => {
|
||||
@@ -813,6 +875,9 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
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,
|
||||
@@ -848,8 +913,13 @@ export async function createRenderer(opts: RendererOptions): Promise<RendererHan
|
||||
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
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
// * `getMapFog()` — the current visibility-fog circle list.
|
||||
// * `getMapCamera()` — the wrap-mode test reads the centre before
|
||||
// and after the flip to confirm camera preservation.
|
||||
// * `getMapRenderCount()` — painted-frame counter used by the
|
||||
// render-on-demand specs at the bottom of this file: an idle map
|
||||
// must not keep repainting, and a released drag must not coast
|
||||
// (the `decelerate` plugin was removed).
|
||||
|
||||
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
@@ -400,3 +404,105 @@ test("toggle state persists across a page reload", async ({ page }) => {
|
||||
expect(await visibleHighBitCount(page, 0xa0000000)).toBe(0);
|
||||
expect(await visibleHighBitCount(page, 0xc0000000)).toBe(0);
|
||||
});
|
||||
|
||||
// settledRenderCount waits out the mount/resize paint burst and returns
|
||||
// the painted-frame count once it stops advancing. The renderer runs
|
||||
// render-on-demand, so the count goes flat as soon as the scene is
|
||||
// static; the loop bails after a fixed number of samples so a renderer
|
||||
// that never settles fails the spec instead of hanging.
|
||||
async function settledRenderCount(page: Page): Promise<number> {
|
||||
await page.waitForFunction(
|
||||
() => (window.__galaxyDebug?.getMapRenderCount?.() ?? 0) > 0,
|
||||
);
|
||||
return await page.evaluate(async () => {
|
||||
const read = (): number =>
|
||||
window.__galaxyDebug!.getMapRenderCount!() ?? 0;
|
||||
let prev = read();
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 150));
|
||||
const cur = read();
|
||||
if (cur === prev) return cur;
|
||||
prev = cur;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
|
||||
test("render-on-demand: an idle map does not repaint, a content mutation does", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockGateway(page, { currentTurn: 1 });
|
||||
await bootSession(page);
|
||||
await openGame(page);
|
||||
|
||||
const settled = await settledRenderCount(page);
|
||||
|
||||
// Idle window: no pointer interaction, no toggle. A continuous
|
||||
// auto-render loop would add ~40 frames over 700ms at 60fps; render
|
||||
// -on-demand adds none. The +2 slack tolerates a lone stray frame
|
||||
// (e.g. a late layout settle) while still failing hard if the
|
||||
// always-on loop ever comes back.
|
||||
await page.waitForTimeout(700);
|
||||
const afterIdle = await page.evaluate(
|
||||
() => window.__galaxyDebug!.getMapRenderCount!(),
|
||||
);
|
||||
expect(afterIdle).toBeLessThanOrEqual(settled + 2);
|
||||
|
||||
// Toggling the fog mutates the scene graph and must repaint.
|
||||
await page.getByTestId("map-toggles-trigger").click();
|
||||
await page.getByTestId("map-toggles-visible-hyperspace").click();
|
||||
await page.waitForFunction(
|
||||
() => window.__galaxyDebug!.getMapFog!().circles.length === 0,
|
||||
);
|
||||
// The repaint lands on the next shared-ticker frame after the fog
|
||||
// input changed, so poll for the counter to advance rather than
|
||||
// reading it synchronously (the timing of that frame is racy).
|
||||
await page.waitForFunction(
|
||||
(baseline) => window.__galaxyDebug!.getMapRenderCount!() > baseline,
|
||||
afterIdle,
|
||||
);
|
||||
});
|
||||
|
||||
test("pan stops immediately on release: no inertia tail after a drag", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockGateway(page, { currentTurn: 1 });
|
||||
await bootSession(page);
|
||||
await openGame(page);
|
||||
|
||||
await settledRenderCount(page);
|
||||
|
||||
const canvas = page.getByTestId("active-view-map").locator("canvas");
|
||||
const box = await canvas.boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
if (box === null) return;
|
||||
const cx = box.x + box.width / 2;
|
||||
const cy = box.y + box.height / 2;
|
||||
|
||||
// Decisive drag with intermediate steps so pixi-viewport's drag
|
||||
// plugin clears its movement threshold.
|
||||
await page.mouse.move(cx, cy);
|
||||
await page.mouse.down();
|
||||
for (let step = 1; step <= 16; step++) {
|
||||
await page.mouse.move(cx - (160 * step) / 16, cy - (120 * step) / 16);
|
||||
}
|
||||
await page.mouse.up();
|
||||
|
||||
// Let the final drag frame flush, then snapshot the camera centre
|
||||
// and confirm it does not drift over the next ~500ms. Without the
|
||||
// `decelerate` plugin the viewport freezes the instant the drag
|
||||
// ends, so the centre is identical; a re-introduced inertia tail
|
||||
// would coast it by many world units. (If the synthetic drag never
|
||||
// registered the centre is also static, so the spec never
|
||||
// false-fails — it only catches a returning inertia tail.)
|
||||
await page.waitForTimeout(120);
|
||||
const atRelease = await page.evaluate(
|
||||
() => window.__galaxyDebug!.getMapCamera!()!.camera,
|
||||
);
|
||||
await page.waitForTimeout(500);
|
||||
const later = await page.evaluate(
|
||||
() => window.__galaxyDebug!.getMapCamera!()!.camera,
|
||||
);
|
||||
expect(Math.abs(later.centerX - atRelease.centerX)).toBeLessThan(1);
|
||||
expect(Math.abs(later.centerY - atRelease.centerY)).toBeLessThan(1);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ interface DebugSurface {
|
||||
getMapCamera(): MapCameraSnapshot | null;
|
||||
getMapFog(): MapFogSnapshot;
|
||||
getMapMode(): WrapMode | null;
|
||||
getMapRenderCount(): number;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
Reference in New Issue
Block a user