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);
}