/** * 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. */ import { circleBlocked, slideCircle } from "./glide.ts"; 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 this grid. The maths is in `glide.ts`, which the * browser runs too; here it is bound to this map's tiles. */ export const moveCircle = (x: number, y: number, dx: number, dy: number, r: number): Point => slideCircle(x, y, dx, dy, r, solidTile, TILE); /** True when a circle at (x,y) overlaps any solid tile. */ export const blockedCircle = (x: number, y: number, r: number): boolean => circleBlocked(x, y, r, solidTile, TILE); /** * 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; }