SOURCE / PINNED RELEASE
Made of little things.
Squad Table
- Release
- d36201f8ed4f…
- Author-recorded commit
- 7b9bb681d6ba…
- License
- LICENSE
- Author’s source reference
- nostr://npub1sj2vketepqw9v6fmge5fdzuj7atj9sv6qv2jss05n2z6rl64lcfqumdsnt/wss%3A%2F%2Fgit.napplet.soy%2F/n-c043b0c0f1d
Archive hash verified: 8c0604b31f00cc5a…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
// Line of sight between hex centres. Obstacles (woods, buildings, higher
// ground) block when the sight line passes below their top; grain, orchards
// and brush hinder. Elevation uses a straight line between firer and target
// levels, which approximates blind-hex rules rather than reproducing them.
import { center, parseHex, thread } from '../hex.js';
import type { TableState } from '../model.js';
import { MAPS, TERRAIN_NAMES, type MapCell } from '../scenarios.js';
import { FIRE } from './data.js';
export type Los = {
clear: boolean;
reason?: string;
blockedBy?: string;
hindrances: { hex: string; value: number; label: string }[];
hindrance: number;
hexes: string[]; // hexes crossed, firer first
/** The hex the LOS enters the target from (for hexside TEM). */
approach?: string;
};
function smokeIn(state: TableState, hex: string): boolean {
return (state.stacks.get(hex) ?? []).some((id) => {
const c = state.counters.get(id);
return c?.k === 'mk-smoke' && c.face === 0;
});
}
export function lineOfSight(state: TableState, from: string, to: string): Los {
const map = MAPS[state.scenario.map];
const a = parseHex(from);
const b = parseHex(to);
const fromCell = map.cells.get(from);
const toCell = map.cells.get(to);
if (!a || !b || !fromCell || !toCell) return { clear: false, reason: 'Not on the map.', hindrances: [], hindrance: 0, hexes: [] };
const steps = thread(a, b).filter((s) => map.cells.has(s.id));
const pa = center(a);
const pb = center(b);
const len2 = (pb.x - pa.x) ** 2 + (pb.y - pa.y) ** 2 || 1;
const along = (cell: MapCell) => {
const p = center(cell.hex);
return Math.max(0, Math.min(1, ((p.x - pa.x) * (pb.x - pa.x) + (p.y - pa.y) * (pb.y - pa.y)) / len2));
};
// Eye level is one level above the ground of each hex.
const hFrom = fromCell.level;
const hTo = toCell.level;
const blocks = (cell: MapCell): boolean => {
const t = along(cell);
const sight = hFrom + (hTo - hFrom) * t;
const top = cell.level + (FIRE.obstacleHeight[cell.terrain] ?? 0);
return top > sight + 1e-9;
};
const hindrances: Los['hindrances'] = [];
const edgeBlockers: MapCell[] = [];
const edgeHindrances: MapCell[] = [];
for (const s of steps) {
const cell = map.cells.get(s.id)!;
const end = s.id === from || s.id === to;
if (smokeIn(state, s.id)) hindrances.push({ hex: s.id, value: FIRE.smokeHindrance, label: `Smoke ${s.id}` });
if (end) continue;
if (blocks(cell)) {
if (!s.edge) {
return {
clear: false,
blockedBy: s.id,
reason: `Blocked by ${cell.level > Math.max(hFrom, hTo) && !FIRE.obstacleHeight[cell.terrain] ? 'higher ground' : TERRAIN_NAMES[cell.terrain].toLowerCase()} in ${s.id}.`,
hindrances,
hindrance: 0,
hexes: steps.map((x) => x.id),
};
}
edgeBlockers.push(cell);
continue;
}
const h = FIRE.hindrance[cell.terrain] ?? 0;
const t = along(cell);
const sight = hFrom + (hTo - hFrom) * t;
// Hindrances only matter when the sight line is at their height.
if (h && sight < cell.level + 1 - 1e-9) {
if (s.edge) edgeHindrances.push(cell);
else hindrances.push({ hex: s.id, value: h, label: `${TERRAIN_NAMES[cell.terrain]} ${s.id}` });
}
}
// A line running exactly along a hexside is blocked only when both hexes are obstacles.
for (let i = 0; i < edgeBlockers.length; i += 1) {
for (let j = i + 1; j < edgeBlockers.length; j += 1) {
const x = edgeBlockers[i].hex;
const y = edgeBlockers[j].hex;
const dx = Math.abs(center(x).x - center(y).x);
const dy = Math.abs(center(x).y - center(y).y);
if (Math.hypot(dx, dy) < 75) {
return {
clear: false,
blockedBy: edgeBlockers[i].id,
reason: `Blocked along the hexside between ${edgeBlockers[i].id} and ${edgeBlockers[j].id}.`,
hindrances,
hindrance: 0,
hexes: steps.map((s) => s.id),
};
}
}
}
// Along a hexside, a hindrance counts once even if both hexes hinder. VERIFY
const counted = new Set<string>();
for (const cell of edgeHindrances) {
const pair = edgeHindrances.find((o) => o !== cell && counted.has(o.id) && Math.hypot(center(o.hex).x - center(cell.hex).x, center(o.hex).y - center(cell.hex).y) < 75);
if (pair) continue;
counted.add(cell.id);
hindrances.push({ hex: cell.id, value: FIRE.hindrance[cell.terrain] ?? 0, label: `${TERRAIN_NAMES[cell.terrain]} ${cell.id} (hexside)` });
}
const hindrance = hindrances.reduce((s, h) => s + h.value, 0);
const hexes = steps.map((s) => s.id);
const approach = hexes.length >= 2 ? hexes[hexes.length - 2] : undefined;
if (hindrance >= FIRE.maxHindrance) {
return { clear: false, reason: `Hindrances total +${hindrance}, which blocks LOS.`, hindrances, hindrance, hexes, approach };
}
return { clear: true, hindrances, hindrance, hexes, approach };
}
