Step 10C.6: sprite bodies, orbits, labels and tap targets
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
edc7925d0e
commit
5916cf8728
10
DECISIONS.md
10
DECISIONS.md
|
|
@ -717,3 +717,13 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
||||||
- **No background art means no background sprite**: the canvas's solid background color is the placeholder.
|
- **No background art means no background sprite**: the canvas's solid background color is the placeholder.
|
||||||
- **Map height fixed:** the Map section used `100dvh − 7.5rem` and stopped 42 px above the nav, which showed as a dark band under the new art. It now uses the real header and nav heights (4.875rem plus the safe-area inset) and cancels the layout's 5rem scroll padding, so the page does not scroll.
|
- **Map height fixed:** the Map section used `100dvh − 7.5rem` and stopped 42 px above the nav, which showed as a dark band under the new art. It now uses the real header and nav heights (4.875rem plus the safe-area inset) and cancels the layout's 5rem scroll padding, so the page does not scroll.
|
||||||
- **Screenshots** come from a scratchpad Playwright harness driving local Chrome against a separate dev stack (API 3125, web 5199). The Vite proxy target is now configurable (`API_PROXY_TARGET`, default `http://127.0.0.1:3000`) for that.
|
- **Screenshots** come from a scratchpad Playwright harness driving local Chrome against a separate dev stack (API 3125, web 5199). The Vite proxy target is now configurable (`API_PROXY_TARGET`, default `http://127.0.0.1:3000`) for that.
|
||||||
|
|
||||||
|
## Step 10C.6
|
||||||
|
|
||||||
|
- **The child offset angle is measured from the outward direction (Sun → parent on screen)**, not from screen right. With a fixed screen angle the Moon landed on the Sun whenever Earth was on the Sun's opposite side at phone width (seen in the first screenshot). The distance is still a fixed screen offset (decision 0.5.39), and the tests check the exact offset at several zooms and dates.
|
||||||
|
- **Moon distance 46 px** (the style config draft said 30): at the largest sprite scale Earth and the Moon need (32 + 18) / 2 × 1.75 = 43.75 px to clear each other.
|
||||||
|
- **Sprite scale = zoom ^ 0.35, clamped to 0.75–1.75** (new `spriteScale.zoomExponent`): sprites grow a little when zooming in and are about the same size at the default zoom.
|
||||||
|
- **Scene anchors:** a child body's scene `point` is its parent's map point, and flights carry origin and destination ids. `layoutScene` adds the screen offset, so routes to and from the Moon end exactly at the drawn Moon. `locationMapPoint` (map-space Moon offset) is kept but no longer drawn.
|
||||||
|
- **Labels and badges avoid collisions** with a small greedy pass: a label goes below its body, else above it if below hits another body or label; an idle badge takes the first clear diagonal. This handles Earth and the Moon and long names (Jupiter Cloud Platform next to Mars) without special cases.
|
||||||
|
- **Tap targets** come from the same layout as drawing: each is a circle of the larger of the sprite size and 44 px. Ships still win ties.
|
||||||
|
- **The idle badge sits just off the body's edge** (2 px, up-left by default) instead of a fixed distance from the centre, so it clears big bodies like Jupiter.
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,15 @@
|
||||||
import { toScreen, type Camera, type Viewport } from "./camera";
|
import type { Camera, Viewport } from "./camera";
|
||||||
|
import { layoutScene } from "./layout";
|
||||||
import type { MapPoint } from "./projection";
|
import type { MapPoint } from "./projection";
|
||||||
import type { Scene } from "./scene";
|
import type { Scene } from "./scene";
|
||||||
|
|
||||||
export type MapSelection =
|
export type MapSelection =
|
||||||
{ kind: "location"; id: string } | { kind: "flight"; dispatchId: string };
|
{ 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
|
* What a tap at `tap` (screen pixels) selects: the nearest ship or location
|
||||||
* location within reach. Ships win ties, since they are drawn on top.
|
* whose tap target (at least `minTapPx`, Step 10C.6) contains it, as laid out
|
||||||
|
* on screen. Ships win ties, since they are drawn on top.
|
||||||
*/
|
*/
|
||||||
export function hitTest(
|
export function hitTest(
|
||||||
scene: Scene,
|
scene: Scene,
|
||||||
|
|
@ -18,19 +17,19 @@ export function hitTest(
|
||||||
viewport: Viewport,
|
viewport: Viewport,
|
||||||
camera: Camera,
|
camera: Camera,
|
||||||
): MapSelection | null {
|
): MapSelection | null {
|
||||||
|
const layout = layoutScene(scene, viewport, camera);
|
||||||
let best: { selection: MapSelection; distance: number } | null = null;
|
let best: { selection: MapSelection; distance: number } | null = null;
|
||||||
const consider = (point: MapPoint, selection: MapSelection, bias: number) => {
|
const consider = (center: MapPoint, size: number, selection: MapSelection, bias: number) => {
|
||||||
const p = toScreen(point, viewport, camera);
|
const distance = Math.hypot(center.x - tap.x, center.y - tap.y);
|
||||||
const distance = Math.hypot(p.x - tap.x, p.y - tap.y) - bias;
|
if (distance <= size / 2 && (!best || distance - bias < best.distance)) {
|
||||||
if (distance <= TAP_RADIUS && (!best || distance < best.distance)) {
|
best = { selection, distance: distance - bias };
|
||||||
best = { selection, distance };
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (const location of scene.locations) {
|
for (const location of layout.locations) {
|
||||||
consider(location.point, { kind: "location", id: location.id }, 0);
|
consider(location.center, location.tap, { kind: "location", id: location.id }, 0);
|
||||||
}
|
}
|
||||||
for (const flight of scene.flights) {
|
for (const flight of layout.flights) {
|
||||||
consider(flight.marker, { kind: "flight", dispatchId: flight.dispatchId }, 4);
|
consider(flight.center, flight.tap, { kind: "flight", dispatchId: flight.dispatchId }, 4);
|
||||||
}
|
}
|
||||||
return (best as { selection: MapSelection } | null)?.selection ?? null;
|
return (best as { selection: MapSelection } | null)?.selection ?? null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import type { Camera, Viewport } from "./camera";
|
import { toScreen, type Camera, type Viewport } from "./camera";
|
||||||
|
import type { MapPoint } from "./projection";
|
||||||
|
import type { Scene } from "./scene";
|
||||||
import { mapStyle, type MapStyle } from "./style";
|
import { mapStyle, type MapStyle } from "./style";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -41,3 +43,197 @@ export function backgroundRect(
|
||||||
height,
|
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;
|
||||||
|
center: MapPoint;
|
||||||
|
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: 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,
|
||||||
|
},
|
||||||
|
size: shipSize,
|
||||||
|
tap: tapSize(shipSize, style),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,13 @@ import { Container, Graphics, Sprite, Text, type Texture } from "pixi.js";
|
||||||
import type { ArtTextures } from "./art/load";
|
import type { ArtTextures } from "./art/load";
|
||||||
import { sceneVisuals, type Visual } from "./art/visuals";
|
import { sceneVisuals, type Visual } from "./art/visuals";
|
||||||
import { toScreen, type Camera, type Viewport } from "./camera";
|
import { toScreen, type Camera, type Viewport } from "./camera";
|
||||||
import { backgroundRect } from "./layout";
|
import { backgroundRect, decorate, layoutScene, polar } from "./layout";
|
||||||
import type { MapPoint } from "./projection";
|
import type { MapPoint } from "./projection";
|
||||||
import type { Scene } from "./scene";
|
import type { Scene } from "./scene";
|
||||||
import { mapStyle } from "./style";
|
import { mapStyle } from "./style";
|
||||||
|
|
||||||
const { colors } = mapStyle;
|
const { colors } = mapStyle;
|
||||||
|
|
||||||
/** Base on-screen size (long side, px) of a sprite asset. */
|
|
||||||
function baseSize(assetId: string): number {
|
|
||||||
return mapStyle.spriteSizePx[assetId] ?? mapStyle.shipSizePx;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fits a sprite's long side to `size` px, keeping its aspect ratio. */
|
/** Fits a sprite's long side to `size` px, keeping its aspect ratio. */
|
||||||
function fit(sprite: Sprite, size: number) {
|
function fit(sprite: Sprite, size: number) {
|
||||||
const { width, height } = sprite.texture;
|
const { width, height } = sprite.texture;
|
||||||
|
|
@ -21,9 +16,9 @@ function fit(sprite: Sprite, size: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Draws the map with PixiJS (Steps 10.1–10.2, CR-003). Sprites and text are
|
* Draws the map with PixiJS (Steps 10.1–10.2, CR-003). Positions and sizes come
|
||||||
* created once per item and updated every frame; code-drawn shapes go in a few
|
* from `layoutScene`; sprites and text are created once per item and updated
|
||||||
* Graphics layers that are redrawn each frame.
|
* every frame, and code-drawn shapes go in Graphics layers redrawn each frame.
|
||||||
*/
|
*/
|
||||||
export class MapRenderer {
|
export class MapRenderer {
|
||||||
/** Non-tiled, parallax background (decision 0.5.40); absent if its art is missing. */
|
/** Non-tiled, parallax background (decision 0.5.40); absent if its art is missing. */
|
||||||
|
|
@ -60,6 +55,7 @@ export class MapRenderer {
|
||||||
return sprite;
|
return sprite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Draws `visual` as a sprite at `at`, or calls `placeholder` (decision 0.5.41). */
|
||||||
private place(
|
private place(
|
||||||
key: string,
|
key: string,
|
||||||
visual: Visual<Texture>,
|
visual: Visual<Texture>,
|
||||||
|
|
@ -67,16 +63,32 @@ export class MapRenderer {
|
||||||
at: MapPoint,
|
at: MapPoint,
|
||||||
size: number,
|
size: number,
|
||||||
placeholder: () => void,
|
placeholder: () => void,
|
||||||
) {
|
): Sprite | null {
|
||||||
if (visual.kind === "placeholder") return placeholder();
|
if (visual.kind === "placeholder") {
|
||||||
|
placeholder();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const sprite = this.sprite(key, visual.texture, layer);
|
const sprite = this.sprite(key, visual.texture, layer);
|
||||||
fit(sprite, size);
|
fit(sprite, size);
|
||||||
sprite.position.set(at.x, at.y);
|
sprite.position.set(at.x, at.y);
|
||||||
|
return sprite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A text for `key` in the text layer, created on first use. */
|
||||||
|
private text(map: Map<string, Text>, key: string, create: () => Text): Text {
|
||||||
|
let text = map.get(key);
|
||||||
|
if (!text) {
|
||||||
|
text = create();
|
||||||
|
map.set(key, text);
|
||||||
|
this.texts.addChild(text);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
draw(scene: Scene, view: Viewport, camera: Camera) {
|
draw(scene: Scene, view: Viewport, camera: Camera) {
|
||||||
const visuals = sceneVisuals(scene, this.textures);
|
const visuals = sceneVisuals(scene, this.textures);
|
||||||
const sun = toScreen({ x: 0, y: 0 }, view, camera);
|
const layout = layoutScene(scene, view, camera);
|
||||||
|
const sun = layout.sun.center;
|
||||||
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
|
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
|
||||||
for (const sprite of this.sprites.values()) sprite.visible = false;
|
for (const sprite of this.sprites.values()) sprite.visible = false;
|
||||||
|
|
||||||
|
|
@ -90,77 +102,118 @@ export class MapRenderer {
|
||||||
this.routes.clear();
|
this.routes.clear();
|
||||||
this.overlay.clear();
|
this.overlay.clear();
|
||||||
|
|
||||||
|
const placed = new Map(layout.locations.map((l) => [l.id, l]));
|
||||||
|
// Faint orbit rings: around the Sun at each body's current compressed
|
||||||
|
// radius, and a small one around a parent for its child (the Moon).
|
||||||
for (const location of scene.locations) {
|
for (const location of scene.locations) {
|
||||||
if (location.orbitRadius !== null) {
|
if (location.orbitRadius !== null) {
|
||||||
this.orbits
|
this.orbits
|
||||||
.circle(sun.x, sun.y, location.orbitRadius * unit)
|
.circle(sun.x, sun.y, location.orbitRadius * unit)
|
||||||
.stroke({ width: 1, color: colors.orbit, alpha: colors.orbitAlpha });
|
.stroke({ width: 1, color: colors.orbit, alpha: colors.orbitAlpha });
|
||||||
}
|
}
|
||||||
|
const child = mapStyle.childOffsets[location.id];
|
||||||
|
const parent = location.parentId ? placed.get(location.parentId) : undefined;
|
||||||
|
if (child && parent) {
|
||||||
|
this.orbits
|
||||||
|
.circle(parent.center.x, parent.center.y, child.distancePx)
|
||||||
|
.stroke({ width: 1, color: colors.orbit, alpha: colors.orbitAlpha });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
this.place("sun", visuals.sun, this.bodies, sun, baseSize("sun"), () =>
|
|
||||||
|
this.place("sun", visuals.sun, this.bodies, sun, layout.sun.size, () =>
|
||||||
this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun),
|
this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const location of scene.locations) {
|
for (const location of scene.locations) {
|
||||||
const p = toScreen(location.point, view, camera);
|
const { center: p, size } = placed.get(location.id)!;
|
||||||
const color = location.discovered ? colors.placeholderBody : colors.labelUndiscovered;
|
const placeholderColor = location.discovered
|
||||||
const size = baseSize(location.sprite);
|
? colors.placeholderBody
|
||||||
|
: colors.labelUndiscovered;
|
||||||
this.place(
|
this.place(
|
||||||
`loc:${location.id}`,
|
`loc:${location.id}`,
|
||||||
visuals.locations.get(location.id)!,
|
visuals.locations.get(location.id)!,
|
||||||
this.bodies,
|
this.bodies,
|
||||||
p,
|
p,
|
||||||
size,
|
size,
|
||||||
() => this.overlay.circle(p.x, p.y, 5).fill(color),
|
() => this.overlay.circle(p.x, p.y, 5).fill(placeholderColor),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Location badge art (Jupiter's cloud platform) on the body's edge, if provided.
|
||||||
|
const badgeArt = visuals.badges.get(location.id);
|
||||||
|
if (badgeArt) {
|
||||||
|
const edge = polar(size / 2, mapStyle.locationBadge.angleDeg);
|
||||||
|
const at = { x: p.x + edge.x, y: p.y + edge.y };
|
||||||
|
const badgeSize = mapStyle.locationBadge.sizePx * layout.scale;
|
||||||
|
this.place(`badge:${location.id}`, badgeArt, this.bodies, at, badgeSize, () => {});
|
||||||
|
}
|
||||||
|
|
||||||
if (location.hasBay) {
|
if (location.hasBay) {
|
||||||
this.overlay.circle(p.x, p.y, size / 2 + 4).stroke({ width: 2, color: colors.hq });
|
this.overlay.circle(p.x, p.y, size / 2 + 4).stroke({ width: 2, color: colors.hq });
|
||||||
}
|
}
|
||||||
|
|
||||||
let label = this.labels.get(location.id);
|
// The name under the body, with a dark halo for contrast on the background.
|
||||||
if (!label) {
|
const label = this.text(this.labels, location.id, () => {
|
||||||
label = new Text({ text: location.name, style: { fontSize: 12, fill: color } });
|
const text = new Text({
|
||||||
this.labels.set(location.id, label);
|
text: location.name,
|
||||||
this.texts.addChild(label);
|
style: {
|
||||||
}
|
fontSize: mapStyle.label.fontSizePx,
|
||||||
|
fontWeight: "600",
|
||||||
|
fill: colors.label,
|
||||||
|
stroke: { color: colors.labelHalo, width: mapStyle.label.haloPx * 2, join: "round" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
text.anchor.set(0.5, 0);
|
||||||
|
return text;
|
||||||
|
});
|
||||||
// Undiscovered locations are locked until a delivery reaches them (Step 10A.16).
|
// Undiscovered locations are locked until a delivery reaches them (Step 10A.16).
|
||||||
label.text = location.discovered ? location.name : `🔒 ${location.name}`;
|
label.text = location.discovered ? location.name : `🔒 ${location.name}`;
|
||||||
label.style.fill = location.discovered ? colors.label : colors.labelUndiscovered;
|
label.style.fill = location.discovered ? colors.label : colors.labelUndiscovered;
|
||||||
label.position.set(p.x + size / 2 + 4, p.y - 7);
|
|
||||||
|
|
||||||
// Idle ships here: a small badge with the count (Step 10.2).
|
// Idle ships here: a count badge just off the body's edge (Step 10.2).
|
||||||
let badge = this.badges.get(location.id);
|
const badge = this.text(this.badges, location.id, () => {
|
||||||
if (!badge) {
|
const text = new Text({
|
||||||
badge = new Text({ text: "", style: { fontSize: 10, fill: colors.badgeText } });
|
text: "",
|
||||||
badge.anchor.set(0.5);
|
style: {
|
||||||
this.badges.set(location.id, badge);
|
fontSize: mapStyle.idleBadge.fontSizePx,
|
||||||
this.texts.addChild(badge);
|
fontWeight: "700",
|
||||||
}
|
fill: colors.badgeText,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
text.anchor.set(0.5);
|
||||||
|
return text;
|
||||||
|
});
|
||||||
badge.visible = location.idleShips > 0;
|
badge.visible = location.idleShips > 0;
|
||||||
if (badge.visible) {
|
badge.text = String(location.idleShips);
|
||||||
const b = { x: p.x - size / 2 - 4, y: p.y + size / 2 + 4 };
|
}
|
||||||
this.overlay.circle(b.x, b.y, 7).fill(colors.badgeFill);
|
|
||||||
badge.text = String(location.idleShips);
|
// Labels below (or above) bodies, badges on a clear diagonal (Step 10C.6).
|
||||||
badge.position.set(b.x, b.y);
|
const decorations = decorate(layout, (id) => this.labels.get(id)!);
|
||||||
|
for (const location of scene.locations) {
|
||||||
|
const { label, badge } = decorations.get(location.id)!;
|
||||||
|
const text = this.labels.get(location.id)!;
|
||||||
|
text.anchor.set(0.5, label.anchorY);
|
||||||
|
text.position.set(label.x, label.y);
|
||||||
|
const count = this.badges.get(location.id)!;
|
||||||
|
if (count.visible) {
|
||||||
|
this.overlay.circle(badge.x, badge.y, mapStyle.idleBadge.radiusPx).fill(colors.badgeFill);
|
||||||
|
count.position.set(badge.x, badge.y);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-flight ships: the route line and the ship on it (Step 10.2).
|
// In-flight ships: the route line and the ship on it (Step 10.2).
|
||||||
for (const flight of scene.flights) {
|
for (const flight of layout.flights) {
|
||||||
const from = toScreen(flight.from, view, camera);
|
|
||||||
const to = toScreen(flight.to, view, camera);
|
|
||||||
const marker = toScreen(flight.marker, view, camera);
|
|
||||||
this.routes
|
this.routes
|
||||||
.moveTo(from.x, from.y)
|
.moveTo(flight.from.x, flight.from.y)
|
||||||
.lineTo(to.x, to.y)
|
.lineTo(flight.to.x, flight.to.y)
|
||||||
.stroke({ width: 1, color: colors.routeBehind });
|
.stroke({ width: 1, color: colors.routeBehind });
|
||||||
|
const { center } = flight;
|
||||||
this.place(
|
this.place(
|
||||||
`ship:${flight.dispatchId}`,
|
`ship:${flight.dispatchId}`,
|
||||||
visuals.flights.get(flight.dispatchId)!,
|
visuals.flights.get(flight.dispatchId)!,
|
||||||
this.ships,
|
this.ships,
|
||||||
marker,
|
center,
|
||||||
mapStyle.shipSizePx,
|
flight.size,
|
||||||
() => this.overlay.circle(marker.x, marker.y, 4).fill(colors.placeholderShip),
|
() => this.overlay.circle(center.x, center.y, 4).fill(colors.placeholderShip),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,18 @@ import { content } from "@deadhead/content";
|
||||||
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
||||||
import { bodyPosition, dispatchProgress, loanerModel, realToGame } from "@deadhead/sim";
|
import { bodyPosition, dispatchProgress, loanerModel, realToGame } from "@deadhead/sim";
|
||||||
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
|
import { locationMapPoint, radialScale, type MapPoint } from "./projection";
|
||||||
|
import { mapStyle } from "./style";
|
||||||
|
|
||||||
export interface SceneLocation {
|
export interface SceneLocation {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Map position; for a child body (the Moon) its parent's position, since
|
||||||
|
* children are drawn at a fixed screen offset from it (decision 0.5.39).
|
||||||
|
*/
|
||||||
point: MapPoint;
|
point: MapPoint;
|
||||||
|
/** The parent location of a child body, else null. */
|
||||||
|
parentId: string | null;
|
||||||
/** Map radius of its orbit around the Sun (the faint ring); null for moons. */
|
/** Map radius of its orbit around the Sun (the faint ring); null for moons. */
|
||||||
orbitRadius: number | null;
|
orbitRadius: number | null;
|
||||||
discovered: boolean;
|
discovered: boolean;
|
||||||
|
|
@ -22,7 +29,9 @@ export interface SceneFlight {
|
||||||
dispatchId: string;
|
dispatchId: string;
|
||||||
/** The ship's id, or "loaner" for a tutorial run. */
|
/** The ship's id, or "loaner" for a tutorial run. */
|
||||||
shipId: string;
|
shipId: string;
|
||||||
/** Origin at departure and destination at arrival: the route line. */
|
originId: string;
|
||||||
|
destinationId: string;
|
||||||
|
/** Origin at departure and destination at arrival (anchors; see `point`): the route line. */
|
||||||
from: MapPoint;
|
from: MapPoint;
|
||||||
to: MapPoint;
|
to: MapPoint;
|
||||||
/** Where the ship is now, on the route line. */
|
/** Where the ship is now, on the route line. */
|
||||||
|
|
@ -44,6 +53,11 @@ function location(id: string) {
|
||||||
return found;
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The location whose map position a location is drawn from: its parent, if it has one. */
|
||||||
|
export function anchorLocation(id: string) {
|
||||||
|
return location(mapStyle.childOffsets[id]?.parentId ?? id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flight's route and current marker (Step 10.2). The marker moves along the
|
* A flight's route and current marker (Step 10.2). The marker moves along the
|
||||||
* straight route line by the sim's progress, in map space, so it always stays
|
* straight route line by the sim's progress, in map space, so it always stays
|
||||||
|
|
@ -51,17 +65,19 @@ function location(id: string) {
|
||||||
*/
|
*/
|
||||||
export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight {
|
export function flightMarker(dispatch: Dispatch, now: number, modelId: string): SceneFlight {
|
||||||
const from = locationMapPoint(
|
const from = locationMapPoint(
|
||||||
location(dispatch.originId),
|
anchorLocation(dispatch.originId),
|
||||||
realToGame(dispatch.departureAt, content.config),
|
realToGame(dispatch.departureAt, content.config),
|
||||||
);
|
);
|
||||||
const to = locationMapPoint(
|
const to = locationMapPoint(
|
||||||
location(dispatch.destinationId),
|
anchorLocation(dispatch.destinationId),
|
||||||
realToGame(dispatch.arrivalAt, content.config),
|
realToGame(dispatch.arrivalAt, content.config),
|
||||||
);
|
);
|
||||||
const progress = dispatchProgress(dispatch, now);
|
const progress = dispatchProgress(dispatch, now);
|
||||||
return {
|
return {
|
||||||
dispatchId: dispatch.id,
|
dispatchId: dispatch.id,
|
||||||
shipId: dispatch.shipId,
|
shipId: dispatch.shipId,
|
||||||
|
originId: dispatch.originId,
|
||||||
|
destinationId: dispatch.destinationId,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
marker: { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress },
|
marker: { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress },
|
||||||
|
|
@ -86,7 +102,8 @@ export function buildScene(state: PlayerState, now: number): Scene {
|
||||||
return {
|
return {
|
||||||
id: loc.id,
|
id: loc.id,
|
||||||
name: loc.name,
|
name: loc.name,
|
||||||
point: locationMapPoint(loc, gameTime),
|
point: locationMapPoint(anchorLocation(loc.id), gameTime),
|
||||||
|
parentId: mapStyle.childOffsets[loc.id]?.parentId ?? null,
|
||||||
orbitRadius: loc.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)),
|
orbitRadius: loc.body === "moon" ? null : radialScale(Math.hypot(sun.x, sun.y)),
|
||||||
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
|
discovered: state.locations.some((l) => l.id === loc.id && l.discovered),
|
||||||
hasBay: state.bays.some((b) => b.locationId === loc.id),
|
hasBay: state.bays.some((b) => b.locationId === loc.id),
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,21 @@ export const mapStyleSchema = z
|
||||||
spriteSizePx: z.record(z.string(), px),
|
spriteSizePx: z.record(z.string(), px),
|
||||||
/** Every ship sprite's base size (long side, px). */
|
/** Every ship sprite's base size (long side, px). */
|
||||||
shipSizePx: px,
|
shipSizePx: px,
|
||||||
/** Sprite size factor limits as the player zooms (decision 0.5.38). */
|
/**
|
||||||
spriteScale: z.object({ min: px, max: px }),
|
* Sprite size factor as the player zooms (decision 0.5.38): zoom ^ exponent,
|
||||||
/** Child bodies drawn at a fixed screen offset from their parent (decision 0.5.39). */
|
* clamped to [min, max], so sprites stay roughly the same size on screen.
|
||||||
|
*/
|
||||||
|
spriteScale: z.object({ min: px, max: px, zoomExponent: z.number().min(0).max(1) }),
|
||||||
|
/**
|
||||||
|
* Child bodies drawn at a fixed screen distance from their parent (decision
|
||||||
|
* 0.5.39). The angle is measured from the outward direction (Sun → parent),
|
||||||
|
* so a child never lands on the Sun.
|
||||||
|
*/
|
||||||
childOffsets: z.record(z.string(), offset.extend({ parentId: z.string() })),
|
childOffsets: z.record(z.string(), offset.extend({ parentId: z.string() })),
|
||||||
/** Idle-ship count badge, from the body's center. */
|
/**
|
||||||
|
* Idle-ship count badge: `distancePx` beyond the body's edge, at `angleDeg`,
|
||||||
|
* or the first of the other diagonals that is clear of bodies and labels.
|
||||||
|
*/
|
||||||
idleBadge: offset.extend({ radiusPx: px, fontSizePx: px }),
|
idleBadge: offset.extend({ radiusPx: px, fontSizePx: px }),
|
||||||
/** Location badge art (e.g. Jupiter's cloud platform), on the body's edge. */
|
/** Location badge art (e.g. Jupiter's cloud platform), on the body's edge. */
|
||||||
locationBadge: z.object({ sizePx: px, angleDeg: z.number() }),
|
locationBadge: z.object({ sizePx: px, angleDeg: z.number() }),
|
||||||
|
|
@ -69,9 +79,10 @@ export const mapStyle: MapStyle = mapStyleSchema.parse({
|
||||||
"body-moon": 18,
|
"body-moon": 18,
|
||||||
},
|
},
|
||||||
shipSizePx: 24,
|
shipSizePx: 24,
|
||||||
spriteScale: { min: 0.75, max: 1.75 },
|
spriteScale: { min: 0.75, max: 1.75, zoomExponent: 0.35 },
|
||||||
childOffsets: { moon: { parentId: "earth", distancePx: 30, angleDeg: -45 } },
|
// 46 px clears Earth and the Moon at the largest sprite scale: (32 + 18) / 2 × 1.75 = 43.75.
|
||||||
idleBadge: { distancePx: 20, angleDeg: 135, radiusPx: 8, fontSizePx: 10 },
|
childOffsets: { moon: { parentId: "earth", distancePx: 46, angleDeg: -30 } },
|
||||||
|
idleBadge: { distancePx: 2, angleDeg: -135, radiusPx: 8, fontSizePx: 10 },
|
||||||
locationBadge: { sizePx: 16, angleDeg: -45 },
|
locationBadge: { sizePx: 16, angleDeg: -45 },
|
||||||
minTapPx: 44,
|
minTapPx: 44,
|
||||||
parallax: 0.15,
|
parallax: 0.15,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera";
|
import { MAX_ZOOM, MIN_ZOOM } from "../src/map/camera";
|
||||||
import { backgroundRect } from "../src/map/layout";
|
import {
|
||||||
|
backgroundRect,
|
||||||
|
decorate,
|
||||||
|
layoutScene,
|
||||||
|
polar,
|
||||||
|
spriteScale,
|
||||||
|
type SceneLayout,
|
||||||
|
} from "../src/map/layout";
|
||||||
|
import { buildScene } from "../src/map/scene";
|
||||||
|
import { mapStyle } from "../src/map/style";
|
||||||
|
import { samplePlayerState } from "./render";
|
||||||
|
|
||||||
const viewports = [
|
const viewports = [
|
||||||
{ width: 390, height: 700 },
|
{ width: 390, height: 700 },
|
||||||
|
|
@ -54,3 +64,119 @@ describe("background placement (decision 0.5.40)", () => {
|
||||||
expect(far.width / one.width).toBeLessThan(1.3);
|
expect(far.width / one.width).toBeLessThan(1.3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dates = [
|
||||||
|
Date.parse("2100-01-01T00:00:00Z"),
|
||||||
|
Date.parse("2100-03-17T08:00:00Z"),
|
||||||
|
Date.parse("2101-11-02T19:30:00Z"),
|
||||||
|
];
|
||||||
|
const view = { width: 390, height: 700 };
|
||||||
|
const cameraAt = (zoom: number) => ({ zoom, panX: 13, panY: -27 });
|
||||||
|
|
||||||
|
describe("bodies on screen (decisions 0.5.38, 0.5.39)", () => {
|
||||||
|
it("keeps the sprite scale within the configured limits at extreme zooms", () => {
|
||||||
|
const { min, max } = mapStyle.spriteScale;
|
||||||
|
for (const zoom of [1e-6, MIN_ZOOM, 0.75, 1, 2, MAX_ZOOM, 1e6]) {
|
||||||
|
expect(spriteScale(zoom)).toBeGreaterThanOrEqual(min);
|
||||||
|
expect(spriteScale(zoom)).toBeLessThanOrEqual(max);
|
||||||
|
}
|
||||||
|
expect(spriteScale(1e-6)).toBe(min);
|
||||||
|
expect(spriteScale(1e6)).toBe(max);
|
||||||
|
expect(spriteScale(1)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the Moon exactly the configured offset from Earth, at any zoom and date", () => {
|
||||||
|
const { distancePx, angleDeg } = mapStyle.childOffsets.moon!;
|
||||||
|
for (const now of dates) {
|
||||||
|
const scene = buildScene(samplePlayerState(), now);
|
||||||
|
for (const zoom of [MIN_ZOOM, 1, 7, MAX_ZOOM]) {
|
||||||
|
const layout = layoutScene(scene, view, cameraAt(zoom));
|
||||||
|
const earth = layout.locations.find((l) => l.id === "earth")!;
|
||||||
|
const moon = layout.locations.find((l) => l.id === "moon")!;
|
||||||
|
// The angle is measured from the outward direction, Sun → Earth.
|
||||||
|
const outward =
|
||||||
|
(Math.atan2(earth.center.y - layout.sun.center.y, earth.center.x - layout.sun.center.x) *
|
||||||
|
180) /
|
||||||
|
Math.PI;
|
||||||
|
const offset = polar(distancePx, outward + angleDeg);
|
||||||
|
expect(moon.center.x - earth.center.x).toBeCloseTo(offset.x, 9);
|
||||||
|
expect(moon.center.y - earth.center.y).toBeCloseTo(offset.y, 9);
|
||||||
|
// And the two sprites never overlap.
|
||||||
|
expect(Math.hypot(offset.x, offset.y)).toBeGreaterThanOrEqual((earth.size + moon.size) / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts and ends Moon routes where the Moon is drawn", () => {
|
||||||
|
const now = dates[0]!;
|
||||||
|
const state = samplePlayerState({
|
||||||
|
activeDispatches: [
|
||||||
|
{
|
||||||
|
id: "d",
|
||||||
|
shipId: "loaner",
|
||||||
|
pilotId: "avatar",
|
||||||
|
contract: null,
|
||||||
|
originId: "earth",
|
||||||
|
destinationId: "moon",
|
||||||
|
tutorial: true,
|
||||||
|
departureAt: now,
|
||||||
|
arrivalAt: now + 60_000,
|
||||||
|
distanceMillionKm: 0.4,
|
||||||
|
fuelCost: 0,
|
||||||
|
expectedPayout: 0,
|
||||||
|
seed: 1,
|
||||||
|
status: "in-flight",
|
||||||
|
resolvedAt: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const layout = layoutScene(buildScene(state, now + 60_000), view, cameraAt(1));
|
||||||
|
const moon = layout.locations.find((l) => l.id === "moon")!;
|
||||||
|
expect(layout.flights[0]!.to.x).toBeCloseTo(moon.center.x, 6);
|
||||||
|
expect(layout.flights[0]!.to.y).toBeCloseTo(moon.center.y, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never makes a tap target smaller than the minimum", () => {
|
||||||
|
const scene = buildScene(samplePlayerState(), dates[0]!);
|
||||||
|
for (const zoom of [1e-6, MIN_ZOOM, 1, MAX_ZOOM]) {
|
||||||
|
const layout = layoutScene(scene, view, cameraAt(zoom));
|
||||||
|
for (const item of [layout.sun, ...layout.locations, ...layout.flights]) {
|
||||||
|
expect(item.tap).toBeGreaterThanOrEqual(mapStyle.minTapPx);
|
||||||
|
expect(item.tap).toBeGreaterThanOrEqual(item.size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("labels and badges (Step 10C.6)", () => {
|
||||||
|
const body = (id: string, x: number, y: number, size = 32) => ({
|
||||||
|
id,
|
||||||
|
center: { x, y },
|
||||||
|
size,
|
||||||
|
tap: 44,
|
||||||
|
});
|
||||||
|
const layout = (locations: ReturnType<typeof body>[]): SceneLayout => ({
|
||||||
|
scale: 1,
|
||||||
|
sun: { center: { x: -1000, y: -1000 }, size: 64, tap: 64 },
|
||||||
|
locations,
|
||||||
|
flights: [],
|
||||||
|
});
|
||||||
|
const size = () => ({ width: 60, height: 14 });
|
||||||
|
|
||||||
|
it("puts a label below its body when that is clear", () => {
|
||||||
|
const placed = decorate(layout([body("a", 100, 100)]), size);
|
||||||
|
expect(placed.get("a")!.label).toEqual({ x: 100, y: 120, anchorY: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves a label above its body when below would cover another body", () => {
|
||||||
|
const placed = decorate(layout([body("a", 100, 100), body("b", 110, 140)]), size);
|
||||||
|
expect(placed.get("a")!.label).toEqual({ x: 100, y: 80, anchorY: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the idle badge on a diagonal clear of other bodies", () => {
|
||||||
|
// The default spot (up-left) is taken by another body.
|
||||||
|
const placed = decorate(layout([body("a", 100, 100), body("b", 70, 70)]), size);
|
||||||
|
const badge = placed.get("a")!.badge;
|
||||||
|
expect(Math.hypot(badge.x - 70, badge.y - 70)).toBeGreaterThan(24);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ describe("map style config (CR-003)", () => {
|
||||||
{ ...mapStyle, shipSizePx: 0 },
|
{ ...mapStyle, shipSizePx: 0 },
|
||||||
{ ...mapStyle, spriteSizePx: { ...mapStyle.spriteSizePx, sun: -1 } },
|
{ ...mapStyle, spriteSizePx: { ...mapStyle.spriteSizePx, sun: -1 } },
|
||||||
{ ...mapStyle, minTapPx: 0 },
|
{ ...mapStyle, minTapPx: 0 },
|
||||||
{ ...mapStyle, spriteScale: { min: 2, max: 1 } },
|
{ ...mapStyle, spriteScale: { min: 2, max: 1, zoomExponent: 0.35 } },
|
||||||
{ ...mapStyle, parallax: 1.5 },
|
{ ...mapStyle, parallax: 1.5 },
|
||||||
{ ...mapStyle, parallax: -0.1 },
|
{ ...mapStyle, parallax: -0.1 },
|
||||||
];
|
];
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 768 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -454,6 +454,7 @@ Vite + React 19 + Tailwind 4, React Router 8 (declarative routes). In developmen
|
||||||
| `scripts/map-art.mjs` | Generates `public/assets/map-gen/` (`<id>@1x.png`, `<id>@2x.png`, `bg.webp`) from the manifest with sharp; output committed |
|
| `scripts/map-art.mjs` | Generates `public/assets/map-gen/` (`<id>@1x.png`, `<id>@2x.png`, `bg.webp`) from the manifest with sharp; output committed |
|
||||||
| `src/map/style.ts` | Map presentation config (CR-003): sprite sizes, zoom scale limits, child-body offsets, badges, tap size, parallax, colors; validated by `mapStyleSchema` |
|
| `src/map/style.ts` | Map presentation config (CR-003): sprite sizes, zoom scale limits, child-body offsets, badges, tap size, parallax, colors; validated by `mapStyleSchema` |
|
||||||
| `src/map/art/load.ts`, `art/visuals.ts` | Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41) |
|
| `src/map/art/load.ts`, `art/visuals.ts` | Load the generated art as one PixiJS bundle (2x at DPR ≥ 2); texture-or-placeholder per scene item (decision 0.5.41) |
|
||||||
|
| `src/map/layout.ts` | Pure screen layout: `backgroundRect`, `spriteScale`, `childOffset`, `layoutScene` (centres, sizes, tap targets; used by renderer and hit test), `decorate` (label and badge placement) |
|
||||||
| `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state |
|
| `src/map/renderer.ts` | `MapRenderer`: all PixiJS drawing for the map, layer by layer; `MapCanvas` owns the app, input and loading state |
|
||||||
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
| `src/screens/*` | HQ, Contracts (TutorialBoard / LocationBoards + DispatchSheet), Fleet, Pilots, Market, Locations, StartScreen |
|
||||||
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
| `src/components/*` | `AwaySummary` dialog, `Countdown` |
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as
|
||||||
- [x] **Step 10C.3 — Map style config.** `apps/web/src/map/style.ts`: `mapStyleSchema` (Zod) and `mapStyle` with sprite sizes (Sun 64, Jupiter 48, Earth/Mars 32, Ceres 22, Moon 18, ships 24 px), sprite scale 0.75–1.75×, Moon offset 30 px at −45° from Earth, idle and location badge offsets, 44 px minimum tap, parallax 0.15, background margin 10%, label and colors. Test: `map-style.test.ts`.
|
- [x] **Step 10C.3 — Map style config.** `apps/web/src/map/style.ts`: `mapStyleSchema` (Zod) and `mapStyle` with sprite sizes (Sun 64, Jupiter 48, Earth/Mars 32, Ceres 22, Moon 18, ships 24 px), sprite scale 0.75–1.75×, Moon offset 30 px at −45° from Earth, idle and location badge offsets, 44 px minimum tap, parallax 0.15, background margin 10%, label and colors. Test: `map-style.test.ts`.
|
||||||
- [x] **Step 10C.4 — Asset loading and fallback.** `map/art/load.ts`: `artBundle(dpr)` (2x at DPR ≥ 2) registered once as PixiJS bundle `map-art`, each asset loaded separately so a failure only costs that asset (one warning, placeholder). `map/art/visuals.ts` picks texture or placeholder per item; the scene now carries sprite keys and each flight's ship model (loaner → Company Courier). Drawing moved from `MapCanvas` into `map/renderer.ts` (`MapRenderer`); the map shows "Loading map…" until art and renderer are ready. Test: `map-art-loading.test.ts`.
|
- [x] **Step 10C.4 — Asset loading and fallback.** `map/art/load.ts`: `artBundle(dpr)` (2x at DPR ≥ 2) registered once as PixiJS bundle `map-art`, each asset loaded separately so a failure only costs that asset (one warning, placeholder). `map/art/visuals.ts` picks texture or placeholder per item; the scene now carries sprite keys and each flight's ship model (loaner → Company Courier). Drawing moved from `MapCanvas` into `map/renderer.ts` (`MapRenderer`); the map shows "Loading map…" until art and renderer are ready. Test: `map-art-loading.test.ts`.
|
||||||
- [x] **Step 10C.5 — Background.** `map/layout.ts` `backgroundRect`: cover viewport + 10% margin each side, zoom follow `zoom^0.05` (never below cover), pan × 0.15 parallax, clamped so no edge shows; drawn as one sprite under everything. The Map screen now fills the space down to the nav (it stopped 42 px short). Test: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c5-background-tutorial-{desktop,phone}{,-panned}.png`.
|
- [x] **Step 10C.5 — Background.** `map/layout.ts` `backgroundRect`: cover viewport + 10% margin each side, zoom follow `zoom^0.05` (never below cover), pan × 0.15 parallax, clamped so no edge shows; drawn as one sprite under everything. The Map screen now fills the space down to the nav (it stopped 42 px short). Test: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c5-background-tutorial-{desktop,phone}{,-panned}.png`.
|
||||||
- [ ] Step 10C.6 — Bodies, orbits, and labels
|
- [x] **Step 10C.6 — Bodies, orbits, and labels.** `layoutScene` (map/layout.ts) gives every body, the Sun and each flight a screen centre, size (base × `spriteScale(zoom)` = zoom^0.35 clamped 0.75–1.75) and tap size (≥ 44 px); hit-testing uses it. The Moon is drawn 46 px from Earth at −30° from the Sun→Earth direction, with a faint ring around Earth; Moon routes start and end there. `decorate` places labels (bold, dark halo) below or above to avoid bodies and other labels, and idle badges on a clear diagonal. Jupiter's badge art is drawn on its edge when provided (not yet). Tests: `map-layout.test.ts`. **Manual check:** `docs/screenshots/10c6-bodies-{tutorial,midgame}-{desktop,phone}.png`.
|
||||||
- [ ] Step 10C.7 — Location state overlays
|
- [ ] Step 10C.7 — Location state overlays
|
||||||
- [ ] Step 10C.8 — Ships in flight
|
- [ ] Step 10C.8 — Ships in flight
|
||||||
- [ ] Step 10C.9 — Performance
|
- [ ] Step 10C.9 — Performance
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue