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 : 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(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(cena, "efeitos-gotas"); const marcas = achar(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(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(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(cena, "efeitos-clarao"); const luz = achar(cena, "efeitos-luz"); const tracantes = achar(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(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(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(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(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(); });