Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
309 lines
8.4 KiB
TypeScript
309 lines
8.4 KiB
TypeScript
/** 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];
|
|
}
|