/** Grade do escritório: leitura, colisão, visão e caminho dos zumbis. Sem three, React nem DOM. */ import type { Mapa } from "./tipos"; type Ponto = readonly [number, number]; type Camada = "solido" | "opaco"; const CARACTERES = "#M.JN"; const SOLIDOS = "#M"; /** Tolerância de ponto flutuante na comparação de distâncias da visão. */ const FOLGA = 1e-9; const VIZINHOS_4: readonly Ponto[] = [ [1, 0], [-1, 0], [0, 1], [0, -1], ]; const VIZINHOS_8: readonly Ponto[] = [ ...VIZINHOS_4, [1, 1], [1, -1], [-1, 1], [-1, -1], ]; function validarLinhas(linhas: readonly string[]): void { if (linhas.length === 0) { throw new Error("O mapa precisa de ao menos uma linha."); } if (linhas.some((linha) => linha.length !== linhas[0].length)) { throw new Error("As linhas do mapa têm larguras diferentes."); } } export function lerMapa(linhas: readonly string[]): Mapa { validarLinhas(linhas); const largura = linhas[0].length; const altura = linhas.length; const solido = new Uint8Array(largura * altura); const opaco = new Uint8Array(largura * altura); const inicios: Ponto[] = []; const nascedouros: Ponto[] = []; for (let z = 0; z < altura; z++) { for (let x = 0; x < largura; x++) { const c = linhas[z].charAt(x); if (!CARACTERES.includes(c)) { throw new Error( `Caractere desconhecido "${c}" no mapa (coluna ${x}, linha ${z}).`, ); } solido[z * largura + x] = SOLIDOS.includes(c) ? 1 : 0; opaco[z * largura + x] = c === "#" ? 1 : 0; if (c === "J") inicios.push([x + 0.5, z + 0.5]); if (c === "N") nascedouros.push([x + 0.5, z + 0.5]); } } if (inicios.length !== 1) { throw new Error("O mapa precisa de exatamente um início (J)."); } if (nascedouros.length === 0) { throw new Error("O mapa precisa de ao menos um nascedouro (N)."); } return { largura, altura, solido, opaco, inicio: inicios[0], nascedouros }; } function dentro(mapa: Mapa, cx: number, cz: number): boolean { return cx >= 0 && cz >= 0 && cx < mapa.largura && cz < mapa.altura; } function consultar(mapa: Mapa, camada: Camada, x: number, z: number): boolean { const cx = Math.floor(x); const cz = Math.floor(z); if (!dentro(mapa, cx, cz)) return true; return mapa[camada][cz * mapa.largura + cx] === 1; } /** Coordenadas em metros; fora do mapa é sólido. */ export function ehSolido(mapa: Mapa, x: number, z: number): boolean { return consultar(mapa, "solido", x, z); } /** Coordenadas em metros; fora do mapa é opaco. */ export function ehOpaco(mapa: Mapa, x: number, z: number): boolean { return consultar(mapa, "opaco", x, z); } type Eixo = { passo: number; delta: number; t: number }; /** Estado do DDA em um eixo: sentido, distância por célula e distância até a próxima borda. */ function prepararEixo(pos: number, d: number): Eixo { if (d === 0) return { passo: 0, delta: Infinity, t: Infinity }; const base = Math.floor(pos); const ate = d > 0 ? base + 1 - pos : pos - base; return { passo: d > 0 ? 1 : -1, delta: 1 / Math.abs(d), t: ate / Math.abs(d), }; } /** Distância até a primeira célula da camada na direção (sin(rumo), cos(rumo)); `alcance` se não bater antes. */ function distanciaAteCamada( mapa: Mapa, camada: Camada, x: number, z: number, rumo: number, alcance: number, ): number { const ex = prepararEixo(x, Math.sin(rumo)); const ez = prepararEixo(z, Math.cos(rumo)); let cx = Math.floor(x); let cz = Math.floor(z); let t = 0; while (t < alcance) { if (consultar(mapa, camada, cx, cz)) return t; if (ex.t < ez.t) { t = ex.t; ex.t += ex.delta; cx += ex.passo; } else { t = ez.t; ez.t += ez.delta; cz += ez.passo; } } return alcance; } /** Distância até a primeira célula opaca na direção (sin(rumo), cos(rumo)); `alcance` se não bater antes. */ export function raioAteParede( mapa: Mapa, x: number, z: number, rumo: number, alcance: number, ): number { return distanciaAteCamada(mapa, "opaco", x, z, rumo, alcance); } /** Verdadeiro se nenhuma célula da camada fica entre os dois pontos (inclusive o de chegada). */ function segmentoLivre( mapa: Mapa, camada: Camada, x0: number, z0: number, x1: number, z1: number, ): boolean { if (consultar(mapa, camada, x1, z1)) return false; const dx = x1 - x0; const dz = z1 - z0; const distancia = Math.hypot(dx, dz); const rumo = Math.atan2(dx, dz); return ( distanciaAteCamada(mapa, camada, x0, z0, rumo, distancia) >= distancia - FOLGA ); } /** Verdadeiro se não há célula opaca entre os dois pontos (inclusive o de chegada). */ export function temLinhaDeVisao( mapa: Mapa, x0: number, z0: number, x1: number, z1: number, ): boolean { return segmentoLivre(mapa, "opaco", x0, z0, x1, z1); } /** Verdadeiro se o segmento e os dois paralelos deslocados `raio` para cada lado não cruzam célula sólida. */ export function caminhoLivre( mapa: Mapa, x0: number, z0: number, x1: number, z1: number, raio: number, ): boolean { const distancia = Math.hypot(x1 - x0, z1 - z0); const escala = distancia === 0 ? 0 : raio / distancia; const nx = (z0 - z1) * escala; const nz = (x1 - x0) * escala; return [0, 1, -1].every((lado) => segmentoLivre( mapa, "solido", x0 + nx * lado, z0 + nz * lado, x1 + nx * lado, z1 + nz * lado, ), ); } function circuloLivre(mapa: Mapa, x: number, z: number, raio: number): boolean { return !( ehSolido(mapa, x - raio, z - raio) || ehSolido(mapa, x + raio, z - raio) || ehSolido(mapa, x - raio, z + raio) || ehSolido(mapa, x + raio, z + raio) ); } /** Tenta x e depois z, separadamente, então desliza na parede. */ export function moverComColisao( mapa: Mapa, x: number, z: number, dx: number, dz: number, raio: number, ): [number, number] { const nx = circuloLivre(mapa, x + dx, z, raio) ? x + dx : x; const nz = circuloLivre(mapa, nx, z + dz, raio) ? z + dz : z; return [nx, nz]; } function visitar( mapa: Mapa, campo: Int16Array, fila: number[], cx: number, cz: number, distancia: number, ): void { if (ehSolido(mapa, cx, cz)) return; const i = cz * mapa.largura + cx; if (campo[i] !== -1) return; campo[i] = distancia; fila.push(i); } /** Distância em células até o alvo (BFS de 4 vizinhos); -1 para sólida ou inalcançável. */ export function campoDeFluxo(mapa: Mapa, x: number, z: number): Int16Array { const campo = new Int16Array(mapa.largura * mapa.altura).fill(-1); const fila: number[] = []; visitar(mapa, campo, fila, Math.floor(x), Math.floor(z), 0); for (let k = 0; k < fila.length; k++) { const cx = fila[k] % mapa.largura; const cz = Math.floor(fila[k] / mapa.largura); for (const [ox, oz] of VIZINHOS_4) { visitar(mapa, campo, fila, cx + ox, cz + oz, campo[fila[k]] + 1); } } return campo; } function distanciaEm( mapa: Mapa, campo: Int16Array, cx: number, cz: number, ): number { return dentro(mapa, cx, cz) ? campo[cz * mapa.largura + cx] : -1; } /** Na diagonal, só passa se as duas células ortogonais vizinhas estão livres. */ function quinaLivre( mapa: Mapa, cx: number, cz: number, ox: number, oz: number, ): boolean { return !(ehSolido(mapa, cx + ox, cz) || ehSolido(mapa, cx, cz + oz)); } /** Vizinho (8 direções, sem cortar quina) de menor distância; undefined na célula do alvo ou sem caminho. */ function melhorVizinho( mapa: Mapa, campo: Int16Array, cx: number, cz: number, ): Ponto | undefined { let melhorDistancia = distanciaEm(mapa, campo, cx, cz); if (melhorDistancia <= 0) return undefined; let melhor: Ponto = [0, 0]; for (const [ox, oz] of VIZINHOS_8) { const d = distanciaEm(mapa, campo, cx + ox, cz + oz); if (d < 0 || d >= melhorDistancia) continue; if (!quinaLivre(mapa, cx, cz, ox, oz)) continue; melhorDistancia = d; melhor = [ox, oz]; } return melhor; } /** Vetor unitário da posição real até o centro do melhor vizinho; [0, 0] na célula do alvo ou sem caminho. */ export function direcaoNoCampo( mapa: Mapa, campo: Int16Array, x: number, z: number, ): [number, number] { const cx = Math.floor(x); const cz = Math.floor(z); const vizinho = melhorVizinho(mapa, campo, cx, cz); if (!vizinho) return [0, 0]; const dx = cx + vizinho[0] + 0.5 - x; const dz = cz + vizinho[1] + 0.5 - z; const tamanho = Math.hypot(dx, dz); return [dx / tamanho, dz / tamanho]; }