/** * The map: one grid, and the geometry everything else asks questions of. * * Two sites (A left, B right), a mid corridor between the spawns, and a back * corridor along the bottom that links A main, spawn and B main — the flank. * Tiles are 80 units; a player is a circle of radius 16, so a one-tile * corridor is comfortably wide and a wall is never thin enough for a ray to * step over. * * Everything here is a pure function of the grid: collision, line of sight * and the walk distance a bot uses to path. No state. */ export const TILE = 80; // '#' wall · '.' floor · 'A'/'B' plant site · 'a' attacker spawn · 'd' defender spawn const ROWS_SRC = [ "##############################", "#........###.dddd.###........#", "#.AAAAA..###.dddd.###..BBBBB.#", "#.AAAAA..##........##..BBBBB.#", "#.AAAAA..##.#....#.##..BBBBB.#", "#.AAAAA..##........##..BBBBB.#", "#...##......#....#......##...#", "#..####....#......#....####..#", "#..####..###......###..####..#", "#..####..####....####..####..#", "#..####..####....####..####..#", "#........####....####........#", "#..####..####....####..####..#", "#..####..####....####..####..#", "#..####..####....####..####..#", "#..####..###......###..####..#", "#........##..aaaa..##........#", "#............aaaa............#", "##############################", ]; export const COLS = ROWS_SRC[0]!.length; export const ROWS = ROWS_SRC.length; export const WIDTH = COLS * TILE; export const HEIGHT = ROWS * TILE; export const grid: string[] = ROWS_SRC; export const gridRows = (): string[] => ROWS_SRC.slice(); export type Site = "A" | "B"; export type Point = { x: number; y: number }; const at = (col: number, row: number): string => row < 0 || row >= ROWS || col < 0 || col >= COLS ? "#" : ROWS_SRC[row]![col]!; export const solidTile = (col: number, row: number): boolean => at(col, row) === "#"; export const tileOf = (x: number, y: number): { col: number; row: number } => ({ col: Math.floor(x / TILE), row: Math.floor(y / TILE), }); export const centreOf = (col: number, row: number): Point => ({ x: col * TILE + TILE / 2, y: row * TILE + TILE / 2, }); export const solidAt = (x: number, y: number): boolean => { const t = tileOf(x, y); return solidTile(t.col, t.row); }; /** The site a point plants on, or null: the spike only arms inside a site. */ export const siteAt = (x: number, y: number): Site | null => { const t = tileOf(x, y); const c = at(t.col, t.row); return c === "A" || c === "B" ? c : null; }; const cellsOf = (mark: string): Point[] => { const out: Point[] = []; for (let row = 0; row < ROWS; row++) for (let col = 0; col < COLS; col++) if (at(col, row) === mark) out.push(centreOf(col, row)); return out; }; export const spawnCells = (side: "atk" | "def"): Point[] => cellsOf(side === "atk" ? "a" : "d"); export const siteCells = (site: Site): Point[] => cellsOf(site); export const siteCentre = (site: Site): Point => { const cells = siteCells(site); const sum = cells.reduce((a, p) => ({ x: a.x + p.x, y: a.y + p.y }), { x: 0, y: 0 }); return { x: sum.x / cells.length, y: sum.y / cells.length }; }; /** Ult orbs: one on each half of mid, free for whoever walks over them. */ export const orbSpots = (): Point[] => [centreOf(4, 11), centreOf(25, 11)]; /** * Slide a circle through the grid: each axis is moved and resolved on its * own, so running along a wall slides instead of sticking. */ export function moveCircle(x: number, y: number, dx: number, dy: number, r: number): Point { let nx = x; let ny = y; if (dx !== 0) { const test = nx + dx; if (!blockedCircle(test, ny, r)) nx = test; else { const col = Math.floor(test / TILE); nx = dx > 0 ? col * TILE - r - 0.01 : (col + 1) * TILE + r + 0.01; if (blockedCircle(nx, ny, r)) nx = x; } } if (dy !== 0) { const test = ny + dy; if (!blockedCircle(nx, test, r)) ny = test; else { const row = Math.floor(test / TILE); ny = dy > 0 ? row * TILE - r - 0.01 : (row + 1) * TILE + r + 0.01; if (blockedCircle(nx, ny, r)) ny = y; } } return { x: nx, y: ny }; } /** True when a circle at (x,y) overlaps any solid tile. */ export function blockedCircle(x: number, y: number, r: number): boolean { const c0 = Math.floor((x - r) / TILE); const c1 = Math.floor((x + r) / TILE); const r0 = Math.floor((y - r) / TILE); const r1 = Math.floor((y + r) / TILE); for (let row = r0; row <= r1; row++) for (let col = c0; col <= c1; col++) { if (!solidTile(col, row)) continue; const nearX = Math.max(col * TILE, Math.min(x, col * TILE + TILE)); const nearY = Math.max(row * TILE, Math.min(y, row * TILE + TILE)); const dx = x - nearX; const dy = y - nearY; if (dx * dx + dy * dy < r * r) return true; } return false; } /** * Does a wall stand between two points? Sampled every third of a tile: a * wall is a whole tile thick, so nothing is ever stepped over. */ export function wallBetween(a: Point, b: Point): boolean { const dx = b.x - a.x; const dy = b.y - a.y; const dist = Math.hypot(dx, dy); const steps = Math.ceil(dist / (TILE / 3)); for (let i = 1; i < steps; i++) { const t = i / steps; if (solidAt(a.x + dx * t, a.y + dy * t)) return true; } return false; } /** Where a shot stops: the first wall along the ray, or its full range. */ export function rayHitWall(from: Point, angle: number, range: number): Point { const dx = Math.cos(angle); const dy = Math.sin(angle); const step = TILE / 4; for (let d = step; d <= range; d += step) { const x = from.x + dx * d; const y = from.y + dy * d; if (solidAt(x, y)) return { x: from.x + dx * (d - step), y: from.y + dy * (d - step) }; } return { x: from.x + dx * range, y: from.y + dy * range }; } /** Closest approach of a segment to a point, as distance and travel along it. */ export function segmentPoint(a: Point, b: Point, p: Point): { dist: number; travel: number } { const dx = b.x - a.x; const dy = b.y - a.y; const len2 = dx * dx + dy * dy; const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / len2)); const cx = a.x + dx * t; const cy = a.y + dy * t; return { dist: Math.hypot(p.x - cx, p.y - cy), travel: t * Math.sqrt(len2) }; } export const segmentHitsCircle = (a: Point, b: Point, c: Point, r: number): boolean => segmentPoint(a, b, c).dist <= r; // Walk distance from a goal tile, breadth-first over floor. Bots ask for it // every few ticks; the field for a goal is built once and kept for the round. const fields = new Map(); export const clearPaths = (): void => void fields.clear(); function field(goalCol: number, goalRow: number): Int32Array { const key = goalRow * COLS + goalCol; const cached = fields.get(key); if (cached) return cached; const dist = new Int32Array(COLS * ROWS).fill(-1); if (!solidTile(goalCol, goalRow)) { dist[key] = 0; const queue: number[] = [key]; for (let head = 0; head < queue.length; head++) { const here = queue[head]!; const col = here % COLS; const row = (here - col) / COLS; const d = dist[here]!; for (const [dc, dr] of [ [1, 0], [-1, 0], [0, 1], [0, -1], ] as const) { const nc = col + dc; const nr = row + dr; if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue; const nk = nr * COLS + nc; if (dist[nk] !== -1 || solidTile(nc, nr)) continue; dist[nk] = d + 1; queue.push(nk); } } } if (fields.size > 64) fields.clear(); fields.set(key, dist); return dist; } /** Walk distance in tiles between two points, or -1 when unreachable. */ export function walkDistance(from: Point, to: Point): number { const f = tileOf(from.x, from.y); const g = tileOf(to.x, to.y); return field(g.col, g.row)[f.row * COLS + f.col] ?? -1; } /** * The next point to walk toward on the way to a goal: the centre of the * neighbouring tile that is one step closer, so bots round corners instead * of grinding into them. */ export function pathStep(from: Point, to: Point): Point { const f = tileOf(from.x, from.y); const g = tileOf(to.x, to.y); if (f.col === g.col && f.row === g.row) return to; const dist = field(g.col, g.row); const here = dist[f.row * COLS + f.col] ?? -1; if (here <= 0) return to; let best: Point | null = null; let bestD = here; for (const [dc, dr] of [ [1, 0], [-1, 0], [0, 1], [0, -1], ] as const) { const nc = f.col + dc; const nr = f.row + dr; if (nc < 0 || nc >= COLS || nr < 0 || nr >= ROWS) continue; const d = dist[nr * COLS + nc] ?? -1; if (d !== -1 && d < bestD) { bestD = d; best = centreOf(nc, nr); } } // Straight to the goal when nothing stands in the way — smoother than // walking tile centre to tile centre down an open corridor. if (best && !wallBetween(from, to) && (walkDistance(from, to) ?? 99) < 6) return to; return best ?? to; }