Spaces:
Paused
Paused
File size: 15,487 Bytes
17641e3 1aaa2ca 17641e3 9eded26 1aaa2ca 430c532 681e447 71c607d 681e447 71c607d 681e447 71c607d 681e447 71c607d 681e447 71c607d 681e447 71c607d 681e447 6d7e8fa 6bd2c72 430c532 a53030d 34ea8bc a53030d 17641e3 34ea8bc 17641e3 34ea8bc 6d7e8fa 71c607d 6bd2c72 03f71c7 34ea8bc 6bd2c72 dcbdeac 6bd2c72 71c607d cae3dc6 845de25 b1fda0e f894a44 9eded26 17641e3 34ea8bc a53030d 947d9db 34ea8bc a4bbde9 82e04a5 34ea8bc 6d7e8fa a4bbde9 6d7e8fa 82e04a5 6d7e8fa 82e04a5 34ea8bc 17641e3 03f71c7 82e04a5 03f71c7 82e04a5 03f71c7 82e04a5 03f71c7 82e04a5 03f71c7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 | /* ============================================================
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);
}
|