Files
portifolio-evandrodevbr/lib/jogo/mapa.ts
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
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.
2026-11-10 19:37:10 -03:00

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];
}