deadhead-space-freight-tycoon/apps/web/src/map/layout.ts

321 lines
11 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 { 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;
}