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(); private readonly labels = new Map(); private readonly badges = new Map(); /** One shared filter for every undiscovered location's sprite. */ private readonly desaturate = new ColorMatrixFilter(); constructor( stage: Container, private readonly textures: ArtTextures, ) { 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, 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, 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); } } } }