feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s

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:
evandro committed 2026-11-10 19:37:10 -03:00
1 parent c7d09d63d4
commit 67b32fcab3
118 files changed
+16730

No files matched your search

+379
View File
@@ -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([]);
},
);
});