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); });