Spaces:
Paused
Paused
File size: 13,013 Bytes
1aaa2ca b870df3 1aaa2ca 6648f62 1aaa2ca 6648f62 1aaa2ca 6648f62 1aaa2ca 505ae6b 1aaa2ca b870df3 | 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 | /* ============================================================
Render shared toolkit β the primitives and render-side state every draw
module needs, kept in one leaf (no engine, no cross-render imports) so the
node / building / unit / effect draw modules can share them without an import
cycle:
β’ DETAIL β the near-white accent used for hull greebles across both colours.
β’ the facing map + frame-to-frame interpolation state (prevPos), with
snapshotPositions / lerpXY / updateFacing / pruneFacing / resetFacing.
β’ pure geometry + colour helpers (hash/seed/shade/hexA/polygon/path/orient).
β’ inView culling and the drawHealthBar bar primitive (used by units AND
buildings).
Pure render-side bookkeeping β never read by the deterministic sim.
============================================================ */
"use strict";
import { isVisibleAt } from "./engine/fog.js";
// A light, near-white accent used for hull details (sensor eyes, canopy
// glass, engine glow, antenna lights) across both players' colors β the
// same "light outline reads at small sizes" reasoning the old triangle
// used, just reused for interior greebles too instead of only the outline.
export const DETAIL = "#dce6ff";
// Facing angle per unit id, inferred frame-to-frame from movement β pure
// render-side bookkeeping, never read by the sim. Shared by every oriented
// unit type (currently Skiff, Bastion and Lancer) so hull/turret art can
// point the way the unit is actually moving. Buildings ("b*" ids) reuse it too
// (a Sentinel Turret's barrel), and they can't collide with unit ("u*") ids.
export const facing = new Map();
// --- render interpolation ---------------------------------------------------
// The sim ticks at a fixed 20 Hz but the screen paints at 60β144 Hz, so drawing raw sim
// positions makes every unit teleport in 50 ms steps. boot.js snapshots each unit's position
// BEFORE each tick (snapshotPositions), the loop hands render the leftover fraction (alpha),
// and we draw at prev + (cur - prev) * alpha β pure render-side smoothing that never touches
// the deterministic sim (drawFrame's "never mutates" contract holds). A unit that JUMPED this
// step (relief spawn, world relocation) moves too far to slide, so we snap it instead.
const prevPos = new Map(); // unit id -> { x, y } at the start of the current tick
const TELEPORT_SQ = 60 * 60; // a one-tick move past this is a teleport, not motion β don't lerp it
const _lerpPt = { x: 0, y: 0 }; // reused scratch so per-frame interpolation allocates nothing
// Record every live unit's current position as the interpolation baseline. Called by boot.js
// immediately before each tick(); reuses stored objects, so it allocates only for new units.
export function snapshotPositions(state) {
for (const u of state.units.values()) {
const p = prevPos.get(u.id);
if (p) { p.x = u.x; p.y = u.y; } else prevPos.set(u.id, { x: u.x, y: u.y });
}
}
// The position to DRAW unit `u` at, given the frame's interpolation alpha (0..1). Returns the
// live unit when there's no baseline (a unit spawned this tick), at a full step (alpha β₯ 1), or
// across a teleport; otherwise a reused {x,y} scratch on the prevβcur segment. Read it
// immediately β the scratch is overwritten on the next call.
export function lerpXY(u, alpha) {
const p = prevPos.get(u.id);
if (!p || alpha >= 1) return u;
const dx = u.x - p.x, dy = u.y - p.y;
if (dx * dx + dy * dy >= TELEPORT_SQ) return u;
_lerpPt.x = p.x + dx * alpha; _lerpPt.y = p.y + dy * alpha;
return _lerpPt;
}
// Drop facing entries for entities that no longer exist, so a long match with
// heavy unit churn (or repeated restarts) doesn't grow the Map without bound.
// Cheap: one Map.has per live-or-dead key, and after pruning the Map holds at
// most one entry per currently-live oriented entity.
export function pruneFacing(state) {
for (const id of facing.keys()) {
if (!state.units.has(id) && !state.buildings.has(id)) facing.delete(id);
}
for (const id of prevPos.keys()) {
if (!state.units.has(id)) prevPos.delete(id); // interpolation baselines for dead units
}
}
// Cleared on a fresh game so orientations/positions from a previous match don't linger.
export function resetFacing() {
facing.clear();
prevPos.clear();
}
// Infer and store a unit's facing angle from its frame-to-frame movement; returns the angle so
// an oriented hull can draw pointing the way it's actually moving. A near-stationary unit keeps
// its previous angle EXCEPT when the player has set an explicit one (unit.facing β a real,
// engine-side field set by a click-and-drag move/attack-move, engine/commands.js applyFacing):
// then it snaps to/holds THAT instead, so a formation told to face the enemy actually does once
// it stops, rather than freezing at whatever direction its last step of pathing happened to
// leave it pointing. While actually moving, travel direction still wins β a unit sliding
// sideways while nominally "facing" a fixed angle would read as broken, not deliberate. Buildings
// call it too (a turret barrel, which never sets unit.facing) β the "b*"/"u*" id spaces can't
// collide in the shared map.
export function updateFacing(unit) {
const prev = facing.get(unit.id);
const explicit = Number.isFinite(unit.facing);
let angle = prev ? prev.angle : (explicit ? unit.facing : -Math.PI / 2);
if (prev) {
const dx = unit.x - prev.x, dy = unit.y - prev.y;
if (Math.hypot(dx, dy) > 0.5) angle = Math.atan2(dy, dx);
else if (explicit) angle = unit.facing;
}
facing.set(unit.id, { x: unit.x, y: unit.y, angle });
return angle;
}
// True when (x,y) β padded by radius r β overlaps the on-screen world rect (viewBounds). Every
// draw module culls off-screen entities with it, so on a big map the draw cost tracks what's
// visible, not total map size.
export function inView(view, x, y, r = 0) {
return x + r >= view.minX && x - r <= view.maxX && y + r >= view.minY && y - r <= view.maxY;
}
/* ---------- small geometry helpers ---------- */
// Deterministic string hash β seeded PRNG, so each resource node's
// "irregular rock" silhouette is stable frame to frame (derived from its
// id) instead of jittering every draw call like a fresh Math.random() would.
export function hashStr(s) {
let h = 2166136261;
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
export function seededRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Lightens (positive percent) or darkens (negative) a "#rrggbb" color, used
// to derive hull-shadow/highlight tones from a player's own color so
// buildings/units read as one paint job rather than a flat single fill.
// NOT memoized, and deliberately so. This is called per building per frame and builds a few
// short-lived strings, so caching on (hex, percent) looks like an easy win β it was tried and
// MEASURED, and it is a loss: the cache key is itself a fresh string per call, which costs more
// than the result it saves. On a 120-building, 306-unit frame the memo took garbage from
// 51.5 KB/frame to 62.2 and the frame from 0.22ms to 0.24. Left plain; test/renderShared.test.js
// pins the returned strings either way, so a future attempt starts from a real baseline.
export function shade(hex, percent) {
const num = parseInt(hex.slice(1), 16);
const amt = Math.round(2.55 * percent);
const clamp = (v) => Math.min(255, Math.max(0, v));
const r = clamp((num >> 16) + amt);
const g = clamp(((num >> 8) & 0xff) + amt);
const b = clamp((num & 0xff) + amt);
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, "0")}`;
}
// A "#rrggbb" hex plus an alpha β an "rgba(r,g,b,a)" string, so a fixed palette
// colour can be drawn translucent (the efficiency-zone fills/rings).
export function hexA(hex, alpha) {
const num = parseInt(hex.slice(1), 16);
return `rgba(${num >> 16}, ${(num >> 8) & 0xff}, ${num & 0xff}, ${alpha})`;
}
export function polygonPoints(cx, cy, r, sides, rotation = 0) {
const pts = [];
for (let i = 0; i < sides; i++) {
const a = rotation + (i / sides) * Math.PI * 2;
pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
}
return pts;
}
export function pathPoints(ctx, pts) {
ctx.beginPath();
pts.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y)));
ctx.closePath();
}
// Rotates a local point (nose along +x) by `angle` and places it at (cx,cy)
// β lets oriented-unit shapes be authored once in "facing right" space.
export function toWorld(cx, cy, angle, lx, ly) {
const cos = Math.cos(angle), sin = Math.sin(angle);
return [cx + lx * cos - ly * sin, cy + lx * sin + ly * cos];
}
export function pathOriented(ctx, cx, cy, angle, localPts) {
pathPoints(ctx, localPts.map(([lx, ly]) => toWorld(cx, cy, angle, lx, ly)));
}
// A three-band (green / amber / red) health bar, drawn only when damaged (or forced, e.g. a
// selected unit). Shared by the unit overlay pass and the building bar pass.
export function drawHealthBar(ctx, cx, y, w, hp, maxHp, force = false) {
if (hp >= maxHp && !force) return;
const pct = Math.max(0, hp / maxHp);
ctx.fillStyle = "#243162";
ctx.fillRect(cx - w / 2, y, w, 3);
ctx.fillStyle = pct > 0.6 ? "#4ade80" : pct > 0.3 ? "#fbbf24" : "#f87171";
ctx.fillRect(cx - w / 2, y, w * pct, 3);
}
// Centered text, with the canvas defaults put back afterwards.
//
// The restore is the whole point. textAlign/textBaseline are CANVAS-WIDE state, not per-call
// arguments, so a helper that centers text and walks away shifts every later draw in the frame.
// That is not hypothetical: two sites here shipped without the restore and were invisible
// precisely because drawFrame's outer save/restore papered over them once per frame β the
// mis-aligned text was whatever happened to be drawn NEXT, in a different file. Making the pair
// un-splittable is the only fix that stays fixed.
//
// A caller already inside its own ctx.save()/restore() doesn't need this and can set the state
// directly; this is for the majority that aren't.
/** @param {any} ctx @param {string} text @param {number} x @param {number} y @param {string} font @param {string} [baseline] */
export function centeredText(ctx, text, x, y, font, baseline = "middle") {
ctx.font = font;
ctx.textAlign = "center";
ctx.textBaseline = baseline;
ctx.fillText(text, x, y);
ctx.textBaseline = "alphabetic";
ctx.textAlign = "left";
}
// A label on a dark plate, centered on x and sitting with its BASELINE at y β the floating
// annotation the placement ghosts hang above a building footprint (the grid-efficiency readout,
// the Plasma Rig survey verdict). The plate is measured from the text so it always fits, which is
// the part worth having in one place: the two call sites had independently hand-rolled the same
// `measureText β fillRect(x - w/2 - 5, y - 15, w + 10, 17)` arithmetic, and a plate sized by a
// stale constant is a legible label on an illegible background.
//
// Assumes the caller holds a ctx.save() β it leaves font/textAlign/textBaseline/fillStyle set,
// exactly as the inline code it replaces did.
/** @param {any} ctx @param {string} label @param {number} x @param {number} y @param {string} color @param {string} [plate] */
export function drawLabelChip(ctx, label, x, y, color, plate = "rgba(5, 7, 15, 0.78)") {
ctx.font = "bold 13px system-ui, sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "bottom";
const w = ctx.measureText(label).width;
ctx.fillStyle = plate;
ctx.fillRect(x - w / 2 - 5, y - 15, w + 10, 17);
ctx.fillStyle = color;
ctx.fillText(label, x, y);
}
// "Should this entity be drawn at all?" β the fog rule, in one named place.
//
// Four draw passes (units' hulls, units' overlays, buildings' hulls, buildings' bars) each
// carried their own copy of `owner !== "player" && !observerMode && !isVisibleAt(...)`. Every
// clause is easy to get backwards and the failure is silent in the direction that matters: an
// inverted or dropped test doesn't crash or look wrong, it quietly paints the enemy's army
// through the fog. Naming it makes the rule auditable and drivable from a test instead of
// re-derived at four call sites.
//
// observerMode is the self-play/replay camera: it bypasses the gate rather than mutating
// state.fog, so what the player's own fog records is untouched (see observer.js's header).
/** @param {State} state @param {{owner:string}} e @param {number} x @param {number} y @param {boolean} [observerMode] @returns {boolean} */
export function hiddenByFog(state, e, x, y, observerMode = false) {
return e.owner !== "player" && !observerMode && !isVisibleAt(state.fog, x, y);
}
|