deadhead-space-freight-tycoon/apps/web/src/map/MapCanvas.tsx

207 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
/>
);
}