File size: 7,673 Bytes
e3658fa
34ea8bc
816885c
 
 
 
 
 
 
 
 
 
 
 
 
34ea8bc
 
 
 
 
8f89fac
34ea8bc
 
 
8f89fac
 
 
 
 
 
 
 
e3658fa
34ea8bc
 
 
 
 
 
 
 
 
 
 
 
 
 
e3658fa
34ea8bc
 
 
 
 
6db3533
34ea8bc
 
 
 
 
df20f1d
 
 
 
 
e3658fa
df20f1d
 
 
 
c310233
 
 
 
 
 
 
e3658fa
c310233
 
 
 
 
 
 
 
 
 
 
 
 
 
8f89fac
 
 
 
 
 
 
34ea8bc
 
4559046
8f89fac
 
4559046
 
 
 
 
 
 
8f89fac
 
 
 
34ea8bc
 
 
 
 
 
 
 
e3658fa
34ea8bc
 
ac7d42f
 
d228ca7
8f89fac
 
 
03f71c7
8f89fac
34ea8bc
8f89fac
 
 
34ea8bc
 
8f89fac
 
 
34ea8bc
 
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
// @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]);
  }
}