207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
import type { PlayerState } from "@deadhead/schema";
|
||
import { Application, Graphics, Text } from "pixi.js";
|
||
import { useEffect, useRef } from "react";
|
||
import { serverNow } from "../clock/clock";
|
||
import { pan, toScreen, useCamera, zoomAt, type Viewport } from "./camera";
|
||
import { hitTest, type MapSelection } from "./hitTest";
|
||
import { buildScene } from "./scene";
|
||
|
||
const COLORS = {
|
||
background: 0x020617,
|
||
orbit: 0x1e293b,
|
||
sun: 0xfbbf24,
|
||
discovered: 0xe2e8f0,
|
||
undiscovered: 0x475569,
|
||
bay: 0x34d399,
|
||
route: 0x38bdf8,
|
||
ship: 0xfbbf24,
|
||
};
|
||
|
||
/**
|
||
* The system map canvas (Steps 10.1–10.2). Draws locations and ships every
|
||
* frame from `state` and the server clock; drag to pan, pinch or wheel to zoom.
|
||
*/
|
||
export function MapCanvas({
|
||
state,
|
||
onSelect,
|
||
}: {
|
||
state: PlayerState;
|
||
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
|
||
onSelect?: (selection: MapSelection | null) => void;
|
||
}) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const stateRef = useRef(state);
|
||
stateRef.current = state;
|
||
const onSelectRef = useRef(onSelect);
|
||
onSelectRef.current = onSelect;
|
||
|
||
useEffect(() => {
|
||
const container = containerRef.current;
|
||
if (!container) return;
|
||
let cancelled = false;
|
||
const app = new Application();
|
||
const graphics = new Graphics();
|
||
const labels = new Map<string, Text>();
|
||
const badges = new Map<string, Text>();
|
||
|
||
const viewport = (): Viewport => ({
|
||
width: container.clientWidth,
|
||
height: container.clientHeight,
|
||
});
|
||
|
||
const draw = () => {
|
||
const scene = buildScene(stateRef.current, serverNow());
|
||
const camera = useCamera.getState();
|
||
const view = viewport();
|
||
const sun = toScreen({ x: 0, y: 0 }, view, camera);
|
||
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
|
||
|
||
graphics.clear();
|
||
for (const location of scene.locations) {
|
||
if (location.orbitRadius !== null) {
|
||
graphics
|
||
.circle(sun.x, sun.y, location.orbitRadius * unit)
|
||
.stroke({ width: 1, color: COLORS.orbit });
|
||
}
|
||
}
|
||
graphics.circle(sun.x, sun.y, 7).fill(COLORS.sun);
|
||
|
||
for (const location of scene.locations) {
|
||
const p = toScreen(location.point, view, camera);
|
||
const color = location.discovered ? COLORS.discovered : COLORS.undiscovered;
|
||
graphics.circle(p.x, p.y, 5).fill(color);
|
||
if (location.hasBay) graphics.circle(p.x, p.y, 9).stroke({ width: 2, color: COLORS.bay });
|
||
|
||
let label = labels.get(location.id);
|
||
if (!label) {
|
||
label = new Text({ text: location.name, style: { fontSize: 12, fill: color } });
|
||
labels.set(location.id, label);
|
||
app.stage.addChild(label);
|
||
}
|
||
// Undiscovered locations are locked until a delivery reaches them (Step 10A.16).
|
||
label.text = location.discovered ? location.name : `🔒 ${location.name}`;
|
||
label.style.fill = color;
|
||
label.position.set(p.x + 8, p.y - 7);
|
||
|
||
// Idle ships here: a small badge with the count (Step 10.2).
|
||
let badge = badges.get(location.id);
|
||
if (!badge) {
|
||
badge = new Text({ text: "", style: { fontSize: 10, fill: COLORS.background } });
|
||
badge.anchor.set(0.5);
|
||
badges.set(location.id, badge);
|
||
app.stage.addChild(badge);
|
||
}
|
||
badge.visible = location.idleShips > 0;
|
||
if (badge.visible) {
|
||
graphics.circle(p.x - 10, p.y + 10, 7).fill(COLORS.ship);
|
||
badge.text = String(location.idleShips);
|
||
badge.position.set(p.x - 10, p.y + 10);
|
||
}
|
||
}
|
||
|
||
// In-flight ships: the route line and a marker moving along it (Step 10.2).
|
||
for (const flight of scene.flights) {
|
||
const from = toScreen(flight.from, view, camera);
|
||
const to = toScreen(flight.to, view, camera);
|
||
const marker = toScreen(flight.marker, view, camera);
|
||
graphics
|
||
.moveTo(from.x, from.y)
|
||
.lineTo(to.x, to.y)
|
||
.stroke({ width: 1, color: COLORS.route });
|
||
graphics.circle(marker.x, marker.y, 4).fill(COLORS.ship);
|
||
}
|
||
};
|
||
|
||
void app
|
||
.init({
|
||
resizeTo: container,
|
||
background: COLORS.background,
|
||
antialias: true,
|
||
autoDensity: true,
|
||
resolution: window.devicePixelRatio,
|
||
})
|
||
.then(() => {
|
||
if (cancelled) {
|
||
app.destroy(true);
|
||
return;
|
||
}
|
||
container.appendChild(app.canvas);
|
||
app.stage.addChild(graphics);
|
||
app.ticker.add(draw);
|
||
})
|
||
.catch(() => {
|
||
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
||
});
|
||
|
||
// Pan with one pointer, pinch-zoom with two, wheel-zoom on desktop. A single
|
||
// pointer that barely moves is a tap, which selects what is under it.
|
||
const pointers = new Map<number, { x: number; y: number }>();
|
||
let tap: { id: number; start: { x: number; y: number }; moved: number } | null = null;
|
||
const local = (e: PointerEvent | WheelEvent) => {
|
||
const rect = container.getBoundingClientRect();
|
||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||
};
|
||
const onDown = (e: PointerEvent) => {
|
||
container.setPointerCapture(e.pointerId);
|
||
pointers.set(e.pointerId, local(e));
|
||
tap = pointers.size === 1 ? { id: e.pointerId, start: local(e), moved: 0 } : null;
|
||
};
|
||
const onMove = (e: PointerEvent) => {
|
||
const previous = pointers.get(e.pointerId);
|
||
if (!previous) return;
|
||
const current = local(e);
|
||
if (tap?.id === e.pointerId) {
|
||
tap.moved = Math.max(
|
||
tap.moved,
|
||
Math.hypot(current.x - tap.start.x, current.y - tap.start.y),
|
||
);
|
||
}
|
||
if (pointers.size === 1) {
|
||
useCamera.setState((c) => pan(c, current.x - previous.x, current.y - previous.y));
|
||
} else if (pointers.size === 2) {
|
||
const other = [...pointers.entries()].find(([id]) => id !== e.pointerId)![1];
|
||
const before = Math.hypot(previous.x - other.x, previous.y - other.y);
|
||
const after = Math.hypot(current.x - other.x, current.y - other.y);
|
||
const mid = { x: (current.x + other.x) / 2, y: (current.y + other.y) / 2 };
|
||
if (before > 0) useCamera.setState((c) => zoomAt(c, after / before, mid, viewport()));
|
||
}
|
||
pointers.set(e.pointerId, current);
|
||
};
|
||
const onUp = (e: PointerEvent) => {
|
||
pointers.delete(e.pointerId);
|
||
if (e.type === "pointerup" && tap?.id === e.pointerId && tap.moved < 8) {
|
||
const scene = buildScene(stateRef.current, serverNow());
|
||
onSelectRef.current?.(hitTest(scene, local(e), viewport(), useCamera.getState()));
|
||
}
|
||
if (tap?.id === e.pointerId) tap = null;
|
||
};
|
||
const onWheel = (e: WheelEvent) => {
|
||
e.preventDefault();
|
||
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));
|
||
};
|
||
container.addEventListener("pointerdown", onDown);
|
||
container.addEventListener("pointermove", onMove);
|
||
container.addEventListener("pointerup", onUp);
|
||
container.addEventListener("pointercancel", onUp);
|
||
container.addEventListener("wheel", onWheel, { passive: false });
|
||
|
||
return () => {
|
||
cancelled = true;
|
||
container.removeEventListener("pointerdown", onDown);
|
||
container.removeEventListener("pointermove", onMove);
|
||
container.removeEventListener("pointerup", onUp);
|
||
container.removeEventListener("pointercancel", onUp);
|
||
container.removeEventListener("wheel", onWheel);
|
||
if (app.renderer) app.destroy(true, { children: true });
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<div
|
||
ref={containerRef}
|
||
data-testid="map-canvas"
|
||
className="relative h-full w-full touch-none overflow-hidden"
|
||
/>
|
||
);
|
||
}
|