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.
This commit is contained in:
1 parent
c7d09d63d4
commit
67b32fcab3
118 files changed
+16730
No files matched your search
@@ -0,0 +1,27 @@
|
||||
import { alavanca } from "./alavanca";
|
||||
|
||||
describe("alavanca", () => {
|
||||
it("fica parada dentro da zona morta", () => {
|
||||
expect(alavanca(4, -4, 56)).toEqual([0, 0]);
|
||||
expect(alavanca(0, 0, 56)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("empurrar para cima anda para a frente", () => {
|
||||
const [x, y] = alavanca(0, -28, 56);
|
||||
expect(x).toBeCloseTo(0);
|
||||
expect(y).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
it("empurrar para baixo e para a direita anda para trás e para a direita", () => {
|
||||
const [x, y] = alavanca(28, 28, 56);
|
||||
expect(x).toBeCloseTo(0.5);
|
||||
expect(y).toBeCloseTo(-0.5);
|
||||
});
|
||||
|
||||
it("limita o comprimento a 1, mantendo a direção", () => {
|
||||
const [x, y] = alavanca(-300, -300, 56);
|
||||
expect(Math.hypot(x, y)).toBeCloseTo(1);
|
||||
expect(x).toBeCloseTo(-Math.SQRT1_2);
|
||||
expect(y).toBeCloseTo(Math.SQRT1_2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Deslocamento do dedo em pixels (dy cresce para baixo) em [direita, frente], de -1 a 1. */
|
||||
|
||||
const ZONA_MORTA = 0.12;
|
||||
|
||||
export function alavanca(
|
||||
dx: number,
|
||||
dy: number,
|
||||
raio: number,
|
||||
): [number, number] {
|
||||
const comprimento = Math.hypot(dx, dy) / raio;
|
||||
if (comprimento < ZONA_MORTA) return [0, 0];
|
||||
const escala = Math.min(1, comprimento) / comprimento / raio;
|
||||
return [dx * escala, 0 - dy * escala];
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import {
|
||||
alvoNaMira,
|
||||
atirar,
|
||||
raioNoCirculo,
|
||||
recolherCaixas,
|
||||
resfriar,
|
||||
trocarArma,
|
||||
} from "./armas";
|
||||
import { lerMapa } from "./mapa";
|
||||
import type { ArmaId, Partida, TipoZumbi, Zumbi } from "./tipos";
|
||||
import { ARMAS, JOGADOR, ZUMBIS } from "./valores";
|
||||
|
||||
const LARGURA = 7;
|
||||
|
||||
function mapaAberto() {
|
||||
const linhas = ["#######", "#N...J#"];
|
||||
for (let i = 0; i < 13; i++) linhas.push("#.....#");
|
||||
linhas.push("#######");
|
||||
return lerMapa(linhas);
|
||||
}
|
||||
|
||||
function zumbi(
|
||||
id: number,
|
||||
x: number,
|
||||
z: number,
|
||||
tipo: TipoZumbi = "comum",
|
||||
): Zumbi {
|
||||
return {
|
||||
id,
|
||||
tipo,
|
||||
problemaId: "site-lento",
|
||||
x,
|
||||
z,
|
||||
rumo: 0,
|
||||
vida: ZUMBIS[tipo].vida,
|
||||
estado: "andando",
|
||||
tempo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Jogador em (3.5, 2.5) olhando para +z, com a pistola. */
|
||||
function partida(arma: ArmaId = 0): Partida {
|
||||
return {
|
||||
passo: 0,
|
||||
rng: 1,
|
||||
dificuldade: "normal",
|
||||
problemaId: "site-lento",
|
||||
hordas: [],
|
||||
horda: 0,
|
||||
fase: "horda",
|
||||
tempoFase: 0,
|
||||
tetoAVista: 16,
|
||||
mapa: mapaAberto(),
|
||||
jogador: {
|
||||
x: 3.5,
|
||||
z: 2.5,
|
||||
rumo: 0,
|
||||
inclinacao: 0,
|
||||
vida: JOGADOR.vida,
|
||||
arma,
|
||||
municao: 10,
|
||||
recarga: 0,
|
||||
},
|
||||
zumbis: [],
|
||||
fila: [],
|
||||
drones: [],
|
||||
caixas: [],
|
||||
feed: [],
|
||||
resolvidos: [],
|
||||
placar: { jogador: 0, drones: 0 },
|
||||
eventos: [],
|
||||
proximoId: 100,
|
||||
};
|
||||
}
|
||||
|
||||
describe("alvoNaMira", () => {
|
||||
it("é nulo com a faca", () => {
|
||||
const p = partida(2);
|
||||
p.zumbis = [zumbi(1, 3.5, 3.5)];
|
||||
expect(alvoNaMira(p)).toBeNull();
|
||||
});
|
||||
|
||||
it("é nulo sem zumbi na linha", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 1.5, 5.5)];
|
||||
expect(alvoNaMira(p)).toBeNull();
|
||||
});
|
||||
|
||||
it("devolve o corpo, com a distância e a altura", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
p.jogador.inclinacao = Math.atan(-0.2);
|
||||
const alvo = alvoNaMira(p);
|
||||
expect(alvo?.zumbi.id).toBe(1);
|
||||
expect(alvo?.cabeca).toBe(false);
|
||||
expect(alvo?.distancia).toBeCloseTo(2.65);
|
||||
expect(alvo?.y).toBeCloseTo(1.6 - 0.2 * 2.65);
|
||||
});
|
||||
|
||||
it("devolve a cabeça e não fere ninguém", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
expect(alvoNaMira(p)?.cabeca).toBe(true);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.eventos).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("raioNoCirculo", () => {
|
||||
it("devolve a distância até a primeira interseção", () => {
|
||||
expect(raioNoCirculo(0, 0, 0, 1, 0, 5, 1)).toBeCloseTo(4);
|
||||
});
|
||||
|
||||
it("devolve null quando o raio passa ao lado", () => {
|
||||
expect(raioNoCirculo(0, 0, 0, 1, 3, 5, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("devolve null quando o círculo está atrás", () => {
|
||||
expect(raioNoCirculo(0, 0, 0, 1, 0, -5, 1)).toBeNull();
|
||||
});
|
||||
|
||||
it("devolve 0 com a origem dentro do círculo", () => {
|
||||
expect(raioNoCirculo(0, 0.2, 0, 1, 0, 0, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trocarArma", () => {
|
||||
it("troca e põe a recarga em 10", () => {
|
||||
const p = partida(0);
|
||||
trocarArma(p, 1);
|
||||
expect(p.jogador.arma).toBe(1);
|
||||
expect(p.jogador.recarga).toBe(10);
|
||||
});
|
||||
|
||||
it("não faz nada para a mesma arma", () => {
|
||||
const p = partida(0);
|
||||
trocarArma(p, 0);
|
||||
expect(p.jogador.recarga).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resfriar", () => {
|
||||
it("desconta 1 da recarga", () => {
|
||||
const p = partida();
|
||||
p.jogador.recarga = 3;
|
||||
resfriar(p);
|
||||
expect(p.jogador.recarga).toBe(2);
|
||||
});
|
||||
|
||||
it("para em 0", () => {
|
||||
const p = partida();
|
||||
resfriar(p);
|
||||
expect(p.jogador.recarga).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recolherCaixas", () => {
|
||||
it("recolhe a caixa perto e deixa a longe", () => {
|
||||
const p = partida();
|
||||
p.caixas = [
|
||||
{ id: 1, x: 3.5, z: 3.0 },
|
||||
{ id: 2, x: 3.5, z: 8.5 },
|
||||
];
|
||||
recolherCaixas(p);
|
||||
expect(p.caixas.map((c) => c.id)).toEqual([2]);
|
||||
expect(p.jogador.municao).toBe(40);
|
||||
expect(p.eventos).toEqual([{ tipo: "caixa", balas: 30 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atirar: regras gerais", () => {
|
||||
it("não faz nada com recarga pendente", () => {
|
||||
const p = partida();
|
||||
p.jogador.recarga = 1;
|
||||
atirar(p);
|
||||
expect(p.eventos).toEqual([]);
|
||||
expect(p.jogador.recarga).toBe(1);
|
||||
});
|
||||
|
||||
it("automática sem bala não atira e não gasta recarga", () => {
|
||||
const p = partida(1);
|
||||
p.jogador.municao = 0;
|
||||
atirar(p);
|
||||
expect(p.eventos).toEqual([]);
|
||||
expect(p.jogador.recarga).toBe(0);
|
||||
});
|
||||
|
||||
it("automática gasta uma bala e arma a recarga", () => {
|
||||
const p = partida(1);
|
||||
atirar(p);
|
||||
expect(p.jogador.municao).toBe(9);
|
||||
expect(p.jogador.recarga).toBe(ARMAS[1].intervalo);
|
||||
});
|
||||
|
||||
it("pistola nunca gasta bala, nem com a munição zerada", () => {
|
||||
const p = partida(0);
|
||||
p.jogador.municao = 0;
|
||||
atirar(p);
|
||||
expect(p.jogador.municao).toBe(0);
|
||||
expect(p.jogador.recarga).toBe(ARMAS[0].intervalo);
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atirar: tiro por raio", () => {
|
||||
it("sem zumbi, o tiro vai até a parede", () => {
|
||||
const p = partida();
|
||||
atirar(p);
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
const e = p.eventos[0];
|
||||
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(12.5);
|
||||
});
|
||||
|
||||
it("acerta o corpo com o dano da arma", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
p.jogador.inclinacao = Math.atan(-0.2);
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100 - ARMAS[0].dano);
|
||||
const [tiro, acerto] = p.eventos;
|
||||
expect(tiro).toMatchObject({ tipo: "tiro", autor: "jogador", arma: 0 });
|
||||
expect(tiro.tipo === "tiro" && tiro.distancia).toBeCloseTo(2.65);
|
||||
expect(acerto).toMatchObject({
|
||||
tipo: "acerto",
|
||||
zumbiId: 1,
|
||||
cabeca: false,
|
||||
});
|
||||
expect(acerto.tipo === "acerto" && acerto.y).toBeCloseTo(1.6 - 0.2 * 2.65);
|
||||
expect(acerto.tipo === "acerto" && acerto.x).toBeCloseTo(3.5);
|
||||
expect(acerto.tipo === "acerto" && acerto.z).toBeCloseTo(5.15);
|
||||
});
|
||||
|
||||
it("acerta a cabeça com o dano dobrado", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100 - ARMAS[0].dano * 2);
|
||||
expect(p.eventos[1]).toMatchObject({ tipo: "acerto", cabeca: true });
|
||||
});
|
||||
|
||||
it("erra por cima com a inclinação alta", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
p.jogador.inclinacao = 1;
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
const e = p.eventos[0];
|
||||
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(12.5);
|
||||
});
|
||||
|
||||
it("erra por baixo, no chão", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
p.jogador.inclinacao = Math.atan(-1);
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("o raio que passa ao lado não acerta", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 5.5, 5.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
});
|
||||
|
||||
it("parede na frente do zumbi o protege", () => {
|
||||
const p = partida();
|
||||
p.mapa.opaco[8 * LARGURA + 3] = 1;
|
||||
p.zumbis = [zumbi(1, 3.5, 10.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
const e = p.eventos[0];
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(5.5);
|
||||
});
|
||||
|
||||
it("o zumbi da frente protege o de trás", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 9.5), zumbi(2, 3.5, 5.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.zumbis[1].vida).toBe(32);
|
||||
});
|
||||
|
||||
it("zumbi morrendo é atravessado", () => {
|
||||
const p = partida();
|
||||
const morto = zumbi(1, 3.5, 5.5);
|
||||
morto.estado = "morrendo";
|
||||
p.zumbis = [morto, zumbi(2, 3.5, 9.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.zumbis[1].vida).toBe(32);
|
||||
});
|
||||
|
||||
it("o tiro que passa por cima do da frente pode acertar o de trás", () => {
|
||||
const p = partida();
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5, "rapido"), zumbi(2, 3.5, 9.5, "chefao")];
|
||||
p.jogador.inclinacao = Math.atan(0.05);
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(50);
|
||||
expect(p.zumbis[1].vida).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
it("matar registra a morte pelo ferir", () => {
|
||||
const p = partida();
|
||||
const fraco = zumbi(1, 3.5, 5.5);
|
||||
fraco.vida = 10;
|
||||
p.zumbis = [fraco];
|
||||
atirar(p);
|
||||
expect(fraco.estado).toBe("morrendo");
|
||||
expect(p.eventos.some((e) => e.tipo === "morte")).toBe(true);
|
||||
});
|
||||
|
||||
it("a automática usa o dano da automática", () => {
|
||||
const p = partida(1);
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
p.jogador.inclinacao = Math.atan(-0.2);
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100 - ARMAS[1].dano);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atirar: faca", () => {
|
||||
it("fere o mais próximo dentro do alcance e do arco", () => {
|
||||
const p = partida(2);
|
||||
p.zumbis = [zumbi(1, 3.5, 3.9), zumbi(2, 3.5, 3.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.zumbis[1].vida).toBe(100 - ARMAS[2].dano);
|
||||
expect(p.eventos[0]).toMatchObject({ tipo: "tiro", arma: 2 });
|
||||
const acerto = p.eventos.find((e) => e.tipo === "acerto");
|
||||
expect(acerto).toMatchObject({
|
||||
zumbiId: 2,
|
||||
cabeca: false,
|
||||
y: ZUMBIS.comum.altura / 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("não acerta fora do arco", () => {
|
||||
const p = partida(2);
|
||||
p.zumbis = [zumbi(1, 3.5, 1.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.eventos).toEqual([
|
||||
{ tipo: "tiro", autor: "jogador", arma: 2, distancia: ARMAS[2].alcance },
|
||||
]);
|
||||
});
|
||||
|
||||
it("não acerta fora do alcance", () => {
|
||||
const p = partida(2);
|
||||
p.zumbis = [zumbi(1, 3.5, 5.5)];
|
||||
atirar(p);
|
||||
expect(p.zumbis[0].vida).toBe(100);
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignora o zumbi morrendo", () => {
|
||||
const p = partida(2);
|
||||
const morto = zumbi(1, 3.5, 3.5);
|
||||
morto.estado = "morrendo";
|
||||
p.zumbis = [morto];
|
||||
atirar(p);
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("a distância do tiro é a do zumbi ferido", () => {
|
||||
const p = partida(2);
|
||||
p.zumbis = [zumbi(1, 3.5, 3.5)];
|
||||
atirar(p);
|
||||
const e = p.eventos[0];
|
||||
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(0.65);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
/** Armas do jogador: tiro por raio, faca, troca, recarga e coleta de caixas. Sem three, React nem DOM. */
|
||||
|
||||
import { raioAteParede } from "./mapa";
|
||||
import type { ArmaId, Partida, Zumbi } from "./tipos";
|
||||
import {
|
||||
ARCO_DA_FACA,
|
||||
ARMAS,
|
||||
FRACAO_DA_CABECA,
|
||||
JOGADOR,
|
||||
MULTIPLICADOR_DA_CABECA,
|
||||
MUNICAO,
|
||||
ZUMBIS,
|
||||
} from "./valores";
|
||||
import { ferir } from "./zumbis";
|
||||
|
||||
const FACA: ArmaId = 2;
|
||||
const RECARGA_DA_TROCA = 10;
|
||||
|
||||
/** Distância ao longo do raio até o círculo; `null` se não cruza ou fica atrás; 0 se a origem está dentro. */
|
||||
export function raioNoCirculo(
|
||||
ox: number,
|
||||
oz: number,
|
||||
dx: number,
|
||||
dz: number,
|
||||
cx: number,
|
||||
cz: number,
|
||||
r: number,
|
||||
): number | null {
|
||||
const fx = ox - cx;
|
||||
const fz = oz - cz;
|
||||
const fora = fx * fx + fz * fz - r * r;
|
||||
if (fora <= 0) return 0;
|
||||
const b = fx * dx + fz * dz;
|
||||
const delta = b * b - fora;
|
||||
if (delta < 0) return null;
|
||||
const t = -b - Math.sqrt(delta);
|
||||
return t < 0 ? null : t;
|
||||
}
|
||||
|
||||
export function trocarArma(p: Partida, arma: ArmaId): void {
|
||||
if (arma === p.jogador.arma) return;
|
||||
p.jogador.arma = arma;
|
||||
p.jogador.recarga = RECARGA_DA_TROCA;
|
||||
}
|
||||
|
||||
export function resfriar(p: Partida): void {
|
||||
p.jogador.recarga = Math.max(0, p.jogador.recarga - 1);
|
||||
}
|
||||
|
||||
export function recolherCaixas(p: Partida): void {
|
||||
const { x, z } = p.jogador;
|
||||
const perto = (c: { x: number; z: number }) =>
|
||||
Math.hypot(c.x - x, c.z - z) <= MUNICAO.raioDeColeta;
|
||||
const recolhidas = p.caixas.filter(perto);
|
||||
for (let i = 0; i < recolhidas.length; i++) {
|
||||
p.jogador.municao += MUNICAO.porCaixa;
|
||||
p.eventos.push({ tipo: "caixa", balas: MUNICAO.porCaixa });
|
||||
}
|
||||
p.caixas = p.caixas.filter((c) => !perto(c));
|
||||
}
|
||||
|
||||
type Cruzamento = { zumbi: Zumbi; t: number };
|
||||
|
||||
/** Zumbis vivos cruzados pelo raio antes do limite, do mais perto para o mais longe. */
|
||||
function cruzamentos(
|
||||
p: Partida,
|
||||
dx: number,
|
||||
dz: number,
|
||||
limite: number,
|
||||
): Cruzamento[] {
|
||||
const lista: Cruzamento[] = [];
|
||||
for (const zumbi of p.zumbis) {
|
||||
if (zumbi.estado === "morrendo") continue;
|
||||
const { x, z } = p.jogador;
|
||||
const t = raioNoCirculo(
|
||||
x,
|
||||
z,
|
||||
dx,
|
||||
dz,
|
||||
zumbi.x,
|
||||
zumbi.z,
|
||||
ZUMBIS[zumbi.tipo].raio,
|
||||
);
|
||||
if (t !== null && t < limite) lista.push({ zumbi, t });
|
||||
}
|
||||
return lista.sort((a, b) => a.t - b.t);
|
||||
}
|
||||
|
||||
/** O primeiro zumbi cuja altura contém a altura do tiro, com essa altura. */
|
||||
function primeiroAtingido(
|
||||
p: Partida,
|
||||
lista: Cruzamento[],
|
||||
): (Cruzamento & { y: number }) | null {
|
||||
const subida = Math.tan(p.jogador.inclinacao);
|
||||
for (const c of lista) {
|
||||
const y = JOGADOR.olhos + subida * c.t;
|
||||
if (y >= 0 && y <= ZUMBIS[c.zumbi.tipo].altura) return { ...c, y };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Distância até a parede (ou o alcance da arma) no rumo atual. */
|
||||
function limiteDoRaio(p: Partida): number {
|
||||
const { x, z, rumo, arma } = p.jogador;
|
||||
return raioAteParede(p.mapa, x, z, rumo, ARMAS[arma].alcance);
|
||||
}
|
||||
|
||||
/** O zumbi que um tiro de raio acertaria agora; `null` com a faca ou sem alvo. */
|
||||
export function alvoNaMira(p: Partida): {
|
||||
zumbi: Zumbi;
|
||||
distancia: number;
|
||||
cabeca: boolean;
|
||||
y: number;
|
||||
} | null {
|
||||
const { rumo, arma } = p.jogador;
|
||||
if (arma === FACA) return null;
|
||||
const lista = cruzamentos(p, Math.sin(rumo), Math.cos(rumo), limiteDoRaio(p));
|
||||
const alvo = primeiroAtingido(p, lista);
|
||||
if (!alvo) return null;
|
||||
const { zumbi, t, y } = alvo;
|
||||
const cabeca = y >= FRACAO_DA_CABECA * ZUMBIS[zumbi.tipo].altura;
|
||||
return { zumbi, distancia: t, cabeca, y };
|
||||
}
|
||||
|
||||
function atirarPorRaio(p: Partida): void {
|
||||
const { x, z, rumo, arma } = p.jogador;
|
||||
const alvo = alvoNaMira(p);
|
||||
p.eventos.push({
|
||||
tipo: "tiro",
|
||||
autor: "jogador",
|
||||
arma,
|
||||
distancia: alvo ? alvo.distancia : limiteDoRaio(p),
|
||||
});
|
||||
if (!alvo) return;
|
||||
const { zumbi, distancia: t, cabeca, y } = alvo;
|
||||
const dx = Math.sin(rumo);
|
||||
const dz = Math.cos(rumo);
|
||||
ferir(
|
||||
p,
|
||||
zumbi,
|
||||
cabeca ? ARMAS[arma].dano * MULTIPLICADOR_DA_CABECA : ARMAS[arma].dano,
|
||||
"jogador",
|
||||
);
|
||||
p.eventos.push({
|
||||
tipo: "acerto",
|
||||
zumbiId: zumbi.id,
|
||||
cabeca,
|
||||
x: x + dx * t,
|
||||
y,
|
||||
z: z + dz * t,
|
||||
});
|
||||
}
|
||||
|
||||
/** Ângulo, de 0 a π, entre o rumo do jogador e a direção até o ponto. */
|
||||
function desvio(p: Partida, zumbi: Zumbi): number {
|
||||
const d =
|
||||
Math.atan2(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z) - p.jogador.rumo;
|
||||
return Math.abs(Math.atan2(Math.sin(d), Math.cos(d)));
|
||||
}
|
||||
|
||||
/** Zumbi vivo mais próximo ao alcance da faca e dentro do arco, com a folga até a borda dele. */
|
||||
function alvoDaFaca(p: Partida): { zumbi: Zumbi; folga: number } | null {
|
||||
let melhor: { zumbi: Zumbi; folga: number } | null = null;
|
||||
for (const zumbi of p.zumbis) {
|
||||
if (zumbi.estado === "morrendo") continue;
|
||||
const folga =
|
||||
Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z) -
|
||||
ZUMBIS[zumbi.tipo].raio;
|
||||
const vale =
|
||||
folga <= ARMAS[FACA].alcance && desvio(p, zumbi) <= ARCO_DA_FACA;
|
||||
if (vale && (melhor === null || folga < melhor.folga))
|
||||
melhor = { zumbi, folga };
|
||||
}
|
||||
return melhor;
|
||||
}
|
||||
|
||||
function golpearComFaca(p: Partida): void {
|
||||
const alvo = alvoDaFaca(p);
|
||||
p.eventos.push({
|
||||
tipo: "tiro",
|
||||
autor: "jogador",
|
||||
arma: FACA,
|
||||
distancia: alvo ? alvo.folga : ARMAS[FACA].alcance,
|
||||
});
|
||||
if (!alvo) return;
|
||||
const { zumbi } = alvo;
|
||||
ferir(p, zumbi, ARMAS[FACA].dano, "jogador");
|
||||
p.eventos.push({
|
||||
tipo: "acerto",
|
||||
zumbiId: zumbi.id,
|
||||
cabeca: false,
|
||||
x: zumbi.x,
|
||||
y: ZUMBIS[zumbi.tipo].altura / 2,
|
||||
z: zumbi.z,
|
||||
});
|
||||
}
|
||||
|
||||
export function atirar(p: Partida): void {
|
||||
const j = p.jogador;
|
||||
const arma = ARMAS[j.arma];
|
||||
if (j.recarga > 0 || (arma.gasta && j.municao === 0)) return;
|
||||
j.recarga = arma.intervalo;
|
||||
if (arma.gasta) j.municao -= 1;
|
||||
if (j.arma === FACA) golpearComFaca(p);
|
||||
else atirarPorRaio(p);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const LIMITE = 10;
|
||||
const PASTA = __dirname;
|
||||
|
||||
type Medida = { nome: string; linha: number; complexidade: number };
|
||||
|
||||
const OPERADORES = new Set([
|
||||
ts.SyntaxKind.AmpersandAmpersandToken,
|
||||
ts.SyntaxKind.BarBarToken,
|
||||
ts.SyntaxKind.QuestionQuestionToken,
|
||||
]);
|
||||
|
||||
function ehDecisao(no: ts.Node): boolean {
|
||||
if (ts.isBinaryExpression(no)) return OPERADORES.has(no.operatorToken.kind);
|
||||
return (
|
||||
ts.isIfStatement(no) ||
|
||||
ts.isConditionalExpression(no) ||
|
||||
ts.isForStatement(no) ||
|
||||
ts.isForInStatement(no) ||
|
||||
ts.isForOfStatement(no) ||
|
||||
ts.isWhileStatement(no) ||
|
||||
ts.isDoStatement(no) ||
|
||||
ts.isCaseClause(no) ||
|
||||
ts.isCatchClause(no)
|
||||
);
|
||||
}
|
||||
|
||||
function ehFuncao(no: ts.Node): no is ts.FunctionLikeDeclaration {
|
||||
return (
|
||||
ts.isFunctionDeclaration(no) ||
|
||||
ts.isFunctionExpression(no) ||
|
||||
ts.isArrowFunction(no) ||
|
||||
ts.isMethodDeclaration(no) ||
|
||||
ts.isConstructorDeclaration(no) ||
|
||||
ts.isGetAccessorDeclaration(no) ||
|
||||
ts.isSetAccessorDeclaration(no)
|
||||
);
|
||||
}
|
||||
|
||||
function nomeDaFuncao(no: ts.FunctionLikeDeclaration): string {
|
||||
if (ts.isConstructorDeclaration(no)) return "constructor";
|
||||
if (no.name) return no.name.getText();
|
||||
const pai = no.parent;
|
||||
if (ts.isVariableDeclaration(pai) || ts.isPropertyAssignment(pai)) {
|
||||
return pai.name.getText();
|
||||
}
|
||||
return "(anônima)";
|
||||
}
|
||||
|
||||
/** Complexidade ciclomática de cada função do código-fonte (cada uma medida à parte). */
|
||||
export function medirComplexidade(codigo: string): Medida[] {
|
||||
const fonte = ts.createSourceFile(
|
||||
"medida.ts",
|
||||
codigo,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
);
|
||||
const medidas: Medida[] = [];
|
||||
|
||||
function medir(funcao: ts.FunctionLikeDeclaration): void {
|
||||
let complexidade = 1;
|
||||
const visitar = (no: ts.Node): void => {
|
||||
if (ehFuncao(no)) {
|
||||
medir(no);
|
||||
return;
|
||||
}
|
||||
if (ehDecisao(no)) complexidade++;
|
||||
ts.forEachChild(no, visitar);
|
||||
};
|
||||
ts.forEachChild(funcao, visitar);
|
||||
const { line } = fonte.getLineAndCharacterOfPosition(funcao.getStart());
|
||||
medidas.push({
|
||||
nome: nomeDaFuncao(funcao),
|
||||
linha: line + 1,
|
||||
complexidade,
|
||||
});
|
||||
}
|
||||
|
||||
const percorrer = (no: ts.Node): void => {
|
||||
if (ehFuncao(no)) medir(no);
|
||||
else ts.forEachChild(no, percorrer);
|
||||
};
|
||||
percorrer(fonte);
|
||||
return medidas.sort((a, b) => a.linha - b.linha);
|
||||
}
|
||||
|
||||
function comDecisoes(n: number): string {
|
||||
const ifs = Array.from({ length: n }, (_, i) => `if (x > ${i}) y++;`);
|
||||
return `function f(x: number) { let y = 0; ${ifs.join(" ")} return y; }`;
|
||||
}
|
||||
|
||||
describe("medirComplexidade", () => {
|
||||
it("função sem decisão vale 1", () => {
|
||||
expect(
|
||||
medirComplexidade("function f() { return 1; }")[0].complexidade,
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("10 decisões valem 11 e são reprovadas", () => {
|
||||
const [medida] = medirComplexidade(comDecisoes(10));
|
||||
expect(medida.complexidade).toBe(11);
|
||||
expect(medida.complexidade > LIMITE).toBe(true);
|
||||
});
|
||||
|
||||
it("9 decisões valem 10 e passam", () => {
|
||||
const [medida] = medirComplexidade(comDecisoes(9));
|
||||
expect(medida.complexidade).toBe(10);
|
||||
expect(medida.complexidade > LIMITE).toBe(false);
|
||||
});
|
||||
|
||||
it("decisões de função aninhada não contam na de fora", () => {
|
||||
const medidas = medirComplexidade(
|
||||
"function fora(a: number) { const dentro = () => { if (a) return 1; return a ? 2 : 3; }; return dentro; }",
|
||||
);
|
||||
expect(medidas.find((m) => m.nome === "fora")?.complexidade).toBe(1);
|
||||
expect(medidas.find((m) => m.nome === "dentro")?.complexidade).toBe(3);
|
||||
});
|
||||
|
||||
it("default não conta, case conta", () => {
|
||||
const [medida] = medirComplexidade(
|
||||
"function f(x: number) { switch (x) { case 1: return 1; case 2: return 2; default: return 0; } }",
|
||||
);
|
||||
expect(medida.complexidade).toBe(3);
|
||||
});
|
||||
|
||||
it("&&, || e ?? contam", () => {
|
||||
const [medida] = medirComplexidade(
|
||||
"function f(a: any, b: any, c: any) { return (a && b) || (c ?? a); }",
|
||||
);
|
||||
expect(medida.complexidade).toBe(4);
|
||||
});
|
||||
|
||||
it("laços, catch, do e ternário contam; métodos são medidos", () => {
|
||||
const medidas = medirComplexidade(
|
||||
"class A { m(l: number[]) { for (const i of l) {} for (const k in l) {} for (;;) {} while (1) {} do {} while (0); try {} catch {} return l ? 1 : 2; } }",
|
||||
);
|
||||
expect(medidas[0]).toMatchObject({ nome: "m", complexidade: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("complexidade ciclomática de lib/jogo", () => {
|
||||
it(`nenhuma função passa de ${LIMITE}`, () => {
|
||||
const reprovadas: string[] = [];
|
||||
const arquivos = readdirSync(PASTA).filter(
|
||||
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts"),
|
||||
);
|
||||
for (const arquivo of arquivos) {
|
||||
const codigo = readFileSync(join(PASTA, arquivo), "utf8");
|
||||
for (const m of medirComplexidade(codigo)) {
|
||||
if (m.complexidade > LIMITE) {
|
||||
reprovadas.push(`${arquivo}:${m.linha} ${m.nome} ${m.complexidade}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(reprovadas).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { type Credito, validarCreditos } from "./creditos";
|
||||
|
||||
const ok: Credito = {
|
||||
arquivo: "som/tiro.ogg",
|
||||
origem: "https://exemplo.org/tiro",
|
||||
autor: "Fulano",
|
||||
licenca: "CC0-1.0",
|
||||
};
|
||||
|
||||
describe("validarCreditos", () => {
|
||||
it("não devolve erro quando está tudo certo", () => {
|
||||
const propria: Credito = {
|
||||
arquivo: "a.png",
|
||||
origem: "interna",
|
||||
autor: "Evandro",
|
||||
licenca: "propria",
|
||||
};
|
||||
expect(validarCreditos(["som/tiro.ogg", "a.png"], [ok, propria])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("aponta arquivo sem crédito", () => {
|
||||
expect(validarCreditos(["som/tiro.ogg", "b.png"], [ok])).toEqual([
|
||||
"b.png: arquivo sem crédito.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aponta crédito de arquivo que não existe", () => {
|
||||
expect(validarCreditos([], [ok])).toEqual([
|
||||
"som/tiro.ogg: crédito de arquivo que não existe.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aponta licença não aceita", () => {
|
||||
const erros = validarCreditos(
|
||||
["som/tiro.ogg"],
|
||||
[{ ...ok, licenca: "CC-BY-4.0" }],
|
||||
);
|
||||
expect(erros).toEqual(["som/tiro.ogg: licença não aceita (CC-BY-4.0)."]);
|
||||
});
|
||||
|
||||
it("exige origem https:// para CC0-1.0", () => {
|
||||
const erros = validarCreditos(
|
||||
["som/tiro.ogg"],
|
||||
[{ ...ok, origem: "http://exemplo.org" }],
|
||||
);
|
||||
expect(erros).toEqual([
|
||||
"som/tiro.ogg: a origem de CC0-1.0 deve começar com https://.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("aponta autor vazio", () => {
|
||||
expect(validarCreditos(["som/tiro.ogg"], [{ ...ok, autor: " " }])).toEqual(
|
||||
["som/tiro.ogg: autor vazio."],
|
||||
);
|
||||
});
|
||||
|
||||
it("aponta arquivo creditado duas vezes", () => {
|
||||
expect(validarCreditos(["som/tiro.ogg"], [ok, ok])).toEqual([
|
||||
"som/tiro.ogg: creditado mais de uma vez.",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function listar(pasta: string, base = ""): string[] {
|
||||
if (!existsSync(pasta)) return [];
|
||||
return readdirSync(pasta).flatMap((nome) => {
|
||||
const caminho = join(pasta, nome);
|
||||
const rel = base ? `${base}/${nome}` : nome;
|
||||
if (statSync(caminho).isDirectory()) return listar(caminho, rel);
|
||||
return rel === "CREDITOS.json" ? [] : [rel];
|
||||
});
|
||||
}
|
||||
|
||||
describe("public/jogo", () => {
|
||||
it("tem crédito válido para cada arquivo", () => {
|
||||
const pasta = join(process.cwd(), "public", "jogo");
|
||||
const json = join(pasta, "CREDITOS.json");
|
||||
const creditos: Credito[] = existsSync(json)
|
||||
? JSON.parse(readFileSync(json, "utf8"))
|
||||
: [];
|
||||
expect(validarCreditos(listar(pasta), creditos)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type Credito = {
|
||||
arquivo: string;
|
||||
origem: string;
|
||||
autor: string;
|
||||
licenca: string;
|
||||
};
|
||||
|
||||
export const LICENCAS_ACEITAS = ["CC0-1.0", "propria"] as const;
|
||||
|
||||
function errosDoCredito({
|
||||
arquivo,
|
||||
origem,
|
||||
autor,
|
||||
licenca,
|
||||
}: Credito): string[] {
|
||||
const erros: string[] = [];
|
||||
if (!(LICENCAS_ACEITAS as readonly string[]).includes(licenca)) {
|
||||
erros.push(`${arquivo}: licença não aceita (${licenca}).`);
|
||||
}
|
||||
if (licenca === "CC0-1.0" && !origem.startsWith("https://")) {
|
||||
erros.push(`${arquivo}: a origem de CC0-1.0 deve começar com https://.`);
|
||||
}
|
||||
if (autor.trim() === "") erros.push(`${arquivo}: autor vazio.`);
|
||||
return erros;
|
||||
}
|
||||
|
||||
export function validarCreditos(
|
||||
arquivos: string[],
|
||||
creditos: Credito[],
|
||||
): string[] {
|
||||
const erros: string[] = [];
|
||||
const vistos = new Set<string>();
|
||||
for (const credito of creditos) {
|
||||
if (vistos.has(credito.arquivo))
|
||||
erros.push(`${credito.arquivo}: creditado mais de uma vez.`);
|
||||
vistos.add(credito.arquivo);
|
||||
if (!arquivos.includes(credito.arquivo))
|
||||
erros.push(`${credito.arquivo}: crédito de arquivo que não existe.`);
|
||||
erros.push(...errosDoCredito(credito));
|
||||
}
|
||||
for (const arquivo of arquivos) {
|
||||
if (!vistos.has(arquivo)) erros.push(`${arquivo}: arquivo sem crédito.`);
|
||||
}
|
||||
return erros;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ajustar } from "./dificuldade";
|
||||
import { ZUMBIS } from "./valores";
|
||||
|
||||
describe("ajustar", () => {
|
||||
it("na dificuldade normal devolve os valores do tipo", () => {
|
||||
expect(ajustar("blindado", "normal")).toEqual(ZUMBIS.blindado);
|
||||
});
|
||||
|
||||
it("multiplica velocidade e dano no fácil", () => {
|
||||
const r = ajustar("comum", "facil");
|
||||
expect(r.velocidade).toBeCloseTo(1.2 * 0.6);
|
||||
expect(r.dano).toBeCloseTo(10 * 0.4);
|
||||
});
|
||||
|
||||
it("multiplica velocidade e dano no difícil sem mexer em vida, raio e altura", () => {
|
||||
const r = ajustar("rapido", "dificil");
|
||||
expect(r.velocidade).toBeCloseTo(2.4 * 1.3);
|
||||
expect(r.dano).toBeCloseTo(6 * 1.5);
|
||||
expect(r.vida).toBe(ZUMBIS.rapido.vida);
|
||||
expect(r.raio).toBe(ZUMBIS.rapido.raio);
|
||||
expect(r.altura).toBe(ZUMBIS.rapido.altura);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Dificuldade, TipoZumbi } from "./tipos";
|
||||
import { DIFICULDADES, ZUMBIS } from "./valores";
|
||||
|
||||
/** Os valores do tipo com velocidade e dano multiplicados pela dificuldade. */
|
||||
export function ajustar(
|
||||
tipo: TipoZumbi,
|
||||
dificuldade: Dificuldade,
|
||||
): {
|
||||
vida: number;
|
||||
velocidade: number;
|
||||
dano: number;
|
||||
raio: number;
|
||||
altura: number;
|
||||
} {
|
||||
const base = ZUMBIS[tipo];
|
||||
const nivel = DIFICULDADES[dificuldade];
|
||||
return {
|
||||
...base,
|
||||
velocidade: base.velocidade * nivel.velocidade,
|
||||
dano: base.dano * nivel.dano,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { avancarDrones, criarDrone } from "./drones";
|
||||
import { lerMapa } from "./mapa";
|
||||
import { PROBLEMAS } from "./problemas";
|
||||
import type { Drone, Partida, Zumbi } from "./tipos";
|
||||
import { DRONE, ESCRITORIO, PASSO } from "./valores";
|
||||
|
||||
const PASSO_MAX = DRONE.velocidade * PASSO;
|
||||
const [PROBLEMA_DRONE, PROBLEMA_ZUMBI] = PROBLEMAS;
|
||||
|
||||
function partida(x: number, z: number, rumo = 0): Partida {
|
||||
return {
|
||||
passo: 0,
|
||||
rng: 1,
|
||||
dificuldade: "normal",
|
||||
problemaId: PROBLEMA_DRONE.id,
|
||||
hordas: [],
|
||||
horda: 0,
|
||||
fase: "horda",
|
||||
tempoFase: 0,
|
||||
tetoAVista: 16,
|
||||
mapa: lerMapa(ESCRITORIO),
|
||||
jogador: {
|
||||
x,
|
||||
z,
|
||||
rumo,
|
||||
inclinacao: 0,
|
||||
vida: 100,
|
||||
arma: 0,
|
||||
municao: 90,
|
||||
recarga: 0,
|
||||
},
|
||||
zumbis: [],
|
||||
fila: [],
|
||||
drones: [],
|
||||
caixas: [],
|
||||
feed: [],
|
||||
resolvidos: [],
|
||||
placar: { jogador: 0, drones: 0 },
|
||||
eventos: [],
|
||||
proximoId: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function zumbi(p: Partida, x: number, z: number, vida = 100): Zumbi {
|
||||
const z1: Zumbi = {
|
||||
id: p.proximoId++,
|
||||
tipo: "comum",
|
||||
problemaId: PROBLEMA_ZUMBI.id,
|
||||
x,
|
||||
z,
|
||||
rumo: 0,
|
||||
vida,
|
||||
estado: "andando",
|
||||
tempo: 0,
|
||||
};
|
||||
p.zumbis.push(z1);
|
||||
return z1;
|
||||
}
|
||||
|
||||
function drone(p: Partida, recarga = 1): Drone {
|
||||
const d = criarDrone(p, PROBLEMA_DRONE.id);
|
||||
d.recarga = recarga;
|
||||
return d;
|
||||
}
|
||||
|
||||
describe("criarDrone", () => {
|
||||
it("nasce no jogador, com o rumo dele, e entra na lista", () => {
|
||||
const p = partida(5.5, 8.5, 1.25);
|
||||
const d = criarDrone(p, PROBLEMA_DRONE.id);
|
||||
expect(d).toEqual({
|
||||
id: 1,
|
||||
problemaId: PROBLEMA_DRONE.id,
|
||||
x: 5.5,
|
||||
z: 8.5,
|
||||
rumo: 1.25,
|
||||
recarga: DRONE.intervalo,
|
||||
eliminacoes: 0,
|
||||
});
|
||||
expect(p.drones).toEqual([d]);
|
||||
expect(p.proximoId).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avancarDrones: movimento", () => {
|
||||
it("anda no máximo DRONE.velocidade * PASSO por passo e chega ao lugar", () => {
|
||||
const p = partida(13.5, 8.5);
|
||||
const d = drone(p, 1000);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const [x, z] = [d.x, d.z];
|
||||
avancarDrones(p);
|
||||
expect(Math.hypot(d.x - x, d.z - z)).toBeLessThanOrEqual(
|
||||
PASSO_MAX + 1e-12,
|
||||
);
|
||||
}
|
||||
expect(d.x).toBeCloseTo(15.5, 9);
|
||||
expect(d.z).toBeCloseTo(8.5, 9);
|
||||
});
|
||||
|
||||
it("dá um passo cheio quando o lugar está longe", () => {
|
||||
const p = partida(13.5, 8.5);
|
||||
const d = drone(p, 1000);
|
||||
avancarDrones(p);
|
||||
expect(d.x - 13.5).toBeCloseTo(PASSO_MAX, 9);
|
||||
expect(d.z).toBeCloseTo(8.5, 9);
|
||||
});
|
||||
|
||||
it("não atravessa parede", () => {
|
||||
const p = partida(13.5, 15.5, -Math.PI / 2);
|
||||
const d = drone(p, 1000);
|
||||
for (let i = 0; i < 100; i++) avancarDrones(p);
|
||||
expect(d.z).toBeLessThanOrEqual(16 - 0.2);
|
||||
});
|
||||
|
||||
it("três drones ocupam ângulos diferentes em volta do jogador", () => {
|
||||
const p = partida(24.5, 8.5, 0.4);
|
||||
const drones = [drone(p, 1000), drone(p, 1000), drone(p, 1000)];
|
||||
for (let i = 0; i < 200; i++) avancarDrones(p);
|
||||
drones.forEach((d, i) => {
|
||||
const a = p.jogador.rumo + Math.PI / 2 + i * ((2 * Math.PI) / 3);
|
||||
expect(d.x).toBeCloseTo(24.5 + Math.sin(a) * DRONE.distancia, 6);
|
||||
expect(d.z).toBeCloseTo(8.5 + Math.cos(a) * DRONE.distancia, 6);
|
||||
});
|
||||
expect(
|
||||
Math.hypot(drones[0].x - drones[1].x, drones[0].z - drones[1].z),
|
||||
).toBeGreaterThan(1);
|
||||
expect(
|
||||
Math.hypot(drones[1].x - drones[2].x, drones[1].z - drones[2].z),
|
||||
).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avancarDrones: tiro", () => {
|
||||
it("respeita a recarga: desconta até 0, atira e recarrega", () => {
|
||||
const p = partida(5.5, 8.5);
|
||||
const d = drone(p, 3);
|
||||
const alvo = zumbi(p, 11.5, 8.5);
|
||||
avancarDrones(p);
|
||||
avancarDrones(p);
|
||||
expect(d.recarga).toBe(1);
|
||||
expect(p.eventos).toEqual([]);
|
||||
avancarDrones(p);
|
||||
expect(d.recarga).toBe(DRONE.intervalo);
|
||||
expect(p.eventos.map((e) => e.tipo)).toEqual(["tiro", "acerto"]);
|
||||
expect(alvo.vida).toBe(100 - DRONE.dano);
|
||||
avancarDrones(p);
|
||||
expect(d.recarga).toBe(DRONE.intervalo - 1);
|
||||
expect(p.eventos).toHaveLength(2);
|
||||
expect(p.feed).toEqual([]);
|
||||
});
|
||||
|
||||
it("vira para o zumbi e registra tiro e acerto", () => {
|
||||
const p = partida(5.5, 8.5);
|
||||
const d = drone(p);
|
||||
const alvo = zumbi(p, 5.5, 14.5);
|
||||
avancarDrones(p);
|
||||
const distancia = Math.hypot(alvo.x - d.x, alvo.z - d.z);
|
||||
expect(d.rumo).toBeCloseTo(Math.atan2(alvo.x - d.x, alvo.z - d.z), 9);
|
||||
expect(p.eventos).toEqual([
|
||||
{ tipo: "tiro", autor: d.id, arma: "drone", distancia },
|
||||
{
|
||||
tipo: "acerto",
|
||||
zumbiId: alvo.id,
|
||||
cabeca: false,
|
||||
x: 5.5,
|
||||
y: 1.2,
|
||||
z: 14.5,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("escolhe o zumbi mais próximo", () => {
|
||||
const p = partida(5.5, 8.5);
|
||||
drone(p);
|
||||
const longe = zumbi(p, 15.5, 8.5);
|
||||
const perto = zumbi(p, 9.5, 8.5);
|
||||
avancarDrones(p);
|
||||
expect(perto.vida).toBe(100 - DRONE.dano);
|
||||
expect(longe.vida).toBe(100);
|
||||
});
|
||||
|
||||
it("mantém o mais próximo quando o seguinte está mais longe", () => {
|
||||
const p = partida(5.5, 8.5);
|
||||
drone(p);
|
||||
const perto = zumbi(p, 9.5, 8.5);
|
||||
const longe = zumbi(p, 15.5, 8.5);
|
||||
avancarDrones(p);
|
||||
expect(perto.vida).toBe(100 - DRONE.dano);
|
||||
expect(longe.vida).toBe(100);
|
||||
});
|
||||
|
||||
it("ignora zumbi morrendo", () => {
|
||||
const p = partida(5.5, 8.5);
|
||||
drone(p);
|
||||
const morto = zumbi(p, 8.5, 8.5);
|
||||
morto.estado = "morrendo";
|
||||
const vivo = zumbi(p, 15.5, 8.5);
|
||||
avancarDrones(p);
|
||||
expect(morto.vida).toBe(100);
|
||||
expect(vivo.vida).toBe(100 - DRONE.dano);
|
||||
});
|
||||
|
||||
it("não atira com parede no meio e deixa a recarga em 0", () => {
|
||||
const p = partida(9.5, 2.5);
|
||||
const d = drone(p);
|
||||
const alvo = zumbi(p, 12.5, 2.5);
|
||||
avancarDrones(p);
|
||||
expect(alvo.vida).toBe(100);
|
||||
expect(p.eventos).toEqual([]);
|
||||
expect(d.recarga).toBe(0);
|
||||
});
|
||||
|
||||
it("não atira além do alcance", () => {
|
||||
const p = partida(2.5, 8.5);
|
||||
const d = drone(p);
|
||||
const alvo = zumbi(p, 2.5 + DRONE.alcance + 1, 8.5);
|
||||
avancarDrones(p);
|
||||
expect(alvo.vida).toBe(100);
|
||||
expect(p.eventos).toEqual([]);
|
||||
expect(d.recarga).toBe(0);
|
||||
});
|
||||
|
||||
it("a morte gera a linha do feed com a solução do drone e o nome do zumbi", () => {
|
||||
const p = partida(5.5, 8.5);
|
||||
drone(p);
|
||||
const alvo = zumbi(p, 9.5, 8.5, DRONE.dano);
|
||||
avancarDrones(p);
|
||||
expect(alvo.estado).toBe("morrendo");
|
||||
expect(p.feed).toEqual([
|
||||
{
|
||||
passo: 0,
|
||||
texto: `${PROBLEMA_DRONE.solucao} eliminou ${PROBLEMA_ZUMBI.nome}`,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/** Drones aliados: seguem o jogador em órbita e atiram no zumbi mais próximo à vista. */
|
||||
|
||||
import { registrarEliminacao } from "./feed";
|
||||
import { moverComColisao, temLinhaDeVisao } from "./mapa";
|
||||
import type { Drone, Partida, Zumbi } from "./tipos";
|
||||
import { DRONE, PASSO } from "./valores";
|
||||
import { ferir } from "./zumbis";
|
||||
|
||||
const RAIO_DO_DRONE = 0.2;
|
||||
const PASSO_MAXIMO = DRONE.velocidade * PASSO;
|
||||
const ANGULO_ENTRE_DRONES = (2 * Math.PI) / 3;
|
||||
const ALTURA_DO_ACERTO = 1.2;
|
||||
|
||||
export function criarDrone(p: Partida, problemaId: string): Drone {
|
||||
const drone: Drone = {
|
||||
id: p.proximoId++,
|
||||
problemaId,
|
||||
x: p.jogador.x,
|
||||
z: p.jogador.z,
|
||||
rumo: p.jogador.rumo,
|
||||
recarga: DRONE.intervalo,
|
||||
eliminacoes: 0,
|
||||
};
|
||||
p.drones.push(drone);
|
||||
return drone;
|
||||
}
|
||||
|
||||
function seguirJogador(p: Partida, drone: Drone, indice: number): void {
|
||||
const angulo = p.jogador.rumo + Math.PI / 2 + indice * ANGULO_ENTRE_DRONES;
|
||||
const dx = p.jogador.x + Math.sin(angulo) * DRONE.distancia - drone.x;
|
||||
const dz = p.jogador.z + Math.cos(angulo) * DRONE.distancia - drone.z;
|
||||
const fracao = Math.min(1, PASSO_MAXIMO / Math.hypot(dx, dz));
|
||||
[drone.x, drone.z] = moverComColisao(
|
||||
p.mapa,
|
||||
drone.x,
|
||||
drone.z,
|
||||
dx * fracao,
|
||||
dz * fracao,
|
||||
RAIO_DO_DRONE,
|
||||
);
|
||||
}
|
||||
|
||||
function zumbiMaisProximo(p: Partida, drone: Drone): Zumbi | undefined {
|
||||
let alvo: Zumbi | undefined;
|
||||
let melhor = Number.POSITIVE_INFINITY;
|
||||
for (const zumbi of p.zumbis) {
|
||||
const d = Math.hypot(zumbi.x - drone.x, zumbi.z - drone.z);
|
||||
if (
|
||||
zumbi.estado !== "morrendo" &&
|
||||
d <= DRONE.alcance &&
|
||||
d < melhor &&
|
||||
temLinhaDeVisao(p.mapa, drone.x, drone.z, zumbi.x, zumbi.z)
|
||||
) {
|
||||
alvo = zumbi;
|
||||
melhor = d;
|
||||
}
|
||||
}
|
||||
return alvo;
|
||||
}
|
||||
|
||||
function atirar(p: Partida, drone: Drone, alvo: Zumbi): void {
|
||||
const dx = alvo.x - drone.x;
|
||||
const dz = alvo.z - drone.z;
|
||||
drone.rumo = Math.atan2(dx, dz);
|
||||
p.eventos.push(
|
||||
{
|
||||
tipo: "tiro",
|
||||
autor: drone.id,
|
||||
arma: "drone",
|
||||
distancia: Math.hypot(dx, dz),
|
||||
},
|
||||
{
|
||||
tipo: "acerto",
|
||||
zumbiId: alvo.id,
|
||||
cabeca: false,
|
||||
x: alvo.x,
|
||||
y: ALTURA_DO_ACERTO,
|
||||
z: alvo.z,
|
||||
},
|
||||
);
|
||||
const matou = ferir(p, alvo, DRONE.dano, drone.id);
|
||||
drone.recarga = DRONE.intervalo;
|
||||
if (matou) registrarEliminacao(p, drone.problemaId, alvo.problemaId);
|
||||
}
|
||||
|
||||
export function avancarDrones(p: Partida): void {
|
||||
p.drones.forEach((drone, indice) => {
|
||||
seguirJogador(p, drone, indice);
|
||||
drone.recarga = Math.max(0, drone.recarga - 1);
|
||||
if (drone.recarga > 0) return;
|
||||
const alvo = zumbiMaisProximo(p, drone);
|
||||
if (alvo) atirar(p, drone, alvo);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { registrar, registrarEliminacao, registrarResolvido } from "./feed";
|
||||
import type { Partida } from "./tipos";
|
||||
|
||||
const nova = (): Pick<Partida, "feed" | "passo"> => ({ feed: [], passo: 42 });
|
||||
|
||||
describe("registrar", () => {
|
||||
it("acrescenta a linha com o passo atual", () => {
|
||||
const p = nova();
|
||||
registrar(p, "olá");
|
||||
expect(p.feed).toEqual([{ passo: 42, texto: "olá" }]);
|
||||
});
|
||||
|
||||
it("mantém cinco linhas e corta na sexta", () => {
|
||||
const p = nova();
|
||||
for (let i = 1; i <= 5; i++) registrar(p, `l${i}`);
|
||||
expect(p.feed).toHaveLength(5);
|
||||
registrar(p, "l6");
|
||||
expect(p.feed.map((l) => l.texto)).toEqual(["l2", "l3", "l4", "l5", "l6"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registrarEliminacao", () => {
|
||||
it("usa a solução do drone e o nome do zumbi", () => {
|
||||
const p = nova();
|
||||
registrarEliminacao(p, "site-lento", "site-lento");
|
||||
expect(p.feed[0].texto).toBe("Site rápido eliminou Site lento");
|
||||
});
|
||||
|
||||
it("usa o próprio id quando desconhecido", () => {
|
||||
const p = nova();
|
||||
registrarEliminacao(p, "a", "b");
|
||||
expect(p.feed[0].texto).toBe("a eliminou b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("registrarResolvido", () => {
|
||||
it("registra o nome do problema", () => {
|
||||
const p = nova();
|
||||
registrarResolvido(p, "site-lento");
|
||||
expect(p.feed[0].texto).toBe("Resolvido: Site lento");
|
||||
});
|
||||
|
||||
it("usa o próprio id quando desconhecido", () => {
|
||||
const p = nova();
|
||||
registrarResolvido(p, "zzz");
|
||||
expect(p.feed[0].texto).toBe("Resolvido: zzz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { problemaPorId } from "./problemas";
|
||||
import type { Partida } from "./tipos";
|
||||
import { LINHAS_DO_FEED } from "./valores";
|
||||
|
||||
type ComFeed = Pick<Partida, "feed" | "passo">;
|
||||
|
||||
export function registrar(p: ComFeed, texto: string): void {
|
||||
p.feed.push({ passo: p.passo, texto });
|
||||
if (p.feed.length > LINHAS_DO_FEED) {
|
||||
p.feed.splice(0, p.feed.length - LINHAS_DO_FEED);
|
||||
}
|
||||
}
|
||||
|
||||
export function registrarEliminacao(
|
||||
p: ComFeed,
|
||||
problemaDoDrone: string,
|
||||
problemaDoZumbi: string,
|
||||
): void {
|
||||
const solucao = problemaPorId(problemaDoDrone)?.solucao ?? problemaDoDrone;
|
||||
const nome = problemaPorId(problemaDoZumbi)?.nome ?? problemaDoZumbi;
|
||||
registrar(p, `${solucao} eliminou ${nome}`);
|
||||
}
|
||||
|
||||
export function registrarResolvido(p: ComFeed, problemaId: string): void {
|
||||
registrar(p, `Resolvido: ${problemaPorId(problemaId)?.nome ?? problemaId}`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"duracao": 1.4,
|
||||
"impacto": 0.833,
|
||||
"mao": "direita",
|
||||
"alcance": 0.79
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { escolherHordas, montarFila, tamanhoDaHorda } from "./hordas";
|
||||
import { problemaPorId, problemasDaArea } from "./problemas";
|
||||
import { sortearInteiro } from "./rng";
|
||||
import type { Dificuldade } from "./tipos";
|
||||
|
||||
describe("tamanhoDaHorda", () => {
|
||||
it.each([
|
||||
[0, "facil", 8],
|
||||
[1, "facil", 11],
|
||||
[2, "facil", 14],
|
||||
[0, "normal", 12],
|
||||
[1, "normal", 16],
|
||||
[2, "normal", 20],
|
||||
[0, "dificil", 16],
|
||||
[1, "dificil", 21],
|
||||
[2, "dificil", 26],
|
||||
] as const)("horda %i no %s tem %i", (indice, dificuldade, esperado) => {
|
||||
expect(tamanhoDaHorda(indice, dificuldade)).toBe(esperado);
|
||||
});
|
||||
|
||||
it("lança erro para índice fora de 0 a 2", () => {
|
||||
expect(() => tamanhoDaHorda(3, "normal")).toThrow(Error);
|
||||
expect(() => tamanhoDaHorda(-1, "normal")).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escolherHordas", () => {
|
||||
const id = "site-lento";
|
||||
const area = problemaPorId(id)?.area ?? "vender";
|
||||
|
||||
it("devolve 3 outros problemas da área, sem repetir, e o próprio id por último", () => {
|
||||
for (let rng = 1; rng <= 30; rng++) {
|
||||
const ids = escolherHordas({ rng }, id);
|
||||
expect(ids).toHaveLength(4);
|
||||
expect(ids[3]).toBe(id);
|
||||
expect(new Set(ids).size).toBe(4);
|
||||
for (const outro of ids) {
|
||||
expect(problemaPorId(outro)?.area).toBe(area);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("usa sortearInteiro sobre os candidatos restantes", () => {
|
||||
const p = { rng: 7 };
|
||||
const copia = { rng: 7 };
|
||||
const outros = problemasDaArea(area)
|
||||
.map((problema) => problema.id)
|
||||
.filter((outro) => outro !== id);
|
||||
const esperado: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [escolhido] = outros.splice(
|
||||
sortearInteiro(copia, outros.length),
|
||||
1,
|
||||
);
|
||||
esperado.push(escolhido);
|
||||
}
|
||||
expect(escolherHordas(p, id)).toEqual([...esperado, id]);
|
||||
});
|
||||
|
||||
it("lança erro para id inexistente", () => {
|
||||
expect(() => escolherHordas({ rng: 1 }, "nao-existe")).toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe("montarFila", () => {
|
||||
const tipos = (indice: number, dificuldade: Dificuldade = "normal") =>
|
||||
montarFila(indice, "x", dificuldade).map((n) => n.tipo);
|
||||
|
||||
it("horda 0: todos comuns", () => {
|
||||
expect(tipos(0)).toEqual(Array(12).fill("comum"));
|
||||
});
|
||||
|
||||
it("horda 1: alterna comum e rápido", () => {
|
||||
const t = tipos(1);
|
||||
expect(t).toHaveLength(16);
|
||||
expect(t.slice(0, 4)).toEqual(["comum", "rapido", "comum", "rapido"]);
|
||||
expect(t.filter((x) => x === "comum")).toHaveLength(8);
|
||||
expect(t.filter((x) => x === "rapido")).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("horda 2: posição módulo 5", () => {
|
||||
const t = tipos(2);
|
||||
expect(t).toHaveLength(20);
|
||||
expect(t.slice(0, 5)).toEqual([
|
||||
"comum",
|
||||
"comum",
|
||||
"rapido",
|
||||
"rapido",
|
||||
"blindado",
|
||||
]);
|
||||
expect(t.slice(15)).toEqual(t.slice(0, 5));
|
||||
});
|
||||
|
||||
it("chefão: um chefão e a escolta fixa, sem mudar com a dificuldade", () => {
|
||||
const esperado = ["chefao", ...Array(6).fill("comum")];
|
||||
expect(tipos(3)).toEqual(esperado);
|
||||
expect(tipos(3, "dificil")).toEqual(esperado);
|
||||
});
|
||||
|
||||
it("o tamanho segue a dificuldade e todos levam o problemaId", () => {
|
||||
const fila = montarFila(0, "abc", "facil");
|
||||
expect(fila).toHaveLength(8);
|
||||
expect(fila.every((n) => n.problemaId === "abc")).toBe(true);
|
||||
expect(
|
||||
montarFila(3, "abc", "facil").every((n) => n.problemaId === "abc"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("lança erro para índice fora de 0 a 3", () => {
|
||||
expect(() => montarFila(4, "x", "normal")).toThrow(Error);
|
||||
expect(() => montarFila(-1, "x", "normal")).toThrow(Error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { problemaPorId, problemasDaArea } from "./problemas";
|
||||
import { sortearInteiro } from "./rng";
|
||||
import type { Dificuldade, NaFila, TipoZumbi } from "./tipos";
|
||||
import { DIFICULDADES, HORDAS } from "./valores";
|
||||
|
||||
export function tamanhoDaHorda(
|
||||
indice: number,
|
||||
dificuldade: Dificuldade,
|
||||
): number {
|
||||
if (indice !== 0 && indice !== 1 && indice !== 2) {
|
||||
throw new Error(`Índice de horda inválido: ${indice}`);
|
||||
}
|
||||
return Math.max(
|
||||
1,
|
||||
Math.round(HORDAS.tamanhos[indice] * DIFICULDADES[dificuldade].quantidade),
|
||||
);
|
||||
}
|
||||
|
||||
export function escolherHordas(
|
||||
p: { rng: number },
|
||||
problemaId: string,
|
||||
): string[] {
|
||||
const problema = problemaPorId(problemaId);
|
||||
if (!problema) throw new Error(`Problema desconhecido: ${problemaId}`);
|
||||
const outros = problemasDaArea(problema.area)
|
||||
.map((candidato) => candidato.id)
|
||||
.filter((id) => id !== problemaId);
|
||||
const escolhidos: string[] = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
escolhidos.push(...outros.splice(sortearInteiro(p, outros.length), 1));
|
||||
}
|
||||
return [...escolhidos, problemaId];
|
||||
}
|
||||
|
||||
function tipoDaPosicao(indice: number, posicao: number): TipoZumbi {
|
||||
if (indice === 0) return "comum";
|
||||
if (indice === 1) return posicao % 2 === 0 ? "comum" : "rapido";
|
||||
const resto = posicao % 5;
|
||||
if (resto < 2) return "comum";
|
||||
return resto < 4 ? "rapido" : "blindado";
|
||||
}
|
||||
|
||||
export function montarFila(
|
||||
indice: number,
|
||||
problemaId: string,
|
||||
dificuldade: Dificuldade,
|
||||
): NaFila[] {
|
||||
if (indice === 3) {
|
||||
const tipos: TipoZumbi[] = [
|
||||
"chefao",
|
||||
...Array<TipoZumbi>(HORDAS.escoltaDoChefao).fill("comum"),
|
||||
];
|
||||
return tipos.map((tipo) => ({ tipo, problemaId }));
|
||||
}
|
||||
const total = tamanhoDaHorda(indice, dificuldade);
|
||||
return Array.from({ length: total }, (_, posicao) => ({
|
||||
tipo: tipoDaPosicao(indice, posicao),
|
||||
problemaId,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
caminhoLivre,
|
||||
campoDeFluxo,
|
||||
direcaoNoCampo,
|
||||
ehOpaco,
|
||||
ehSolido,
|
||||
lerMapa,
|
||||
moverComColisao,
|
||||
raioAteParede,
|
||||
temLinhaDeVisao,
|
||||
} from "./mapa";
|
||||
import { ESCRITORIO } from "./valores";
|
||||
|
||||
const SALA = lerMapa(["#######", "#J...N#", "#.....#", "#..M..#", "#######"]);
|
||||
|
||||
const DIVIDIDA = lerMapa(["#########", "#J..#..N#", "#...#...#", "#########"]);
|
||||
|
||||
describe("lerMapa", () => {
|
||||
it("lê dimensões, células, início e nascedouros", () => {
|
||||
const m = lerMapa(["#####", "#J.N#", "#.M.#", "#####"]);
|
||||
expect(m.largura).toBe(5);
|
||||
expect(m.altura).toBe(4);
|
||||
expect(m.solido[2 * 5 + 2]).toBe(1);
|
||||
expect(m.opaco[2 * 5 + 2]).toBe(0);
|
||||
expect(m.solido[1 * 5 + 1]).toBe(0);
|
||||
expect(m.opaco[0]).toBe(1);
|
||||
expect(m.inicio).toEqual([1.5, 1.5]);
|
||||
expect(m.nascedouros).toEqual([[3.5, 1.5]]);
|
||||
});
|
||||
|
||||
it("recusa lista vazia", () => {
|
||||
expect(() => lerMapa([])).toThrow(/ao menos uma linha/);
|
||||
});
|
||||
|
||||
it("recusa linhas de larguras diferentes", () => {
|
||||
expect(() => lerMapa(["JN", "N"])).toThrow(/larguras diferentes/);
|
||||
});
|
||||
|
||||
it("recusa mapa sem início", () => {
|
||||
expect(() => lerMapa(["N."])).toThrow(/exatamente um início/);
|
||||
});
|
||||
|
||||
it("recusa mapa com dois inícios", () => {
|
||||
expect(() => lerMapa(["JJN"])).toThrow(/exatamente um início/);
|
||||
});
|
||||
|
||||
it("recusa mapa sem nascedouro", () => {
|
||||
expect(() => lerMapa(["J."])).toThrow(/nascedouro/);
|
||||
});
|
||||
|
||||
it("recusa caractere desconhecido", () => {
|
||||
expect(() => lerMapa(["JNX"])).toThrow(/Caractere desconhecido "X"/);
|
||||
});
|
||||
|
||||
it("lê o ESCRITORIO: 28×17, 4 nascedouros, todos com caminho até o início", () => {
|
||||
const m = lerMapa(ESCRITORIO);
|
||||
expect(m.largura).toBe(28);
|
||||
expect(m.altura).toBe(17);
|
||||
expect(m.nascedouros).toHaveLength(4);
|
||||
const campo = campoDeFluxo(m, m.inicio[0], m.inicio[1]);
|
||||
for (const [x, z] of m.nascedouros) {
|
||||
expect(
|
||||
campo[Math.floor(z) * m.largura + Math.floor(x)],
|
||||
).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ehSolido e ehOpaco", () => {
|
||||
it("parede é sólida e opaca", () => {
|
||||
expect(ehSolido(SALA, 0.5, 0.5)).toBe(true);
|
||||
expect(ehOpaco(SALA, 0.5, 0.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("mesa é sólida e não opaca", () => {
|
||||
expect(ehSolido(SALA, 3.5, 3.5)).toBe(true);
|
||||
expect(ehOpaco(SALA, 3.5, 3.5)).toBe(false);
|
||||
});
|
||||
|
||||
it("chão é livre", () => {
|
||||
expect(ehSolido(SALA, 2.5, 2.5)).toBe(false);
|
||||
expect(ehOpaco(SALA, 2.5, 2.5)).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[-0.1, 2.5],
|
||||
[2.5, -0.1],
|
||||
[7.1, 2.5],
|
||||
[2.5, 5.1],
|
||||
])("fora do mapa (%f, %f) é sólido e opaco", (x, z) => {
|
||||
expect(ehSolido(SALA, x, z)).toBe(true);
|
||||
expect(ehOpaco(SALA, x, z)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("raioAteParede", () => {
|
||||
it("anda em +x", () => {
|
||||
expect(raioAteParede(SALA, 1.5, 2.5, Math.PI / 2, 99)).toBeCloseTo(4.5, 6);
|
||||
});
|
||||
|
||||
it("anda em -x", () => {
|
||||
expect(raioAteParede(SALA, 1.5, 2.5, -Math.PI / 2, 99)).toBeCloseTo(0.5, 6);
|
||||
});
|
||||
|
||||
it("anda em +z atravessando a mesa", () => {
|
||||
expect(raioAteParede(SALA, 3.5, 1.5, 0, 99)).toBeCloseTo(2.5, 6);
|
||||
});
|
||||
|
||||
it("anda em -z", () => {
|
||||
expect(raioAteParede(SALA, 3.5, 3.5, Math.PI, 99)).toBeCloseTo(2.5, 6);
|
||||
});
|
||||
|
||||
it("anda em diagonal, trocando de eixo", () => {
|
||||
expect(raioAteParede(SALA, 1.5, 1.5, 0.5, 99)).toBeCloseTo(
|
||||
2.5 / Math.cos(0.5),
|
||||
6,
|
||||
);
|
||||
});
|
||||
|
||||
it("devolve o alcance se não bater antes", () => {
|
||||
expect(raioAteParede(SALA, 1.5, 2.5, Math.PI / 2, 2)).toBe(2);
|
||||
});
|
||||
|
||||
it("devolve 0 se já começa em célula opaca", () => {
|
||||
expect(raioAteParede(SALA, 0.5, 0.5, 1, 5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("temLinhaDeVisao", () => {
|
||||
it("vê em campo aberto", () => {
|
||||
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 3.5, 2.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("não vê através de parede", () => {
|
||||
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 6.5, 1.5)).toBe(false);
|
||||
});
|
||||
|
||||
it("vê através de mesa", () => {
|
||||
expect(temLinhaDeVisao(SALA, 1.5, 3.5, 5.5, 3.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("não vê um ponto dentro da parede", () => {
|
||||
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 4.5, 1.5)).toBe(false);
|
||||
});
|
||||
|
||||
it("não vê a partir de dentro da parede", () => {
|
||||
expect(temLinhaDeVisao(DIVIDIDA, 0.5, 0.5, 1.5, 1.5)).toBe(false);
|
||||
});
|
||||
|
||||
it("o mesmo ponto livre se vê; o mesmo ponto em parede, não", () => {
|
||||
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 1.5, 1.5)).toBe(true);
|
||||
expect(temLinhaDeVisao(DIVIDIDA, 0.5, 0.5, 0.5, 0.5)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/** Mesa em x de 3 a 4, z de 3 a 4. */
|
||||
const COM_MESA = lerMapa([
|
||||
"#######",
|
||||
"#J....#",
|
||||
"#.....#",
|
||||
"#..M..#",
|
||||
"#....N#",
|
||||
"#######",
|
||||
]);
|
||||
|
||||
describe("caminhoLivre", () => {
|
||||
it("livre em campo aberto, com folga dos dois lados", () => {
|
||||
expect(caminhoLivre(COM_MESA, 1.5, 1.5, 5.5, 1.5, 0.35)).toBe(true);
|
||||
});
|
||||
|
||||
it("a mesa no meio do segmento bloqueia, mesmo sem bloquear a visão", () => {
|
||||
expect(caminhoLivre(COM_MESA, 1.5, 3.5, 5.5, 3.5, 0.35)).toBe(false);
|
||||
expect(temLinhaDeVisao(COM_MESA, 1.5, 3.5, 5.5, 3.5)).toBe(true);
|
||||
});
|
||||
|
||||
it("bloqueia quando só o paralelo de um lado cruza a mesa", () => {
|
||||
expect(caminhoLivre(COM_MESA, 1.5, 2.7, 5.5, 2.7, 0.35)).toBe(false);
|
||||
expect(caminhoLivre(COM_MESA, 1.5, 2.7, 5.5, 2.7, 0.25)).toBe(true);
|
||||
});
|
||||
|
||||
it("bloqueia quando só o paralelo do outro lado cruza a mesa", () => {
|
||||
expect(caminhoLivre(COM_MESA, 1.5, 4.3, 5.5, 4.3, 0.35)).toBe(false);
|
||||
expect(caminhoLivre(COM_MESA, 1.5, 4.3, 5.5, 4.3, 0.25)).toBe(true);
|
||||
});
|
||||
|
||||
it("o mesmo ponto: livre em chão, bloqueado em célula sólida", () => {
|
||||
expect(caminhoLivre(COM_MESA, 1.5, 1.5, 1.5, 1.5, 0.35)).toBe(true);
|
||||
expect(caminhoLivre(COM_MESA, 3.5, 3.5, 3.5, 3.5, 0.35)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("moverComColisao", () => {
|
||||
it("anda livre nos dois eixos", () => {
|
||||
const [x, z] = moverComColisao(SALA, 2.5, 2.5, 0.5, 0.2, 0.25);
|
||||
expect(x).toBeCloseTo(3.0, 9);
|
||||
expect(z).toBeCloseTo(2.7, 9);
|
||||
});
|
||||
|
||||
it("desliza em z quando x bate na parede (canto de trás, em cima)", () => {
|
||||
expect(moverComColisao(SALA, 1.5, 1.5, -0.5, 0.3, 0.25)).toEqual([
|
||||
1.5, 1.8,
|
||||
]);
|
||||
});
|
||||
|
||||
it("desliza em x quando z bate na parede", () => {
|
||||
const [x, z] = moverComColisao(SALA, 1.5, 1.5, 0.3, -0.5, 0.25);
|
||||
expect(x).toBeCloseTo(1.8, 9);
|
||||
expect(z).toBe(1.5);
|
||||
});
|
||||
|
||||
it("para quando os dois eixos batem", () => {
|
||||
expect(moverComColisao(SALA, 1.5, 1.5, -0.5, -0.5, 0.25)).toEqual([
|
||||
1.5, 1.5,
|
||||
]);
|
||||
});
|
||||
|
||||
it("confere o canto da frente, em cima (parede à direita)", () => {
|
||||
expect(moverComColisao(SALA, 5.5, 2.5, 0.5, 0.1, 0.25)).toEqual([5.5, 2.6]);
|
||||
});
|
||||
|
||||
it("confere o canto de trás, embaixo (mesa por baixo)", () => {
|
||||
expect(moverComColisao(SALA, 3.5, 2.5, 0, 0.3, 0.25)).toEqual([3.5, 2.5]);
|
||||
});
|
||||
|
||||
it("confere o canto da frente, embaixo (mesa na diagonal)", () => {
|
||||
const [x, z] = moverComColisao(SALA, 2.5, 2.5, 0.3, 0.3, 0.25);
|
||||
expect(x).toBeCloseTo(2.8, 9);
|
||||
expect(z).toBe(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
const FLUXO = lerMapa(["######", "#J.#N#", "#..#.#", "#M.###", "######"]);
|
||||
|
||||
describe("campoDeFluxo", () => {
|
||||
it("guarda distâncias, -1 em sólidas e inalcançáveis", () => {
|
||||
const campo = campoDeFluxo(FLUXO, 1.5, 1.5);
|
||||
expect(Array.from(campo)).toEqual([
|
||||
...[-1, -1, -1, -1, -1, -1],
|
||||
...[-1, 0, 1, -1, -1, -1],
|
||||
...[-1, 1, 2, -1, -1, -1],
|
||||
...[-1, -1, 3, -1, -1, -1],
|
||||
...[-1, -1, -1, -1, -1, -1],
|
||||
]);
|
||||
expect(campo).toBeInstanceOf(Int16Array);
|
||||
});
|
||||
|
||||
it("tudo -1 se o alvo está em célula sólida", () => {
|
||||
expect(
|
||||
Array.from(campoDeFluxo(FLUXO, 1.5, 3.5)).every((v) => v === -1),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("tudo -1 se o alvo está fora do mapa", () => {
|
||||
expect(
|
||||
Array.from(campoDeFluxo(FLUXO, -3, 1.5)).every((v) => v === -1),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
const ABERTO = lerMapa(["J..", "...", "..N"]);
|
||||
|
||||
describe("direcaoNoCampo", () => {
|
||||
const campo = campoDeFluxo(ABERTO, 0.5, 0.5);
|
||||
|
||||
it("vai na diagonal quando livre", () => {
|
||||
const [dx, dz] = direcaoNoCampo(ABERTO, campo, 2.5, 2.5);
|
||||
expect(dx).toBeCloseTo(-Math.SQRT1_2, 9);
|
||||
expect(dz).toBeCloseTo(-Math.SQRT1_2, 9);
|
||||
});
|
||||
|
||||
it("aponta para o centro da melhor vizinha a partir da posição real", () => {
|
||||
const [dx, dz] = direcaoNoCampo(ABERTO, campo, 2.9, 2.1);
|
||||
const tamanho = Math.hypot(1.4, 0.6);
|
||||
expect(dx).toBeCloseTo(-1.4 / tamanho, 9);
|
||||
expect(dz).toBeCloseTo(-0.6 / tamanho, 9);
|
||||
});
|
||||
|
||||
it("vai em linha reta na borda do mapa", () => {
|
||||
expect(direcaoNoCampo(ABERTO, campo, 1.5, 0.5)).toEqual([-1, 0]);
|
||||
});
|
||||
|
||||
it("devolve [0, 0] na célula do alvo", () => {
|
||||
expect(direcaoNoCampo(ABERTO, campo, 0.5, 0.5)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("devolve [0, 0] fora do mapa", () => {
|
||||
expect(direcaoNoCampo(ABERTO, campo, -1, 0.5)).toEqual([0, 0]);
|
||||
expect(direcaoNoCampo(ABERTO, campo, 0.5, -1)).toEqual([0, 0]);
|
||||
expect(direcaoNoCampo(ABERTO, campo, 3.5, 0.5)).toEqual([0, 0]);
|
||||
expect(direcaoNoCampo(ABERTO, campo, 0.5, 3.5)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("devolve [0, 0] em célula sem caminho", () => {
|
||||
const campoF = campoDeFluxo(FLUXO, 1.5, 1.5);
|
||||
expect(direcaoNoCampo(FLUXO, campoF, 4.5, 1.5)).toEqual([0, 0]);
|
||||
});
|
||||
|
||||
it("não corta quina: parede em cima da diagonal", () => {
|
||||
const m = lerMapa(["J#.", "...", "..N"]);
|
||||
const c = campoDeFluxo(m, 0.5, 0.5);
|
||||
expect(direcaoNoCampo(m, c, 1.5, 1.5)).toEqual([-1, 0]);
|
||||
});
|
||||
|
||||
it("não corta quina: parede ao lado da diagonal", () => {
|
||||
const m = lerMapa(["J..", "#..", "..N"]);
|
||||
const c = campoDeFluxo(m, 0.5, 0.5);
|
||||
expect(direcaoNoCampo(m, c, 1.5, 1.5)).toEqual([0, -1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/** 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];
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { campoDeFluxo, direcaoNoCampo, lerMapa } from "../mapa";
|
||||
import { AMBIENTES, ESCRITORIO, PORTAS } from "./escritorio";
|
||||
|
||||
const MAPA = lerMapa(ESCRITORIO);
|
||||
const { largura, altura } = MAPA;
|
||||
const andavel = (bloqueio: Uint8Array, x: number, z: number) =>
|
||||
x >= 0 &&
|
||||
z >= 0 &&
|
||||
x < largura &&
|
||||
z < altura &&
|
||||
bloqueio[z * largura + x] === 0;
|
||||
|
||||
/** Quantas células andáveis a busca em largura alcança a partir do início. */
|
||||
function alcancaveis(bloqueio: Uint8Array): number {
|
||||
const inicio =
|
||||
Math.floor(MAPA.inicio[1]) * largura + Math.floor(MAPA.inicio[0]);
|
||||
const visto = new Uint8Array(largura * altura);
|
||||
const fila = [inicio];
|
||||
visto[inicio] = 1;
|
||||
for (let k = 0; k < fila.length; k++) {
|
||||
const x = fila[k] % largura;
|
||||
const z = Math.floor(fila[k] / largura);
|
||||
for (const [dx, dz] of [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
]) {
|
||||
const i = (z + dz) * largura + x + dx;
|
||||
if (andavel(bloqueio, x + dx, z + dz) && !visto[i]) {
|
||||
visto[i] = 1;
|
||||
fila.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fila.length;
|
||||
}
|
||||
|
||||
const totalAndavel = MAPA.solido.filter((c) => c === 0).length;
|
||||
|
||||
describe("mapa do escritório", () => {
|
||||
it("tem pelo menos 40 × 28 células e é lido por lerMapa", () => {
|
||||
expect(largura).toBeGreaterThanOrEqual(40);
|
||||
expect(altura).toBeGreaterThanOrEqual(28);
|
||||
expect(() => lerMapa(ESCRITORIO)).not.toThrow();
|
||||
});
|
||||
|
||||
it("tem os 7 ambientes com nome", () => {
|
||||
const nomes = AMBIENTES.map((a) => a.nome);
|
||||
for (const nome of [
|
||||
"recepção",
|
||||
"área de mesas",
|
||||
"sala de reunião",
|
||||
"copa",
|
||||
"sala do servidor",
|
||||
"diretoria",
|
||||
"almoxarifado",
|
||||
]) {
|
||||
expect(nomes).toContain(nome);
|
||||
}
|
||||
});
|
||||
|
||||
it("tem 6 ou mais nascedouros, todos a 8 m ou mais do início", () => {
|
||||
expect(MAPA.nascedouros.length).toBeGreaterThanOrEqual(6);
|
||||
for (const [x, z] of MAPA.nascedouros) {
|
||||
expect(
|
||||
Math.hypot(x - MAPA.inicio[0], z - MAPA.inicio[1]),
|
||||
).toBeGreaterThanOrEqual(8);
|
||||
}
|
||||
});
|
||||
|
||||
it("alcança todo o chão a partir do início", () => {
|
||||
expect(alcancaveis(MAPA.solido)).toBe(totalAndavel);
|
||||
});
|
||||
|
||||
it("continua alcançando todo o chão sem qualquer uma das portas", () => {
|
||||
expect(PORTAS.length).toBeGreaterThan(0);
|
||||
for (const porta of PORTAS) {
|
||||
const bloqueio = MAPA.solido.slice();
|
||||
for (let z = porta.z; z < porta.z + porta.altura; z++) {
|
||||
for (let x = porta.x; x < porta.x + porta.largura; x++) {
|
||||
expect(andavel(bloqueio, x, z)).toBe(true);
|
||||
bloqueio[z * largura + x] = 1;
|
||||
}
|
||||
}
|
||||
expect(alcancaveis(bloqueio)).toBe(
|
||||
totalAndavel - porta.largura * porta.altura,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("não tem passagem com menos de 2 células: toda célula andável está num 2 × 2 andável", () => {
|
||||
const livre = (x: number, z: number) => andavel(MAPA.solido, x, z);
|
||||
for (let z = 0; z < altura; z++) {
|
||||
for (let x = 0; x < largura; x++) {
|
||||
if (!livre(x, z)) continue;
|
||||
const emQuadrado = [
|
||||
[-1, -1],
|
||||
[-1, 0],
|
||||
[0, -1],
|
||||
[0, 0],
|
||||
].some(
|
||||
([ox, oz]) =>
|
||||
livre(x + ox, z + oz) &&
|
||||
livre(x + ox + 1, z + oz) &&
|
||||
livre(x + ox, z + oz + 1) &&
|
||||
livre(x + ox + 1, z + oz + 1),
|
||||
);
|
||||
expect([x, z, emQuadrado]).toEqual([x, z, true]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("o campo de fluxo leva cada nascedouro ao início em menos de 400 passos", () => {
|
||||
const campo = campoDeFluxo(MAPA, MAPA.inicio[0], MAPA.inicio[1]);
|
||||
const alvo = [Math.floor(MAPA.inicio[0]), Math.floor(MAPA.inicio[1])];
|
||||
for (const [nx, nz] of MAPA.nascedouros) {
|
||||
let x = Math.floor(nx);
|
||||
let z = Math.floor(nz);
|
||||
let passos = 0;
|
||||
while ((x !== alvo[0] || z !== alvo[1]) && passos < 400) {
|
||||
const [dx, dz] = direcaoNoCampo(MAPA, campo, x + 0.5, z + 0.5);
|
||||
x += Math.round(dx);
|
||||
z += Math.round(dz);
|
||||
passos++;
|
||||
}
|
||||
expect([x, z]).toEqual(alvo);
|
||||
expect(passos).toBeLessThan(400);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
// Gerado por `bash 3d/mapa.sh` (`python3 3d/mapa.py converter`) a partir de 3d/mapa/escritorio.tmx: não edite à mão.
|
||||
|
||||
/** Planta do escritório para `lerMapa`: `#` parede ou móvel alto, `M` móvel baixo, `.` chão, `J` início, `N` nascedouro. */
|
||||
export const ESCRITORIO: readonly string[] = [
|
||||
"############################################",
|
||||
"#..MMM.........#............#MMM..........##",
|
||||
"#..............#............#.............##",
|
||||
"#M......MMMM...#..MMMMMMMM.........MMMM...##",
|
||||
"#M.............#..MMMMMMMM.........MMMM...##",
|
||||
"#M.....J.......#............#..............#",
|
||||
"#M.............#............#..............#",
|
||||
"####..######..####..####..####..############",
|
||||
"#........#........................#........#",
|
||||
"#........#........................#........#",
|
||||
"#........#..#########..#####..##..#..#######",
|
||||
"#M..MM......#.................##...........#",
|
||||
"#M..MM......#.................##...........#",
|
||||
"#M.......#..#..MMMMM...MMMMM..##..#..#######",
|
||||
"#M.......#..#..MMMMM...MMMMM...#..#........#",
|
||||
"#MMMM..###........................#........#",
|
||||
"##########........................####..####",
|
||||
"###..MMMM#..#..MMMMM...MMMMM...#..#.......##",
|
||||
"###......#..#..MMMMM...MMMMM...#..#.......##",
|
||||
"###.........#..................#..........##",
|
||||
"###.........#..................#.....###...#",
|
||||
"###...N..#..#########..#########..#........#",
|
||||
"#........#........................#......N.#",
|
||||
"#........#........................###......#",
|
||||
"####..#######..#####..######..########..####",
|
||||
"#................#......#..................#",
|
||||
"#................#......#..................#",
|
||||
"#.N....N.............####...............MMM#",
|
||||
"#....................####...............MMM#",
|
||||
"####..###........#.N.####.N................#",
|
||||
"####..###..MMMM..#...####..######........N.#",
|
||||
"############################################",
|
||||
];
|
||||
|
||||
/** Salas com nome, em células (x para a direita, z para baixo). */
|
||||
export const AMBIENTES: readonly {
|
||||
nome: string;
|
||||
x: number;
|
||||
z: number;
|
||||
largura: number;
|
||||
altura: number;
|
||||
}[] = [
|
||||
{ nome: "recepção", x: 1, z: 1, largura: 14, altura: 6 },
|
||||
{ nome: "sala de reunião", x: 16, z: 1, largura: 12, altura: 6 },
|
||||
{ nome: "diretoria", x: 29, z: 1, largura: 14, altura: 6 },
|
||||
{ nome: "copa", x: 1, z: 8, largura: 8, altura: 8 },
|
||||
{ nome: "banheiros", x: 1, z: 17, largura: 8, altura: 7 },
|
||||
{ nome: "área de mesas", x: 13, z: 11, largura: 18, altura: 10 },
|
||||
{ nome: "sala do servidor", x: 35, z: 8, largura: 8, altura: 8 },
|
||||
{ nome: "almoxarifado", x: 35, z: 17, largura: 8, altura: 7 },
|
||||
{ nome: "hall dos elevadores", x: 1, z: 25, largura: 16, altura: 6 },
|
||||
{ nome: "escada", x: 18, z: 25, largura: 6, altura: 6 },
|
||||
{ nome: "área de serviço", x: 25, z: 25, largura: 18, altura: 6 },
|
||||
];
|
||||
|
||||
/** Células abertas nas paredes, em células. */
|
||||
export const PORTAS: readonly {
|
||||
x: number;
|
||||
z: number;
|
||||
largura: number;
|
||||
altura: number;
|
||||
}[] = [
|
||||
{ x: 4, z: 7, largura: 2, altura: 1 },
|
||||
{ x: 12, z: 7, largura: 2, altura: 1 },
|
||||
{ x: 18, z: 7, largura: 2, altura: 1 },
|
||||
{ x: 24, z: 7, largura: 2, altura: 1 },
|
||||
{ x: 28, z: 3, largura: 1, altura: 2 },
|
||||
{ x: 30, z: 7, largura: 2, altura: 1 },
|
||||
{ x: 9, z: 11, largura: 1, altura: 2 },
|
||||
{ x: 9, z: 19, largura: 1, altura: 2 },
|
||||
{ x: 4, z: 24, largura: 2, altura: 1 },
|
||||
{ x: 13, z: 24, largura: 2, altura: 1 },
|
||||
{ x: 17, z: 27, largura: 1, altura: 2 },
|
||||
{ x: 20, z: 24, largura: 2, altura: 1 },
|
||||
{ x: 28, z: 24, largura: 2, altura: 1 },
|
||||
{ x: 38, z: 24, largura: 2, altura: 1 },
|
||||
{ x: 34, z: 19, largura: 1, altura: 2 },
|
||||
{ x: 38, z: 16, largura: 2, altura: 1 },
|
||||
{ x: 34, z: 11, largura: 1, altura: 2 },
|
||||
{ x: 21, z: 10, largura: 2, altura: 1 },
|
||||
{ x: 28, z: 10, largura: 2, altura: 1 },
|
||||
{ x: 21, z: 21, largura: 2, altura: 1 },
|
||||
{ x: 12, z: 15, largura: 1, altura: 2 },
|
||||
{ x: 31, z: 15, largura: 1, altura: 2 },
|
||||
];
|
||||
@@ -0,0 +1,192 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const RAIZ = process.cwd();
|
||||
const PASTA = join(RAIZ, "public/jogo");
|
||||
const BLOCO = join(RAIZ, "public/3d/bloco.glb");
|
||||
const MB = 1024 * 1024;
|
||||
|
||||
function arquivos(pasta: string): string[] {
|
||||
if (!existsSync(pasta)) return [];
|
||||
return readdirSync(pasta, { withFileTypes: true }).flatMap((e) =>
|
||||
e.isDirectory() ? arquivos(join(pasta, e.name)) : [join(pasta, e.name)],
|
||||
);
|
||||
}
|
||||
|
||||
const nome = (caminho: string) => relative(RAIZ, caminho);
|
||||
const doJogo = arquivos(PASTA).filter((a) => !a.endsWith("CREDITOS.json"));
|
||||
const glbs = [...doJogo, BLOCO].filter((a) => a.endsWith(".glb"));
|
||||
|
||||
/** Maior lado de um JPEG, PNG ou WebP, lido do cabeçalho; `null` se não for nenhum deles. */
|
||||
function lado(b: Buffer): number | null {
|
||||
if (
|
||||
b.length > 30 &&
|
||||
b.toString("latin1", 0, 4) === "RIFF" &&
|
||||
b.toString("latin1", 8, 15) === "WEBPVP8"
|
||||
) {
|
||||
const tipo = b.toString("latin1", 15, 16);
|
||||
if (tipo === "X")
|
||||
return Math.max(b.readUIntLE(24, 3), b.readUIntLE(27, 3)) + 1;
|
||||
if (tipo === " ")
|
||||
return Math.max(b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff);
|
||||
if (tipo === "L") {
|
||||
const v = b.readUInt32LE(21);
|
||||
return Math.max((v & 0x3fff) + 1, ((v >> 14) & 0x3fff) + 1);
|
||||
}
|
||||
}
|
||||
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47)
|
||||
return Math.max(b.readUInt32BE(16), b.readUInt32BE(20));
|
||||
if (b[0] === 0xff && b[1] === 0xd8) {
|
||||
let i = 2;
|
||||
while (i + 9 < b.length) {
|
||||
if (b[i] !== 0xff) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const marcador = b[i + 1];
|
||||
if (marcador === 0xff) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (marcador === 0xd8 || (marcador >= 0xd0 && marcador <= 0xd7)) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
const sof =
|
||||
marcador >= 0xc0 &&
|
||||
marcador <= 0xcf &&
|
||||
marcador !== 0xc4 &&
|
||||
marcador !== 0xc8 &&
|
||||
marcador !== 0xcc;
|
||||
if (sof) return Math.max(b.readUInt16BE(i + 5), b.readUInt16BE(i + 7));
|
||||
i += 2 + b.readUInt16BE(i + 2);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
type Gltf = {
|
||||
accessors?: Array<{ count: number }>;
|
||||
meshes?: Array<{ primitives: Array<{ indices?: number }> }>;
|
||||
images?: Array<{ bufferView?: number; uri?: string }>;
|
||||
bufferViews?: Array<{ byteOffset?: number; byteLength: number }>;
|
||||
};
|
||||
|
||||
function lerGlb(caminho: string) {
|
||||
const b = readFileSync(caminho);
|
||||
const tamanhoJson = b.readUInt32LE(12);
|
||||
const json = JSON.parse(
|
||||
b.subarray(20, 20 + tamanhoJson).toString("utf8"),
|
||||
) as Gltf;
|
||||
const fimJson = 20 + tamanhoJson;
|
||||
const bin =
|
||||
fimJson + 8 <= b.length
|
||||
? b.subarray(fimJson + 8, fimJson + 8 + b.readUInt32LE(fimJson))
|
||||
: Buffer.alloc(0);
|
||||
return { json, bin };
|
||||
}
|
||||
|
||||
describe("orçamento de peso do jogo", () => {
|
||||
it("public/jogo + bloco.glb somam até 6 MB", () => {
|
||||
const total = [...doJogo, BLOCO].reduce((s, a) => s + statSync(a).size, 0);
|
||||
expect({
|
||||
total: `${(total / MB).toFixed(2)} MB`,
|
||||
ok: total <= 6 * MB,
|
||||
}).toEqual({ total: `${(total / MB).toFixed(2)} MB`, ok: true });
|
||||
});
|
||||
|
||||
it.each(glbs.map((g) => [nome(g), g]))("%s tem até 1,5 MB", (n, g) => {
|
||||
const tamanho = statSync(g).size;
|
||||
expect({
|
||||
arquivo: n,
|
||||
MB: +(tamanho / MB).toFixed(2),
|
||||
ok: tamanho <= 1.5 * MB,
|
||||
}).toEqual({ arquivo: n, MB: +(tamanho / MB).toFixed(2), ok: true });
|
||||
});
|
||||
|
||||
it("zumbi.glb tem até 12.000 triângulos", () => {
|
||||
const { json } = lerGlb(join(PASTA, "zumbi.glb"));
|
||||
let triangulos = 0;
|
||||
for (const malha of json.meshes ?? [])
|
||||
for (const p of malha.primitives)
|
||||
if (p.indices !== undefined)
|
||||
triangulos += (json.accessors?.[p.indices]?.count ?? 0) / 3;
|
||||
expect({ arquivo: "public/jogo/zumbi.glb", triangulos }).toEqual({
|
||||
arquivo: "public/jogo/zumbi.glb",
|
||||
triangulos: Math.min(triangulos, 12000),
|
||||
});
|
||||
});
|
||||
|
||||
it("imagens de public/jogo têm lado até 1024 px", () => {
|
||||
for (const a of doJogo.filter(
|
||||
(f) => /\.(jpe?g|png|webp)$/i.test(f) && !f.endsWith("capa.jpg"),
|
||||
)) {
|
||||
const l = lado(readFileSync(a));
|
||||
expect({ arquivo: nome(a), lado: l }).toEqual({
|
||||
arquivo: nome(a),
|
||||
lado: Math.min(l ?? 0, 1024),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("itens.webp tem 512 px de lado e até 120 KB", () => {
|
||||
const a = join(PASTA, "itens.webp");
|
||||
const kb = statSync(a).size / 1024;
|
||||
expect({
|
||||
arquivo: "public/jogo/itens.webp",
|
||||
lado: lado(readFileSync(a)),
|
||||
ok: kb <= 120,
|
||||
}).toEqual({ arquivo: "public/jogo/itens.webp", lado: 512, ok: true });
|
||||
});
|
||||
|
||||
it("capa.jpg tem lado até 1600 px e até 220 KB", () => {
|
||||
const a = join(PASTA, "capa.jpg");
|
||||
const l = lado(readFileSync(a));
|
||||
const kb = statSync(a).size / 1024;
|
||||
expect({
|
||||
arquivo: "public/jogo/capa.jpg",
|
||||
lado: l,
|
||||
KB: Math.round(kb),
|
||||
}).toEqual({
|
||||
arquivo: "public/jogo/capa.jpg",
|
||||
lado: Math.min(l ?? 0, 1600),
|
||||
KB: Math.min(Math.round(kb), 220),
|
||||
});
|
||||
});
|
||||
|
||||
it("imagens embutidas nos GLB têm lado até 1024 px", () => {
|
||||
for (const g of glbs) {
|
||||
const { json, bin } = lerGlb(g);
|
||||
(json.images ?? []).forEach((img, i) => {
|
||||
let dados: Buffer | null = null;
|
||||
if (img.bufferView !== undefined) {
|
||||
const v = json.bufferViews?.[img.bufferView];
|
||||
if (v)
|
||||
dados = bin.subarray(
|
||||
v.byteOffset ?? 0,
|
||||
(v.byteOffset ?? 0) + v.byteLength,
|
||||
);
|
||||
} else if (img.uri?.startsWith("data:"))
|
||||
dados = Buffer.from(img.uri.split(",")[1] ?? "", "base64");
|
||||
const l = dados ? lado(dados) : null;
|
||||
const rotulo = `${nome(g)}#imagem${i}`;
|
||||
expect({ arquivo: rotulo, lado: l }).toEqual({
|
||||
arquivo: rotulo,
|
||||
lado: Math.min(l ?? 0, 1024),
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("cada .mp3 de public/jogo/sons tem até 60 KB", () => {
|
||||
for (const a of arquivos(join(PASTA, "sons")).filter((f) =>
|
||||
f.endsWith(".mp3"),
|
||||
)) {
|
||||
const kb = statSync(a).size / 1024;
|
||||
expect({ arquivo: nome(a), KB: Math.round(kb) }).toEqual({
|
||||
arquivo: nome(a),
|
||||
KB: Math.min(Math.round(kb), 60),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,379 @@
|
||||
import { criarDrone } from "./drones";
|
||||
import { montarFila } from "./hordas";
|
||||
import * as mapaModulo from "./mapa";
|
||||
import { avancar, criarPartida } from "./partida";
|
||||
import { pilotar } from "./piloto";
|
||||
import { semear } from "./rng";
|
||||
import type { Dificuldade, Entrada, Fase, Partida } from "./tipos";
|
||||
import { ESCRITORIO, HORDAS, JOGADOR, MUNICAO, PASSO } from "./valores";
|
||||
|
||||
const MAPA = ["#########", "#N......#", "#.......#", "#......J#", "#########"];
|
||||
|
||||
const PARADO: Entrada = {
|
||||
mover: [0, 0],
|
||||
olhar: [0, 0],
|
||||
atirar: false,
|
||||
arma: 0,
|
||||
};
|
||||
|
||||
function nova(extra: Partial<Parameters<typeof criarPartida>[0]> = {}) {
|
||||
return criarPartida({
|
||||
semente: 1,
|
||||
dificuldade: "facil",
|
||||
problemaId: "site-lento",
|
||||
tetoAVista: 2,
|
||||
mapa: MAPA,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
function passo(p: Partida, parcial: Partial<Entrada> = {}) {
|
||||
avancar(p, { ...PARADO, ...parcial });
|
||||
}
|
||||
|
||||
function em(p: Partida, fase: Fase, extra: Partial<Partida> = {}) {
|
||||
p.fase = fase;
|
||||
p.tempoFase = 0;
|
||||
p.jogador.x = 4.5;
|
||||
p.jogador.z = 2.5;
|
||||
Object.assign(p, extra);
|
||||
}
|
||||
|
||||
const FILA_UM = [{ tipo: "comum" as const, problemaId: "site-lento" }];
|
||||
|
||||
describe("criarPartida", () => {
|
||||
it("monta o estado inicial no mapa do escritório por padrão", () => {
|
||||
const p = criarPartida({
|
||||
semente: 7,
|
||||
dificuldade: "normal",
|
||||
problemaId: "site-lento",
|
||||
});
|
||||
expect(p.fase).toBe("inicio");
|
||||
expect(p.tetoAVista).toBe(16);
|
||||
expect(p.mapa.largura).toBe(ESCRITORIO[0].length);
|
||||
expect(p.jogador).toEqual({
|
||||
x: p.mapa.inicio[0],
|
||||
z: p.mapa.inicio[1],
|
||||
rumo: 0,
|
||||
inclinacao: 0,
|
||||
vida: JOGADOR.vida,
|
||||
arma: 0,
|
||||
municao: MUNICAO.inicial,
|
||||
recarga: 0,
|
||||
});
|
||||
expect(p.hordas).toHaveLength(4);
|
||||
expect(p.hordas[3]).toBe("site-lento");
|
||||
expect(p).toMatchObject({
|
||||
passo: 0,
|
||||
horda: 0,
|
||||
tempoFase: 0,
|
||||
zumbis: [],
|
||||
fila: [],
|
||||
drones: [],
|
||||
caixas: [],
|
||||
feed: [],
|
||||
resolvidos: [],
|
||||
placar: { jogador: 0, drones: 0 },
|
||||
eventos: [],
|
||||
proximoId: 1,
|
||||
problemaId: "site-lento",
|
||||
dificuldade: "normal",
|
||||
});
|
||||
});
|
||||
|
||||
it("usa o mapa e o teto informados e consome o sorteio da própria partida", () => {
|
||||
const p = nova({ tetoAVista: 3 });
|
||||
expect(p.tetoAVista).toBe(3);
|
||||
expect(p.mapa.largura).toBe(9);
|
||||
expect(p.jogador.x).toBe(7.5);
|
||||
expect(p.rng).not.toBe(semear(1));
|
||||
expect(nova().hordas).toEqual(p.hordas);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avancar: jogador", () => {
|
||||
it("anda para a frente em +z com rumo 0", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa");
|
||||
passo(p, { mover: [0, 1] });
|
||||
expect(p.jogador.x).toBeCloseTo(4.5);
|
||||
expect(p.jogador.z).toBeCloseTo(2.5 + JOGADOR.velocidade * PASSO);
|
||||
});
|
||||
|
||||
it("anda para a direita em −x com rumo 0", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa");
|
||||
passo(p, { mover: [1, 0] });
|
||||
expect(p.jogador.x).toBeCloseTo(4.5 - JOGADOR.velocidade * PASSO);
|
||||
expect(p.jogador.z).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it("gira o rumo e anda para a frente na direção nova", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa");
|
||||
passo(p, { olhar: [Math.PI / 2, 0], mover: [0, 1] });
|
||||
expect(p.jogador.rumo).toBeCloseTo(Math.PI / 2);
|
||||
expect(p.jogador.x).toBeCloseTo(4.5 + JOGADOR.velocidade * PASSO);
|
||||
expect(p.jogador.z).toBeCloseTo(2.5);
|
||||
});
|
||||
|
||||
it("limita o movimento diagonal a comprimento 1", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa");
|
||||
passo(p, { mover: [1, 1] });
|
||||
const andou = Math.hypot(p.jogador.x - 4.5, p.jogador.z - 2.5);
|
||||
expect(andou).toBeCloseTo(JOGADOR.velocidade * PASSO);
|
||||
});
|
||||
|
||||
it("não aumenta um movimento menor que 1", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa");
|
||||
passo(p, { mover: [0, 0.5] });
|
||||
expect(p.jogador.z).toBeCloseTo(2.5 + 0.5 * JOGADOR.velocidade * PASSO);
|
||||
});
|
||||
|
||||
it("soma a inclinação e a limita para cima e para baixo", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa");
|
||||
passo(p, { olhar: [0, 0.1] });
|
||||
expect(p.jogador.inclinacao).toBeCloseTo(0.1);
|
||||
passo(p, { olhar: [0, 5] });
|
||||
expect(p.jogador.inclinacao).toBe(JOGADOR.inclinacaoMax);
|
||||
passo(p, { olhar: [0, -9] });
|
||||
expect(p.jogador.inclinacao).toBe(-JOGADOR.inclinacaoMax);
|
||||
});
|
||||
|
||||
it("troca de arma, mas só atira se pedido", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa");
|
||||
passo(p, { arma: 1 });
|
||||
expect(p.jogador.arma).toBe(1);
|
||||
expect(p.eventos).toEqual([]);
|
||||
p.jogador.recarga = 0;
|
||||
passo(p, { arma: 1, atirar: true });
|
||||
expect(p.eventos[0]).toMatchObject({ tipo: "tiro", autor: "jogador" });
|
||||
});
|
||||
|
||||
it("recolhe as caixas ao alcance", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa", { caixas: [{ id: 99, x: 4.5, z: 2.5 }] });
|
||||
passo(p);
|
||||
expect(p.caixas).toEqual([]);
|
||||
expect(p.jogador.municao).toBe(MUNICAO.inicial + MUNICAO.porCaixa);
|
||||
expect(p.eventos).toEqual([{ tipo: "caixa", balas: MUNICAO.porCaixa }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avancar: fases", () => {
|
||||
it("esvazia os eventos e conta passos", () => {
|
||||
const p = nova();
|
||||
p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 });
|
||||
passo(p);
|
||||
expect(p.eventos).toEqual([]);
|
||||
expect(p.passo).toBe(1);
|
||||
expect(p.tempoFase).toBe(1);
|
||||
});
|
||||
|
||||
it("fica em inicio até o preparo acabar", () => {
|
||||
const p = nova();
|
||||
p.tempoFase = HORDAS.preparo - 2;
|
||||
passo(p);
|
||||
expect(p.fase).toBe("inicio");
|
||||
expect(p.eventos).toEqual([]);
|
||||
});
|
||||
|
||||
it("começa a horda 0 ao fim do preparo", () => {
|
||||
const p = nova();
|
||||
p.tempoFase = HORDAS.preparo - 1;
|
||||
passo(p);
|
||||
expect(p.fase).toBe("horda");
|
||||
expect(p.tempoFase).toBe(0);
|
||||
expect(p.horda).toBe(0);
|
||||
expect(p.fila).toEqual(montarFila(0, p.hordas[0], "facil"));
|
||||
expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]);
|
||||
});
|
||||
|
||||
it("não termina a horda com zumbis na fila", () => {
|
||||
const p = nova();
|
||||
em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 });
|
||||
passo(p);
|
||||
expect(p.fase).toBe("horda");
|
||||
expect(p.resolvidos).toEqual([]);
|
||||
});
|
||||
|
||||
it("não termina a horda com zumbis no mapa", () => {
|
||||
const p = nova();
|
||||
em(p, "horda", {
|
||||
zumbis: [
|
||||
{
|
||||
id: 50,
|
||||
tipo: "comum",
|
||||
problemaId: "site-lento",
|
||||
x: 1.5,
|
||||
z: 1.5,
|
||||
rumo: 0,
|
||||
vida: 100,
|
||||
estado: "andando",
|
||||
tempo: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
passo(p);
|
||||
expect(p.fase).toBe("horda");
|
||||
expect(p.resolvidos).toEqual([]);
|
||||
});
|
||||
|
||||
it("termina a horda: resolve, cria o drone, cura e vai para a pausa", () => {
|
||||
const p = nova();
|
||||
em(p, "horda", { horda: 1 });
|
||||
p.jogador.vida = 50;
|
||||
passo(p);
|
||||
const resolvido = p.hordas[1];
|
||||
expect(p.resolvidos).toEqual([resolvido]);
|
||||
expect(p.drones).toHaveLength(1);
|
||||
expect(p.drones[0].problemaId).toBe(resolvido);
|
||||
expect(p.jogador.vida).toBe(50 + JOGADOR.curaPorHorda);
|
||||
expect(p.fase).toBe("pausa");
|
||||
expect(p.tempoFase).toBe(0);
|
||||
expect(p.feed[0].texto).toMatch(/^Resolvido: /);
|
||||
expect(p.eventos).toEqual([
|
||||
{ tipo: "resolvido", problemaId: resolvido, droneId: p.drones[0].id },
|
||||
{ tipo: "fase", fase: "pausa" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("a cura não passa da vida máxima", () => {
|
||||
const p = nova();
|
||||
em(p, "horda");
|
||||
p.jogador.vida = 90;
|
||||
passo(p);
|
||||
expect(p.jogador.vida).toBe(JOGADOR.vida);
|
||||
});
|
||||
|
||||
it("vai para a derrota com a vida zerada, antes de resolver a horda", () => {
|
||||
const p = nova();
|
||||
em(p, "horda");
|
||||
p.jogador.vida = 0;
|
||||
passo(p);
|
||||
expect(p.fase).toBe("derrota");
|
||||
expect(p.resolvidos).toEqual([]);
|
||||
expect(p.drones).toEqual([]);
|
||||
expect(p.eventos).toEqual([{ tipo: "fase", fase: "derrota" }]);
|
||||
});
|
||||
|
||||
it("fica na pausa até o tempo acabar, e os drones seguem o jogador", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa", { horda: 0 });
|
||||
const drone = criarDrone(p, "site-lento");
|
||||
drone.x = 1.5;
|
||||
drone.z = 1.5;
|
||||
passo(p);
|
||||
expect(p.fase).toBe("pausa");
|
||||
expect(p.horda).toBe(0);
|
||||
expect(drone.x).toBeGreaterThan(1.5);
|
||||
expect(p.eventos).toEqual([]);
|
||||
});
|
||||
|
||||
it("sai da pausa para a horda seguinte", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa", { horda: 0 });
|
||||
p.tempoFase = HORDAS.pausa - 1;
|
||||
passo(p);
|
||||
expect(p.horda).toBe(1);
|
||||
expect(p.fase).toBe("horda");
|
||||
expect(p.tempoFase).toBe(0);
|
||||
expect(p.fila).toEqual(montarFila(1, p.hordas[1], "facil"));
|
||||
expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]);
|
||||
});
|
||||
|
||||
it("sai da pausa da horda 2 para o chefão", () => {
|
||||
const p = nova();
|
||||
em(p, "pausa", { horda: 2 });
|
||||
p.tempoFase = HORDAS.pausa - 1;
|
||||
passo(p);
|
||||
expect(p.horda).toBe(3);
|
||||
expect(p.fase).toBe("chefao");
|
||||
expect(p.fila).toEqual(montarFila(3, p.hordas[3], "facil"));
|
||||
expect(p.eventos).toEqual([{ tipo: "fase", fase: "chefao" }]);
|
||||
});
|
||||
|
||||
it("vence ao derrotar o chefão, sem criar drone", () => {
|
||||
const p = nova();
|
||||
em(p, "chefao", { horda: 3 });
|
||||
passo(p);
|
||||
expect(p.fase).toBe("vitoria");
|
||||
expect(p.resolvidos).toEqual(["site-lento"]);
|
||||
expect(p.drones).toEqual([]);
|
||||
expect(p.eventos).toEqual([
|
||||
{ tipo: "resolvido", problemaId: "site-lento", droneId: -1 },
|
||||
{ tipo: "fase", fase: "vitoria" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("perde no chefão com a vida zerada", () => {
|
||||
const p = nova();
|
||||
em(p, "chefao", { horda: 3 });
|
||||
p.jogador.vida = -5;
|
||||
passo(p);
|
||||
expect(p.fase).toBe("derrota");
|
||||
});
|
||||
|
||||
it.each(["vitoria", "derrota"] as const)(
|
||||
"depois da %s nada mais muda, só os eventos esvaziam",
|
||||
(fase) => {
|
||||
const p = nova();
|
||||
em(p, fase);
|
||||
p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 });
|
||||
const antes = JSON.stringify({ ...p, eventos: [] });
|
||||
passo(p, { mover: [1, 1], olhar: [1, 1], atirar: true, arma: 1 });
|
||||
expect(p.eventos).toEqual([]);
|
||||
expect(JSON.stringify(p)).toBe(antes);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("avancar: campo de fluxo", () => {
|
||||
it("só recalcula quando o jogador muda de célula", () => {
|
||||
const espiao = jest.spyOn(mapaModulo, "campoDeFluxo");
|
||||
const p = nova();
|
||||
em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 });
|
||||
passo(p);
|
||||
passo(p);
|
||||
expect(espiao).toHaveBeenCalledTimes(1);
|
||||
p.jogador.x -= 1;
|
||||
passo(p);
|
||||
expect(espiao).toHaveBeenCalledTimes(2);
|
||||
passo(p);
|
||||
expect(espiao).toHaveBeenCalledTimes(2);
|
||||
espiao.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("partida inteira com o piloto", () => {
|
||||
const TETO_DE_PASSOS = 60_000;
|
||||
|
||||
// regressão: na fácil, as sementes 4, 6, 13, 20, 23, 24 e 38 empacavam na horda 3 com "faltam 2"
|
||||
it.each<Dificuldade>(["facil", "normal", "dificil"])(
|
||||
"40 sementes na %s terminam em vitória ou derrota",
|
||||
(dificuldade) => {
|
||||
const fins: [number, Fase][] = [];
|
||||
for (let semente = 1; semente <= 40; semente++) {
|
||||
const p = criarPartida({
|
||||
semente,
|
||||
dificuldade,
|
||||
problemaId: "site-lento",
|
||||
});
|
||||
for (
|
||||
let i = 0;
|
||||
i < TETO_DE_PASSOS && p.fase !== "vitoria" && p.fase !== "derrota";
|
||||
i++
|
||||
) {
|
||||
avancar(p, pilotar(p));
|
||||
}
|
||||
fins.push([semente, p.fase]);
|
||||
}
|
||||
const presas = fins.filter(([, f]) => f !== "vitoria" && f !== "derrota");
|
||||
expect(presas).toEqual([]);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/** Integra a lógica do jogo: cria a partida e avança um passo por vez. Sem three, React nem DOM. */
|
||||
|
||||
import { atirar, recolherCaixas, resfriar, trocarArma } from "./armas";
|
||||
import { avancarDrones, criarDrone } from "./drones";
|
||||
import { registrarResolvido } from "./feed";
|
||||
import { escolherHordas, montarFila } from "./hordas";
|
||||
import { campoDeFluxo, lerMapa, moverComColisao } from "./mapa";
|
||||
import { semear } from "./rng";
|
||||
import type { Dificuldade, Entrada, Fase, Partida } from "./tipos";
|
||||
import {
|
||||
ESCRITORIO,
|
||||
HORDAS,
|
||||
JOGADOR,
|
||||
MUNICAO,
|
||||
PASSO,
|
||||
TETO_A_VISTA,
|
||||
} from "./valores";
|
||||
import { avancarZumbis, nascer } from "./zumbis";
|
||||
|
||||
const HORDA_DO_CHEFAO = 3;
|
||||
|
||||
export function criarPartida(opcoes: {
|
||||
semente: number;
|
||||
dificuldade: Dificuldade;
|
||||
problemaId: string;
|
||||
tetoAVista?: number;
|
||||
mapa?: readonly string[];
|
||||
}): Partida {
|
||||
const sorteio = { rng: semear(opcoes.semente) };
|
||||
const hordas = escolherHordas(sorteio, opcoes.problemaId);
|
||||
const mapa = lerMapa(opcoes.mapa ?? ESCRITORIO);
|
||||
return {
|
||||
passo: 0,
|
||||
rng: sorteio.rng,
|
||||
dificuldade: opcoes.dificuldade,
|
||||
problemaId: opcoes.problemaId,
|
||||
hordas,
|
||||
horda: 0,
|
||||
fase: "inicio",
|
||||
tempoFase: 0,
|
||||
tetoAVista: opcoes.tetoAVista ?? TETO_A_VISTA,
|
||||
mapa,
|
||||
jogador: {
|
||||
x: mapa.inicio[0],
|
||||
z: mapa.inicio[1],
|
||||
rumo: 0,
|
||||
inclinacao: 0,
|
||||
vida: JOGADOR.vida,
|
||||
arma: 0,
|
||||
municao: MUNICAO.inicial,
|
||||
recarga: 0,
|
||||
},
|
||||
zumbis: [],
|
||||
fila: [],
|
||||
drones: [],
|
||||
caixas: [],
|
||||
feed: [],
|
||||
resolvidos: [],
|
||||
placar: { jogador: 0, drones: 0 },
|
||||
eventos: [],
|
||||
proximoId: 1,
|
||||
};
|
||||
}
|
||||
|
||||
const campos = new WeakMap<Partida, { celula: number; campo: Int16Array }>();
|
||||
|
||||
/** Só recalcula o campo de fluxo quando o jogador muda de célula. */
|
||||
function campoAtual(p: Partida): Int16Array {
|
||||
const { x, z } = p.jogador;
|
||||
const celula = Math.floor(z) * p.mapa.largura + Math.floor(x);
|
||||
const guardado = campos.get(p);
|
||||
if (guardado?.celula === celula) return guardado.campo;
|
||||
const campo = campoDeFluxo(p.mapa, x, z);
|
||||
campos.set(p, { celula, campo });
|
||||
return campo;
|
||||
}
|
||||
|
||||
function moverJogador(p: Partida, entrada: Entrada): void {
|
||||
const j = p.jogador;
|
||||
j.rumo += entrada.olhar[0];
|
||||
j.inclinacao = Math.min(
|
||||
JOGADOR.inclinacaoMax,
|
||||
Math.max(-JOGADOR.inclinacaoMax, j.inclinacao + entrada.olhar[1]),
|
||||
);
|
||||
const [lado, frente] = entrada.mover;
|
||||
const escala = Math.min(1, 1 / Math.hypot(lado, frente));
|
||||
const passo = escala * JOGADOR.velocidade * PASSO;
|
||||
const dx = (Math.sin(j.rumo) * frente - Math.cos(j.rumo) * lado) * passo;
|
||||
const dz = (Math.cos(j.rumo) * frente + Math.sin(j.rumo) * lado) * passo;
|
||||
[j.x, j.z] = moverComColisao(p.mapa, j.x, j.z, dx, dz, JOGADOR.raio);
|
||||
}
|
||||
|
||||
function usarArma(p: Partida, entrada: Entrada): void {
|
||||
trocarArma(p, entrada.arma);
|
||||
resfriar(p);
|
||||
if (entrada.atirar) atirar(p);
|
||||
recolherCaixas(p);
|
||||
}
|
||||
|
||||
function mudarFase(p: Partida, fase: Fase): void {
|
||||
p.fase = fase;
|
||||
p.eventos.push({ tipo: "fase", fase });
|
||||
}
|
||||
|
||||
/** Começa a horda `p.horda`: a última é a do chefão. */
|
||||
function comecarHorda(p: Partida): void {
|
||||
p.tempoFase = 0;
|
||||
p.fila = montarFila(p.horda, p.hordas[p.horda], p.dificuldade);
|
||||
mudarFase(p, p.horda === HORDA_DO_CHEFAO ? "chefao" : "horda");
|
||||
}
|
||||
|
||||
function fecharHorda(p: Partida): void {
|
||||
const problemaId = p.hordas[p.horda];
|
||||
p.resolvidos.push(problemaId);
|
||||
registrarResolvido(p, problemaId);
|
||||
if (p.fase === "chefao") {
|
||||
p.eventos.push({ tipo: "resolvido", problemaId, droneId: -1 });
|
||||
mudarFase(p, "vitoria");
|
||||
return;
|
||||
}
|
||||
const drone = criarDrone(p, problemaId);
|
||||
p.eventos.push({ tipo: "resolvido", problemaId, droneId: drone.id });
|
||||
p.jogador.vida = Math.min(
|
||||
JOGADOR.vida,
|
||||
p.jogador.vida + JOGADOR.curaPorHorda,
|
||||
);
|
||||
p.tempoFase = 0;
|
||||
mudarFase(p, "pausa");
|
||||
}
|
||||
|
||||
function combater(p: Partida): void {
|
||||
nascer(p);
|
||||
avancarZumbis(p, campoAtual(p));
|
||||
avancarDrones(p);
|
||||
if (p.jogador.vida <= 0) {
|
||||
mudarFase(p, "derrota");
|
||||
} else if (p.fila.length === 0 && p.zumbis.length === 0) {
|
||||
fecharHorda(p);
|
||||
}
|
||||
}
|
||||
|
||||
function avancarFase(p: Partida): void {
|
||||
if (p.fase === "inicio") {
|
||||
if (p.tempoFase >= HORDAS.preparo) comecarHorda(p);
|
||||
} else if (p.fase === "pausa") {
|
||||
avancarDrones(p);
|
||||
if (p.tempoFase >= HORDAS.pausa) {
|
||||
p.horda++;
|
||||
comecarHorda(p);
|
||||
}
|
||||
} else {
|
||||
combater(p);
|
||||
}
|
||||
}
|
||||
|
||||
export function avancar(p: Partida, entrada: Entrada): void {
|
||||
p.eventos.length = 0;
|
||||
if (p.fase === "vitoria" || p.fase === "derrota") return;
|
||||
p.passo++;
|
||||
p.tempoFase++;
|
||||
moverJogador(p, entrada);
|
||||
usarArma(p, entrada);
|
||||
avancarFase(p);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { avancar, criarPartida } from "./partida";
|
||||
import { pilotar } from "./piloto";
|
||||
import type { Partida, TipoZumbi, Zumbi } from "./tipos";
|
||||
import { ESCRITORIO, ZUMBIS } from "./valores";
|
||||
|
||||
/** 21 x 15, parede por dentro em x = 10, de z = 5 a z = 9. */
|
||||
const MAPA = Array.from({ length: 15 }, (_, z) => {
|
||||
if (z === 0 || z === 14) return "#".repeat(21);
|
||||
const miolo = Array.from({ length: 19 }, (_, i) => {
|
||||
if (z === 1 && i === 0) return "N";
|
||||
if (z === 2 && i === 0) return "J";
|
||||
return i === 9 && z >= 5 && z <= 9 ? "#" : ".";
|
||||
});
|
||||
return `#${miolo.join("")}#`;
|
||||
});
|
||||
|
||||
const JX = 5.5;
|
||||
const JZ = 7.5;
|
||||
|
||||
function zumbi(
|
||||
id: number,
|
||||
x: number,
|
||||
z: number,
|
||||
tipo: TipoZumbi = "comum",
|
||||
estado: Zumbi["estado"] = "andando",
|
||||
): Zumbi {
|
||||
return {
|
||||
id,
|
||||
tipo,
|
||||
problemaId: "site-lento",
|
||||
x,
|
||||
z,
|
||||
rumo: 0,
|
||||
vida: ZUMBIS[tipo].vida,
|
||||
estado,
|
||||
tempo: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Jogador em (5.5, 7.5), rumo 0, com os zumbis dados. */
|
||||
function cena(...zumbis: Zumbi[]): Partida {
|
||||
const p = criarPartida({
|
||||
semente: 1,
|
||||
dificuldade: "facil",
|
||||
problemaId: "site-lento",
|
||||
mapa: MAPA,
|
||||
});
|
||||
p.jogador.x = JX;
|
||||
p.jogador.z = JZ;
|
||||
p.zumbis = zumbis;
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Zumbi a `distancia` metros do jogador, no rumo dado. */
|
||||
function aoRumo(
|
||||
id: number,
|
||||
rumo: number,
|
||||
distancia: number,
|
||||
tipo: TipoZumbi = "comum",
|
||||
): Zumbi {
|
||||
return zumbi(
|
||||
id,
|
||||
JX + Math.sin(rumo) * distancia,
|
||||
JZ + Math.cos(rumo) * distancia,
|
||||
tipo,
|
||||
);
|
||||
}
|
||||
|
||||
describe("pilotar: sem alvo", () => {
|
||||
it("fica parado sem zumbis", () => {
|
||||
expect(pilotar(cena())).toEqual({
|
||||
mover: [0, 0],
|
||||
olhar: [0, 0],
|
||||
atirar: false,
|
||||
arma: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignora zumbis morrendo", () => {
|
||||
const p = cena(zumbi(1, 5.5, 9.5, "comum", "morrendo"));
|
||||
expect(pilotar(p).atirar).toBe(false);
|
||||
expect(pilotar(p).mover).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pilotar: escolha do alvo", () => {
|
||||
it("prefere o mais próximo com linha de visão", () => {
|
||||
const atras = zumbi(1, 11.5, 7.5);
|
||||
const livre = zumbi(2, 1.5, 1.5);
|
||||
expect(pilotar(cena(atras, livre)).olhar[0]).toBe(-0.12);
|
||||
});
|
||||
|
||||
it("sem visão de ninguém, escolhe o mais próximo", () => {
|
||||
const perto = zumbi(1, 11.5, 7.5);
|
||||
const longe = zumbi(2, 11.5, 5.5);
|
||||
expect(pilotar(cena(longe, perto)).olhar[0]).toBe(0.12);
|
||||
});
|
||||
|
||||
it("não atira sem linha de visão, mesmo alinhado", () => {
|
||||
const p = cena(zumbi(1, 11.5, 7.5));
|
||||
p.jogador.rumo = Math.PI / 2;
|
||||
expect(pilotar(p).atirar).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pilotar: mira", () => {
|
||||
it("gira no máximo 0,12 rad por passo", () => {
|
||||
expect(pilotar(cena(aoRumo(1, 1, 5))).olhar[0]).toBe(0.12);
|
||||
expect(pilotar(cena(aoRumo(1, -1, 5))).olhar[0]).toBe(-0.12);
|
||||
});
|
||||
|
||||
it("gira só o que falta quando o erro é menor que o limite", () => {
|
||||
const p = cena(aoRumo(1, 0.03, 5));
|
||||
expect(pilotar(p).olhar[0]).toBeCloseTo(0.03);
|
||||
});
|
||||
|
||||
it("normaliza a diferença de rumo para −π a π", () => {
|
||||
const p = cena(aoRumo(1, -3, 5));
|
||||
p.jogador.rumo = 3;
|
||||
expect(pilotar(p).olhar[0]).toBe(0.12);
|
||||
});
|
||||
|
||||
it("inclina no máximo 0,05 rad por passo, para baixo e para cima", () => {
|
||||
const p = cena(aoRumo(1, 0, 5));
|
||||
expect(pilotar(p).olhar[1]).toBe(-0.05);
|
||||
p.jogador.inclinacao = -1;
|
||||
expect(pilotar(p).olhar[1]).toBe(0.05);
|
||||
});
|
||||
|
||||
it("mira a 65% da altura do tipo", () => {
|
||||
const p = cena(aoRumo(1, 0, 5));
|
||||
p.jogador.inclinacao = Math.atan2(0.65 * ZUMBIS.comum.altura - 1.6, 5);
|
||||
expect(pilotar(p).olhar[1]).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("atira quando o erro de rumo é menor que 0,05 e há visão", () => {
|
||||
expect(pilotar(cena(aoRumo(1, 0.04, 5))).atirar).toBe(true);
|
||||
});
|
||||
|
||||
it("não atira com erro de rumo de 0,05 ou mais", () => {
|
||||
expect(pilotar(cena(aoRumo(1, 0.06, 5))).atirar).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pilotar: arma", () => {
|
||||
it("usa a faca quando a borda está a 1,3 m ou menos", () => {
|
||||
const comum = ZUMBIS.comum.raio;
|
||||
expect(pilotar(cena(aoRumo(1, 0, 1.25 + comum))).arma).toBe(2);
|
||||
expect(pilotar(cena(aoRumo(1, 0, 1.4 + comum))).arma).toBe(0);
|
||||
});
|
||||
|
||||
it("usa a automática com 3 zumbis a até 12 m", () => {
|
||||
const tres = cena(aoRumo(1, 0, 5), aoRumo(2, 1, 6), aoRumo(3, 2, 12));
|
||||
expect(pilotar(tres).arma).toBe(1);
|
||||
});
|
||||
|
||||
it("fica na pistola com só 2 zumbis a até 12 m", () => {
|
||||
const dois = cena(aoRumo(1, 0, 5), aoRumo(2, 1, 6), aoRumo(3, 2, 12.5));
|
||||
expect(pilotar(dois).arma).toBe(0);
|
||||
});
|
||||
|
||||
it("usa a automática contra blindado e contra chefão", () => {
|
||||
expect(pilotar(cena(aoRumo(1, 0, 6, "blindado"))).arma).toBe(1);
|
||||
expect(pilotar(cena(aoRumo(1, 0, 6, "chefao"))).arma).toBe(1);
|
||||
});
|
||||
|
||||
it("fica na pistola contra um comum ou um rápido", () => {
|
||||
expect(pilotar(cena(aoRumo(1, 0, 6, "rapido"))).arma).toBe(0);
|
||||
});
|
||||
|
||||
it("sem bala não usa a automática", () => {
|
||||
const p = cena(aoRumo(1, 0, 6, "blindado"));
|
||||
p.jogador.municao = 0;
|
||||
expect(pilotar(p).arma).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pilotar: distância", () => {
|
||||
it("anda para trás com o alvo a menos de 4 m", () => {
|
||||
expect(pilotar(cena(aoRumo(1, 0, 3.9))).mover).toEqual([0, -1]);
|
||||
});
|
||||
|
||||
it("fica parado com o alvo a 4 m ou mais", () => {
|
||||
expect(pilotar(cena(aoRumo(1, 0, 4))).mover).toEqual([0, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pilotar: partida inteira no escritório", () => {
|
||||
function jogar(dificuldade: Partida["dificuldade"] = "facil") {
|
||||
const p = criarPartida({
|
||||
semente: 1,
|
||||
dificuldade,
|
||||
problemaId: "site-lento",
|
||||
mapa: ESCRITORIO,
|
||||
});
|
||||
while (p.fase !== "vitoria" && p.fase !== "derrota" && p.passo < 36000) {
|
||||
avancar(p, pilotar(p));
|
||||
}
|
||||
return p;
|
||||
}
|
||||
|
||||
it("vence em menos de 36.000 passos, com 3 drones e 4 problemas resolvidos", () => {
|
||||
const p = jogar();
|
||||
expect(p.fase).toBe("vitoria");
|
||||
expect(p.passo).toBeLessThan(36000);
|
||||
expect(p.drones).toHaveLength(3);
|
||||
expect(p.resolvidos).toHaveLength(4);
|
||||
expect(p.placar.drones).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each(["normal", "dificil"] as const)(
|
||||
"termina em vitória ou derrota antes de 36.000 passos no %s",
|
||||
(dificuldade) => {
|
||||
const p = jogar(dificuldade);
|
||||
expect(["vitoria", "derrota"]).toContain(p.fase);
|
||||
expect(p.passo).toBeLessThan(36000);
|
||||
},
|
||||
);
|
||||
|
||||
it("é determinística: mesma partida, mesmo passo e mesmo placar", () => {
|
||||
const a = jogar();
|
||||
const b = jogar();
|
||||
expect(b.passo).toBe(a.passo);
|
||||
expect(b.placar).toEqual(a.placar);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/** Jogador automático para testes e medição: função pura do estado, sem memória própria. */
|
||||
|
||||
import { temLinhaDeVisao } from "./mapa";
|
||||
import type { ArmaId, Entrada, Partida, Zumbi } from "./tipos";
|
||||
import { JOGADOR, ZUMBIS } from "./valores";
|
||||
|
||||
const GIRO_MAXIMO = 0.12;
|
||||
const INCLINACAO_MAXIMA = 0.05;
|
||||
const ALTURA_DA_MIRA = 0.65;
|
||||
const ALCANCE_DA_FACA = 1.3;
|
||||
const ERRO_PARA_ATIRAR = 0.05;
|
||||
const DISTANCIA_DE_RECUO = 4;
|
||||
const RAIO_DA_MULTIDAO = 12;
|
||||
const TAMANHO_DA_MULTIDAO = 3;
|
||||
|
||||
const PISTOLA: ArmaId = 0;
|
||||
const AUTOMATICA: ArmaId = 1;
|
||||
const FACA: ArmaId = 2;
|
||||
|
||||
type Alvo = { zumbi: Zumbi; vivos: Zumbi[]; visao: boolean };
|
||||
|
||||
function distanciaAte(p: Partida, zumbi: Zumbi): number {
|
||||
return Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z);
|
||||
}
|
||||
|
||||
function escolherAlvo(p: Partida): Alvo | undefined {
|
||||
const { x, z } = p.jogador;
|
||||
const vivos = p.zumbis.filter((zumbi) => zumbi.estado !== "morrendo");
|
||||
if (vivos.length === 0) return undefined;
|
||||
const vistos = vivos.filter((zumbi) =>
|
||||
temLinhaDeVisao(p.mapa, x, z, zumbi.x, zumbi.z),
|
||||
);
|
||||
const lista = vistos.length > 0 ? vistos : vivos;
|
||||
const zumbi = lista.reduce((a, b) =>
|
||||
distanciaAte(p, b) < distanciaAte(p, a) ? b : a,
|
||||
);
|
||||
return { zumbi, vivos, visao: vistos.length > 0 };
|
||||
}
|
||||
|
||||
function limitar(valor: number, maximo: number): number {
|
||||
return Math.max(-maximo, Math.min(maximo, valor));
|
||||
}
|
||||
|
||||
function escolherArma(p: Partida, alvo: Alvo): ArmaId {
|
||||
const { tipo } = alvo.zumbi;
|
||||
if (distanciaAte(p, alvo.zumbi) - ZUMBIS[tipo].raio <= ALCANCE_DA_FACA) {
|
||||
return FACA;
|
||||
}
|
||||
const perto = alvo.vivos.filter(
|
||||
(zumbi) => distanciaAte(p, zumbi) <= RAIO_DA_MULTIDAO,
|
||||
);
|
||||
const exige =
|
||||
perto.length >= TAMANHO_DA_MULTIDAO ||
|
||||
tipo === "blindado" ||
|
||||
tipo === "chefao";
|
||||
return p.jogador.municao > 0 && exige ? AUTOMATICA : PISTOLA;
|
||||
}
|
||||
|
||||
export function pilotar(p: Partida): Entrada {
|
||||
const alvo = escolherAlvo(p);
|
||||
if (!alvo) return { mover: [0, 0], olhar: [0, 0], atirar: false, arma: 0 };
|
||||
const { zumbi } = alvo;
|
||||
const { x, z, rumo, inclinacao } = p.jogador;
|
||||
const distancia = distanciaAte(p, zumbi);
|
||||
const desvio = Math.atan2(zumbi.x - x, zumbi.z - z) - rumo;
|
||||
const erro = Math.atan2(Math.sin(desvio), Math.cos(desvio));
|
||||
const altura = ZUMBIS[zumbi.tipo].altura * ALTURA_DA_MIRA - JOGADOR.olhos;
|
||||
const subida = Math.atan2(altura, distancia) - inclinacao;
|
||||
return {
|
||||
mover: [0, distancia < DISTANCIA_DE_RECUO ? -1 : 0],
|
||||
olhar: [limitar(erro, GIRO_MAXIMO), limitar(subida, INCLINACAO_MAXIMA)],
|
||||
atirar: Math.abs(erro) < ERRO_PARA_ATIRAR && alvo.visao,
|
||||
arma: escolherArma(p, alvo),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { getServicePage } from "@/lib/site-content";
|
||||
import {
|
||||
AREAS,
|
||||
type AreaId,
|
||||
PROBLEMAS,
|
||||
problemaPorId,
|
||||
problemasDaArea,
|
||||
} from "./problemas";
|
||||
|
||||
const areas = Object.keys(AREAS) as AreaId[];
|
||||
|
||||
describe("PROBLEMAS", () => {
|
||||
it("tem 27 problemas", () => {
|
||||
expect(PROBLEMAS).toHaveLength(27);
|
||||
});
|
||||
|
||||
it("tem ids únicos em kebab-case sem acento", () => {
|
||||
const ids = PROBLEMAS.map(({ id }) => id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const id of ids) expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
|
||||
});
|
||||
|
||||
it("tem os rótulos das seis áreas", () => {
|
||||
expect(AREAS).toEqual({
|
||||
vender: "Vender e ser encontrado",
|
||||
atender: "Atender",
|
||||
operar: "Operar",
|
||||
fiscal: "Fiscal e financeiro",
|
||||
sistemas: "Sistemas e integrações",
|
||||
seguranca: "Segurança e dados",
|
||||
});
|
||||
});
|
||||
|
||||
it("dá a toda área ao menos 4 problemas", () => {
|
||||
for (const area of areas)
|
||||
expect(problemasDaArea(area).length).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
it("aponta cada destino para uma página de serviço que existe", () => {
|
||||
const fora = PROBLEMAS.filter(
|
||||
({ destino }) => !destino.startsWith("/servicos/"),
|
||||
);
|
||||
expect(fora.map(({ destino }) => destino)).toEqual(["/ia"]);
|
||||
for (const { destino } of PROBLEMAS.filter((p) =>
|
||||
p.destino.startsWith("/servicos/"),
|
||||
)) {
|
||||
expect(getServicePage(destino.replace("/servicos/", ""))).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("não cita nome de cliente", () => {
|
||||
const clientes = /SEV|Lavação|NutriScan|SC Plus|AS Serviços|Julia/;
|
||||
for (const { nome, solucao } of PROBLEMAS) {
|
||||
expect(nome).not.toMatch(clientes);
|
||||
expect(solucao).not.toMatch(clientes);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("problemaPorId", () => {
|
||||
it("acha pelo id", () => {
|
||||
expect(problemaPorId(PROBLEMAS[0].id)).toBe(PROBLEMAS[0]);
|
||||
});
|
||||
|
||||
it("devolve undefined para id inexistente", () => {
|
||||
expect(problemaPorId("nao-existe")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("problemasDaArea", () => {
|
||||
it("devolve só os da área", () => {
|
||||
for (const area of areas) {
|
||||
const lista = problemasDaArea(area);
|
||||
expect(lista.every((p) => p.area === area)).toBe(true);
|
||||
}
|
||||
expect(areas.reduce((n, a) => n + problemasDaArea(a).length, 0)).toBe(27);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
export type AreaId =
|
||||
| "vender"
|
||||
| "atender"
|
||||
| "operar"
|
||||
| "fiscal"
|
||||
| "sistemas"
|
||||
| "seguranca";
|
||||
export type Problema = {
|
||||
id: string;
|
||||
area: AreaId;
|
||||
nome: string;
|
||||
solucao: string;
|
||||
destino: string;
|
||||
};
|
||||
|
||||
export const AREAS: Record<AreaId, string> = {
|
||||
vender: "Vender e ser encontrado",
|
||||
atender: "Atender",
|
||||
operar: "Operar",
|
||||
fiscal: "Fiscal e financeiro",
|
||||
sistemas: "Sistemas e integrações",
|
||||
seguranca: "Segurança e dados",
|
||||
};
|
||||
|
||||
const DESTINO_DA_AREA: Record<AreaId, string> = {
|
||||
vender: "/servicos/criacao-de-sites",
|
||||
atender: "/servicos/atendimento-whatsapp-ia",
|
||||
operar: "/servicos/engenharia-de-software",
|
||||
fiscal: "/servicos/nota-fiscal-reforma-tributaria",
|
||||
sistemas: "/servicos/arquitetura-de-software",
|
||||
seguranca: "/servicos/arquitetura-de-software",
|
||||
};
|
||||
|
||||
function p(
|
||||
area: AreaId,
|
||||
id: string,
|
||||
nome: string,
|
||||
solucao: string,
|
||||
destino = DESTINO_DA_AREA[area],
|
||||
): Problema {
|
||||
return { id, area, nome, solucao, destino };
|
||||
}
|
||||
|
||||
export const PROBLEMAS: readonly Problema[] = [
|
||||
p(
|
||||
"vender",
|
||||
"site-que-ninguem-acha",
|
||||
"Site que ninguém acha no Google",
|
||||
"Site que aparece no Google",
|
||||
),
|
||||
p(
|
||||
"vender",
|
||||
"site-ruim-no-celular",
|
||||
"Site que não abre direito no celular",
|
||||
"Site que funciona no celular",
|
||||
),
|
||||
p("vender", "site-lento", "Site lento", "Site rápido"),
|
||||
p(
|
||||
"vender",
|
||||
"catalogo-desatualizado",
|
||||
"Catálogo desatualizado",
|
||||
"Catálogo em dia",
|
||||
),
|
||||
p(
|
||||
"vender",
|
||||
"pedido-perdido-no-whatsapp",
|
||||
"Pedido perdido no WhatsApp",
|
||||
"Pedido organizado",
|
||||
),
|
||||
p(
|
||||
"vender",
|
||||
"pagamento-que-falha",
|
||||
"Pagamento que falha",
|
||||
"Pagamento funcionando",
|
||||
),
|
||||
p(
|
||||
"atender",
|
||||
"atraso-no-atendimento",
|
||||
"Atraso no atendimento",
|
||||
"Atendimento na hora",
|
||||
),
|
||||
p(
|
||||
"atender",
|
||||
"mesma-pergunta-cem-vezes",
|
||||
"Mesma pergunta respondida cem vezes",
|
||||
"Resposta automática",
|
||||
),
|
||||
p(
|
||||
"atender",
|
||||
"agenda-duplicada",
|
||||
"Agenda com horário duplicado",
|
||||
"Agenda organizada",
|
||||
),
|
||||
p("atender", "cliente-sem-retorno", "Cliente sem retorno", "Retorno em dia"),
|
||||
p(
|
||||
"operar",
|
||||
"planilha-duplicada",
|
||||
"Planilha duplicada",
|
||||
"Sistema no lugar da planilha",
|
||||
),
|
||||
p(
|
||||
"operar",
|
||||
"digitar-duas-vezes",
|
||||
"Digitar a mesma coisa em dois lugares",
|
||||
"Cadastro único",
|
||||
),
|
||||
p("operar", "estoque-furado", "Estoque furado", "Estoque em dia"),
|
||||
p(
|
||||
"operar",
|
||||
"processo-de-uma-pessoa",
|
||||
"Processo que só uma pessoa sabe fazer",
|
||||
"Processo no sistema",
|
||||
),
|
||||
p("operar", "controle-no-papel", "Controle no papel", "Controle digital"),
|
||||
p(
|
||||
"fiscal",
|
||||
"nota-fiscal-rejeitada",
|
||||
"Nota fiscal rejeitada",
|
||||
"Notas fiscais em dia",
|
||||
),
|
||||
p(
|
||||
"fiscal",
|
||||
"fechamento-que-nao-bate",
|
||||
"Fechamento do mês que não bate",
|
||||
"Fechamento conferido",
|
||||
),
|
||||
p(
|
||||
"fiscal",
|
||||
"cobranca-esquecida",
|
||||
"Cobrança esquecida",
|
||||
"Cobrança automática",
|
||||
),
|
||||
p(
|
||||
"fiscal",
|
||||
"relatorio-demorado",
|
||||
"Relatório que demora dias",
|
||||
"Relatório na hora",
|
||||
),
|
||||
p(
|
||||
"sistemas",
|
||||
"sistemas-que-nao-conversam",
|
||||
"Sistemas que não conversam",
|
||||
"Sistemas integrados",
|
||||
),
|
||||
p(
|
||||
"sistemas",
|
||||
"maquina-que-nao-conversa",
|
||||
"Máquina que não conversa com o sistema",
|
||||
"Equipamento integrado",
|
||||
"/servicos/integracao-hardware-software-iot",
|
||||
),
|
||||
p(
|
||||
"sistemas",
|
||||
"sistema-antigo",
|
||||
"Sistema antigo em que ninguém mexe",
|
||||
"Sistema atualizado",
|
||||
),
|
||||
p(
|
||||
"sistemas",
|
||||
"sistema-que-cai-no-pico",
|
||||
"Sistema que cai na hora do pico",
|
||||
"Sistema estável",
|
||||
),
|
||||
p(
|
||||
"seguranca",
|
||||
"senha-compartilhada",
|
||||
"Senha compartilhada",
|
||||
"Acesso por pessoa",
|
||||
),
|
||||
p("seguranca", "dados-sem-backup", "Dados sem backup", "Backup automático"),
|
||||
p(
|
||||
"seguranca",
|
||||
"acesso-de-ex-funcionario",
|
||||
"Acesso de ex-funcionário",
|
||||
"Acesso revogado",
|
||||
),
|
||||
p(
|
||||
"seguranca",
|
||||
"app-de-ia-com-falha",
|
||||
"App feito por IA cheio de falha",
|
||||
"App revisado",
|
||||
"/ia",
|
||||
),
|
||||
];
|
||||
|
||||
export function problemaPorId(id: string): Problema | undefined {
|
||||
return PROBLEMAS.find((problema) => problema.id === id);
|
||||
}
|
||||
|
||||
export function problemasDaArea(area: AreaId): Problema[] {
|
||||
return PROBLEMAS.filter((problema) => problema.area === area);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import {
|
||||
amostrar,
|
||||
criarRegulador,
|
||||
nivelAtual,
|
||||
type Regulador,
|
||||
} from "./regulador";
|
||||
import { NIVEIS } from "./valores";
|
||||
|
||||
const ULTIMO = NIVEIS.length - 1;
|
||||
const HZ_60 = 1000 / 60;
|
||||
const HZ_30 = 1000 / 30;
|
||||
|
||||
/** Alimenta `n` janelas inteiras (2 s cada) de quadros iguais; para antes se o regulador travar. */
|
||||
function janelas(
|
||||
r: Regulador,
|
||||
intervalo: number,
|
||||
trabalho: number,
|
||||
n: number,
|
||||
): number {
|
||||
for (let i = 0; i < n && !r.travado; i++) {
|
||||
do amostrar(r, intervalo, trabalho);
|
||||
while (r.somaDaJanela > 0);
|
||||
}
|
||||
return r.nivel;
|
||||
}
|
||||
|
||||
/** Regulador com o topo no nível 0 (tela de pixelRatio 2), já no nível `nivel`. */
|
||||
function reguladorEm(nivel: number): Regulador {
|
||||
const r = criarRegulador(2);
|
||||
r.nivel = nivel;
|
||||
return r;
|
||||
}
|
||||
|
||||
describe("regulador", () => {
|
||||
it("começa no primeiro nível cujo pixelRatio cabe no do aparelho", () => {
|
||||
expect(criarRegulador(3).nivel).toBe(0);
|
||||
expect(criarRegulador(2).nivel).toBe(0);
|
||||
expect(criarRegulador(1.5).nivel).toBe(1);
|
||||
expect(criarRegulador(1.25).nivel).toBe(2);
|
||||
expect(criarRegulador(1).nivel).toBe(2);
|
||||
// zoom de afastar: abaixo de 1 vale o primeiro degrau de pixelRatio 1
|
||||
expect(criarRegulador(0.8).nivel).toBe(2);
|
||||
});
|
||||
|
||||
it("nunca sobe acima do nível inicial do aparelho", () => {
|
||||
const r = criarRegulador(1);
|
||||
expect(janelas(r, 8, 2, 20)).toBe(2);
|
||||
r.nivel = 4;
|
||||
expect(janelas(r, 8, 2, 20)).toBe(2);
|
||||
});
|
||||
|
||||
it("nivelAtual devolve o degrau de NIVEIS", () => {
|
||||
expect(nivelAtual(reguladorEm(2))).toBe(NIVEIS[2]);
|
||||
});
|
||||
|
||||
it("60 Hz com folga nunca muda o nível 0", () => {
|
||||
expect(janelas(criarRegulador(2), HZ_60, 3, 30)).toBe(0);
|
||||
});
|
||||
|
||||
it("60 Hz saudável sobe depois de cair", () => {
|
||||
const r = criarRegulador(2);
|
||||
expect(janelas(r, 30, 25, 1)).toBe(1);
|
||||
// a janela depois da descida já ganhou e conta como folga: 5 janelas (10 s) sobem
|
||||
expect(janelas(r, HZ_60, 3, 4)).toBe(1);
|
||||
expect(janelas(r, HZ_60, 3, 1)).toBe(0);
|
||||
expect(r.travado).toBe(false);
|
||||
});
|
||||
|
||||
it("60 Hz com trabalho pesado não conta folga", () => {
|
||||
const r = reguladorEm(2);
|
||||
expect(janelas(r, HZ_60, 8, 10)).toBe(2);
|
||||
});
|
||||
|
||||
it("tela limitada a 30 Hz desce 2, volta 2 e trava", () => {
|
||||
const r = criarRegulador(2);
|
||||
expect(janelas(r, HZ_30, 3, 1)).toBe(1);
|
||||
expect(janelas(r, HZ_30, 3, 1)).toBe(2);
|
||||
expect(janelas(r, HZ_30, 3, 1)).toBe(0);
|
||||
expect(r.travado).toBe(true);
|
||||
expect(janelas(r, HZ_30, 3, 1)).toBe(0);
|
||||
expect(amostrar(r, HZ_30, 3)).toBe(0);
|
||||
expect(r.intervalos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("carga real desce e fica", () => {
|
||||
const r = criarRegulador(2);
|
||||
expect(janelas(r, 30, 25, 1)).toBe(1);
|
||||
// cada descida melhora o intervalo em mais de 10%
|
||||
expect(janelas(r, 24, 20, 1)).toBe(2);
|
||||
expect(janelas(r, 18, 15, 15)).toBe(2);
|
||||
expect(r.travado).toBe(false);
|
||||
});
|
||||
|
||||
it("descida com ganho zera a conta de descidas sem ganho", () => {
|
||||
const r = criarRegulador(2);
|
||||
janelas(r, 30, 25, 1);
|
||||
janelas(r, 30, 25, 1);
|
||||
expect(r.descidasSemGanho).toBe(1);
|
||||
expect(janelas(r, 25, 20, 1)).toBe(3);
|
||||
expect(r.descidasSemGanho).toBe(0);
|
||||
});
|
||||
|
||||
it("descida sem ganho que não desce de novo não trava, e a volta não passa do topo", () => {
|
||||
const r = criarRegulador(2);
|
||||
janelas(r, 21, 3, 1);
|
||||
// 20 ms: sem ganho, mas dentro do limite; sobe de volta depois da folga
|
||||
expect(janelas(r, 20, 3, 5)).toBe(0);
|
||||
expect(r.descidasSemGanho).toBe(1);
|
||||
janelas(r, 21, 3, 1);
|
||||
expect(janelas(r, 21, 3, 1)).toBe(0);
|
||||
expect(r.travado).toBe(true);
|
||||
});
|
||||
|
||||
it("não passa do último degrau", () => {
|
||||
const r = reguladorEm(ULTIMO);
|
||||
expect(janelas(r, 30, 25, 5)).toBe(ULTIMO);
|
||||
});
|
||||
|
||||
it("janela mediana zera a folga acumulada", () => {
|
||||
const r = reguladorEm(1);
|
||||
janelas(r, 8, 2, 4);
|
||||
janelas(r, 16, 8, 1);
|
||||
expect(janelas(r, 8, 2, 4)).toBe(1);
|
||||
});
|
||||
|
||||
it("descer logo depois de subir trava a subida: depois disso 20 s de folga não sobem", () => {
|
||||
const r = reguladorEm(2);
|
||||
expect(janelas(r, 8, 2, 5)).toBe(1);
|
||||
expect(janelas(r, 30, 25, 1)).toBe(2);
|
||||
expect(janelas(r, 8, 2, 10)).toBe(2);
|
||||
expect(r.subidaTravada).toBe(true);
|
||||
});
|
||||
|
||||
it("descer muito depois de subir não trava", () => {
|
||||
const r = reguladorEm(2);
|
||||
expect(janelas(r, 8, 2, 5)).toBe(1);
|
||||
janelas(r, 16, 8, 15);
|
||||
expect(janelas(r, 30, 25, 1)).toBe(2);
|
||||
expect(janelas(r, 8, 2, 5)).toBe(1);
|
||||
});
|
||||
|
||||
it("quadro de 300 ms é ignorado", () => {
|
||||
const r = reguladorEm(1);
|
||||
for (let i = 0; i < 20; i++) expect(amostrar(r, 300, 3)).toBe(1);
|
||||
expect(r.intervalos).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("o p95 decide, não a média", () => {
|
||||
const r = criarRegulador(2);
|
||||
let nivel = 0;
|
||||
for (let g = 0; g < 16; g++) {
|
||||
for (let i = 0; i < 9; i++) nivel = amostrar(r, 10, 2);
|
||||
nivel = amostrar(r, 40, 2);
|
||||
}
|
||||
expect(nivel).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Regulador de qualidade: numa janela, mede o p95 do intervalo entre quadros e o do trabalho de JS de cada quadro,
|
||||
* e sobe/desce degraus de `NIVEIS`. Sem estado no módulo.
|
||||
*/
|
||||
|
||||
import { NIVEIS, REGULADOR } from "./valores";
|
||||
|
||||
export type Regulador = {
|
||||
nivel: number;
|
||||
/** Primeiro nível cujo `pixelRatio` cabe no do aparelho; o regulador nunca sobe acima dele. */
|
||||
topo: number;
|
||||
/** Intervalos entre quadros da janela atual, em ms. */
|
||||
intervalos: number[];
|
||||
/** Tempo de JS de cada quadro da janela atual (lógica + chamada de render), em ms. */
|
||||
trabalhos: number[];
|
||||
/** Soma dos intervalos da janela atual, em ms. */
|
||||
somaDaJanela: number;
|
||||
/** Tempo seguido de janelas com folga, em ms. */
|
||||
folga: number;
|
||||
/** Soma dos intervalos aceitos desde o início, em ms. */
|
||||
tempo: number;
|
||||
/** Valor de `tempo` na última subida; null se nunca subiu. */
|
||||
tempoDaSubida: number | null;
|
||||
/** Desceu logo depois de subir: não sobe mais. */
|
||||
subidaTravada: boolean;
|
||||
/** Descer não adiantou duas vezes seguidas: o limite é da tela, não da nossa carga. Não muda mais de nível. */
|
||||
travado: boolean;
|
||||
/** p95 do intervalo na janela que acabou de descer; null se a janela anterior não desceu. */
|
||||
p95DaDescida: number | null;
|
||||
descidasSemGanho: number;
|
||||
};
|
||||
|
||||
const ULTIMO = NIVEIS.length - 1;
|
||||
|
||||
export function criarRegulador(pixelRatio: number): Regulador {
|
||||
// abaixo do menor `pixelRatio` (zoom de afastar), vale o primeiro degrau com o menor
|
||||
const tela = Math.max(pixelRatio, NIVEIS[ULTIMO].pixelRatio);
|
||||
const topo = NIVEIS.findIndex((n) => n.pixelRatio <= tela);
|
||||
return {
|
||||
nivel: topo,
|
||||
topo,
|
||||
intervalos: [],
|
||||
trabalhos: [],
|
||||
somaDaJanela: 0,
|
||||
folga: 0,
|
||||
tempo: 0,
|
||||
tempoDaSubida: null,
|
||||
subidaTravada: false,
|
||||
travado: false,
|
||||
p95DaDescida: null,
|
||||
descidasSemGanho: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function nivelAtual(r: Regulador): (typeof NIVEIS)[number] {
|
||||
return NIVEIS[r.nivel];
|
||||
}
|
||||
|
||||
/** Ordena a própria janela: ela é esvaziada logo depois. */
|
||||
function p95(quadros: number[]): number {
|
||||
quadros.sort((a, b) => a - b);
|
||||
return quadros[Math.ceil(0.95 * quadros.length) - 1];
|
||||
}
|
||||
|
||||
function descer(r: Regulador, intervalo: number): void {
|
||||
r.folga = 0;
|
||||
if (r.nivel >= ULTIMO) return;
|
||||
r.nivel += 1;
|
||||
r.p95DaDescida = intervalo;
|
||||
const subiuHaPouco =
|
||||
r.tempoDaSubida !== null && r.tempo - r.tempoDaSubida < REGULADOR.trava;
|
||||
if (subiuHaPouco) r.subidaTravada = true;
|
||||
}
|
||||
|
||||
function acumularFolga(r: Regulador, duracao: number): void {
|
||||
r.folga += duracao;
|
||||
if (r.folga < REGULADOR.subida) return;
|
||||
if (!r.subidaTravada && r.nivel > r.topo) {
|
||||
r.nivel -= 1;
|
||||
r.tempoDaSubida = r.tempo;
|
||||
}
|
||||
r.folga = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Na janela seguinte a uma descida: se o intervalo não melhorou `REGULADOR.ganho`, conta uma descida sem ganho; na
|
||||
* segunda seguida, volta os degraus e trava. Devolve true se travou.
|
||||
*/
|
||||
function conferirGanho(r: Regulador, intervalo: number): boolean {
|
||||
const antes = r.p95DaDescida;
|
||||
r.p95DaDescida = null;
|
||||
if (antes === null) return false;
|
||||
if (intervalo <= antes * (1 - REGULADOR.ganho)) {
|
||||
r.descidasSemGanho = 0;
|
||||
return false;
|
||||
}
|
||||
r.descidasSemGanho += 1;
|
||||
if (r.descidasSemGanho < REGULADOR.descidasSemGanho) return false;
|
||||
r.nivel = Math.max(r.topo, r.nivel - r.descidasSemGanho);
|
||||
r.travado = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function fecharJanela(r: Regulador): void {
|
||||
const intervalo = p95(r.intervalos);
|
||||
const trabalho = p95(r.trabalhos);
|
||||
const duracao = r.somaDaJanela;
|
||||
r.intervalos.length = 0;
|
||||
r.trabalhos.length = 0;
|
||||
r.somaDaJanela = 0;
|
||||
if (conferirGanho(r, intervalo)) return;
|
||||
if (intervalo > REGULADOR.limite) descer(r, intervalo);
|
||||
else if (trabalho < REGULADOR.folgaDeTrabalho) acumularFolga(r, duracao);
|
||||
else r.folga = 0;
|
||||
}
|
||||
|
||||
export function amostrar(
|
||||
r: Regulador,
|
||||
intervaloMs: number,
|
||||
trabalhoMs: number,
|
||||
): number {
|
||||
if (r.travado || intervaloMs > REGULADOR.ignorar) return r.nivel;
|
||||
r.intervalos.push(intervaloMs);
|
||||
r.trabalhos.push(trabalhoMs);
|
||||
r.somaDaJanela += intervaloMs;
|
||||
r.tempo += intervaloMs;
|
||||
if (r.somaDaJanela >= REGULADOR.janela) fecharJanela(r);
|
||||
return r.nivel;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { semear, sortear, sortearInteiro } from "./rng";
|
||||
|
||||
function sequencia(semente: number, n: number): number[] {
|
||||
const p = { rng: semear(semente) };
|
||||
return Array.from({ length: n }, () => sortear(p));
|
||||
}
|
||||
|
||||
describe("semear", () => {
|
||||
it("converte a semente em uint32", () => {
|
||||
expect(semear(5)).toBe(5);
|
||||
expect(semear(2 ** 32 + 7)).toBe(7);
|
||||
expect(semear(-1)).toBe(0xffffffff);
|
||||
});
|
||||
|
||||
it("troca a semente 0 por 0x9e3779b9", () => {
|
||||
expect(semear(0)).toBe(0x9e3779b9);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortear", () => {
|
||||
it("dá a mesma sequência para a mesma semente", () => {
|
||||
expect(sequencia(42, 20)).toEqual(sequencia(42, 20));
|
||||
});
|
||||
|
||||
it("dá sequências diferentes para sementes diferentes", () => {
|
||||
expect(sequencia(1, 20)).not.toEqual(sequencia(2, 20));
|
||||
});
|
||||
|
||||
it("mantém 1000 sorteios em [0, 1)", () => {
|
||||
for (const v of sequencia(7, 1000)) {
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("funciona com a semente 0", () => {
|
||||
const valores = sequencia(0, 10);
|
||||
expect(new Set(valores).size).toBe(10);
|
||||
});
|
||||
|
||||
it("avança o estado de p.rng", () => {
|
||||
const p = { rng: semear(9) };
|
||||
const antes = p.rng;
|
||||
sortear(p);
|
||||
expect(p.rng).not.toBe(antes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortearInteiro", () => {
|
||||
it("devolve inteiros de 0 a n-1 e usa todos os valores", () => {
|
||||
const p = { rng: semear(3) };
|
||||
const vistos = new Set<number>();
|
||||
for (let i = 0; i < 500; i++) {
|
||||
const v = sortearInteiro(p, 6);
|
||||
expect(Number.isInteger(v)).toBe(true);
|
||||
expect(v).toBeGreaterThanOrEqual(0);
|
||||
expect(v).toBeLessThan(6);
|
||||
vistos.add(v);
|
||||
}
|
||||
expect(vistos.size).toBe(6);
|
||||
});
|
||||
|
||||
it("devolve sempre 0 quando n é 1", () => {
|
||||
expect(sortearInteiro({ rng: semear(1) }, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Sorteio determinístico (mulberry32). Único ponto de aleatoriedade da lógica do jogo. */
|
||||
|
||||
/** Estado inicial uint32 a partir de uma semente; 0 vira 0x9e3779b9. */
|
||||
export function semear(semente: number): number {
|
||||
const estado = semente >>> 0;
|
||||
return estado === 0 ? 0x9e3779b9 : estado;
|
||||
}
|
||||
|
||||
/** Avança `p.rng` e devolve um número em [0, 1). */
|
||||
export function sortear(p: { rng: number }): number {
|
||||
p.rng = (p.rng + 0x6d2b79f5) >>> 0;
|
||||
let t = p.rng;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
}
|
||||
|
||||
/** Inteiro de 0 a n−1. */
|
||||
export function sortearInteiro(p: { rng: number }, n: number): number {
|
||||
return Math.floor(sortear(p) * n);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Contrato da lógica do jogo. Nada aqui conhece three nem DOM.
|
||||
*
|
||||
* Plano XZ em metros, Y para cima. `rumo` em radianos, com direção `(sin(rumo), cos(rumo))` em (x, z);
|
||||
* `inclinacao` positiva olha para cima. O tempo anda em passos fixos de `PASSO` segundos (`valores.ts`).
|
||||
* A partida é um objeto só, alterado no lugar por `avancar`.
|
||||
*/
|
||||
|
||||
export type Dificuldade = "facil" | "normal" | "dificil";
|
||||
export type TipoZumbi = "comum" | "rapido" | "blindado" | "chefao";
|
||||
export type Fase =
|
||||
| "inicio"
|
||||
| "horda"
|
||||
| "pausa"
|
||||
| "chefao"
|
||||
| "vitoria"
|
||||
| "derrota";
|
||||
|
||||
/** 0 pistola, 1 automática, 2 faca. */
|
||||
export type ArmaId = 0 | 1 | 2;
|
||||
|
||||
/** O que o jogador (ou o piloto) pede em um passo. */
|
||||
export type Entrada = {
|
||||
/** x: direita (+) e esquerda (−); y: frente (+) e trás (−). Cada um de −1 a 1. */
|
||||
mover: readonly [number, number];
|
||||
/** Giro pedido neste passo, em radianos: [rumo, inclinação]. */
|
||||
olhar: readonly [number, number];
|
||||
atirar: boolean;
|
||||
arma: ArmaId;
|
||||
};
|
||||
|
||||
export type Jogador = {
|
||||
x: number;
|
||||
z: number;
|
||||
rumo: number;
|
||||
inclinacao: number;
|
||||
vida: number;
|
||||
arma: ArmaId;
|
||||
/** Balas da automática. A pistola e a faca não gastam. */
|
||||
municao: number;
|
||||
/** Passos que faltam para a arma poder agir de novo. */
|
||||
recarga: number;
|
||||
};
|
||||
|
||||
export type EstadoZumbi = "andando" | "atacando" | "morrendo";
|
||||
|
||||
export type Zumbi = {
|
||||
id: number;
|
||||
tipo: TipoZumbi;
|
||||
/** O problema que este zumbi representa (id de `problemas.ts`). */
|
||||
problemaId: string;
|
||||
x: number;
|
||||
z: number;
|
||||
rumo: number;
|
||||
vida: number;
|
||||
estado: EstadoZumbi;
|
||||
/** Passos no estado atual. */
|
||||
tempo: number;
|
||||
};
|
||||
|
||||
/** Zumbi da horda que ainda não entrou no mapa, por causa do teto de zumbis à vista. */
|
||||
export type NaFila = { tipo: TipoZumbi; problemaId: string };
|
||||
|
||||
export type Drone = {
|
||||
id: number;
|
||||
/** O problema resolvido que virou este drone; o nome dele é a `solucao` do problema. */
|
||||
problemaId: string;
|
||||
x: number;
|
||||
z: number;
|
||||
rumo: number;
|
||||
recarga: number;
|
||||
eliminacoes: number;
|
||||
};
|
||||
|
||||
/** Caixa de munição caída no chão. */
|
||||
export type Caixa = { id: number; x: number; z: number };
|
||||
|
||||
export type TipoItem = "municao" | "kit" | "cafe" | "bateria";
|
||||
|
||||
/** Item largado por um zumbi morto, no chão até ser recolhido ou vencer. */
|
||||
export type Item = {
|
||||
id: number;
|
||||
tipo: TipoItem;
|
||||
x: number;
|
||||
z: number;
|
||||
/** Passo em que caiu. */
|
||||
nasceu: number;
|
||||
};
|
||||
|
||||
export type LinhaFeed = { passo: number; texto: string };
|
||||
|
||||
/** Quem atirou ou matou: o jogador ou o drone com este id. */
|
||||
export type Autor = "jogador" | number;
|
||||
|
||||
/** O que aconteceu neste passo e a tela precisa mostrar. A lista é esvaziada no começo de cada `avancar`. */
|
||||
export type Evento =
|
||||
| {
|
||||
tipo: "tiro";
|
||||
autor: Autor;
|
||||
arma: ArmaId | "drone";
|
||||
/** Distância até onde o tiro parou (zumbi, parede ou alcance). */
|
||||
distancia: number;
|
||||
}
|
||||
| {
|
||||
tipo: "acerto";
|
||||
zumbiId: number;
|
||||
cabeca: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
| { tipo: "morte"; zumbiId: number; autor: Autor }
|
||||
/** A mão do zumbi bateu no jogador: é o único momento em que ele tira vida. */
|
||||
| { tipo: "golpe"; dano: number; zumbiId: number }
|
||||
/** O golpe chegou ao impacto com o jogador fora do alcance: não tira vida. */
|
||||
| { tipo: "golpeNoAr"; zumbiId: number }
|
||||
| { tipo: "itemCaiu"; item: TipoItem; x: number; z: number }
|
||||
| { tipo: "itemPego"; item: TipoItem }
|
||||
| { tipo: "caixa"; balas: number }
|
||||
| { tipo: "resolvido"; problemaId: string; droneId: number }
|
||||
| { tipo: "fase"; fase: Fase };
|
||||
|
||||
export type Mapa = {
|
||||
largura: number;
|
||||
altura: number;
|
||||
/** 1 onde não dá para andar (parede ou mesa). Índice `z * largura + x`. */
|
||||
solido: Uint8Array;
|
||||
/** 1 onde o tiro e a visão param (só parede). */
|
||||
opaco: Uint8Array;
|
||||
inicio: readonly [number, number];
|
||||
nascedouros: readonly (readonly [number, number])[];
|
||||
};
|
||||
|
||||
export type Partida = {
|
||||
passo: number;
|
||||
/** Estado do sorteio; só `sortear` (`rng.ts`) mexe nele. */
|
||||
rng: number;
|
||||
dificuldade: Dificuldade;
|
||||
/** O problema escolhido pela pessoa: é o chefão. */
|
||||
problemaId: string;
|
||||
/** Problema de cada horda, na ordem; o último é o do chefão. */
|
||||
hordas: readonly string[];
|
||||
/** Índice em `hordas` da horda atual. */
|
||||
horda: number;
|
||||
fase: Fase;
|
||||
/** Passos na fase atual. */
|
||||
tempoFase: number;
|
||||
/** Quantos zumbis podem estar no mapa ao mesmo tempo. */
|
||||
tetoAVista: number;
|
||||
mapa: Mapa;
|
||||
jogador: Jogador;
|
||||
zumbis: Zumbi[];
|
||||
fila: NaFila[];
|
||||
drones: Drone[];
|
||||
caixas: Caixa[];
|
||||
feed: LinhaFeed[];
|
||||
/** Problemas já resolvidos, na ordem. */
|
||||
resolvidos: string[];
|
||||
placar: { jogador: number; drones: number };
|
||||
eventos: Evento[];
|
||||
proximoId: number;
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
/** Números do jogo em um lugar só. São valores iniciais: mudam jogando, nunca espalhados pelo código. */
|
||||
|
||||
import GOLPE from "./golpe.json";
|
||||
import type { Dificuldade, TipoItem, TipoZumbi } from "./tipos";
|
||||
|
||||
/** Duração de um passo da lógica, em segundos. */
|
||||
export const PASSO = 1 / 60;
|
||||
export const PASSOS_POR_SEGUNDO = 60;
|
||||
|
||||
export const JOGADOR = {
|
||||
vida: 100,
|
||||
/** m/s */
|
||||
velocidade: 4.2,
|
||||
raio: 0.35,
|
||||
/** Altura dos olhos, de onde sai o tiro. */
|
||||
olhos: 1.6,
|
||||
/** Limite da inclinação, para cima e para baixo. */
|
||||
inclinacaoMax: 1.2,
|
||||
/** Vida devolvida quando uma horda cai. */
|
||||
curaPorHorda: 25,
|
||||
} as const;
|
||||
|
||||
export const ZUMBIS: Record<
|
||||
TipoZumbi,
|
||||
{
|
||||
vida: number;
|
||||
velocidade: number;
|
||||
dano: number;
|
||||
raio: number;
|
||||
altura: number;
|
||||
}
|
||||
> = {
|
||||
comum: { vida: 100, velocidade: 1.2, dano: 10, raio: 0.35, altura: 1.8 },
|
||||
rapido: { vida: 50, velocidade: 2.4, dano: 6, raio: 0.3, altura: 1.7 },
|
||||
blindado: { vida: 250, velocidade: 0.9, dano: 15, raio: 0.4, altura: 1.9 },
|
||||
chefao: { vida: 1500, velocidade: 1.0, dano: 25, raio: 0.6, altura: 2.4 },
|
||||
};
|
||||
|
||||
/** Passos de um golpe inteiro (preparo, impacto e volta): 0,8 s. */
|
||||
const PASSOS_DO_GOLPE = 48;
|
||||
|
||||
export const ATAQUE_DO_ZUMBI = {
|
||||
/** Distância entre as bordas do zumbi e do jogador para o ataque começar. */
|
||||
alcance: 1.0,
|
||||
/** Folga máxima, no passo do impacto, para o golpe acertar. Acima disso o golpe pega o ar. */
|
||||
alcanceDoGolpe: 1.4,
|
||||
/** Passos entre um golpe e o próximo. Etapa 2: passa a `PASSOS_DO_GOLPE`. */
|
||||
intervalo: 60,
|
||||
/**
|
||||
* Passo do ciclo em que a mão bate. Vem do clipe `atacar` medido no Blender
|
||||
* (`golpe.json`, gravado por `3d/jogo.py`): a tela toca o clipe no tempo do ciclo,
|
||||
* então o impacto do clipe e o da lógica caem no mesmo passo.
|
||||
*/
|
||||
passoDoGolpe: Math.round((GOLPE.impacto / GOLPE.duracao) * PASSOS_DO_GOLPE),
|
||||
/** Passos da animação de morte, até o zumbi sair da lista. */
|
||||
passosMorrendo: 90,
|
||||
} as const;
|
||||
|
||||
/** Acima desta fração da altura do zumbi o acerto é na cabeça e dobra o dano. */
|
||||
export const FRACAO_DA_CABECA = 0.86;
|
||||
export const MULTIPLICADOR_DA_CABECA = 2;
|
||||
|
||||
export const ARMAS = [
|
||||
{ nome: "Pistola", dano: 34, intervalo: 20, alcance: 40, gasta: false },
|
||||
{ nome: "Automática", dano: 20, intervalo: 6, alcance: 40, gasta: true },
|
||||
{ nome: "Faca", dano: 100, intervalo: 30, alcance: 1.5, gasta: false },
|
||||
] as const;
|
||||
|
||||
/** Meio ângulo do arco da faca, em radianos. */
|
||||
export const ARCO_DA_FACA = Math.PI / 4;
|
||||
|
||||
export const MUNICAO = {
|
||||
inicial: 90,
|
||||
porCaixa: 30,
|
||||
/** Chance de um zumbi morto largar uma caixa. */
|
||||
chanceDeCaixa: 0.25,
|
||||
/** Distância para o jogador recolher a caixa. */
|
||||
raioDeColeta: 0.9,
|
||||
} as const;
|
||||
|
||||
/** O que os zumbis largam ao morrer. */
|
||||
export const ITENS = {
|
||||
/** Chance de um zumbi morto largar um item. O chefão não larga. */
|
||||
chance: 0.3,
|
||||
/** Peso de cada tipo no sorteio. */
|
||||
pesos: { municao: 45, kit: 25, cafe: 15, bateria: 15 } as Record<
|
||||
TipoItem,
|
||||
number
|
||||
>,
|
||||
/** Abaixo desta vida o peso do kit dobra. */
|
||||
vidaBaixa: 40,
|
||||
/** Itens no chão ao mesmo tempo; com o teto cheio, o mais antigo some. */
|
||||
teto: 10,
|
||||
/** Passos até o item sumir (20 s). */
|
||||
duracao: 1200,
|
||||
/** Distância para o jogador recolher. */
|
||||
raioDeColeta: 0.9,
|
||||
/** Balas da automática por item de munição. */
|
||||
balas: 30,
|
||||
/** Vida devolvida pelo kit. */
|
||||
vida: 25,
|
||||
/** Café: fator da velocidade de andar e passos que dura (8 s). */
|
||||
cafe: { fator: 1.3, duracao: 480 },
|
||||
/** Bateria: fator da cadência dos drones e passos que dura (10 s). */
|
||||
bateria: { fator: 2, duracao: 600 },
|
||||
} as const;
|
||||
|
||||
export const DRONE = {
|
||||
dano: 25,
|
||||
intervalo: 30,
|
||||
alcance: 15,
|
||||
/** m/s */
|
||||
velocidade: 5,
|
||||
/** Distância que o drone mantém do jogador. */
|
||||
distancia: 2,
|
||||
} as const;
|
||||
|
||||
export const HORDAS = {
|
||||
/** Zumbis das três hordas, antes do multiplicador da dificuldade. */
|
||||
tamanhos: [12, 16, 20],
|
||||
/** Comuns que acompanham o chefão. */
|
||||
escoltaDoChefao: 6,
|
||||
/** Passos da pausa entre hordas. */
|
||||
pausa: 300,
|
||||
/** Passos da fase "inicio", antes da primeira horda. */
|
||||
preparo: 120,
|
||||
/** Passos entre dois nascimentos, por horda (a 4ª é a do chefão). */
|
||||
nascerACada: [90, 50, 30, 30],
|
||||
/** Fator da velocidade dos zumbis, por horda. */
|
||||
velocidade: [0.8, 1, 1.15, 1.15],
|
||||
} as const;
|
||||
|
||||
/** Quantas linhas o feed guarda. */
|
||||
export const LINHAS_DO_FEED = 5;
|
||||
|
||||
export const TETO_A_VISTA = 16;
|
||||
|
||||
export const DIFICULDADES: Record<
|
||||
Dificuldade,
|
||||
{ velocidade: number; dano: number; quantidade: number }
|
||||
> = {
|
||||
facil: { velocidade: 0.6, dano: 0.4, quantidade: 0.7 },
|
||||
normal: { velocidade: 1, dano: 1, quantidade: 1 },
|
||||
dificil: { velocidade: 1.3, dano: 1.5, quantidade: 1.3 },
|
||||
};
|
||||
|
||||
/**
|
||||
* O escritório, em células de 1 m. `#` parede, `M` mesa (não dá para andar, o tiro passa),
|
||||
* `J` onde o jogador começa, `N` onde os zumbis nascem, `.` chão.
|
||||
*/
|
||||
export const ESCRITORIO = [
|
||||
"############################",
|
||||
"#N.........#......#.......N#",
|
||||
"#..MM..MM..#......#..MM....#",
|
||||
"#..MM..MM.........#..MM....#",
|
||||
"#..........#...............#",
|
||||
"#..MM..MM..#......#..MM....#",
|
||||
"#..MM..MM..###..###........#",
|
||||
"#..........................#",
|
||||
"#............J.............#",
|
||||
"#..........................#",
|
||||
"#..MM..MM..###..###..MM....#",
|
||||
"#..MM..MM..#......#..MM....#",
|
||||
"#..........#......#........#",
|
||||
"#..MM..MM.........#..MM....#",
|
||||
"#..MM..MM..#...............#",
|
||||
"#N.........#......#.......N#",
|
||||
"############################",
|
||||
] as const;
|
||||
|
||||
/** Degraus de qualidade da tela, do melhor para o mais leve. Desce na ordem: resolução, sombras, zumbis à vista. */
|
||||
export const NIVEIS = [
|
||||
{ pixelRatio: 2, sombras: true, teto: 16 },
|
||||
{ pixelRatio: 1.5, sombras: true, teto: 16 },
|
||||
{ pixelRatio: 1, sombras: true, teto: 16 },
|
||||
{ pixelRatio: 1, sombras: false, teto: 16 },
|
||||
{ pixelRatio: 1, sombras: false, teto: 12 },
|
||||
{ pixelRatio: 1, sombras: false, teto: 8 },
|
||||
] as const;
|
||||
|
||||
/** Tempos em milissegundos. */
|
||||
export const REGULADOR = {
|
||||
/** Tamanho da janela em que o p95 do intervalo entre quadros e o do trabalho são medidos. */
|
||||
janela: 2000,
|
||||
/** p95 do intervalo acima disto na janela: desce um degrau. */
|
||||
limite: 20,
|
||||
/** Janela sem descida e com p95 do trabalho de JS abaixo disto conta como folga. */
|
||||
folgaDeTrabalho: 6,
|
||||
/** Tempo seguido de folga para subir um degrau. */
|
||||
subida: 10000,
|
||||
/** Quadro mais longo que isto é pausa da aba, não lentidão: não entra na conta. */
|
||||
ignorar: 250,
|
||||
/** Se descer de novo antes disto depois de subir, não sobe mais. */
|
||||
trava: 20000,
|
||||
/** Fração mínima em que o p95 do intervalo cai na janela seguinte a uma descida para ela contar como ganho. */
|
||||
ganho: 0.1,
|
||||
/** Descidas seguidas sem ganho que fazem o regulador voltar esses degraus e travar. */
|
||||
descidasSemGanho: 2,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Raio máximo usado para andar e desviar de parede. O chefão é mais largo que meia célula (0,6 m): com o raio inteiro
|
||||
* ele nasce encostado na parede e não sai do lugar. Para levar tiro, vale o raio do tipo.
|
||||
*/
|
||||
export const RAIO_DE_PASSAGEM = 0.45;
|
||||
@@ -0,0 +1,459 @@
|
||||
import { campoDeFluxo, lerMapa } from "./mapa";
|
||||
import { sortear } from "./rng";
|
||||
import type { Partida, Zumbi } from "./tipos";
|
||||
import { ATAQUE_DO_ZUMBI, MUNICAO } from "./valores";
|
||||
import { avancarZumbis, ferir, nascer } from "./zumbis";
|
||||
|
||||
const SALA = [
|
||||
"##############",
|
||||
"#J...........#",
|
||||
"#.....N......#",
|
||||
"##############",
|
||||
];
|
||||
|
||||
const DOIS_NASCEDOUROS_LONGE = [
|
||||
"################",
|
||||
"#JN.........N.N#",
|
||||
"#..............#",
|
||||
"################",
|
||||
];
|
||||
|
||||
const PAREDE_NO_MEIO = ["#######", "#J#..N#", "#.#...#", "#.....#", "#######"];
|
||||
|
||||
const BOLSO_FECHADO = ["#######", "#J#..N#", "#.#...#", "#######"];
|
||||
|
||||
/** Mesa entre o zumbi e o jogador: o tiro passa, o zumbi não. */
|
||||
const MESA_NO_CAMINHO = [
|
||||
"#########",
|
||||
"#.......#",
|
||||
"#J..M..N#",
|
||||
"#.......#",
|
||||
"#########",
|
||||
];
|
||||
|
||||
/** Corredor com parede em cima de x 4 a 6; o jogador fica depois da curva, fora da vista. */
|
||||
const QUINA = [
|
||||
"#########",
|
||||
"#...##..#",
|
||||
"#......N#",
|
||||
"#.#######",
|
||||
"#J#######",
|
||||
"#########",
|
||||
];
|
||||
|
||||
/** Vão de uma célula entre duas mesas (x 4): cabe um zumbi por vez. */
|
||||
const VAO_ENTRE_MESAS = [
|
||||
"#########",
|
||||
"#...M...#",
|
||||
"#J.....N#",
|
||||
"#...M...#",
|
||||
"#########",
|
||||
];
|
||||
|
||||
function partida(linhas: string[], extra: Partial<Partida> = {}): Partida {
|
||||
const mapa = lerMapa(linhas);
|
||||
return {
|
||||
passo: 0,
|
||||
rng: 1,
|
||||
dificuldade: "normal",
|
||||
problemaId: "p",
|
||||
hordas: ["h"],
|
||||
horda: 0,
|
||||
fase: "horda",
|
||||
tempoFase: 0,
|
||||
tetoAVista: 16,
|
||||
mapa,
|
||||
jogador: {
|
||||
x: mapa.inicio[0],
|
||||
z: mapa.inicio[1],
|
||||
rumo: 0,
|
||||
inclinacao: 0,
|
||||
vida: 100,
|
||||
arma: 0,
|
||||
municao: 90,
|
||||
recarga: 0,
|
||||
},
|
||||
zumbis: [],
|
||||
fila: [],
|
||||
drones: [],
|
||||
caixas: [],
|
||||
feed: [],
|
||||
resolvidos: [],
|
||||
placar: { jogador: 0, drones: 0 },
|
||||
eventos: [],
|
||||
proximoId: 1,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function zumbi(extra: Partial<Zumbi> = {}): Zumbi {
|
||||
return {
|
||||
id: 1,
|
||||
tipo: "comum",
|
||||
problemaId: "p",
|
||||
x: 8.5,
|
||||
z: 1.5,
|
||||
rumo: 0,
|
||||
vida: 100,
|
||||
estado: "andando",
|
||||
tempo: 0,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function avancar(p: Partida): void {
|
||||
avancarZumbis(p, campoDeFluxo(p.mapa, p.jogador.x, p.jogador.z));
|
||||
}
|
||||
|
||||
/** Uma semente de `p.rng` cujo próximo sorteio fica abaixo (ou não) da chance de caixa. */
|
||||
function sementeComSorteio(abaixo: boolean): number {
|
||||
for (let s = 1; ; s++) {
|
||||
if (sortear({ rng: s }) < MUNICAO.chanceDeCaixa === abaixo) return s;
|
||||
}
|
||||
}
|
||||
|
||||
describe("nascer", () => {
|
||||
it("não age fora do passo múltiplo de 20", () => {
|
||||
const p = partida(SALA, {
|
||||
passo: 21,
|
||||
fila: [{ tipo: "comum", problemaId: "a" }],
|
||||
});
|
||||
nascer(p);
|
||||
expect(p.zumbis).toHaveLength(0);
|
||||
expect(p.fila).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("não age com a fila vazia", () => {
|
||||
const p = partida(SALA, { passo: 20 });
|
||||
nascer(p);
|
||||
expect(p.zumbis).toHaveLength(0);
|
||||
expect(p.proximoId).toBe(1);
|
||||
});
|
||||
|
||||
it("respeita o teto de zumbis à vista, sem contar os morrendo", () => {
|
||||
const p = partida(SALA, {
|
||||
passo: 20,
|
||||
tetoAVista: 1,
|
||||
zumbis: [zumbi({ id: 1 })],
|
||||
fila: [{ tipo: "comum", problemaId: "a" }],
|
||||
});
|
||||
nascer(p);
|
||||
expect(p.zumbis).toHaveLength(1);
|
||||
p.zumbis[0].estado = "morrendo";
|
||||
nascer(p);
|
||||
expect(p.zumbis).toHaveLength(2);
|
||||
expect(p.fila).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("cria o primeiro da fila com os valores ajustados, virado para o jogador", () => {
|
||||
const p = partida(SALA, {
|
||||
passo: 40,
|
||||
dificuldade: "dificil",
|
||||
proximoId: 5,
|
||||
fila: [
|
||||
{ tipo: "blindado", problemaId: "a" },
|
||||
{ tipo: "comum", problemaId: "b" },
|
||||
],
|
||||
});
|
||||
nascer(p);
|
||||
expect(p.fila).toEqual([{ tipo: "comum", problemaId: "b" }]);
|
||||
expect(p.proximoId).toBe(6);
|
||||
const z = p.zumbis[0];
|
||||
expect(z).toMatchObject({
|
||||
id: 5,
|
||||
tipo: "blindado",
|
||||
problemaId: "a",
|
||||
vida: 250,
|
||||
estado: "andando",
|
||||
tempo: 0,
|
||||
});
|
||||
expect([z.x, z.z]).toEqual([6.5, 2.5]);
|
||||
expect(z.rumo).toBeCloseTo(Math.atan2(1.5 - 6.5, 1.5 - 2.5));
|
||||
});
|
||||
|
||||
it("escolhe só nascedouros a 8 m ou mais, pelo índice id % candidatos", () => {
|
||||
const fila = [{ tipo: "comum" as const, problemaId: "a" }];
|
||||
const a = partida(DOIS_NASCEDOUROS_LONGE, {
|
||||
passo: 0,
|
||||
proximoId: 4,
|
||||
fila: [...fila],
|
||||
});
|
||||
const b = partida(DOIS_NASCEDOUROS_LONGE, {
|
||||
passo: 0,
|
||||
proximoId: 5,
|
||||
fila: [...fila],
|
||||
});
|
||||
nascer(a);
|
||||
nascer(b);
|
||||
expect(a.zumbis[0].x).toBe(12.5);
|
||||
expect(b.zumbis[0].x).toBe(14.5);
|
||||
});
|
||||
|
||||
it("usa todos os nascedouros quando nenhum está longe", () => {
|
||||
const p = partida(["#####", "#JN.#", "#####"], {
|
||||
fila: [{ tipo: "comum", problemaId: "a" }],
|
||||
});
|
||||
nascer(p);
|
||||
expect(p.zumbis[0].x).toBe(2.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ferir", () => {
|
||||
it("ignora zumbi que já está morrendo", () => {
|
||||
const p = partida(SALA);
|
||||
const z = zumbi({ estado: "morrendo", vida: 0 });
|
||||
expect(ferir(p, z, 50, "jogador")).toBe(false);
|
||||
expect(z.vida).toBe(0);
|
||||
expect(p.placar.jogador).toBe(0);
|
||||
});
|
||||
|
||||
it("só desconta a vida quando não mata", () => {
|
||||
const p = partida(SALA);
|
||||
const z = zumbi();
|
||||
expect(ferir(p, z, 30, "jogador")).toBe(false);
|
||||
expect(z.vida).toBe(70);
|
||||
expect(z.estado).toBe("andando");
|
||||
expect(p.eventos).toEqual([]);
|
||||
});
|
||||
|
||||
it("mata para o jogador: placar, evento e estado", () => {
|
||||
const p = partida(SALA, { rng: sementeComSorteio(false), proximoId: 9 });
|
||||
const z = zumbi({ id: 3, tempo: 12 });
|
||||
expect(ferir(p, z, 100, "jogador")).toBe(true);
|
||||
expect(z.estado).toBe("morrendo");
|
||||
expect(z.tempo).toBe(0);
|
||||
expect(p.placar).toEqual({ jogador: 1, drones: 0 });
|
||||
expect(p.eventos).toEqual([
|
||||
{ tipo: "morte", zumbiId: 3, autor: "jogador" },
|
||||
]);
|
||||
expect(p.caixas).toEqual([]);
|
||||
expect(p.proximoId).toBe(9);
|
||||
});
|
||||
|
||||
it("larga uma caixa quando o sorteio fica abaixo da chance", () => {
|
||||
const p = partida(SALA, { rng: sementeComSorteio(true), proximoId: 9 });
|
||||
ferir(p, zumbi({ x: 4.5, z: 2.5 }), 500, "jogador");
|
||||
expect(p.caixas).toEqual([{ id: 9, x: 4.5, z: 2.5 }]);
|
||||
expect(p.proximoId).toBe(10);
|
||||
});
|
||||
|
||||
it("morte por drone soma em placar.drones e nas eliminações dele", () => {
|
||||
const p = partida(SALA, {
|
||||
drones: [
|
||||
{
|
||||
id: 7,
|
||||
problemaId: "a",
|
||||
x: 0,
|
||||
z: 0,
|
||||
rumo: 0,
|
||||
recarga: 0,
|
||||
eliminacoes: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
ferir(p, zumbi({ id: 2 }), 100, 7);
|
||||
expect(p.placar).toEqual({ jogador: 0, drones: 1 });
|
||||
expect(p.drones[0].eliminacoes).toBe(1);
|
||||
expect(p.eventos).toEqual([{ tipo: "morte", zumbiId: 2, autor: 7 }]);
|
||||
});
|
||||
|
||||
it("morte por drone que não existe só soma no placar", () => {
|
||||
const p = partida(SALA);
|
||||
ferir(p, zumbi(), 100, 99);
|
||||
expect(p.placar.drones).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("avancarZumbis", () => {
|
||||
it("remove o morrendo só ao completar a animação", () => {
|
||||
const passos = ATAQUE_DO_ZUMBI.passosMorrendo;
|
||||
const p = partida(SALA, {
|
||||
zumbis: [
|
||||
zumbi({ id: 1, estado: "morrendo", tempo: passos - 2 }),
|
||||
zumbi({ id: 2, estado: "morrendo", tempo: passos - 1 }),
|
||||
],
|
||||
});
|
||||
avancar(p);
|
||||
expect(p.zumbis.map((z) => z.id)).toEqual([1]);
|
||||
expect(p.zumbis[0].tempo).toBe(passos - 1);
|
||||
});
|
||||
|
||||
it("anda em linha reta quando vê o jogador", () => {
|
||||
const p = partida(SALA, { zumbis: [zumbi({ tempo: 3 })] });
|
||||
avancar(p);
|
||||
const z = p.zumbis[0];
|
||||
expect(z.x).toBeCloseTo(8.5 - 1.2 / 60);
|
||||
expect(z.z).toBeCloseTo(1.5);
|
||||
expect(z.rumo).toBeCloseTo(-Math.PI / 2);
|
||||
expect(z.estado).toBe("andando");
|
||||
expect(z.tempo).toBe(4);
|
||||
});
|
||||
|
||||
it("usa a velocidade ajustada pela dificuldade", () => {
|
||||
const p = partida(SALA, {
|
||||
dificuldade: "dificil",
|
||||
zumbis: [zumbi()],
|
||||
});
|
||||
avancar(p);
|
||||
expect(p.zumbis[0].x).toBeCloseTo(8.5 - (1.2 * 1.3) / 60);
|
||||
});
|
||||
|
||||
it("zera o tempo ao voltar a andar", () => {
|
||||
const p = partida(SALA, {
|
||||
zumbis: [zumbi({ estado: "atacando", tempo: 40 })],
|
||||
});
|
||||
avancar(p);
|
||||
expect(p.zumbis[0].estado).toBe("andando");
|
||||
expect(p.zumbis[0].tempo).toBe(0);
|
||||
});
|
||||
|
||||
it("sem caminho livre, contorna a parede pelo campo de fluxo", () => {
|
||||
const p = partida(PAREDE_NO_MEIO, {
|
||||
zumbis: [zumbi({ x: 3.5, z: 1.5 })],
|
||||
});
|
||||
avancar(p);
|
||||
const z = p.zumbis[0];
|
||||
expect(z.x).toBeCloseTo(3.5);
|
||||
expect(z.z).toBeGreaterThan(1.5);
|
||||
expect(z.rumo).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("sem caminho nem visão, fica parado e mantém o rumo", () => {
|
||||
const p = partida(BOLSO_FECHADO, {
|
||||
zumbis: [zumbi({ x: 4.5, z: 1.5, rumo: 1.25 })],
|
||||
});
|
||||
avancar(p);
|
||||
const z = p.zumbis[0];
|
||||
expect([z.x, z.z, z.rumo]).toEqual([4.5, 1.5, 1.25]);
|
||||
});
|
||||
|
||||
it("com mesa entre ele e o jogador, contorna a mesa e chega", () => {
|
||||
const p = partida(MESA_NO_CAMINHO, {
|
||||
zumbis: [zumbi({ x: 7.5, z: 2.5 })],
|
||||
});
|
||||
for (let i = 0; i < 600; i++) avancar(p);
|
||||
expect(p.zumbis[0].estado).toBe("atacando");
|
||||
});
|
||||
|
||||
it("encostado numa quina, não fica parado e chega", () => {
|
||||
const p = partida(QUINA, { zumbis: [zumbi({ x: 6.36, z: 2.0 })] });
|
||||
avancar(p);
|
||||
expect(p.zumbis[0].z).toBeGreaterThan(2.0);
|
||||
for (let i = 0; i < 600; i++) avancar(p);
|
||||
expect(p.zumbis[0].estado).toBe("atacando");
|
||||
});
|
||||
|
||||
it("ataca ao chegar no alcance, virado para o jogador, sem golpear de cara", () => {
|
||||
const p = partida(SALA, {
|
||||
zumbis: [zumbi({ x: 2.5, z: 1.5, tempo: 7 })],
|
||||
});
|
||||
avancar(p);
|
||||
const z = p.zumbis[0];
|
||||
expect(z.estado).toBe("atacando");
|
||||
expect(z.tempo).toBe(0);
|
||||
expect(z.rumo).toBeCloseTo(-Math.PI / 2);
|
||||
expect([z.x, z.z]).toEqual([2.5, 1.5]);
|
||||
expect(p.eventos).toEqual([]);
|
||||
});
|
||||
|
||||
it("golpeia só no passo certo do intervalo", () => {
|
||||
const p = partida(SALA, {
|
||||
dificuldade: "dificil",
|
||||
zumbis: [zumbi({ x: 2.5, z: 1.5, estado: "atacando", tempo: 17 })],
|
||||
});
|
||||
avancar(p);
|
||||
avancar(p);
|
||||
expect(p.eventos).toEqual([]);
|
||||
avancar(p);
|
||||
expect(p.zumbis[0].tempo).toBe(20);
|
||||
expect(p.eventos).toEqual([
|
||||
{ tipo: "golpe", dano: 15, zumbiId: p.zumbis[0].id },
|
||||
]);
|
||||
expect(p.jogador.vida).toBe(85);
|
||||
for (let i = 0; i < ATAQUE_DO_ZUMBI.intervalo - 1; i++) avancar(p);
|
||||
expect(p.eventos).toHaveLength(1);
|
||||
avancar(p);
|
||||
expect(p.eventos).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a vida do jogador não fica negativa", () => {
|
||||
const p = partida(SALA, {
|
||||
zumbis: [zumbi({ x: 2.5, z: 1.5, estado: "atacando", tempo: 19 })],
|
||||
});
|
||||
p.jogador.vida = 4;
|
||||
avancar(p);
|
||||
expect(p.jogador.vida).toBe(0);
|
||||
});
|
||||
|
||||
it("dois zumbis sobrepostos terminam afastados", () => {
|
||||
const p = partida(SALA, {
|
||||
zumbis: [
|
||||
zumbi({ id: 1, x: 6.5, z: 1.5 }),
|
||||
zumbi({ id: 2, x: 6.8, z: 1.5 }),
|
||||
],
|
||||
});
|
||||
avancar(p);
|
||||
const [a, b] = p.zumbis;
|
||||
expect(Math.hypot(b.x - a.x, b.z - a.z)).toBeGreaterThanOrEqual(0.7 - 1e-9);
|
||||
});
|
||||
|
||||
it("na sobreposição só o mais longe do jogador recua; o da frente segue o passo normal", () => {
|
||||
for (const perto of [0, 1]) {
|
||||
const xs = perto === 0 ? [6.5, 6.8] : [6.8, 6.5];
|
||||
const p = partida(SALA, {
|
||||
zumbis: xs.map((x, i) => zumbi({ id: i + 1, x, z: 1.5 })),
|
||||
});
|
||||
avancar(p);
|
||||
expect(p.zumbis[perto].x).toBeCloseTo(6.5 - 1.2 / 60);
|
||||
expect(p.zumbis[1 - perto].x).toBeCloseTo(6.5 - 1.2 / 60 + 0.7);
|
||||
}
|
||||
});
|
||||
|
||||
it("par exatamente no mesmo ponto é afastado no eixo x", () => {
|
||||
const p = partida(SALA, {
|
||||
zumbis: [
|
||||
zumbi({ id: 1, x: 6.5, z: 1.5 }),
|
||||
zumbi({ id: 2, x: 6.5, z: 1.5 }),
|
||||
],
|
||||
});
|
||||
avancar(p);
|
||||
const [a, b] = p.zumbis;
|
||||
expect(b.x - a.x).toBeCloseTo(0.7);
|
||||
expect(b.z).toBeCloseTo(a.z);
|
||||
});
|
||||
|
||||
it("dois zumbis lado a lado na boca de um vão estreito passam os dois, um por vez", () => {
|
||||
const p = partida(VAO_ENTRE_MESAS, {
|
||||
zumbis: [
|
||||
zumbi({ id: 1, x: 5.4, z: 2.15 }),
|
||||
zumbi({ id: 2, x: 5.4, z: 2.85 }),
|
||||
],
|
||||
});
|
||||
for (let i = 0; i < 300; i++) avancar(p);
|
||||
expect(p.zumbis.map((z) => z.x < 4)).toEqual([true, true]);
|
||||
});
|
||||
|
||||
it("não separa quem está morrendo nem quem já está afastado", () => {
|
||||
const p = partida(SALA, {
|
||||
zumbis: [
|
||||
zumbi({ id: 1, x: 6.5, z: 1.5, estado: "morrendo" }),
|
||||
zumbi({ id: 2, x: 6.5, z: 1.5, estado: "morrendo" }),
|
||||
zumbi({ id: 3, x: 9.5, z: 1.5 }),
|
||||
zumbi({ id: 4, x: 11.5, z: 1.5 }),
|
||||
],
|
||||
});
|
||||
avancar(p);
|
||||
expect([p.zumbis[0].x, p.zumbis[1].x]).toEqual([6.5, 6.5]);
|
||||
expect(p.zumbis[2].x).toBeCloseTo(9.5 - 1.2 / 60);
|
||||
expect(p.zumbis[3].x).toBeCloseTo(11.5 - 1.2 / 60);
|
||||
});
|
||||
|
||||
it("chefão no centro de uma célula cercada de parede por três lados anda para o lado livre", () => {
|
||||
const p = partida(["#######", "#N...J#", "#######"], {
|
||||
zumbis: [zumbi({ tipo: "chefao", x: 1.5, z: 1.5 })],
|
||||
});
|
||||
avancar(p);
|
||||
expect(p.zumbis[0].x).toBeGreaterThan(1.5);
|
||||
expect(p.zumbis[0].z).toBeCloseTo(1.5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/** Zumbis: nascer, ferir, andar, atacar e se afastar uns dos outros. Altera a `Partida` no lugar. */
|
||||
|
||||
import { ajustar } from "./dificuldade";
|
||||
import { caminhoLivre, direcaoNoCampo, moverComColisao } from "./mapa";
|
||||
import { sortear } from "./rng";
|
||||
import type { Autor, Partida, Zumbi } from "./tipos";
|
||||
import {
|
||||
ATAQUE_DO_ZUMBI,
|
||||
JOGADOR,
|
||||
MUNICAO,
|
||||
PASSO,
|
||||
RAIO_DE_PASSAGEM,
|
||||
ZUMBIS,
|
||||
} from "./valores";
|
||||
|
||||
const INTERVALO_DE_NASCER = 20;
|
||||
const DISTANCIA_MINIMA_DO_NASCEDOURO = 8;
|
||||
/** Passo (dentro do intervalo) em que o golpe acontece. */
|
||||
const PASSO_DO_GOLPE = 20;
|
||||
|
||||
/** Raio usado contra o mapa: corpo grande não pode ficar maior que a passagem. */
|
||||
function raioNoMapa(zumbi: Zumbi): number {
|
||||
return Math.min(ZUMBIS[zumbi.tipo].raio, RAIO_DE_PASSAGEM);
|
||||
}
|
||||
|
||||
function distanciaAoJogador(p: Partida, x: number, z: number): number {
|
||||
return Math.hypot(p.jogador.x - x, p.jogador.z - z);
|
||||
}
|
||||
|
||||
function nascedouroPara(p: Partida, id: number): readonly [number, number] {
|
||||
const todos = p.mapa.nascedouros;
|
||||
const longe = todos.filter(
|
||||
([x, z]) => distanciaAoJogador(p, x, z) >= DISTANCIA_MINIMA_DO_NASCEDOURO,
|
||||
);
|
||||
const lista = longe.length > 0 ? longe : todos;
|
||||
return lista[id % lista.length];
|
||||
}
|
||||
|
||||
export function nascer(p: Partida): void {
|
||||
const proximo = p.fila[0];
|
||||
const aVista = p.zumbis.filter((z) => z.estado !== "morrendo").length;
|
||||
if (
|
||||
p.passo % INTERVALO_DE_NASCER !== 0 ||
|
||||
!proximo ||
|
||||
aVista >= p.tetoAVista
|
||||
) {
|
||||
return;
|
||||
}
|
||||
p.fila.shift();
|
||||
const id = p.proximoId++;
|
||||
const [x, z] = nascedouroPara(p, id);
|
||||
p.zumbis.push({
|
||||
id,
|
||||
tipo: proximo.tipo,
|
||||
problemaId: proximo.problemaId,
|
||||
x,
|
||||
z,
|
||||
rumo: Math.atan2(p.jogador.x - x, p.jogador.z - z),
|
||||
vida: ajustar(proximo.tipo, p.dificuldade).vida,
|
||||
estado: "andando",
|
||||
tempo: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function pontuar(p: Partida, autor: Autor): void {
|
||||
if (autor === "jogador") {
|
||||
p.placar.jogador++;
|
||||
return;
|
||||
}
|
||||
p.placar.drones++;
|
||||
const drone = p.drones.find((d) => d.id === autor);
|
||||
if (drone) drone.eliminacoes++;
|
||||
}
|
||||
|
||||
function registrarMorte(p: Partida, zumbi: Zumbi, autor: Autor): void {
|
||||
zumbi.estado = "morrendo";
|
||||
zumbi.tempo = 0;
|
||||
pontuar(p, autor);
|
||||
p.eventos.push({ tipo: "morte", zumbiId: zumbi.id, autor });
|
||||
if (sortear(p) < MUNICAO.chanceDeCaixa) {
|
||||
p.caixas.push({ id: p.proximoId++, x: zumbi.x, z: zumbi.z });
|
||||
}
|
||||
}
|
||||
|
||||
/** Devolve true se este golpe matou. */
|
||||
export function ferir(
|
||||
p: Partida,
|
||||
zumbi: Zumbi,
|
||||
dano: number,
|
||||
autor: Autor,
|
||||
): boolean {
|
||||
if (zumbi.estado === "morrendo") return false;
|
||||
zumbi.vida -= dano;
|
||||
if (zumbi.vida > 0) return false;
|
||||
registrarMorte(p, zumbi, autor);
|
||||
return true;
|
||||
}
|
||||
|
||||
function noAlcance(p: Partida, zumbi: Zumbi): boolean {
|
||||
const folga =
|
||||
distanciaAoJogador(p, zumbi.x, zumbi.z) -
|
||||
ZUMBIS[zumbi.tipo].raio -
|
||||
JOGADOR.raio;
|
||||
return folga <= ATAQUE_DO_ZUMBI.alcance;
|
||||
}
|
||||
|
||||
function entrarEm(zumbi: Zumbi, estado: "andando" | "atacando"): void {
|
||||
if (zumbi.estado === estado) return;
|
||||
zumbi.estado = estado;
|
||||
zumbi.tempo = 0;
|
||||
}
|
||||
|
||||
function atacar(p: Partida, zumbi: Zumbi): void {
|
||||
entrarEm(zumbi, "atacando");
|
||||
zumbi.rumo = Math.atan2(p.jogador.x - zumbi.x, p.jogador.z - zumbi.z);
|
||||
if (zumbi.tempo % ATAQUE_DO_ZUMBI.intervalo !== PASSO_DO_GOLPE) return;
|
||||
const { dano } = ajustar(zumbi.tipo, p.dificuldade);
|
||||
p.jogador.vida = Math.max(0, p.jogador.vida - dano);
|
||||
p.eventos.push({ tipo: "golpe", dano, zumbiId: zumbi.id });
|
||||
}
|
||||
|
||||
/** Em linha reta só se o corpo inteiro passa até o jogador; senão segue o campo. */
|
||||
function direcaoDoZumbi(
|
||||
p: Partida,
|
||||
campo: Int16Array,
|
||||
zumbi: Zumbi,
|
||||
): [number, number] {
|
||||
const { x, z } = zumbi;
|
||||
const raio = raioNoMapa(zumbi);
|
||||
if (caminhoLivre(p.mapa, x, z, p.jogador.x, p.jogador.z, raio)) {
|
||||
const d = distanciaAoJogador(p, x, z);
|
||||
return [(p.jogador.x - x) / d, (p.jogador.z - z) / d];
|
||||
}
|
||||
return direcaoNoCampo(p.mapa, campo, x, z);
|
||||
}
|
||||
|
||||
function andar(p: Partida, campo: Int16Array, zumbi: Zumbi): void {
|
||||
entrarEm(zumbi, "andando");
|
||||
const [dx, dz] = direcaoDoZumbi(p, campo, zumbi);
|
||||
if (dx === 0 && dz === 0) return;
|
||||
const passo = ajustar(zumbi.tipo, p.dificuldade).velocidade * PASSO;
|
||||
[zumbi.x, zumbi.z] = moverComColisao(
|
||||
p.mapa,
|
||||
zumbi.x,
|
||||
zumbi.z,
|
||||
dx * passo,
|
||||
dz * passo,
|
||||
raioNoMapa(zumbi),
|
||||
);
|
||||
zumbi.rumo = Math.atan2(dx, dz);
|
||||
}
|
||||
|
||||
function avancarUm(p: Partida, campo: Int16Array, zumbi: Zumbi): void {
|
||||
zumbi.tempo++;
|
||||
if (zumbi.estado === "morrendo") return;
|
||||
if (noAlcance(p, zumbi)) atacar(p, zumbi);
|
||||
else andar(p, campo, zumbi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Só o mais longe do jogador cede, recuando a sobreposição inteira; no mesmo ponto, no eixo x.
|
||||
* Meio a meio, dois zumbis na boca de um vão de uma célula se empurravam contra as mesas para sempre.
|
||||
*/
|
||||
function separarPar(p: Partida, a: Zumbi, b: Zumbi): void {
|
||||
const d = Math.hypot(b.x - a.x, b.z - a.z);
|
||||
const sobra = ZUMBIS[a.tipo].raio + ZUMBIS[b.tipo].raio - d;
|
||||
if (sobra <= 0) return;
|
||||
const bCede =
|
||||
distanciaAoJogador(p, a.x, a.z) <= distanciaAoJogador(p, b.x, b.z);
|
||||
const [fica, cede] = bCede ? [a, b] : [b, a];
|
||||
const [ux, uz] =
|
||||
d === 0 ? [1, 0] : [(cede.x - fica.x) / d, (cede.z - fica.z) / d];
|
||||
[cede.x, cede.z] = moverComColisao(
|
||||
p.mapa,
|
||||
cede.x,
|
||||
cede.z,
|
||||
ux * sobra,
|
||||
uz * sobra,
|
||||
raioNoMapa(cede),
|
||||
);
|
||||
}
|
||||
|
||||
function separar(p: Partida): void {
|
||||
const vivos = p.zumbis.filter((z) => z.estado !== "morrendo");
|
||||
for (let i = 0; i < vivos.length; i++) {
|
||||
for (let j = i + 1; j < vivos.length; j++) {
|
||||
separarPar(p, vivos[i], vivos[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function avancarZumbis(p: Partida, campo: Int16Array): void {
|
||||
for (const zumbi of p.zumbis) avancarUm(p, campo, zumbi);
|
||||
const restantes = p.zumbis.filter(
|
||||
(z) => z.estado !== "morrendo" || z.tempo < ATAQUE_DO_ZUMBI.passosMorrendo,
|
||||
);
|
||||
p.zumbis.splice(0, p.zumbis.length, ...restantes);
|
||||
separar(p);
|
||||
}
|
||||
Reference in new issue
Block a user