SpaceCities / render.js
Claude
Claude Opus 5
Tier 1, step 4b: stop one bad entity killing the view (A7, A8)
a53030d unverified
Raw History Blame Contribute Delete
15.5 kB
/* ============================================================
Canvas rendering β€” the frame ORCHESTRATOR. Pure read of state β€” never mutates
it. Assumes the caller already set up a device-pixel-ratio transform; drawFrame
lays the camera transform on top, so every draw* helper works in plain world
coordinates and never has to know about the viewport or camera itself.
No image assets β€” the project is deliberately zero-dependency/no-build, so every
entity is a small vector silhouette built from canvas paths rather than sprite
files. To keep any one file from being a god object, the ~1900-line original was
split into cohesive sibling modules that this file composes:
β€’ renderShared.js β€” DETAIL, the facing/interpolation state, geometry+colour
primitives, inView culling, the health-bar primitive.
β€’ renderNodes.js β€” resource-deposit silhouettes.
β€’ renderBuildings.js β€” building hulls + bars + Odyssey selection cues.
β€’ renderUnits.js β€” unit hulls (two-pass) + overlays.
β€’ renderEffects.js β€” tracers/flashes/pings, scenario route, ghosts, overlays.
This file keeps drawFrame (the draw order), spriteIcon (the HUD button art),
viewBounds, and the map backdrop (fog wash, terrain, screen-space fireworks).
============================================================ */
"use strict";
import { UNITS, BUILDINGS } from "./engine/entities.js";
import { FOG_CELL_SIZE } from "./engine/fog.js";
import { activeFireworks } from "./effects.js";
import { DETAIL, facing, pruneFacing } from "./renderShared.js";
import { drawNodes } from "./renderNodes.js";
import { drawBuildings, drawBuildingShape, drawBuildingBars, drawJumpStaging, drawPowerGrid } from "./renderBuildings.js";
import { drawUnits, drawUnitShape } from "./renderUnits.js";
import { drawScenario, drawEffects, drawBuildGhost, drawWaypoints, drawEscortLinks,
drawSelectionRings, drawRallyPoint, drawDragBox } from "./renderEffects.js";
// snapshotPositions + resetFacing live with the interpolation/facing state they drive
// (renderShared.js); re-export them here so boot.js keeps importing them from render.js.
export { snapshotPositions, resetFacing } from "./renderShared.js";
const ICON_BOX = 40; // css px of the square icon
const ICON_R = 14; // normalized sprite radius inside the box
const iconCache = new Map();
export function spriteIcon(kind, type, color = "#8fd3ff") {
// Rasterize at device-pixel density (min 2Γ—) so button art stays crisp next to the
// DPR-scaled map art on a 3Γ— display, instead of a fixed 2Γ—. Computed BEFORE the cache
// key (and folded INTO it below) so dragging the window to a different-DPR display
// busts the cache instead of the icon staying rasterized at the old, now-wrong scale
// for the rest of the session.
const scale = Math.max(2, Math.ceil((typeof window !== "undefined" && window.devicePixelRatio) || 1));
const key = `${kind}:${type}:${color}:${scale}`;
if (iconCache.has(key)) return iconCache.get(key);
let canvas, c;
try {
canvas = document.createElement("canvas");
canvas.width = canvas.height = ICON_BOX * scale;
c = canvas.getContext("2d");
if (!c) throw new Error("2d context unavailable");
c.scale(scale, scale);
} catch (e) {
// Canvas/context setup failing is an ENVIRONMENT problem (a momentary resource cap,
// a lost context, ...) β€” not something specific to this (kind, type). Don't cache
// it, so once the environment recovers the next call gets a real render instead of
// being stuck on "" for the rest of the session.
console.error(`spriteIcon: canvas setup failed for "${key}"`, e);
return "";
}
let url = "";
try {
const def = kind === "unit" ? UNITS[type] : BUILDINGS[type];
const actualR = (def && def.radius) || 16;
c.translate(ICON_BOX / 2, ICON_BOX / 2);
c.scale(ICON_R / actualR, ICON_R / actualR); // normalize the sprite to ICON_R
if (kind === "unit") {
c.fillStyle = color; c.strokeStyle = DETAIL; c.lineWidth = 1.5;
drawUnitShape(c, { id: "__icon__", type, x: 0, y: 0 }, def, color);
} else {
drawBuildingShape(c, { units: new Map(), buildings: new Map() }, { id: "__icon__", type, x: 0, y: 0, radius: actualR }, color);
}
facing.delete("__icon__"); // don't leave a stray orientation in the shared map
url = canvas.toDataURL();
} catch (e) {
// A failure HERE is deterministic for this (kind, type) β€” the shape-drawing code
// itself is broken (e.g. it reads an undefined `def` property) β€” so cache the empty
// result too: otherwise a genuinely broken icon type would redo the full
// allocate+draw+throw cycle on every single call, forever, instead of failing once.
console.error(`spriteIcon: failed to draw icon for "${key}"`, e);
iconCache.set(key, "");
return "";
}
iconCache.set(key, url); // the valid path: identical to before β€” a working icon type
return url; // renders and caches exactly as it always has
}
// The world-space rectangle currently on screen, padded so an entity straddling
// an edge still draws. Everything outside it is skipped β€” on a Gigantic (4x)
// map the vast majority of the field, its fog cells, and its entities are
// off-screen every frame, so culling is what keeps the draw cost bounded by
// what's visible rather than by total map size.
function viewBounds(camera, vw, vh, pad = 0) {
const halfW = vw / (2 * camera.zoom), halfH = vh / (2 * camera.zoom);
return {
minX: camera.x - halfW - pad, maxX: camera.x + halfW + pad,
minY: camera.y - halfH - pad, maxY: camera.y + halfH + pad,
};
}
// observerMode (default false β€” every existing caller/test stays byte-identical): Observer
// Mode's whole point is seeing everything on the spectated world, so the fog wash is skipped
// outright and every per-entity fog gate below (drawBuildings/drawUnits/drawBuildingBars) is
// bypassed too β€” a pure render-time flag, never a mutation of state.fog itself.
export function drawFrame(ctx, state, camera, viewportW, viewportH, dragBox, buildGhost, alpha = 1, observerMode = false) {
pruneFacing(state);
// try/finally, because engine/loop.js deliberately SWALLOWS a throwing render so a bad entity
// can't brick the session. That only works if the frame unwinds: without the finally, a throw
// left this save() unmatched, so the camera transform was never popped and every later frame
// drew on top of it β€” including the backdrop fillRect below, which is why the screen stopped
// clearing at all. The loop kept running while the view stayed dead, which to a player is a
// freeze, and it is exactly the symptom test/renderEffects.test.js already records as shipped.
ctx.save();
try {
ctx.fillStyle = "#05070f";
ctx.fillRect(0, 0, viewportW, viewportH);
ctx.translate(viewportW / 2, viewportH / 2);
ctx.scale(camera.zoom, camera.zoom);
ctx.translate(-camera.x, -camera.y);
const view = viewBounds(camera, viewportW, viewportH, 40); // 40px pad β‰₯ largest entity radius
// Computed ONCE per frame and threaded down to every draw* helper that needs to test
// membership, instead of each of drawBuildingBars/drawJumpStaging/drawPowerGrid/drawUnits
// independently allocating its own `new Set(state.selection)` sixty times a second.
const selSet = new Set(state.selection);
if (!observerMode) drawFogBase(ctx, state, view);
drawTerrain(ctx, state, view); // charted geography β€” under nodes/units, always visible
// Resource deposits are charted map knowledge (see data.js), not
// battlefield intel β€” they render at full visibility regardless of
// fog, on top of the dimmed backdrop.
drawNodes(ctx, state, view, observerMode);
if (state.scenario) drawScenario(ctx, state); // the convoy route + stations, under the units
drawBuildings(ctx, state, view, observerMode); // building hulls + foe pips (bars deferred below)
drawUnits(ctx, state, view, alpha, selSet, observerMode); // unit hulls, then unit overlays (two passes)
drawBuildingBars(ctx, state, view, selSet, observerMode); // building health bars last, so a passing ship can't paint them out
drawJumpStaging(ctx, state, view, selSet); // staging ring around a selected Spaceport (Odyssey)
drawPowerGrid(ctx, state, view, selSet); // efficiency zones around a selected Reactor (Odyssey)
drawEffects(ctx);
if (buildGhost) drawBuildGhost(ctx, state, buildGhost);
drawWaypoints(ctx, state);
drawEscortLinks(ctx, state);
drawSelectionRings(ctx, state, alpha);
drawRallyPoint(ctx, state);
if (dragBox) drawDragBox(ctx, dragBox);
} finally {
ctx.restore();
}
drawFireworks(ctx, viewportW, viewportH); // screen-space (post-camera): milestone celebration, always on-screen
}
// Screen-space celebratory bursts for Odyssey progress milestones (see effects.js). Drawn
// AFTER the world/camera transform is popped, so a firework sits in viewport pixels and shows
// no matter where the camera is. Each spark flies out from its shell along its angle, arcs down
// under a little gravity, and fades as the shell ages; additive blending makes them glow.
function drawFireworks(ctx, vw, vh) {
const shells = activeFireworks();
if (!shells.length) return;
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (const f of shells) {
const cx = f.cx * vw, cy = f.cy * vh;
const ease = 1 - Math.pow(1 - f.age, 2); // fast out, then settling
const grav = 0.18 * f.age * f.age * vh; // downward drift over the shell's life
const alpha = Math.max(0, 1 - f.age);
for (const p of f.parts) {
const dist = p.spd * ease * vh;
const x = cx + Math.cos(p.a) * dist;
const y = cy + Math.sin(p.a) * dist + grav;
ctx.beginPath();
ctx.arc(x, y, 2.4, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 95%, ${58 + 22 * alpha}%, ${alpha})`;
ctx.fill();
}
}
ctx.restore();
}
// Explored-but-not-currently-visible cells get a faint cyan "charted space" wash so the
// player can see where they've already scouted β€” the same language the minimap uses. The
// old code painted this fill (and the unexplored one) in rgba(5,7,15,*), which IS the
// backdrop colour #05070f, so BOTH states were invisible: explored and unexplored ground
// rendered identically to empty space, and the promised scouted-territory cue never
// existed on the main map. Unexplored cells now draw nothing (bare backdrop reads as
// "unknown"); currently-visible cells draw nothing (the world underneath is at full
// brightness); only explored-not-visible cells get the wash.
//
// This layer is genuinely dynamic β€” fog.visible flips every tick as units move β€” so unlike
// the terrain cache below it can't be pre-rendered once. It IS spatially coherent though
// (docs/improvement-proposals.md "Stop per-cell fog/terrain fills": most rows are long
// contiguous runs of the same qualify/don't-qualify state), so instead of one fillRect per
// cell this walks each row and merges a run of qualifying cells into a single fillRect β€”
// same cells painted, same colour, far fewer draw calls on a big map's mostly-uniform fog.
export function drawFogBase(ctx, state, view) {
const fog = state.fog;
if (!fog) return;
// Only the cells overlapping the viewport β€” clamped to the grid β€” instead of
// all rows*cols every frame (16k+ on a 4x map).
const gx0 = view ? Math.max(0, Math.floor(view.minX / FOG_CELL_SIZE)) : 0;
const gx1 = view ? Math.min(fog.cols - 1, Math.floor(view.maxX / FOG_CELL_SIZE)) : fog.cols - 1;
const gy0 = view ? Math.max(0, Math.floor(view.minY / FOG_CELL_SIZE)) : 0;
const gy1 = view ? Math.min(fog.rows - 1, Math.floor(view.maxY / FOG_CELL_SIZE)) : fog.rows - 1;
ctx.fillStyle = "rgba(79, 209, 255, 0.05)"; // charted-but-not-live wash (matches the minimap)
for (let gy = gy0; gy <= gy1; gy++) {
const row = gy * fog.cols;
let runStart = -1; // gx a qualifying run began at, or -1 while no run is open
for (let gx = gx0; gx <= gx1; gx++) {
const idx = row + gx;
const qualifies = !fog.visible[idx] && fog.explored[idx]; // live cells and never-seen cells draw nothing
if (qualifies) {
if (runStart === -1) runStart = gx;
} else if (runStart !== -1) {
ctx.fillRect(runStart * FOG_CELL_SIZE, gy * FOG_CELL_SIZE, (gx - runStart) * FOG_CELL_SIZE, FOG_CELL_SIZE);
runStart = -1;
}
}
if (runStart !== -1) { // a run that reached the last in-view column β€” flush it after the loop
ctx.fillRect(runStart * FOG_CELL_SIZE, gy * FOG_CELL_SIZE, (gx1 + 1 - runStart) * FOG_CELL_SIZE, FOG_CELL_SIZE);
}
}
}
// Terrain washes, drawn as charted geography beneath everything else. Rough
// ground reads as a cool slate haze, high ground as a warm gold glow β€” subtle,
// so it shapes the field without fighting the units for attention. OPEN cells
// (the majority) draw nothing.
const TERRAIN_FILL = { 1: "rgba(120, 140, 180, 0.16)", 2: "rgba(255, 209, 102, 0.13)" };
// Terrain is immutable for the whole match (engine/map.js's own header comment), unlike the
// fog wash above β€” so instead of re-walking every on-screen cell every frame, pre-render the
// WHOLE grid ONCE into an offscreen canvas at cell resolution (one canvas pixel per terrain
// cell β€” cols x rows, tiny even on a Gigantic map) and blit only the visible sub-rect with a
// single drawImage per frame. Same underlayMap cache idiom minimap.js already uses for its own
// static layers (see its header comment for the identical "16k+ fillRects" pathology) β€” keyed
// on state.map identity so a fresh map (a new object from generateMap) invalidates the cache,
// but the same map reuses the cached canvas for the rest of the match.
let terrainCache = null, terrainCacheMap = null;
function ensureTerrainCache(map, t) {
if (terrainCacheMap === map) return terrainCache;
const canvas = document.createElement("canvas");
canvas.width = t.cols; canvas.height = t.rows;
const c = canvas.getContext("2d");
for (let gy = 0; gy < t.rows; gy++) {
const row = gy * t.cols;
for (let gx = 0; gx < t.cols; gx++) {
const code = t.type[row + gx];
if (!code) continue; // OPEN stays transparent, same as the old per-cell "continue"
c.fillStyle = TERRAIN_FILL[code];
c.fillRect(gx, gy, 1, 1); // one canvas pixel per cell β€” exact 1:1, no seam-hiding +1 needed
}
}
terrainCache = canvas;
terrainCacheMap = map;
return terrainCache;
}
export function drawTerrain(ctx, state, view) {
const t = state.map.terrain;
if (!t) return;
const canvas = ensureTerrainCache(state.map, t);
const gx0 = view ? Math.max(0, Math.floor(view.minX / t.cell)) : 0;
const gx1 = view ? Math.min(t.cols - 1, Math.floor(view.maxX / t.cell)) : t.cols - 1;
const gy0 = view ? Math.max(0, Math.floor(view.minY / t.cell)) : 0;
const gy1 = view ? Math.min(t.rows - 1, Math.floor(view.maxY / t.cell)) : t.rows - 1;
if (gx1 < gx0 || gy1 < gy0) return; // camera panned fully off the grid β€” nothing to blit
const cw = gx1 - gx0 + 1, ch = gy1 - gy0 + 1;
ctx.imageSmoothingEnabled = false; // nearest-neighbour scale-up β€” keeps the hard-edged per-cell look
ctx.drawImage(canvas, gx0, gy0, cw, ch, gx0 * t.cell, gy0 * t.cell, cw * t.cell, ch * t.cell);
}