Step 10C.8: rotated ship sprites, routes and separation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
09a89b2a74
commit
0aa8cee953
|
|
@ -734,3 +734,10 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
|
|||
- **The padlock is drawn over the body's centre** and the label loses its 🔒 emoji. Undiscovered labels keep the muted color.
|
||||
- **The HQ marker is a small green house** placed beyond the label (below a label that is below the body, above one that is above), so it never sits between label and body. The old green ring around bay bodies is gone.
|
||||
- **Lock arc fix:** a bare `Graphics.arc` in PixiJS continues the current path, which drew long stray lines. The lock now starts its shackle with `moveTo`.
|
||||
|
||||
## Step 10C.8
|
||||
|
||||
- **Clustering threshold and spacing are both 44 px** (`shipSeparationPx` = the minimum tap size), so after nudging each ship has its own full tap target. Ships are grouped greedily by distance and spread along the normal of the first ship's route, centred on the group's average position.
|
||||
- **Ships keep the sim position on the route line** except when nudged; the route line itself is not moved.
|
||||
- **Highlight rings** (amber, 5 px outside the sprite) go on the selected ship, and on a selected location too, since "selection highlights" are in the CR's code-drawn list.
|
||||
- **Earth–Moon flights stay close to both bodies**: with the Moon at a fixed 46 px from Earth (decision 0.5.39), those routes are always short on screen. That is the intended trade-off.
|
||||
|
|
|
|||
|
|
@ -17,16 +17,21 @@ import { mapStyle } from "./style";
|
|||
export function MapCanvas({
|
||||
state,
|
||||
onSelect,
|
||||
selected = null,
|
||||
}: {
|
||||
state: PlayerState;
|
||||
/** A tap on a location or ship, or on empty space (null) (Step 10.3). */
|
||||
onSelect?: (selection: MapSelection | null) => void;
|
||||
/** Highlighted with a ring (Step 10C.8). */
|
||||
selected?: MapSelection | null;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const stateRef = useRef(state);
|
||||
stateRef.current = state;
|
||||
const onSelectRef = useRef(onSelect);
|
||||
onSelectRef.current = onSelect;
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -64,6 +69,7 @@ export function MapCanvas({
|
|||
buildScene(stateRef.current, serverNow()),
|
||||
viewport(),
|
||||
useCamera.getState(),
|
||||
selectedRef.current,
|
||||
),
|
||||
);
|
||||
})
|
||||
|
|
|
|||
|
|
@ -105,7 +105,10 @@ export interface FlightLayout {
|
|||
/** 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;
|
||||
}
|
||||
|
|
@ -142,7 +145,8 @@ export function layoutScene(
|
|||
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) => {
|
||||
flights: separateShips(
|
||||
scene.flights.map((f) => {
|
||||
const from = drawn(f.from, f.originId);
|
||||
const to = drawn(f.to, f.destinationId);
|
||||
return {
|
||||
|
|
@ -153,13 +157,57 @@ export function layoutScene(
|
|||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nudges ships that would be drawn within `shipSeparationPx` of each other
|
||||
* apart, perpendicular to the first one's route, `shipSeparationPx` apart, so
|
||||
* each stays visible and tappable (Step 10C.8).
|
||||
*/
|
||||
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,
|
||||
};
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ArtTextures } from "./art/load";
|
|||
import { sceneVisuals, type Visual } from "./art/visuals";
|
||||
import { toScreen, type Camera, type Viewport } from "./camera";
|
||||
import { backgroundRect, decorate, layoutScene, polar } from "./layout";
|
||||
import type { MapSelection } from "./hitTest";
|
||||
import { locationOverlays } from "./overlays";
|
||||
import type { MapPoint } from "./projection";
|
||||
import type { Scene } from "./scene";
|
||||
|
|
@ -127,7 +128,7 @@ export class MapRenderer {
|
|||
return text;
|
||||
}
|
||||
|
||||
draw(scene: Scene, view: Viewport, camera: Camera) {
|
||||
draw(scene: Scene, view: Viewport, camera: Camera, selected: MapSelection | null = null) {
|
||||
const visuals = sceneVisuals(scene, this.textures);
|
||||
const layout = layoutScene(scene, view, camera);
|
||||
const sun = layout.sun.center;
|
||||
|
|
@ -258,14 +259,24 @@ export class MapRenderer {
|
|||
}
|
||||
}
|
||||
|
||||
// In-flight ships: the route line and the ship on it (Step 10.2).
|
||||
// In-flight ships (Steps 10.2, 10C.8): a faint route, brighter ahead of the
|
||||
// ship, and the ship's sprite facing its direction of travel.
|
||||
for (const flight of layout.flights) {
|
||||
const scened = scene.flights.find((f) => f.dispatchId === flight.dispatchId)!;
|
||||
const onRoute = {
|
||||
x: flight.from.x + (flight.to.x - flight.from.x) * scened.progress,
|
||||
y: flight.from.y + (flight.to.y - flight.from.y) * scened.progress,
|
||||
};
|
||||
this.routes
|
||||
.moveTo(flight.from.x, flight.from.y)
|
||||
.lineTo(flight.to.x, flight.to.y)
|
||||
.stroke({ width: 1, color: colors.routeBehind });
|
||||
.stroke({ width: 1, color: colors.routeBehind, alpha: 0.35 });
|
||||
this.routes
|
||||
.moveTo(onRoute.x, onRoute.y)
|
||||
.lineTo(flight.to.x, flight.to.y)
|
||||
.stroke({ width: 1.5, color: colors.routeAhead, alpha: 0.9 });
|
||||
const { center } = flight;
|
||||
this.place(
|
||||
const ship = this.place(
|
||||
`ship:${flight.dispatchId}`,
|
||||
visuals.flights.get(flight.dispatchId)!,
|
||||
this.ships,
|
||||
|
|
@ -273,6 +284,22 @@ export class MapRenderer {
|
|||
flight.size,
|
||||
() => this.overlay.circle(center.x, center.y, 4).fill(colors.placeholderShip),
|
||||
);
|
||||
if (ship) ship.rotation = flight.rotation;
|
||||
if (selected?.kind === "flight" && selected.dispatchId === flight.dispatchId) {
|
||||
this.overlay
|
||||
.circle(center.x, center.y, flight.size / 2 + 5)
|
||||
.stroke({ width: 2, color: colors.selection });
|
||||
}
|
||||
}
|
||||
|
||||
// A selected location gets the same highlight ring.
|
||||
if (selected?.kind === "location") {
|
||||
const location = placed.get(selected.id);
|
||||
if (location) {
|
||||
this.overlay
|
||||
.circle(location.center.x, location.center.y, location.size / 2 + 5)
|
||||
.stroke({ width: 2, color: colors.selection });
|
||||
}
|
||||
}
|
||||
|
||||
// Ships whose flight ended: drop their sprites (the texture is shared and kept).
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export function MapScreen() {
|
|||
// area); -mb-20 cancels the layout's scroll padding so the page never scrolls.
|
||||
<section className="relative -mb-20 h-[calc(100dvh-4.875rem-env(safe-area-inset-bottom))]">
|
||||
<h1 className="sr-only">Map</h1>
|
||||
<MapCanvas state={state} onSelect={setSelection} />
|
||||
<MapCanvas state={state} onSelect={setSelection} selected={selection} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useCamera.setState(DEFAULT_CAMERA)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { separateShips, shipRotation, type FlightLayout } from "../src/map/layout";
|
||||
import { mapStyle } from "../src/map/style";
|
||||
|
||||
const deg = (radians: number) => (radians * 180) / Math.PI;
|
||||
|
||||
describe("ships in flight (Step 10C.8)", () => {
|
||||
it("turns the up-pointing art to face the direction of travel", () => {
|
||||
expect(deg(shipRotation({ x: 0, y: 0 }, { x: 10, y: 0 }))).toBeCloseTo(90);
|
||||
expect(deg(shipRotation({ x: 0, y: 0 }, { x: 0, y: 10 }))).toBeCloseTo(180);
|
||||
expect(deg(shipRotation({ x: 0, y: 10 }, { x: 0, y: 0 }))).toBeCloseTo(0);
|
||||
expect(deg(shipRotation({ x: 10, y: 0 }, { x: 0, y: 0 }))).toBeCloseTo(270); // = -90°
|
||||
});
|
||||
|
||||
const flight = (dispatchId: string, x = 100, y = 100): FlightLayout => ({
|
||||
dispatchId,
|
||||
from: { x: 0, y: 100 },
|
||||
to: { x: 200, y: 100 },
|
||||
center: { x, y },
|
||||
rotation: Math.PI / 2,
|
||||
size: 24,
|
||||
tap: 44,
|
||||
});
|
||||
|
||||
it("spreads three ships at the same spot apart, at least a tap target apart", () => {
|
||||
const spread = separateShips([flight("a"), flight("b"), flight("c")]);
|
||||
const points = spread.map((f) => f.center);
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
for (let j = i + 1; j < points.length; j++) {
|
||||
const d = Math.hypot(points[i]!.x - points[j]!.x, points[i]!.y - points[j]!.y);
|
||||
expect(d).toBeGreaterThanOrEqual(mapStyle.minTapPx);
|
||||
}
|
||||
}
|
||||
// Perpendicular to the (horizontal) route, centred on where they were.
|
||||
expect(points.map((p) => p.x)).toEqual([100, 100, 100]);
|
||||
expect(points.map((p) => p.y).reduce((a, b) => a + b) / 3).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("leaves ships that are already apart where they are", () => {
|
||||
const apart = [flight("a", 20), flight("b", 180)];
|
||||
expect(separateShips(apart)).toEqual(apart);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 696 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 766 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
|
|
@ -156,6 +156,6 @@ Spec: `memory-bank/change-requests/CR-003-map-art.md`. New decisions recorded as
|
|||
- [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.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`.
|
||||
- [x] **Step 10C.7 — Location state overlays.** `map/overlays.ts` `locationOverlays(scene)` decides per location: undiscovered → desaturated (one shared `ColorMatrixFilter`), alpha 0.55 and a code-drawn padlock (replaces the 🔒 label of Step 10A.16; the tap panel is unchanged); bay → a small green house marker on the far side of the label; idle ships → count badge only. Test: `map-overlays.test.ts`. **Manual check:** `docs/screenshots/10c7-overlays-{tutorial,midgame}-{desktop,phone}.png`.
|
||||
- [ ] Step 10C.8 — Ships in flight
|
||||
- [x] **Step 10C.8 — Ships in flight.** Each flight draws its model's sprite rotated by `shipRotation` (art points up: right = 90°, down = 180°), positioned by sim progress every frame. Routes are faint end to end and brighter ahead of the ship. `separateShips` spreads ships closer than 44 px apart, perpendicular to their route and 44 px apart; hit-testing uses the same positions. The selected ship or location gets an amber ring (`MapCanvas` `selected` prop). Test: `map-ships.test.ts`. **Manual check:** `docs/screenshots/10c8-ships-midgame-{desktop,desktop-zoomed,phone}.png`.
|
||||
- [ ] Step 10C.9 — Performance
|
||||
- [ ] Step 10C.10 — Plan amendments and regression
|
||||
|
|
|
|||
Loading…
Reference in New Issue