316 lines
12 KiB
TypeScript
316 lines
12 KiB
TypeScript
import { ColorMatrixFilter, Container, Graphics, Sprite, Text, type Texture } from "pixi.js";
|
||
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";
|
||
import { mapStyle } from "./style";
|
||
|
||
const { colors } = mapStyle;
|
||
|
||
/** Fits a sprite's long side to `size` px, keeping its aspect ratio. */
|
||
function fit(sprite: Sprite, size: number) {
|
||
const { width, height } = sprite.texture;
|
||
sprite.scale.set(size / Math.max(width, height, 1));
|
||
}
|
||
|
||
/** A padlock (undiscovered location), `size` px tall, centred on `at`. */
|
||
function drawLock(g: Graphics, at: MapPoint, size: number) {
|
||
const w = size * 0.75;
|
||
const h = size * 0.55;
|
||
const top = at.y - size / 2 + size * 0.45;
|
||
// moveTo first: a bare arc would join on from the previous path point.
|
||
g.moveTo(at.x - w * 0.3, top)
|
||
.arc(at.x, top, w * 0.3, Math.PI, 0)
|
||
.stroke({ width: size * 0.14, color: colors.lock });
|
||
g.roundRect(at.x - w / 2, top, w, h, size * 0.12)
|
||
.fill(colors.lock)
|
||
.stroke({ width: 1.5, color: colors.labelHalo });
|
||
g.circle(at.x, top + h * 0.45, size * 0.08).fill(colors.labelHalo);
|
||
}
|
||
|
||
/** The HQ (bay) marker: a small house, `size` px, centred on `at`. */
|
||
function drawHq(g: Graphics, at: MapPoint, size: number) {
|
||
const s = size / 2;
|
||
g.poly([
|
||
at.x - s,
|
||
at.y,
|
||
at.x,
|
||
at.y - s,
|
||
at.x + s,
|
||
at.y,
|
||
at.x + s * 0.7,
|
||
at.y,
|
||
at.x + s * 0.7,
|
||
at.y + s,
|
||
at.x - s * 0.7,
|
||
at.y + s,
|
||
at.x - s * 0.7,
|
||
at.y,
|
||
])
|
||
.fill(colors.hq)
|
||
.stroke({ width: 1, color: colors.labelHalo });
|
||
}
|
||
|
||
/**
|
||
* Draws the map with PixiJS (Steps 10.1–10.2, CR-003). Positions and sizes come
|
||
* from `layoutScene`; sprites and text are created once per item and updated
|
||
* every frame, and code-drawn shapes go in Graphics layers redrawn each frame.
|
||
*/
|
||
export class MapRenderer {
|
||
/** Non-tiled, parallax background (decision 0.5.40); absent if its art is missing. */
|
||
private readonly background: Sprite | null;
|
||
private readonly orbits = new Graphics();
|
||
private readonly routes = new Graphics();
|
||
private readonly bodies = new Container();
|
||
private readonly ships = new Container();
|
||
private readonly overlay = new Graphics();
|
||
private readonly texts = new Container();
|
||
private readonly sprites = new Map<string, Sprite>();
|
||
private readonly labels = new Map<string, Text>();
|
||
private readonly badges = new Map<string, Text>();
|
||
/** One shared filter for every undiscovered location's sprite. */
|
||
private readonly desaturate = new ColorMatrixFilter();
|
||
|
||
constructor(
|
||
stage: Container,
|
||
private readonly textures: ArtTextures<Texture>,
|
||
) {
|
||
this.desaturate.desaturate();
|
||
const bg = textures.get("bg");
|
||
this.background = bg ? new Sprite({ texture: bg }) : null;
|
||
if (this.background) stage.addChild(this.background);
|
||
stage.addChild(this.orbits, this.routes, this.bodies, this.ships, this.overlay, this.texts);
|
||
}
|
||
|
||
/** A sprite for `key`, created on first use; hidden until positioned this frame. */
|
||
private sprite(key: string, texture: Texture, layer: Container): Sprite {
|
||
let sprite = this.sprites.get(key);
|
||
if (!sprite) {
|
||
sprite = new Sprite({ texture, anchor: 0.5 });
|
||
this.sprites.set(key, sprite);
|
||
layer.addChild(sprite);
|
||
}
|
||
sprite.visible = true;
|
||
return sprite;
|
||
}
|
||
|
||
/** Draws `visual` as a sprite at `at`, or calls `placeholder` (decision 0.5.41). */
|
||
private place(
|
||
key: string,
|
||
visual: Visual<Texture>,
|
||
layer: Container,
|
||
at: MapPoint,
|
||
size: number,
|
||
placeholder: () => void,
|
||
): Sprite | null {
|
||
if (visual.kind === "placeholder") {
|
||
placeholder();
|
||
return null;
|
||
}
|
||
const sprite = this.sprite(key, visual.texture, layer);
|
||
fit(sprite, size);
|
||
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, selected: MapSelection | null = null) {
|
||
const visuals = sceneVisuals(scene, this.textures);
|
||
const layout = layoutScene(scene, view, camera);
|
||
const sun = layout.sun.center;
|
||
const unit = toScreen({ x: 1, y: 0 }, view, camera).x - sun.x;
|
||
for (const sprite of this.sprites.values()) sprite.visible = false;
|
||
|
||
if (this.background) {
|
||
const r = backgroundRect(view, camera, this.background.texture);
|
||
this.background.position.set(r.x, r.y);
|
||
this.background.setSize(r.width, r.height);
|
||
}
|
||
|
||
this.orbits.clear();
|
||
this.routes.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) {
|
||
if (location.orbitRadius !== null) {
|
||
this.orbits
|
||
.circle(sun.x, sun.y, location.orbitRadius * unit)
|
||
.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, layout.sun.size, () =>
|
||
this.overlay.circle(sun.x, sun.y, 7).fill(colors.sun),
|
||
);
|
||
|
||
const overlays = new Map(locationOverlays(scene).map((o) => [o.id, o]));
|
||
for (const location of scene.locations) {
|
||
const { center: p, size } = placed.get(location.id)!;
|
||
const state = overlays.get(location.id)!;
|
||
const placeholderColor = state.locked ? colors.labelUndiscovered : colors.placeholderBody;
|
||
const sprite = this.place(
|
||
`loc:${location.id}`,
|
||
visuals.locations.get(location.id)!,
|
||
this.bodies,
|
||
p,
|
||
size,
|
||
() =>
|
||
this.overlay.circle(p.x, p.y, 5).fill({ color: placeholderColor, alpha: state.alpha }),
|
||
);
|
||
// Undiscovered: desaturated and dimmed (Step 10C.7).
|
||
if (sprite) {
|
||
sprite.filters = state.desaturate ? [this.desaturate] : [];
|
||
sprite.alpha = state.alpha;
|
||
}
|
||
|
||
// 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;
|
||
const art = this.place(
|
||
`badge:${location.id}`,
|
||
badgeArt,
|
||
this.bodies,
|
||
at,
|
||
badgeSize,
|
||
() => {},
|
||
);
|
||
if (art) art.alpha = state.alpha;
|
||
}
|
||
|
||
if (state.locked) drawLock(this.overlay, p, mapStyle.lockSizePx);
|
||
|
||
// The name under the body, with a dark halo for contrast on the background.
|
||
const label = this.text(this.labels, location.id, () => {
|
||
const text = new Text({
|
||
text: location.name,
|
||
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;
|
||
});
|
||
// Only on change: restyling a Text re-renders its texture (Step 10C.9).
|
||
const fill = state.locked ? colors.labelUndiscovered : colors.label;
|
||
if (label.style.fill !== fill) label.style.fill = fill;
|
||
|
||
// Idle ships here: a count badge just off the body's edge; never single sprites.
|
||
const badge = this.text(this.badges, location.id, () => {
|
||
const text = new Text({
|
||
text: "",
|
||
style: {
|
||
fontSize: mapStyle.idleBadge.fontSizePx,
|
||
fontWeight: "700",
|
||
fill: colors.badgeText,
|
||
},
|
||
});
|
||
text.anchor.set(0.5);
|
||
return text;
|
||
});
|
||
badge.visible = state.idleBadge !== null;
|
||
badge.text = String(state.idleBadge ?? "");
|
||
}
|
||
|
||
// Labels below (or above) bodies, badges on a clear diagonal (Step 10C.6), and
|
||
// the HQ marker on the far side of the label from its body (Step 10C.7).
|
||
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);
|
||
if (overlays.get(location.id)!.hq) {
|
||
const gap = 3 + mapStyle.hqSizePx / 2;
|
||
const y = label.anchorY === 0 ? label.y + text.height + gap : label.y - text.height - gap;
|
||
drawHq(this.overlay, { x: label.x, y }, mapStyle.hqSizePx);
|
||
}
|
||
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 (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, 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;
|
||
const ship = this.place(
|
||
`ship:${flight.dispatchId}`,
|
||
visuals.flights.get(flight.dispatchId)!,
|
||
this.ships,
|
||
center,
|
||
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).
|
||
for (const [key, sprite] of this.sprites) {
|
||
if (key.startsWith("ship:") && !sprite.visible) {
|
||
sprite.destroy();
|
||
this.sprites.delete(key);
|
||
}
|
||
}
|
||
}
|
||
}
|