Step 10.3: add map interactions
CI / check (push) Successful in 2m25s
Details
CI / check (push) Successful in 2m25s
Details
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ebbc97bea5
commit
a28fe5c5cb
|
|
@ -468,3 +468,9 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
|
|
||||||
- **Route lines run from the origin's position at departure to the destination's position at arrival**, matching sim's straight-line flight model (Step 3.15). The marker is interpolated in map space by sim `dispatchProgress`; because the camera transform is affine, it stays exactly on the drawn line at any zoom or pan.
|
- **Route lines run from the origin's position at departure to the destination's position at arrival**, matching sim's straight-line flight model (Step 3.15). The marker is interpolated in map space by sim `dispatchProgress`; because the camera transform is affine, it stays exactly on the drawn line at any zoom or pan.
|
||||||
- **Idle ships are a count badge per location** rather than individual markers, so a busy bay stays readable on a phone. The idle tutorial loaner counts as one.
|
- **Idle ships are a count badge per location** rather than individual markers, so a busy bay stays readable on a phone. The idle tutorial loaner counts as one.
|
||||||
|
|
||||||
|
## Step 10.3
|
||||||
|
|
||||||
|
- **Taps are detected in the canvas's own pointer handling:** one pointer, moved less than 8 px, then released. Hit-testing is a pure function over the same scene the canvas draws, with a 22 px reach (about a fingertip); ship markers win close calls because they are drawn on top. Tapping empty space closes the panel.
|
||||||
|
- **Panels are React components over the canvas**, so they work like the rest of the UI. The location panel's board link uses `/contracts?bay=<id>`; the Contracts screen now preselects the bay from that parameter.
|
||||||
|
- **Component tests "tap" through the canvas test double**, which renders one button per tappable item from the real `buildScene` and calls the same `onSelect`. Hit-testing itself is unit-tested with screen coordinates.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Application, Graphics, Text } from "pixi.js";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { serverNow } from "../clock/clock";
|
import { serverNow } from "../clock/clock";
|
||||||
import { pan, toScreen, useCamera, zoomAt, type Viewport } from "./camera";
|
import { pan, toScreen, useCamera, zoomAt, type Viewport } from "./camera";
|
||||||
|
import { hitTest, type MapSelection } from "./hitTest";
|
||||||
import { buildScene } from "./scene";
|
import { buildScene } from "./scene";
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
|
|
@ -20,10 +21,19 @@ const COLORS = {
|
||||||
* The system map canvas (Steps 10.1–10.2). Draws locations and ships every
|
* 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.
|
* frame from `state` and the server clock; drag to pan, pinch or wheel to zoom.
|
||||||
*/
|
*/
|
||||||
export function MapCanvas({ state }: { state: PlayerState }) {
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const stateRef = useRef(state);
|
const stateRef = useRef(state);
|
||||||
stateRef.current = state;
|
stateRef.current = state;
|
||||||
|
const onSelectRef = useRef(onSelect);
|
||||||
|
onSelectRef.current = onSelect;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
|
|
@ -122,8 +132,10 @@ export function MapCanvas({ state }: { state: PlayerState }) {
|
||||||
// No WebGL/WebGPU (e.g. very old browsers, tests): the map stays blank.
|
// 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.
|
// 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 }>();
|
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 local = (e: PointerEvent | WheelEvent) => {
|
||||||
const rect = container.getBoundingClientRect();
|
const rect = container.getBoundingClientRect();
|
||||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
|
|
@ -131,11 +143,18 @@ export function MapCanvas({ state }: { state: PlayerState }) {
|
||||||
const onDown = (e: PointerEvent) => {
|
const onDown = (e: PointerEvent) => {
|
||||||
container.setPointerCapture(e.pointerId);
|
container.setPointerCapture(e.pointerId);
|
||||||
pointers.set(e.pointerId, local(e));
|
pointers.set(e.pointerId, local(e));
|
||||||
|
tap = pointers.size === 1 ? { id: e.pointerId, start: local(e), moved: 0 } : null;
|
||||||
};
|
};
|
||||||
const onMove = (e: PointerEvent) => {
|
const onMove = (e: PointerEvent) => {
|
||||||
const previous = pointers.get(e.pointerId);
|
const previous = pointers.get(e.pointerId);
|
||||||
if (!previous) return;
|
if (!previous) return;
|
||||||
const current = local(e);
|
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) {
|
if (pointers.size === 1) {
|
||||||
useCamera.setState((c) => pan(c, current.x - previous.x, current.y - previous.y));
|
useCamera.setState((c) => pan(c, current.x - previous.x, current.y - previous.y));
|
||||||
} else if (pointers.size === 2) {
|
} else if (pointers.size === 2) {
|
||||||
|
|
@ -147,7 +166,14 @@ export function MapCanvas({ state }: { state: PlayerState }) {
|
||||||
}
|
}
|
||||||
pointers.set(e.pointerId, current);
|
pointers.set(e.pointerId, current);
|
||||||
};
|
};
|
||||||
const onUp = (e: PointerEvent) => pointers.delete(e.pointerId);
|
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) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));
|
useCamera.setState((c) => zoomAt(c, Math.exp(-e.deltaY * 0.001), local(e), viewport()));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { toScreen, type Camera, type Viewport } from "./camera";
|
||||||
|
import type { MapPoint } from "./projection";
|
||||||
|
import type { Scene } from "./scene";
|
||||||
|
|
||||||
|
export type MapSelection =
|
||||||
|
{ kind: "location"; id: string } | { kind: "flight"; dispatchId: string };
|
||||||
|
|
||||||
|
/** How close (screen pixels) a tap must be to a location or ship: about a fingertip. */
|
||||||
|
export const TAP_RADIUS = 22;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a tap at `tap` (screen pixels) selects: the nearest ship marker or
|
||||||
|
* location within reach. Ships win ties, since they are drawn on top.
|
||||||
|
*/
|
||||||
|
export function hitTest(
|
||||||
|
scene: Scene,
|
||||||
|
tap: MapPoint,
|
||||||
|
viewport: Viewport,
|
||||||
|
camera: Camera,
|
||||||
|
): MapSelection | null {
|
||||||
|
let best: { selection: MapSelection; distance: number } | null = null;
|
||||||
|
const consider = (point: MapPoint, selection: MapSelection, bias: number) => {
|
||||||
|
const p = toScreen(point, viewport, camera);
|
||||||
|
const distance = Math.hypot(p.x - tap.x, p.y - tap.y) - bias;
|
||||||
|
if (distance <= TAP_RADIUS && (!best || distance < best.distance)) {
|
||||||
|
best = { selection, distance };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const location of scene.locations) {
|
||||||
|
consider(location.point, { kind: "location", id: location.id }, 0);
|
||||||
|
}
|
||||||
|
for (const flight of scene.flights) {
|
||||||
|
consider(flight.marker, { kind: "flight", dispatchId: flight.dispatchId }, 4);
|
||||||
|
}
|
||||||
|
return (best as { selection: MapSelection } | null)?.selection ?? null;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { Contract } from "@deadhead/schema";
|
import type { Contract } from "@deadhead/schema";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router";
|
||||||
import { useApi } from "../api/context";
|
import { useApi } from "../api/context";
|
||||||
import { useClock } from "../clock/clock";
|
import { useClock } from "../clock/clock";
|
||||||
import { Countdown } from "../components/Countdown";
|
import { Countdown } from "../components/Countdown";
|
||||||
|
|
@ -24,7 +25,9 @@ export function requirementsText(contract: Contract): string | null {
|
||||||
/** Contract boards for the player's bays (Step 9.4). */
|
/** Contract boards for the player's bays (Step 9.4). */
|
||||||
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
export function BayBoards({ onSelect }: { onSelect?: (contract: Contract) => void }) {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
const [selected, setSelected] = useState(state.bays[0]?.locationId);
|
// `?bay=moon` (e.g. from the map) preselects that bay.
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const [selected, setSelected] = useState(params.get("bay") ?? state.bays[0]?.locationId);
|
||||||
const bayId = state.bays.some((b) => b.locationId === selected)
|
const bayId = state.bays.some((b) => b.locationId === selected)
|
||||||
? selected
|
? selected
|
||||||
: state.bays[0]?.locationId;
|
: state.bays[0]?.locationId;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,33 @@
|
||||||
|
import { content, type Location } from "@deadhead/content";
|
||||||
|
import { realToGame } from "@deadhead/sim";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { Link } from "react-router";
|
||||||
|
import { useClock } from "../clock/clock";
|
||||||
|
import { formatGameTime } from "../clock/GameClock";
|
||||||
|
import { Countdown } from "../components/Countdown";
|
||||||
|
import { cargoName, formatMillionKm, locationName } from "../format";
|
||||||
|
import { distanceAt } from "../game";
|
||||||
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
import { DEFAULT_CAMERA, useCamera } from "../map/camera";
|
||||||
|
import type { MapSelection } from "../map/hitTest";
|
||||||
import { MapCanvas } from "../map/MapCanvas";
|
import { MapCanvas } from "../map/MapCanvas";
|
||||||
import { useLoadedPlayer } from "../state/actions";
|
import { useLoadedPlayer } from "../state/actions";
|
||||||
|
|
||||||
/** The solar system map (Phase 10). */
|
const TYPE_LABELS: Record<Location["type"], string> = {
|
||||||
|
"atmospheric-world": "Atmospheric world",
|
||||||
|
"airless-body": "Airless body",
|
||||||
|
"cloud-platform": "Cloud platform",
|
||||||
|
"belt-station": "Belt station",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The solar system map (Phase 10). Tap a location or ship for details. */
|
||||||
export function MapScreen() {
|
export function MapScreen() {
|
||||||
const state = useLoadedPlayer();
|
const state = useLoadedPlayer();
|
||||||
|
const [selection, setSelection] = useState<MapSelection | null>(null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="relative h-[calc(100dvh-7.5rem)]">
|
<section className="relative h-[calc(100dvh-7.5rem)]">
|
||||||
<h1 className="sr-only">Map</h1>
|
<h1 className="sr-only">Map</h1>
|
||||||
<MapCanvas state={state} />
|
<MapCanvas state={state} onSelect={setSelection} />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
||||||
|
|
@ -16,6 +35,102 @@ export function MapScreen() {
|
||||||
>
|
>
|
||||||
Reset view
|
Reset view
|
||||||
</button>
|
</button>
|
||||||
|
{selection?.kind === "location" && (
|
||||||
|
<LocationPanel locationId={selection.id} onClose={() => setSelection(null)} />
|
||||||
|
)}
|
||||||
|
{selection?.kind === "flight" && (
|
||||||
|
<FlightPanel dispatchId={selection.dispatchId} onClose={() => setSelection(null)} />
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Panel({
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
onClose: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-label={title}
|
||||||
|
className="absolute inset-x-2 bottom-2 rounded-xl border border-slate-700 bg-slate-900/95 p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
|
<h2 className="font-semibold">{title}</h2>
|
||||||
|
<button type="button" onClick={onClose} aria-label="Close" className="px-2 text-slate-400">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tapped location: details if discovered, and a link to its board if there is a bay. */
|
||||||
|
function LocationPanel({ locationId, onClose }: { locationId: string; onClose: () => void }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const now = useClock((s) => s.now);
|
||||||
|
const location = content.location(locationId)!;
|
||||||
|
const discovered = state.locations.some((l) => l.id === locationId && l.discovered);
|
||||||
|
const hasBay = state.bays.some((b) => b.locationId === locationId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={location.name} onClose={onClose}>
|
||||||
|
{discovered ? (
|
||||||
|
<>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
{TYPE_LABELS[location.type]} ·{" "}
|
||||||
|
{location.id === "earth"
|
||||||
|
? "Home"
|
||||||
|
: `${formatMillionKm(distanceAt("earth", location.id, now))} from Earth`}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
Produces {location.produces.map(cargoName).join(", ") || "nothing"} · demands{" "}
|
||||||
|
{location.demands.map(cargoName).join(", ") || "nothing"}
|
||||||
|
</p>
|
||||||
|
{hasBay ? (
|
||||||
|
<Link
|
||||||
|
to={`/contracts?bay=${location.id}`}
|
||||||
|
className="mt-2 inline-block font-medium text-amber-400 underline"
|
||||||
|
>
|
||||||
|
View contract board
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-slate-400">No bay here yet.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-slate-400">Undiscovered — deliver here to unlock.</p>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A tapped ship in flight: its route, pilot and ETA. */
|
||||||
|
function FlightPanel({ dispatchId, onClose }: { dispatchId: string; onClose: () => void }) {
|
||||||
|
const state = useLoadedPlayer();
|
||||||
|
const dispatch = state.activeDispatches.find((d) => d.id === dispatchId);
|
||||||
|
if (!dispatch) return null;
|
||||||
|
const shipName = dispatch.tutorial
|
||||||
|
? "Company loaner"
|
||||||
|
: (state.ships.find((s) => s.id === dispatch.shipId)?.name ?? "Ship");
|
||||||
|
const pilotName = state.pilots.find((p) => p.id === dispatch.pilotId)?.name ?? "Unknown pilot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel title={shipName} onClose={onClose}>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
{locationName(dispatch.originId)} → {locationName(dispatch.destinationId)}
|
||||||
|
</p>
|
||||||
|
<p className="text-slate-400">Pilot: {pilotName}</p>
|
||||||
|
<p className="text-slate-400">
|
||||||
|
Arrives in <Countdown to={dispatch.arrivalAt} /> (
|
||||||
|
{formatGameTime(realToGame(dispatch.arrivalAt, content.config))})
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,38 @@
|
||||||
import type { PlayerState } from "@deadhead/schema";
|
import type { PlayerState } from "@deadhead/schema";
|
||||||
|
import type { MapSelection } from "../src/map/hitTest";
|
||||||
|
import { buildScene } from "../src/map/scene";
|
||||||
|
|
||||||
/** Test stand-in for the PixiJS map canvas. */
|
/**
|
||||||
export function MapCanvas(_props: { state: PlayerState }) {
|
* Test stand-in for the PixiJS map canvas: one button per tappable thing in
|
||||||
return <div data-testid="map-canvas" />;
|
* the real scene, calling `onSelect` with the same selection a tap would.
|
||||||
|
*/
|
||||||
|
export function MapCanvas({
|
||||||
|
state,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
state: PlayerState;
|
||||||
|
onSelect?: (selection: MapSelection | null) => void;
|
||||||
|
}) {
|
||||||
|
const scene = buildScene(state, Date.now());
|
||||||
|
return (
|
||||||
|
<div data-testid="map-canvas">
|
||||||
|
{scene.locations.map((l) => (
|
||||||
|
<button key={l.id} type="button" onClick={() => onSelect?.({ kind: "location", id: l.id })}>
|
||||||
|
Tap {l.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{scene.flights.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.dispatchId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect?.({ kind: "flight", dispatchId: f.dispatchId })}
|
||||||
|
>
|
||||||
|
Tap ship {f.shipId}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={() => onSelect?.(null)}>
|
||||||
|
Tap empty space
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
||||||
|
import { screen, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useClock } from "../src/clock/clock";
|
||||||
|
import { DEFAULT_CAMERA, toScreen } from "../src/map/camera";
|
||||||
|
import { hitTest } from "../src/map/hitTest";
|
||||||
|
import { buildScene } from "../src/map/scene";
|
||||||
|
import { fakeApi, renderApp, samplePlayerState } from "./render";
|
||||||
|
|
||||||
|
const NOW = Date.parse("2100-01-01T00:00:00Z");
|
||||||
|
|
||||||
|
const flight: Dispatch = {
|
||||||
|
id: "d1",
|
||||||
|
shipId: "s1",
|
||||||
|
pilotId: "p-rae",
|
||||||
|
contract: null,
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "mars",
|
||||||
|
tutorial: false,
|
||||||
|
departureAt: NOW - 3_600_000,
|
||||||
|
arrivalAt: NOW + 5 * 60_000,
|
||||||
|
distanceMillionKm: 100,
|
||||||
|
fuelCost: 0,
|
||||||
|
expectedPayout: 0,
|
||||||
|
seed: 1,
|
||||||
|
status: "in-flight",
|
||||||
|
resolvedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapState(): PlayerState {
|
||||||
|
const base = samplePlayerState({ tutorial: null });
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
bays: [{ id: "b1", locationId: "moon" }],
|
||||||
|
ships: [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
modelId: "freighter",
|
||||||
|
name: "Slow Ox",
|
||||||
|
status: "in-flight",
|
||||||
|
locationId: null,
|
||||||
|
dispatchId: "d1",
|
||||||
|
pilotId: "p-rae",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
pilots: [
|
||||||
|
...base.pilots,
|
||||||
|
{
|
||||||
|
...base.pilots[0]!,
|
||||||
|
id: "p-rae",
|
||||||
|
name: "Rae Okafor",
|
||||||
|
isAvatar: false,
|
||||||
|
assignedShipId: "s1",
|
||||||
|
status: "in-flight",
|
||||||
|
locationId: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeDispatches: [flight],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("map hit-testing", () => {
|
||||||
|
const viewport = { width: 375, height: 690 };
|
||||||
|
|
||||||
|
it("selects the location under a tap, and nothing on empty space", () => {
|
||||||
|
const scene = buildScene(mapState(), NOW);
|
||||||
|
// Ceres: nothing else is near it (the test flight is almost at Mars).
|
||||||
|
const ceres = scene.locations.find((l) => l.id === "ceres-station")!;
|
||||||
|
const p = toScreen(ceres.point, viewport, DEFAULT_CAMERA);
|
||||||
|
expect(hitTest(scene, { x: p.x + 5, y: p.y - 5 }, viewport, DEFAULT_CAMERA)).toEqual({
|
||||||
|
kind: "location",
|
||||||
|
id: "ceres-station",
|
||||||
|
});
|
||||||
|
expect(hitTest(scene, { x: 2, y: 2 }, viewport, DEFAULT_CAMERA)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selects a ship marker under a tap", () => {
|
||||||
|
const scene = buildScene(mapState(), NOW);
|
||||||
|
const p = toScreen(scene.flights[0]!.marker, viewport, DEFAULT_CAMERA);
|
||||||
|
expect(hitTest(scene, p, viewport, DEFAULT_CAMERA)).toEqual({
|
||||||
|
kind: "flight",
|
||||||
|
dispatchId: "d1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("map panels", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] });
|
||||||
|
vi.setSystemTime(NOW);
|
||||||
|
useClock.setState({ offsetMs: 0, synced: true, now: NOW });
|
||||||
|
});
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
|
||||||
|
const openMap = async () => {
|
||||||
|
renderApp(fakeApi({ getPlayerState: mapState }), "/map");
|
||||||
|
return screen.findByTestId("map-canvas");
|
||||||
|
};
|
||||||
|
|
||||||
|
it("tapping a location with a bay shows its details and a link to its contract board", async () => {
|
||||||
|
const canvas = await openMap();
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||||
|
const panel = screen.getByRole("dialog", { name: "Moon" });
|
||||||
|
expect(panel).toHaveTextContent("Airless body");
|
||||||
|
expect(panel).toHaveTextContent(/from Earth/);
|
||||||
|
expect(within(panel).getByRole("link", { name: "View contract board" })).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"/contracts?bay=moon",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping a discovered location without a bay shows no board link", async () => {
|
||||||
|
const canvas = await openMap();
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
|
||||||
|
const panel = screen.getByRole("dialog", { name: "Earth" });
|
||||||
|
expect(panel).toHaveTextContent("No bay here yet.");
|
||||||
|
expect(within(panel).queryByRole("link")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping an undiscovered location hides its details", async () => {
|
||||||
|
const canvas = await openMap();
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Mars" }));
|
||||||
|
expect(screen.getByRole("dialog", { name: "Mars" })).toHaveTextContent(
|
||||||
|
"Undiscovered — deliver here to unlock.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping a ship shows its route, pilot and ETA", async () => {
|
||||||
|
const canvas = await openMap();
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap ship s1" }));
|
||||||
|
const panel = screen.getByRole("dialog", { name: "Slow Ox" });
|
||||||
|
expect(panel).toHaveTextContent("Earth → Mars");
|
||||||
|
expect(panel).toHaveTextContent("Pilot: Rae Okafor");
|
||||||
|
expect(panel).toHaveTextContent("Arrives in 5m 00s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tapping empty space or Close dismisses the panel", async () => {
|
||||||
|
const canvas = await openMap();
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap empty space" }));
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Earth" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the board link opens Contracts with that bay selected", async () => {
|
||||||
|
const canvas = await openMap();
|
||||||
|
await userEvent.click(within(canvas).getByRole("button", { name: "Tap Moon" }));
|
||||||
|
await userEvent.click(screen.getByRole("link", { name: "View contract board" }));
|
||||||
|
expect(await screen.findByLabelText("Bay")).toHaveValue("moon");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -421,6 +421,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
||||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
| `src/screens/*` | HQ, Contracts (TutorialBoard / BayBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||||
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
|
| `src/state/actions.ts` | `useLoadedPlayer`, `useGameAction(route)` (mutation + invalidate state and boards) |
|
||||||
|
| `src/map/*` | System map: `projection` (square-root radial scale), `camera` (Zustand pan/zoom), `scene` (locations, flights, idle badges), `hitTest`, `MapCanvas` (PixiJS v8, lazy-loaded with the Map screen) |
|
||||||
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
|
| `src/game.ts`, `src/format.ts` | Sim-based predictions (distance, trip time) and display formatting |
|
||||||
|
|
||||||
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.
|
Session: Better Auth's cookie (`credentials: "include"`); the client stores nothing itself.
|
||||||
|
|
|
||||||
|
|
@ -100,4 +100,4 @@
|
||||||
|
|
||||||
- [x] **Step 10.1 — PixiJS map component.** Map tab (`/map`, lazy-loaded) with a PixiJS v8 canvas (`src/map/MapCanvas.tsx`): Sun, orbit rings, five locations (dimmed if undiscovered, ring if a bay), drag to pan, pinch or wheel to zoom, "Reset view". Square-root radial scale and screen projection in `src/map/projection.ts`, camera in `src/map/camera.ts`. Tests: scale monotonic, locations fit a 375 px map for 12 years, zoom keeps the anchor fixed. Manual phone check pending.
|
- [x] **Step 10.1 — PixiJS map component.** Map tab (`/map`, lazy-loaded) with a PixiJS v8 canvas (`src/map/MapCanvas.tsx`): Sun, orbit rings, five locations (dimmed if undiscovered, ring if a bay), drag to pan, pinch or wheel to zoom, "Reset view". Square-root radial scale and screen projection in `src/map/projection.ts`, camera in `src/map/camera.ts`. Tests: scale monotonic, locations fit a 375 px map for 12 years, zoom keeps the anchor fixed. Manual phone check pending.
|
||||||
- [x] **Step 10.2 — Ships on the map.** `flightMarker` / `buildScene` (`src/map/scene.ts`): each in-flight dispatch draws a route line (origin at departure → destination at arrival) with a marker placed by sim progress every frame; idle ships (tutorial loaner included) show as a count badge at their location. Test: marker at 50% lies halfway along the route line on screen. Manual smoothness check pending.
|
- [x] **Step 10.2 — Ships on the map.** `flightMarker` / `buildScene` (`src/map/scene.ts`): each in-flight dispatch draws a route line (origin at departure → destination at arrival) with a marker placed by sim progress every frame; idle ships (tutorial loaner included) show as a count badge at their location. Test: marker at 50% lies halfway along the route line on screen. Manual smoothness check pending.
|
||||||
- [ ] Step 10.3 — Map interactions
|
- [x] **Step 10.3 — Map interactions.** Taps (single pointer, under 8 px of movement) are hit-tested (`src/map/hitTest.ts`, 22 px reach, ships win ties). A location panel shows details (or "Undiscovered") and, with a bay, a link to `/contracts?bay=…` which preselects that board; a ship panel shows route, pilot and ETA. Component tests tap through a canvas test double that uses the real scene.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue