SpaceCities / engine /fog.js
Claude
Tier 1, step 2: make the type layer real (D1, D2, D4)
6db3533 unverified
Raw History Blame
7.67 kB
// @ts-check
/* ============================================================
Fog of war: a coarse grid tracks which cells are currently in sight of
an owner's unit/building (recomputed fresh every tick) and which have
ever been seen (permanent, once set). Both sides get their own grid β€”
state.fog for the player, state.fogAI for the AI β€” so neither is
omniscient; the same createFog/updateFog serves both (updateFog takes
the owner). See engine/ai.js for how the AI's grid gates its decisions.
Deliberately scoped to units/buildings only β€” charted surface deposits
(data.js) stay visible regardless, treated as known map knowledge, not
battlefield intel. Hidden caches (map.js) are the exception: they only
count as known once their cell has been explored (isNodeDiscovered).
There's no "remembered snapshot" of enemy positions once they leave
vision β€” they simply stop rendering (or, for the AI, stop being read).
============================================================ */
"use strict";
import { UNITS, BUILDINGS } from "./entities.js";
import { sampleTerrain, sideMod, TERRAIN } from "./map.js";
export const FOG_CELL_SIZE = 40;
// Uphill concealment (docs/improvement-proposals.md "A real LOS pass: low ground can't see onto
// high ground"): a source whose own tile is NOT high ground only reveals a high-ground CELL out
// to this fraction of its normal sight radius β€” a mesa is a stat pad (extra sight/damage for its
// holder) AND a genuine intelligence blind spot for anyone still down in the lowland, not a free
// look from a comfortable distance. combat.js's acquisition (nearestEnemy/spreadEnemy) imports
// this same constant so "what a lowland unit can see" and "what it can shoot at" agree.
export const UPHILL_FRAC = 0.55;
/** @param {GameMap} map @returns {Fog} */
export function createFog(map) {
const cols = Math.ceil(map.width / FOG_CELL_SIZE);
const rows = Math.ceil(map.height / FOG_CELL_SIZE);
return { cols, rows, explored: new Uint8Array(cols * rows), visible: new Uint8Array(cols * rows) };
}
function cellOf(fog, wx, wy) {
return { cx: Math.floor(wx / FOG_CELL_SIZE), cy: Math.floor(wy / FOG_CELL_SIZE) };
}
function inBounds(fog, cx, cy) {
return cx >= 0 && cy >= 0 && cx < fog.cols && cy < fog.rows;
}
/** @param {Fog} fog @param {number} wx @param {number} wy @returns {boolean} */
export function isVisibleAt(fog, wx, wy) {
const { cx, cy } = cellOf(fog, wx, wy);
return inBounds(fog, cx, cy) && fog.visible[cy * fog.cols + cx] === 1;
}
/** @param {Fog} fog @param {number} wx @param {number} wy @returns {boolean} */
export function isExploredAt(fog, wx, wy) {
const { cx, cy } = cellOf(fog, wx, wy);
return inBounds(fog, cx, cy) && fog.explored[cy * fog.cols + cx] === 1;
}
// Ordinary charted deposits are always known; a hidden cache (map.js) only
// exists for the player once they've scouted its cell. The permanent explored
// memory doubles as the discovery record, so a found cache stays on the map
// even after the scout moves on. Used by render/minimap/input alike so what
// shows, what the minimap dots, and what a right-click can target all agree.
/** @param {Fog} fog @param {ResourceNode} node @returns {boolean} */
export function isNodeDiscovered(fog, node) {
return !node.hidden || isExploredAt(fog, node.x, node.y);
}
// World point at the centre of the nearest never-explored cell to (fromX, fromY),
// or null when the whole grid has been explored. A row-major scan with a strict
// `<` keeps the pick deterministic (first cell wins a distance tie), so it's safe
// to call from the sim. Shared by the Ranger's scout mode (heads for the nearest
// dark ground to reveal it) and the AI's hunt for a hidden Command Center (sweeps
// unexplored ground when it can see no enemy). Cheap enough at this grid
// resolution to call on demand, and only ever when a new target is needed.
/** @param {Fog} fog @param {number} fromX @param {number} fromY */
export function nearestUnexploredPoint(fog, fromX, fromY) {
let best = null, bestD = Infinity;
for (let cy = 0; cy < fog.rows; cy++) {
for (let cx = 0; cx < fog.cols; cx++) {
if (fog.explored[cy * fog.cols + cx]) continue;
const wx = cx * FOG_CELL_SIZE + FOG_CELL_SIZE / 2;
const wy = cy * FOG_CELL_SIZE + FOG_CELL_SIZE / 2;
const d = (wx - fromX) * (wx - fromX) + (wy - fromY) * (wy - fromY);
if (d < bestD) { bestD = d; best = { x: wx, y: wy }; }
}
}
return best;
}
// `terrain` is the map's terrain grid (engine/map.js generateTerrain β€” cols/rows/cell all shared
// 1:1 with this fog grid, both 40px cells, per TERRAIN_CELL_SIZE's own comment) or undefined for a
// map-less state; `srcHigh` is whether the SOURCE's own tile is high ground (type 2), computed
// once by the caller (updateFog) rather than re-sampled per candidate cell here. A source not on
// high ground reveals a high-ground cell only out to UPHILL_FRAC of `sight` β€” everything else
// (open/rough ground, or a source that's itself on the high ground) reveals at the full radius.
function reveal(fog, x, y, sight, terrain, srcHigh) {
const { cx, cy } = cellOf(fog, x, y);
const reach = Math.ceil(sight / FOG_CELL_SIZE);
const sightSq = sight * sight; // compare squared distances β€” no per-cell sqrt (Math.hypot)
const uphillCap = sight * UPHILL_FRAC;
const uphillSq = uphillCap * uphillCap;
const gyMax = Math.min(fog.rows - 1, cy + reach), gxMax = Math.min(fog.cols - 1, cx + reach);
for (let gy = Math.max(0, cy - reach); gy <= gyMax; gy++) {
const row = gy * fog.cols; // hoisted out of the inner loop
const dy = gy * FOG_CELL_SIZE + FOG_CELL_SIZE / 2 - y;
for (let gx = Math.max(0, cx - reach); gx <= gxMax; gx++) {
const dx = gx * FOG_CELL_SIZE + FOG_CELL_SIZE / 2 - x;
const idx = row + gx;
// Uphill concealment: a high-ground cell (terrain type 2) seen from a NON-high-ground
// source is compared against the shorter uphill radius instead of the full one.
const limitSq = (!srcHigh && terrain && terrain.type[idx] === 2) ? uphillSq : sightSq;
if (dx * dx + dy * dy > limitSq) continue;
fog.visible[idx] = 1;
fog.explored[idx] = 1;
}
}
}
// Recomputes `visible` from scratch each call (cheap at this grid
// resolution) and folds newly-seen cells permanently into `explored`.
/** @param {State} state @param {Fog} fog @param {string} owner */
export function updateFog(state, fog, owner) {
fog.visible.fill(0);
// A world's sight modifier scales every reveal radius (see PLANET_MODIFIERS).
// Optional-chained so the fog tests' map-less stubs read the default 1.
const sightMult = sideMod(state, owner, "sightMult"); // per-side on an asymmetric world (updateFog is already called per owner)
// A source standing on high ground sees farther (terrain sightMult) AND is exempt from the
// uphill-concealment cap below (srcHigh) β€” ONE sampleTerrain call per source serves both. OPEN
// ground and the map-less test stubs read TERRAIN[0] (sightMult 1, never high).
const terr = state.map?.terrain;
const srcTerrain = (x, y) => (terr ? sampleTerrain(terr, x, y) : TERRAIN[0]);
for (const u of state.units.values()) {
if (u.owner !== owner) continue;
const t = srcTerrain(u.x, u.y);
reveal(fog, u.x, u.y, UNITS[u.type].sight * sightMult * t.sightMult, terr, t === TERRAIN[2]);
}
for (const b of state.buildings.values()) {
if (b.owner !== owner) continue;
const t = srcTerrain(b.x, b.y);
reveal(fog, b.x, b.y, BUILDINGS[b.type].sight * sightMult * t.sightMult, terr, t === TERRAIN[2]);
}
}