Step 10C.9: pause the map when hidden, cap resolution, pack crowds
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0aa8cee953
commit
1fa7441c9f
|
|
@ -741,3 +741,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **Ships keep the sim position on the route line** except when nudged; the route line itself is not moved.
|
||||
- **Highlight rings** (amber, 5 px outside the sprite) go on the selected ship, and on a selected location too, since "selection highlights" are in the CR's code-drawn list.
|
||||
- **Earth–Moon flights stay close to both bodies**: with the Moon at a fixed 46 px from Earth (decision 0.5.39), those routes are always short on screen. That is the intended trade-off.
|
||||
|
||||
## Step 10C.9
|
||||
|
||||
- **The ticker lifecycle is a small helper (`runWhileVisible`)** tested with a fake ticker and jsdom visibility events. The real `MapCanvas` cannot start PixiJS under jsdom and is mocked in component tests.
|
||||
- **Canvas resolution is capped at 2×.** Measuring in headless Chrome (software WebGL) showed frame time is set by pixel count, not our JavaScript: 58.5 fps at DPR 1 with the CPU throttled 4×, versus 11 fps at DPR 3. The art only has 2× variants, so a 3× canvas cost nine times the pixels of 1× for no visible gain; the cap doubled the rate to 22 fps there. A real phone rasterizes on its GPU, so the user's device check is the real verdict.
|
||||
- **Crowds of more than three ships use a hexagonal grid**, still 44 px apart, after the first busy screenshot showed ten simultaneous departures strung across 400 px of empty space. Up to three ships keep the perpendicular spread from Step 10C.8.
|
||||
- **Per-frame work stays allocation-light:** textures and Text objects are created once, Graphics layers are cleared and redrawn, and labels restyle only when their colour changes (restyling re-renders a text texture).
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { hitTest, type MapSelection } from "./hitTest";
|
|||
import { MapRenderer } from "./renderer";
|
||||
import { buildScene } from "./scene";
|
||||
import { mapStyle } from "./style";
|
||||
import { runWhileVisible } from "./ticker";
|
||||
|
||||
/**
|
||||
* The system map canvas (Steps 10.1–10.2, CR-003). Loads the map art, then
|
||||
|
|
@ -38,6 +39,7 @@ export function MapCanvas({
|
|||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
let cancelled = false;
|
||||
let stopTicker = () => {};
|
||||
const app = new Application();
|
||||
|
||||
const viewport = (): Viewport => ({
|
||||
|
|
@ -53,7 +55,9 @@ export function MapCanvas({
|
|||
background: mapStyle.colors.background,
|
||||
antialias: true,
|
||||
autoDensity: true,
|
||||
resolution: window.devicePixelRatio,
|
||||
// Capped at 2: the art has 2x variants at most, and 3x screens would
|
||||
// triple fill cost for no visible gain (Step 10C.9).
|
||||
resolution: Math.min(window.devicePixelRatio, 2),
|
||||
}),
|
||||
art,
|
||||
])
|
||||
|
|
@ -72,6 +76,8 @@ export function MapCanvas({
|
|||
selectedRef.current,
|
||||
),
|
||||
);
|
||||
// Only draw while the page is visible (Step 10C.9).
|
||||
stopTicker = runWhileVisible(app.ticker);
|
||||
})
|
||||
.catch(() => {
|
||||
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
||||
|
|
@ -134,6 +140,7 @@ export function MapCanvas({
|
|||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
stopTicker();
|
||||
container.removeEventListener("pointerdown", onDown);
|
||||
container.removeEventListener("pointermove", onMove);
|
||||
container.removeEventListener("pointerup", onUp);
|
||||
|
|
|
|||
|
|
@ -175,10 +175,31 @@ export function shipRotation(from: MapPoint, to: MapPoint): number {
|
|||
return Math.atan2(to.y - from.y, to.x - from.x) + Math.PI / 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* `count` points at least `gap` apart around (0, 0) on a hexagonal grid,
|
||||
* filled ring by ring: the most compact way to spread a crowd.
|
||||
*/
|
||||
export function hexSpiral(count: number, gap: number): MapPoint[] {
|
||||
const points: MapPoint[] = [{ x: 0, y: 0 }];
|
||||
const dirs = [0, 1, 2, 3, 4, 5].map((i) => polar(gap, 60 * i));
|
||||
for (let ring = 1; points.length < count; ring++) {
|
||||
// Start at the ring's corner, then walk its six sides.
|
||||
let p = { x: dirs[4]!.x * ring, y: dirs[4]!.y * ring };
|
||||
for (let side = 0; side < 6; side++) {
|
||||
for (let step = 0; step < ring; step++) {
|
||||
points.push(p);
|
||||
p = add(p, dirs[side]!);
|
||||
}
|
||||
}
|
||||
}
|
||||
return points.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudges ships that would be drawn within `shipSeparationPx` of each other
|
||||
* apart, perpendicular to the first one's route, `shipSeparationPx` apart, so
|
||||
* each stays visible and tappable (Step 10C.8).
|
||||
* apart so each stays visible and tappable (Step 10C.8): up to three spread
|
||||
* perpendicular to the first one's route, more on a compact hexagonal grid,
|
||||
* always `shipSeparationPx` apart.
|
||||
*/
|
||||
export function separateShips(flights: FlightLayout[], style: MapStyle = mapStyle): FlightLayout[] {
|
||||
const gap = style.shipSeparationPx;
|
||||
|
|
@ -201,6 +222,10 @@ export function separateShips(flights: FlightLayout[], style: MapStyle = mapStyl
|
|||
x: cluster.reduce((sum, f) => sum + f.center.x, 0) / cluster.length,
|
||||
y: cluster.reduce((sum, f) => sum + f.center.y, 0) / cluster.length,
|
||||
};
|
||||
if (cluster.length > 3) {
|
||||
const spots = hexSpiral(cluster.length, gap);
|
||||
return cluster.map((flight, i) => ({ ...flight, center: add(middle, spots[i]!) }));
|
||||
}
|
||||
return cluster.map((flight, i) => {
|
||||
const k = (i - (cluster.length - 1) / 2) * gap;
|
||||
return { ...flight, center: { x: middle.x + normal.x * k, y: middle.y + normal.y * k } };
|
||||
|
|
|
|||
|
|
@ -220,7 +220,9 @@ export class MapRenderer {
|
|||
text.anchor.set(0.5, 0);
|
||||
return text;
|
||||
});
|
||||
label.style.fill = state.locked ? colors.labelUndiscovered : colors.label;
|
||||
// Only on change: restyling a Text re-renders its texture (Step 10C.9).
|
||||
const fill = state.locked ? colors.labelUndiscovered : colors.label;
|
||||
if (label.style.fill !== fill) label.style.fill = fill;
|
||||
|
||||
// Idle ships here: a count badge just off the body's edge; never single sprites.
|
||||
const badge = this.text(this.badges, location.id, () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
/** The part of a PixiJS `Ticker` the map's lifecycle needs (a fake in tests). */
|
||||
export interface Startable {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `ticker` only while the page is visible (Step 10C.9): stops it when the
|
||||
* page is hidden and starts it again when it returns. The returned cleanup (for
|
||||
* unmounting the Map) stops it and removes the listener.
|
||||
*/
|
||||
export function runWhileVisible(ticker: Startable, doc: Document = document): () => void {
|
||||
const sync = () => (doc.visibilityState === "hidden" ? ticker.stop() : ticker.start());
|
||||
doc.addEventListener("visibilitychange", sync);
|
||||
sync();
|
||||
return () => {
|
||||
doc.removeEventListener("visibilitychange", sync);
|
||||
ticker.stop();
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { separateShips, shipRotation, type FlightLayout } from "../src/map/layout";
|
||||
import { hexSpiral, separateShips, shipRotation, type FlightLayout } from "../src/map/layout";
|
||||
import { mapStyle } from "../src/map/style";
|
||||
|
||||
const deg = (radians: number) => (radians * 180) / Math.PI;
|
||||
|
|
@ -40,4 +40,20 @@ describe("ships in flight (Step 10C.8)", () => {
|
|||
const apart = [flight("a", 20), flight("b", 180)];
|
||||
expect(separateShips(apart)).toEqual(apart);
|
||||
});
|
||||
|
||||
it("packs a crowd of ten compactly, every pair a tap target apart", () => {
|
||||
const crowd = Array.from({ length: 10 }, (_, i) => flight(`s${i}`));
|
||||
const points = separateShips(crowd).map((f) => f.center);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
for (let j = i + 1; j < points.length; j++) {
|
||||
const d = Math.hypot(points[i]!.x - points[j]!.x, points[i]!.y - points[j]!.y);
|
||||
expect(d).toBeGreaterThanOrEqual(mapStyle.minTapPx - 1e-9);
|
||||
}
|
||||
// Within two hex rings of where they were.
|
||||
expect(Math.hypot(points[i]!.x - 100, points[i]!.y - 100)).toBeLessThanOrEqual(
|
||||
2 * mapStyle.shipSeparationPx + 1e-9,
|
||||
);
|
||||
}
|
||||
expect(hexSpiral(19, 10)).toHaveLength(19);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWhileVisible } from "../src/map/ticker";
|
||||
|
||||
const setVisibility = (state: DocumentVisibilityState) => {
|
||||
Object.defineProperty(document, "visibilityState", { value: state, configurable: true });
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
};
|
||||
|
||||
describe("map ticker lifecycle (Step 10C.9)", () => {
|
||||
afterEach(() => setVisibility("visible"));
|
||||
|
||||
it("pauses when the page is hidden and resumes when it is visible", () => {
|
||||
const ticker = { start: vi.fn(), stop: vi.fn() };
|
||||
const cleanup = runWhileVisible(ticker);
|
||||
expect(ticker.start).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("hidden");
|
||||
expect(ticker.stop).toHaveBeenCalledTimes(1);
|
||||
setVisibility("visible");
|
||||
expect(ticker.start).toHaveBeenCalledTimes(2);
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("stops on unmount and ignores visibility changes afterwards", () => {
|
||||
const ticker = { start: vi.fn(), stop: vi.fn() };
|
||||
const cleanup = runWhileVisible(ticker);
|
||||
cleanup();
|
||||
expect(ticker.stop).toHaveBeenCalledTimes(1);
|
||||
|
||||
setVisibility("hidden");
|
||||
setVisibility("visible");
|
||||
expect(ticker.start).toHaveBeenCalledTimes(1);
|
||||
expect(ticker.stop).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 707 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 770 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -456,6 +456,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
|||
| `src/map/art/load.ts`, `art/visuals.ts` | Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41) |
|
||||
| `src/map/layout.ts` | Pure screen layout: `backgroundRect`, `spriteScale`, `childOffset`, `layoutScene` (centres, sizes, tap targets; used by renderer and hit test), `decorate` (label and badge placement) |
|
||||
| `src/map/overlays.ts` | Per-location state overlays (lock, desaturate/dim, HQ marker, idle badge), applied by the renderer |
|
||||
| `src/map/ticker.ts` | `runWhileVisible`: pause the map's ticker while the page is hidden; stop it on unmount |
|
||||
| `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state |
|
||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||
|
|
|
|||
|
|
@ -157,5 +157,5 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as
|
|||
- [x] **Step 10C.6 — Bodies, orbits, and labels.** `layoutScene` (map/layout.ts) gives every body, the Sun and each flight a screen centre, size (base × `spriteScale(zoom)` = zoom^0.35 clamped 0.75–1.75) and tap size (≥ 44 px); hit-testing uses it. The Moon is drawn 46 px from Earth at −30° from the Sun→Earth direction, with a faint ring around Earth; Moon routes start and end there. `decorate` places labels (bold, dark halo) below or above to avoid bodies and other labels, and idle badges on a clear diagonal. Jupiter's badge art is drawn on its edge when provided (not yet). Tests: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c6-bodies-{tutorial,midgame}-{desktop,phone}.png`.
|
||||
- [x] **Step 10C.7 — Location state overlays.** `map/overlays.ts` `locationOverlays(scene)` decides per location: undiscovered → desaturated (one shared `ColorMatrixFilter`), alpha 0.55 and a code-drawn padlock (replaces the 🔒 label of Step 10A.16; the tap panel is unchanged); bay → a small green house marker on the far side of the label; idle ships → count badge only. Test: `map-overlays.test.ts`. **Manual check:** `docs/screenshots/10c7-overlays-{tutorial,midgame}-{desktop,phone}.png`.
|
||||
- [x] **Step 10C.8 — Ships in flight.** Each flight draws its model's sprite rotated by `shipRotation` (art points up: right = 90°, down = 180°), positioned by sim progress every frame. Routes are faint end to end and brighter ahead of the ship. `separateShips` spreads ships closer than 44 px apart, perpendicular to their route and 44 px apart; hit-testing uses the same positions. The selected ship or location gets an amber ring (`MapCanvas` `selected` prop). Test: `map-ships.test.ts`. **Manual check:** `docs/screenshots/10c8-ships-midgame-{desktop,desktop-zoomed,phone}.png`.
|
||||
- [ ] Step 10C.9 — Performance
|
||||
- [x] **Step 10C.9 — Performance.** `map/ticker.ts` `runWhileVisible`: the PixiJS ticker stops when the page is hidden, restarts when visible, and stops on unmount. Canvas resolution capped at 2×. Labels restyle only on change. Big ship crowds (> 3) pack on a hexagonal grid (`hexSpiral`) instead of a long line. Tests: `map-ticker.test.ts`, crowd test in `map-ships.test.ts`. **Measured** (headless Chrome, software WebGL, 10 ships in flight, panning): 58.5 fps at DPR 1 even with CPU throttled 4×; 11 fps at DPR 3 before the 2× cap, 22 fps after — fill-rate bound in software rendering, not JavaScript. **Manual check for the user:** pan and zoom on a real mid-range phone (GPU rendering). Screenshots: `docs/screenshots/10c9-busy-busy-{desktop,desktop-zoomed,phone}.png`.
|
||||
- [ ] Step 10C.10 — Plan amendments and regression
|
||||
|
|
|
|||
Loading…
Reference in New Issue