Files
portifolio-evandrodevbr/app/jogo/efeitos.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

315 lines
9.7 KiB
TypeScript

import {
BufferGeometry,
type InstancedMesh,
Matrix4,
type Mesh,
type Object3D,
PerspectiveCamera,
type PointLight,
Scene,
} from "three";
import { criarPartida } from "@/lib/jogo/partida";
import { PROBLEMAS } from "@/lib/jogo/problemas";
import type { Evento, Partida, Zumbi } from "@/lib/jogo/tipos";
import { criarEfeitos } from "./efeitos";
import type { Som } from "./som";
// O jsdom não desenha em <canvas>: um contexto falso aceita qualquer chamada.
const contextoFalso: unknown = new Proxy(
{},
{
get: () => () => ({ addColorStop() {} }),
set: () => true,
},
);
beforeAll(() => {
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockImplementation((() => contextoFalso) as never);
});
afterAll(() => jest.restoreAllMocks());
function somFalso(): Som & { tocar: jest.Mock } {
return {
liberar: jest.fn(() => Promise.resolve()),
tocar: jest.fn(),
definirMudo: jest.fn(),
descartar: jest.fn(),
};
}
function montar(movimentoReduzido = false) {
const cena = new Scene();
const camera = new PerspectiveCamera(70, 1, 0.05, 100);
cena.add(camera);
const p: Partida = criarPartida({
semente: 1,
dificuldade: "normal",
problemaId: PROBLEMAS[0].id,
});
camera.position.set(p.jogador.x, 1.6, p.jogador.z);
const som = somFalso();
const efeitos = criarEfeitos(cena, camera, { movimentoReduzido, som });
return { cena, camera, p, som, efeitos };
}
function zumbi(p: Partida): Zumbi {
const z: Zumbi = {
id: 7,
tipo: "comum",
problemaId: PROBLEMAS[0].id,
x: p.jogador.x,
z: p.jogador.z + 4,
rumo: Math.PI,
vida: 100,
estado: "andando",
tempo: 0,
};
p.zumbis.push(z);
return z;
}
function acerto(z: Zumbi, cabeca = false): Evento {
return { tipo: "acerto", zumbiId: z.id, cabeca, x: z.x, y: 1.2, z: z.z };
}
const tiro: Evento = { tipo: "tiro", autor: "jogador", arma: 0, distancia: 4 };
/** Instâncias com escala diferente de zero. */
function visiveis(malha: InstancedMesh): number {
const m = new Matrix4();
let n = 0;
for (let i = 0; i < malha.count; i++) {
malha.getMatrixAt(i, m);
if (m.determinant() !== 0) n++;
}
return n;
}
function achar<T extends Object3D>(cena: Scene, nome: string): T {
const o = cena.getObjectByName(nome);
if (!o) throw new Error(`sem ${nome}`);
return o as T;
}
function desenhados(raiz: Object3D): number {
let n = 0;
raiz.traverseVisible((o) => {
if ((o as Mesh).isMesh) n++;
});
return n;
}
it("200 acertos seguidos não passam de 64 gotas nem de 64 marcas", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
for (let i = 0; i < 200; i++) efeitos.aoEvento(acerto(z, i % 2 === 0), p);
const gotas = achar<InstancedMesh>(cena, "efeitos-gotas");
const marcas = achar<InstancedMesh>(cena, "efeitos-marcas");
efeitos.avancar(0.016, p);
expect(gotas.count).toBe(64);
expect(visiveis(gotas)).toBe(64);
expect(marcas.count).toBe(64);
expect(visiveis(marcas)).toBe(64);
});
it("as gotas somem depois de 0,5 s; tiro na cabeça espirra o dobro", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const gotas = achar<InstancedMesh>(cena, "efeitos-gotas");
efeitos.aoEvento(acerto(z), p);
efeitos.avancar(0.3, p);
expect(visiveis(gotas)).toBe(6);
efeitos.avancar(0.25, p);
expect(visiveis(gotas)).toBe(0);
efeitos.aoEvento(acerto(z, true), p);
efeitos.avancar(0.016, p);
expect(visiveis(gotas)).toBe(12);
});
it("a marca fica no chão embaixo do zumbi e a morte deixa uma poça maior", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const marcas = achar<InstancedMesh>(cena, "efeitos-marcas");
efeitos.aoEvento(acerto(z), p);
efeitos.aoEvento({ tipo: "morte", zumbiId: z.id, autor: "jogador" }, p);
efeitos.avancar(2, p);
const m = new Matrix4();
marcas.getMatrixAt(0, m);
const e = m.elements;
expect(e[13]).toBeLessThan(0.05);
expect(Math.abs(e[12] - z.x)).toBeLessThan(0.3);
expect(Math.abs(e[14] - z.z)).toBeLessThan(0.3);
const tamanho = (i: number) => {
marcas.getMatrixAt(i, m);
return Math.hypot(m.elements[0], m.elements[2]);
};
expect(tamanho(1)).toBeGreaterThan(tamanho(0));
});
it("tiro do jogador liga o clarão, a luz e o traçante, que apagam depois de 60 ms", () => {
const { cena, p, som, efeitos } = montar();
const clarao = achar<Mesh>(cena, "efeitos-clarao");
const luz = achar<PointLight>(cena, "efeitos-luz");
const tracantes = achar<InstancedMesh>(cena, "efeitos-tracantes");
expect(clarao.visible).toBe(false);
efeitos.aoEvento(tiro, p);
expect(som.tocar).toHaveBeenCalledWith("pistola", { variar: true });
efeitos.avancar(0.03, p);
expect(clarao.visible).toBe(true);
expect(luz.intensity).toBeGreaterThan(0);
expect(visiveis(tracantes)).toBe(1);
efeitos.avancar(0.04, p);
expect(clarao.visible).toBe(false);
expect(luz.intensity).toBe(0);
expect(visiveis(tracantes)).toBe(0);
});
it("o traçante do drone sai dele, a 2,3 m de altura, com som baixo", () => {
const { cena, p, som, efeitos } = montar();
p.drones.push({
id: 3,
problemaId: PROBLEMAS[0].id,
x: 2,
z: 2,
rumo: 0,
recarga: 0,
eliminacoes: 0,
});
efeitos.aoEvento({ tipo: "tiro", autor: 3, arma: "drone", distancia: 6 }, p);
expect(som.tocar).toHaveBeenCalledWith("drone", {
volume: 0.35,
variar: true,
});
const tracantes = achar<InstancedMesh>(cena, "efeitos-tracantes");
const m = new Matrix4();
tracantes.getMatrixAt(0, m);
// O meio da faixa fica entre o drone (z = 2) e o ponto do tiro (z = 8).
expect(m.elements[14]).toBeCloseTo(5, 5);
expect(m.elements[13]).toBeCloseTo((2.3 + 1.2) / 2, 5);
});
it("no máximo 8 traçantes e 6 chamadas de desenho somando tudo", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const antes = desenhados(cena);
for (let i = 0; i < 20; i++) {
efeitos.aoEvento(tiro, p);
efeitos.aoEvento(acerto(z), p);
}
efeitos.aoEvento({ tipo: "morte", zumbiId: z.id, autor: "jogador" }, p);
efeitos.avancar(0.016, p);
expect(visiveis(achar<InstancedMesh>(cena, "efeitos-tracantes"))).toBe(8);
expect(desenhados(cena)).toBeLessThanOrEqual(6);
expect(antes).toBeLessThanOrEqual(6);
});
it("troca de arma troca o modelo visível", () => {
const { cena, p, efeitos } = montar();
const visivel = () =>
["pistola", "automatica", "faca"].filter(
(nome) => achar<Mesh>(cena, `arma-${nome}`).visible,
);
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["pistola"]);
p.jogador.arma = 1;
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["automatica"]);
p.jogador.arma = 2;
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["faca"]);
});
it("o golpe dá um tranco na câmera por 150 ms e toca o som", () => {
const { camera, p, som, efeitos } = montar();
const x = camera.position.x;
const y = camera.position.y;
efeitos.aoEvento({ tipo: "golpe", dano: 10, zumbiId: 0 }, p);
expect(som.tocar).toHaveBeenCalledWith("golpe");
efeitos.avancar(0.016, p);
expect(
camera.position.x !== x ||
camera.position.y !== y ||
camera.rotation.z !== 0,
).toBe(true);
efeitos.avancar(0.2, p);
expect(camera.position.x).toBeCloseTo(x, 10);
expect(camera.position.y).toBeCloseTo(y, 10);
expect(camera.rotation.z).toBeCloseTo(0, 10);
});
it("movimento reduzido não mexe a câmera no golpe nem balança a arma", () => {
const { cena, camera, p, efeitos } = montar(true);
camera.updateMatrix();
const inicial = camera.matrix.clone();
efeitos.aoEvento({ tipo: "golpe", dano: 10, zumbiId: 0 }, p);
const arma = achar<Object3D>(cena, "efeitos-arma");
const posicaoDaArma = arma.position.clone();
for (let i = 0; i < 20; i++) {
p.jogador.z += 0.07;
efeitos.avancar(0.016, p);
camera.updateMatrix();
expect(camera.matrix.equals(inicial)).toBe(true);
}
expect(arma.position.equals(posicaoDaArma)).toBe(true);
});
it("os eventos tocam os sons certos", () => {
const { p, som, efeitos } = montar();
const z = zumbi(p);
efeitos.aoEvento(
{ tipo: "tiro", autor: "jogador", arma: 2, distancia: 1 },
p,
);
efeitos.aoEvento(acerto(z), p);
efeitos.aoEvento({ tipo: "resolvido", problemaId: "x", droneId: 1 }, p);
efeitos.aoEvento({ tipo: "caixa", balas: 30 }, p);
const nomes = som.tocar.mock.calls.map((c) => c[0]);
expect(nomes).toEqual(["faca", "acerto", "resolvido", "caixa"]);
});
it("um zumbi a até 12 m geme a cada 2 a 5 s, mais baixo quando longe", () => {
const { p, som, efeitos } = montar();
const z = zumbi(p);
z.z = p.jogador.z + 9;
for (let i = 0; i < 300; i++) efeitos.avancar(0.02, p);
const gemidos = som.tocar.mock.calls.filter((c) =>
String(c[0]).startsWith("gemido"),
);
expect(gemidos.length).toBeGreaterThanOrEqual(1);
expect(gemidos.length).toBeLessThanOrEqual(3);
expect(gemidos[0][1].volume).toBeCloseTo(0.25, 5);
som.tocar.mockClear();
z.z = p.jogador.z + 13;
for (let i = 0; i < 300; i++) efeitos.avancar(0.02, p);
expect(som.tocar).not.toHaveBeenCalled();
});
it("descartar tira tudo da cena e da câmera e libera a memória", () => {
const { cena, camera, p, efeitos } = montar();
const z = zumbi(p);
efeitos.aoEvento(tiro, p);
efeitos.aoEvento(acerto(z), p);
const liberadas = jest.spyOn(BufferGeometry.prototype, "dispose");
efeitos.descartar();
expect(camera.children).toHaveLength(0);
expect(cena.children).toEqual([camera]);
expect(liberadas.mock.calls.length).toBeGreaterThanOrEqual(7);
liberadas.mockRestore();
});
it("sem câmera na cena, coloca e depois retira", () => {
const cena = new Scene();
const camera = new PerspectiveCamera();
const efeitos = criarEfeitos(cena, camera, {
movimentoReduzido: false,
som: somFalso(),
});
expect(camera.parent).toBe(cena);
efeitos.descartar();
expect(cena.children).toHaveLength(0);
expect(camera.parent).toBeNull();
});