Files
portifolio-evandrodevbr/lib/jogo/zumbis.test.ts
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
2026-11-10 19:37:10 -03:00

460 lines
13 KiB
TypeScript

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