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.
269 lines
8.3 KiB
TypeScript
269 lines
8.3 KiB
TypeScript
import {
|
|
BoxGeometry,
|
|
Group,
|
|
Mesh,
|
|
MeshStandardMaterial,
|
|
type Object3D,
|
|
Scene,
|
|
Sprite,
|
|
Texture,
|
|
} from "three";
|
|
import { criarPartida } from "@/lib/jogo/partida";
|
|
import type { Partida, TipoZumbi, Zumbi } from "@/lib/jogo/tipos";
|
|
import { criarAtores } from "./atores";
|
|
import type { ModeloDoZumbi } from "./zumbis3d";
|
|
|
|
// O zumbis3d de verdade importa o GLTFLoader (ESM, que o Jest não transforma); aqui o clone é um `clone()` simples.
|
|
const mockTocados: string[] = [];
|
|
jest.mock("./zumbis3d", () => ({
|
|
criarZumbi: (modelo: ModeloDoZumbi) => {
|
|
const raiz = modelo.cena.clone();
|
|
return {
|
|
raiz,
|
|
tocar: (clipe: string, opcoes?: { umaVez?: boolean }) => {
|
|
mockTocados.push(opcoes?.umaVez ? `${clipe} uma vez` : clipe);
|
|
},
|
|
avancar: () => {},
|
|
descartar: () => raiz.removeFromParent(),
|
|
};
|
|
},
|
|
}));
|
|
|
|
/** Zumbi mínimo com as três peças do modelo. */
|
|
function modeloFalso(): ModeloDoZumbi {
|
|
const cena = new Group();
|
|
cena.name = "zumbi";
|
|
for (const nome of ["corpo", "roupa", "olhos"]) {
|
|
cena.add(
|
|
new Mesh(new BoxGeometry(), new MeshStandardMaterial({ name: nome })),
|
|
);
|
|
}
|
|
return { cena, clipes: [] };
|
|
}
|
|
|
|
function zumbi(id: number, x: number, z: number, tipo: TipoZumbi = "comum") {
|
|
const novo: Zumbi = {
|
|
id,
|
|
tipo,
|
|
problemaId: "site-lento",
|
|
x,
|
|
z,
|
|
rumo: 0,
|
|
vida: 100,
|
|
estado: "andando",
|
|
tempo: 0,
|
|
};
|
|
return novo;
|
|
}
|
|
|
|
function partidaCom(zumbis: Zumbi[], passo: number): Partida {
|
|
const p = criarPartida({
|
|
semente: 1,
|
|
dificuldade: "facil",
|
|
problemaId: "site-lento",
|
|
});
|
|
p.zumbis = zumbis;
|
|
p.passo = passo;
|
|
return p;
|
|
}
|
|
|
|
const ehZumbi = (o: Object3D) => o.name === "zumbi";
|
|
|
|
function zumbisNaCena(cena: Scene) {
|
|
const todos = cena.children.filter(ehZumbi);
|
|
return { total: todos.length, visiveis: todos.filter((o) => o.visible) };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
function montar() {
|
|
const cena = new Scene();
|
|
const atores = criarAtores(cena, {
|
|
zumbi: modeloFalso(),
|
|
bloco: new BoxGeometry(),
|
|
roupas: [new Texture(), new Texture()],
|
|
});
|
|
return { cena, atores };
|
|
}
|
|
|
|
test("cria, esconde e reaproveita zumbis sem a reserva passar do maior grupo", () => {
|
|
const { cena, atores } = montar();
|
|
const cinco = [1, 2, 3, 4, 5].map((id) => zumbi(id, 2 + id, 2));
|
|
atores.sincronizar(partidaCom(cinco, 1), 1, 0.016);
|
|
expect(zumbisNaCena(cena).total).toBe(5);
|
|
expect(zumbisNaCena(cena).visiveis).toHaveLength(5);
|
|
|
|
atores.sincronizar(partidaCom(cinco.slice(0, 2), 2), 1, 0.016);
|
|
expect(zumbisNaCena(cena).total).toBe(5);
|
|
expect(zumbisNaCena(cena).visiveis).toHaveLength(2);
|
|
|
|
const seis = [1, 2, 6, 7, 8, 9].map((id) => zumbi(id, 2 + id, 3));
|
|
atores.sincronizar(partidaCom(seis, 3), 1, 0.016);
|
|
const { total, visiveis } = zumbisNaCena(cena);
|
|
expect(total).toBe(6);
|
|
expect(visiveis).toHaveLength(6);
|
|
expect(visiveis.map((o) => o.position.x).sort((a, b) => a - b)).toEqual([
|
|
3, 4, 8, 9, 10, 11,
|
|
]);
|
|
});
|
|
|
|
test("toca o clipe do estado e veste roupa e escala do tipo", () => {
|
|
const { cena, atores } = montar();
|
|
mockTocados.length = 0;
|
|
const rapido = zumbi(1, 3, 3, "rapido");
|
|
const comum = zumbi(2, 4, 3);
|
|
const blindado = zumbi(3, 5, 3, "blindado");
|
|
atores.sincronizar(partidaCom([rapido, comum, blindado], 1), 1, 0);
|
|
expect(mockTocados).toEqual(["correr", "andar", "andar"]);
|
|
atores.sincronizar(
|
|
partidaCom(
|
|
[
|
|
{ ...rapido, estado: "atacando" },
|
|
{ ...comum, estado: "morrendo" },
|
|
blindado,
|
|
],
|
|
2,
|
|
),
|
|
1,
|
|
0,
|
|
);
|
|
expect(mockTocados.slice(3)).toEqual(["atacar", "morrer uma vez"]);
|
|
|
|
const [r, c, b] = cena.children.filter(ehZumbi);
|
|
const roupa = (o: Object3D) =>
|
|
(o.children[1] as Mesh).material as MeshStandardMaterial;
|
|
expect(r.scale.x).toBeCloseTo(0.95);
|
|
expect(b.scale.x).toBeCloseTo(1.08);
|
|
// ids 1, 2 e 3: roupa B, roupa C e a original escurecida e metálica
|
|
expect(new Set([roupa(r), roupa(c), roupa(b)]).size).toBe(3);
|
|
expect(roupa(b).metalness).toBeGreaterThan(0.5);
|
|
});
|
|
|
|
test("põe o zumbi no lugar e no rumo da lógica, interpolando entre passos", () => {
|
|
const { cena, atores } = montar();
|
|
const z = zumbi(1, 4, 4);
|
|
atores.sincronizar(partidaCom([z], 1), 1, 0);
|
|
const p2 = partidaCom([{ ...z, x: 5, rumo: 0.5 }], 2);
|
|
atores.guardarPasso(p2);
|
|
atores.sincronizar(p2, 0.5, 0);
|
|
const raiz = cena.children.find(ehZumbi);
|
|
expect(raiz?.position.x).toBeCloseTo(4.5);
|
|
expect(raiz?.position.z).toBeCloseTo(4);
|
|
expect(raiz?.rotation.y).toBeCloseTo(0.25);
|
|
});
|
|
|
|
test("dois passos no mesmo quadro: interpola entre o penúltimo e o último", () => {
|
|
const { cena, atores } = montar();
|
|
const z = zumbi(1, 4, 4);
|
|
atores.sincronizar(partidaCom([z], 1), 1, 0);
|
|
atores.guardarPasso(partidaCom([{ ...z, x: 5 }], 2));
|
|
const p3 = partidaCom([{ ...z, x: 6 }], 3);
|
|
atores.guardarPasso(p3);
|
|
atores.sincronizar(p3, 0.5, 0);
|
|
expect(cena.children.find(ehZumbi)?.position.x).toBeCloseTo(5.5);
|
|
});
|
|
|
|
test("com a partida parada, antes vira agora e nada treme", () => {
|
|
const { cena, atores } = montar();
|
|
const z = zumbi(1, 4, 4);
|
|
const drone = {
|
|
id: 9,
|
|
problemaId: "site-lento",
|
|
x: 13,
|
|
z: 9,
|
|
rumo: 0,
|
|
recarga: 0,
|
|
eliminacoes: 0,
|
|
};
|
|
const p1 = partidaCom([z], 1);
|
|
p1.drones = [drone];
|
|
atores.sincronizar(p1, 1, 0);
|
|
const p2 = partidaCom([{ ...z, x: 5 }], 2);
|
|
p2.drones = [{ ...drone, x: 14 }];
|
|
atores.guardarPasso(p2);
|
|
// terminada, a lógica não avança `passo`, mas a cena segue chamando a cada passo
|
|
atores.guardarPasso(p2);
|
|
for (const alfa of [0, 0.3, 0.9]) {
|
|
atores.sincronizar(p2, alfa, 0);
|
|
expect(cena.children.find(ehZumbi)?.position.x).toBeCloseTo(5);
|
|
const doDrone = cena.children.find(
|
|
(o) => o instanceof Sprite && o.name === "Site rápido",
|
|
);
|
|
expect(doDrone?.position.x).toBeCloseTo(14);
|
|
}
|
|
});
|
|
|
|
test("o rótulo do zumbi é o nome do problema e só aparece a até 10 m, menos no chefão", () => {
|
|
const { cena, atores } = montar();
|
|
// o jogador começa no centro do escritório (13,5; 8,5)
|
|
const perto = zumbi(1, 13.5, 5);
|
|
const longe = zumbi(2, 1.5, 1.5);
|
|
const chefao = zumbi(3, 26.5, 15.5, "chefao");
|
|
atores.sincronizar(partidaCom([perto, longe, chefao], 1), 1, 0);
|
|
const rotulos = cena.children.filter(
|
|
(o): o is Sprite => o instanceof Sprite && o.name !== "",
|
|
);
|
|
const em = (x: number) => rotulos.find((s) => s.position.x === x);
|
|
expect(em(13.5)?.name).toBe("Site lento");
|
|
expect(em(13.5)?.visible).toBe(true);
|
|
expect(em(1.5)?.visible).toBe(false);
|
|
expect(em(26.5)?.visible).toBe(true);
|
|
expect(em(26.5)?.material.depthTest).toBe(true);
|
|
});
|
|
|
|
test("só os 3 zumbis mais perto e o chefão levam rótulo, do mesmo tamanho na tela a qualquer distância", () => {
|
|
const { cena, atores } = montar();
|
|
const zumbis = [2, 3, 4, 5, 6].map((d, i) => zumbi(i + 1, 13.5, 8.5 + d));
|
|
zumbis.push(zumbi(9, 26.5, 15.5, "chefao"));
|
|
atores.sincronizar(partidaCom(zumbis, 1), 1, 0);
|
|
const rotulos = cena.children.filter(
|
|
(o): o is Sprite => o instanceof Sprite && o.visible,
|
|
);
|
|
expect(rotulos.map((s) => s.position.z).sort((a, b) => a - b)).toEqual([
|
|
10.5, 11.5, 12.5, 15.5,
|
|
]);
|
|
for (const s of rotulos) {
|
|
expect(s.material.sizeAttenuation).toBe(false);
|
|
expect(s.material.depthTest).toBe(true);
|
|
}
|
|
expect(rotulos[0].scale.y).toBeCloseTo(rotulos[1].scale.y, 10);
|
|
});
|
|
|
|
test("o drone leva o nome da solução, acima da cabeça e em rótulo menor que o do zumbi", () => {
|
|
const { cena, atores } = montar();
|
|
const p = partidaCom([zumbi(1, 13.5, 5)], 1);
|
|
p.drones = [
|
|
{
|
|
id: 9,
|
|
problemaId: "site-lento",
|
|
x: 13,
|
|
z: 9,
|
|
rumo: 0,
|
|
recarga: 0,
|
|
eliminacoes: 0,
|
|
},
|
|
];
|
|
atores.sincronizar(p, 1, 0);
|
|
const visivel = (nome: string) =>
|
|
cena.children.find(
|
|
(o): o is Sprite => o instanceof Sprite && o.visible && o.name === nome,
|
|
);
|
|
const doDrone = visivel("Site rápido");
|
|
expect(doDrone?.position.y).toBeGreaterThan(2.3);
|
|
expect(doDrone?.scale.y).toBeLessThan(visivel("Site lento")?.scale.y ?? 0);
|
|
expect(doDrone?.material.depthTest).toBe(false);
|
|
});
|
|
|
|
test("descartar tira tudo da cena", () => {
|
|
const { cena, atores } = montar();
|
|
atores.sincronizar(partidaCom([zumbi(1, 3, 3)], 1), 1, 0);
|
|
atores.descartar();
|
|
expect(cena.children).toHaveLength(0);
|
|
});
|