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

316 lines
12 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 { 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);
}
}
}
}