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(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(); const badges = new Map(); 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(); 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 (
); }