321 lines
11 KiB
TypeScript
321 lines
11 KiB
TypeScript
import { toScreen, type Camera, type Viewport } from "./camera";
|
||
import type { MapPoint } from "./projection";
|
||
import type { Scene } from "./scene";
|
||
import { mapStyle, type MapStyle } from "./style";
|
||
|
||
/**
|
||
* Screen-space layout rules for the map art (CR-003). Pure functions of the
|
||
* viewport, camera and style, so they are unit-tested without PixiJS.
|
||
*/
|
||
|
||
export interface Rect {
|
||
x: number;
|
||
y: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
/**
|
||
* Where the background image goes (decision 0.5.40): scaled to cover the
|
||
* viewport plus a margin on every side, zoomed only slightly with the map
|
||
* (never below cover size), shifted by pan × parallax, then clamped so its
|
||
* edges never show.
|
||
*/
|
||
export function backgroundRect(
|
||
view: Viewport,
|
||
camera: Camera,
|
||
image: { width: number; height: number },
|
||
style: MapStyle = mapStyle,
|
||
): Rect {
|
||
const margin = 1 + 2 * style.backgroundMargin;
|
||
const cover = Math.max(
|
||
(view.width * margin) / image.width,
|
||
(view.height * margin) / image.height,
|
||
);
|
||
const scale = cover * Math.max(1, camera.zoom ** style.backgroundZoomFollow);
|
||
const width = image.width * scale;
|
||
const height = image.height * scale;
|
||
const clamp = (value: number, min: number) => Math.min(0, Math.max(min, value));
|
||
return {
|
||
x: clamp((view.width - width) / 2 + camera.panX * style.parallax, view.width - width),
|
||
y: clamp((view.height - height) / 2 + camera.panY * style.parallax, view.height - height),
|
||
width,
|
||
height,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Sprite size factor at a zoom (decision 0.5.38): sprites grow a little as the
|
||
* player zooms in and shrink a little as they zoom out, within the limits.
|
||
*/
|
||
export function spriteScale(zoom: number, style: MapStyle = mapStyle): number {
|
||
const { min, max, zoomExponent } = style.spriteScale;
|
||
return Math.min(max, Math.max(min, zoom ** zoomExponent));
|
||
}
|
||
|
||
/** A screen-space offset of `distance` px at `angleDeg` (clockwise from screen right). */
|
||
export function polar(distance: number, angleDeg: number): MapPoint {
|
||
const a = (angleDeg * Math.PI) / 180;
|
||
return { x: Math.cos(a) * distance, y: Math.sin(a) * distance };
|
||
}
|
||
|
||
/**
|
||
* A child body's screen offset from its parent (decision 0.5.39): the
|
||
* configured distance, at the configured angle from the outward direction
|
||
* (Sun → parent on screen); zero for other locations.
|
||
*/
|
||
export function childOffset(
|
||
locationId: string,
|
||
parent: MapPoint,
|
||
sun: MapPoint,
|
||
style: MapStyle = mapStyle,
|
||
): MapPoint {
|
||
const child = style.childOffsets[locationId];
|
||
if (!child) return { x: 0, y: 0 };
|
||
const dx = parent.x - sun.x;
|
||
const dy = parent.y - sun.y;
|
||
const outward = dx === 0 && dy === 0 ? 0 : (Math.atan2(dy, dx) * 180) / Math.PI;
|
||
return polar(child.distancePx, outward + child.angleDeg);
|
||
}
|
||
|
||
/** Base on-screen size (long side, px) of a sprite asset. */
|
||
export function baseSpriteSize(assetId: string, style: MapStyle = mapStyle): number {
|
||
return style.spriteSizePx[assetId] ?? style.shipSizePx;
|
||
}
|
||
|
||
/** A tap target is the sprite's size, but never under the minimum (Step 10C.6). */
|
||
export function tapSize(spriteSize: number, style: MapStyle = mapStyle): number {
|
||
return Math.max(style.minTapPx, spriteSize);
|
||
}
|
||
|
||
export interface BodyLayout {
|
||
center: MapPoint;
|
||
/** Drawn size, long side (px). */
|
||
size: number;
|
||
/** Tap target, square side (px). */
|
||
tap: number;
|
||
}
|
||
|
||
export interface LocationLayout extends BodyLayout {
|
||
id: string;
|
||
}
|
||
|
||
export interface FlightLayout {
|
||
dispatchId: string;
|
||
/** Route ends on screen, matching where the locations are drawn. */
|
||
from: MapPoint;
|
||
to: MapPoint;
|
||
/** Where the ship is drawn: on the route, nudged apart from nearby ships. */
|
||
center: MapPoint;
|
||
/** Radians; the art points up (Step 10C.8). */
|
||
rotation: number;
|
||
size: number;
|
||
tap: number;
|
||
}
|
||
|
||
export interface SceneLayout {
|
||
scale: number;
|
||
sun: BodyLayout;
|
||
locations: LocationLayout[];
|
||
flights: FlightLayout[];
|
||
}
|
||
|
||
const add = (a: MapPoint, b: MapPoint): MapPoint => ({ x: a.x + b.x, y: a.y + b.y });
|
||
|
||
/** Where and how big everything in the scene is drawn on screen (CR-003). */
|
||
export function layoutScene(
|
||
scene: Scene,
|
||
view: Viewport,
|
||
camera: Camera,
|
||
style: MapStyle = mapStyle,
|
||
): SceneLayout {
|
||
const scale = spriteScale(camera.zoom, style);
|
||
const sunCenter = toScreen({ x: 0, y: 0 }, view, camera);
|
||
/** Screen point of a (possibly child) location whose anchor is at map point `point`. */
|
||
const drawn = (point: MapPoint, locationId: string | null): MapPoint => {
|
||
const anchor = toScreen(point, view, camera);
|
||
return locationId ? add(anchor, childOffset(locationId, anchor, sunCenter, style)) : anchor;
|
||
};
|
||
const body = (point: MapPoint, assetId: string, locationId: string | null): BodyLayout => {
|
||
const size = baseSpriteSize(assetId, style) * scale;
|
||
return { center: drawn(point, locationId), size, tap: tapSize(size, style) };
|
||
};
|
||
const shipSize = style.shipSizePx * scale;
|
||
return {
|
||
scale,
|
||
sun: body({ x: 0, y: 0 }, "sun", null),
|
||
locations: scene.locations.map((l) => ({ id: l.id, ...body(l.point, l.sprite, l.id) })),
|
||
flights: separateShips(
|
||
scene.flights.map((f) => {
|
||
const from = drawn(f.from, f.originId);
|
||
const to = drawn(f.to, f.destinationId);
|
||
return {
|
||
dispatchId: f.dispatchId,
|
||
from,
|
||
to,
|
||
center: {
|
||
x: from.x + (to.x - from.x) * f.progress,
|
||
y: from.y + (to.y - from.y) * f.progress,
|
||
},
|
||
rotation: shipRotation(from, to),
|
||
size: shipSize,
|
||
tap: tapSize(shipSize, style),
|
||
};
|
||
}),
|
||
style,
|
||
),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* A ship sprite's rotation for travel from `from` to `to` (screen space): the
|
||
* art points up, so moving right is +90° and moving down is 180°.
|
||
*/
|
||
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 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;
|
||
const clusters: FlightLayout[][] = [];
|
||
for (const flight of flights) {
|
||
const near = clusters.find((c) =>
|
||
c.some((o) => Math.hypot(o.center.x - flight.center.x, o.center.y - flight.center.y) < gap),
|
||
);
|
||
if (near) near.push(flight);
|
||
else clusters.push([flight]);
|
||
}
|
||
return clusters.flatMap((cluster) => {
|
||
if (cluster.length === 1) return cluster;
|
||
const first = cluster[0]!;
|
||
const dx = first.to.x - first.from.x;
|
||
const dy = first.to.y - first.from.y;
|
||
const length = Math.hypot(dx, dy) || 1;
|
||
const normal = { x: -dy / length, y: dx / length };
|
||
const middle = {
|
||
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 } };
|
||
});
|
||
});
|
||
}
|
||
|
||
interface Box {
|
||
left: number;
|
||
top: number;
|
||
right: number;
|
||
bottom: number;
|
||
}
|
||
|
||
const overlaps = (a: Box, b: Box) =>
|
||
a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
|
||
|
||
const square = (center: MapPoint, size: number): Box => ({
|
||
left: center.x - size / 2,
|
||
top: center.y - size / 2,
|
||
right: center.x + size / 2,
|
||
bottom: center.y + size / 2,
|
||
});
|
||
|
||
export interface Decoration {
|
||
/** Label anchor: top-centre when below the body (anchorY 0), bottom-centre when above (1). */
|
||
label: { x: number; y: number; anchorY: 0 | 1 };
|
||
/** Idle-ship badge centre. */
|
||
badge: MapPoint;
|
||
}
|
||
|
||
/**
|
||
* Places each location's label below its body, or above it when below would
|
||
* cover another body or an earlier label; then its idle-ship badge on the
|
||
* first diagonal clear of other bodies and the labels. `labelSize` measures a
|
||
* label.
|
||
*/
|
||
export function decorate(
|
||
layout: SceneLayout,
|
||
labelSize: (id: string) => { width: number; height: number },
|
||
style: MapStyle = mapStyle,
|
||
): Map<string, Decoration> {
|
||
const bodies = [
|
||
{ id: "sun", box: square(layout.sun.center, layout.sun.size) },
|
||
...layout.locations.map((l) => ({ id: l.id, box: square(l.center, l.size) })),
|
||
];
|
||
const labels: Box[] = [];
|
||
const clearOfBodies = (box: Box, ownId: string) =>
|
||
!bodies.some((b) => b.id !== ownId && overlaps(box, b.box));
|
||
const clearOfLabels = (box: Box) => !labels.some((l) => overlaps(box, l));
|
||
|
||
const placed = layout.locations.map((location) => {
|
||
const { width, height } = labelSize(location.id);
|
||
const { center, size } = location;
|
||
const gap = style.label.gapPx;
|
||
const below = { x: center.x, y: center.y + size / 2 + gap, anchorY: 0 as const };
|
||
const above = { x: center.x, y: center.y - size / 2 - gap, anchorY: 1 as const };
|
||
const boxOf = (l: { x: number; y: number; anchorY: 0 | 1 }): Box => ({
|
||
left: l.x - width / 2,
|
||
right: l.x + width / 2,
|
||
top: l.anchorY === 0 ? l.y : l.y - height,
|
||
bottom: l.anchorY === 0 ? l.y + height : l.y,
|
||
});
|
||
const label =
|
||
[below, above].find((l) => clearOfBodies(boxOf(l), location.id) && clearOfLabels(boxOf(l))) ??
|
||
below;
|
||
labels.push(boxOf(label));
|
||
return { location, label };
|
||
});
|
||
|
||
const result = new Map<string, Decoration>();
|
||
for (const { location, label } of placed) {
|
||
const { distancePx, angleDeg, radiusPx } = style.idleBadge;
|
||
const angles = [angleDeg, -180 - angleDeg, -angleDeg, 180 + angleDeg];
|
||
const spots = angles.map((a) => add(location.center, polar(location.size / 2 + distancePx, a)));
|
||
const badge =
|
||
spots.find((p) => {
|
||
const box = square(p, radiusPx * 2);
|
||
return clearOfBodies(box, location.id) && clearOfLabels(box);
|
||
}) ?? spots[0]!;
|
||
result.set(location.id, { label, badge });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* The map style with motion settings applied (CR-011): reduced motion keeps the
|
||
* background still (no parallax, no zoom follow). Ships still move.
|
||
*/
|
||
export function mapMotionStyle(reducedMotion: boolean, style: MapStyle = mapStyle): MapStyle {
|
||
return reducedMotion ? { ...style, parallax: 0, backgroundZoomFollow: 0 } : style;
|
||
}
|