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 { 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(); 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; }