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.
315 lines
9.7 KiB
TypeScript
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();
|
|
});
|