/** * A partida na tela: renderer, câmera, laço de passo fixo, regulador de qualidade e a ligação entre a lógica * (`lib/jogo`) e o que se desenha (`ambiente`, `atores`, `efeitos`). */ import { type BufferGeometry, type Mesh, PerspectiveCamera, Scene, SRGBColorSpace, type Texture, TextureLoader, WebGLRenderer, } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { alvoNaMira } from "@/lib/jogo/armas"; import { avancar, criarPartida } from "@/lib/jogo/partida"; import { pilotar } from "@/lib/jogo/piloto"; import { amostrar, criarRegulador } from "@/lib/jogo/regulador"; import type { Partida } from "@/lib/jogo/tipos"; import { JOGADOR, NIVEIS, PASSO } from "@/lib/jogo/valores"; import { criarAmbiente } from "./ambiente"; import { criarAtores } from "./atores"; import type { Controle, Escolha, Resultado, Retrato } from "./contrato"; import { criarEfeitos } from "./efeitos"; import type { Som } from "./som"; import { carregarZumbi } from "./zumbis3d"; export type OpcoesDaCena = { canvas: HTMLCanvasElement; escolha: Escolha; semente: number; controle: Controle; som: Som; movimentoReduzido: boolean; /** true: quem joga é `pilotar` de lib/jogo/piloto (testes e medição). */ piloto?: boolean; /** Passos da lógica por passo de tempo real; padrão 1 (testes usam 8). */ velocidade?: number; /** true se quem pediu a cena desistiu (saiu antes de carregar): conferido depois de cada `await`. */ cancelada(): boolean; /** No máximo 10 vezes por segundo, e sempre que fase ou pausa mudar. */ aoRetrato(retrato: Retrato): void; /** Só quando muda: a mira está ou não num zumbi. */ aoMirar(mirando: boolean): void; /** Uma vez, 2 s depois de a fase virar vitoria ou derrota. */ aoTerminar(resultado: Resultado): void; }; export type Cena = { pausar(): void; continuar(): void; descartar(): void }; /** O que os testes de ponta a ponta leem da partida. */ export type EstadoDoJogo = { fase: Partida["fase"]; horda: number; passo: number; vida: number; placar: { jogador: number; drones: number }; x: number; z: number; rumo: number; nivel: number; /** p95 dos últimos quadros, em ms. */ p95: number; geometrias: number; texturas: number; chamadas: number; /** Só depois de `descartar`: o que o renderer ainda contava como vivo. O esperado é 0 e 0. */ sobras?: { geometrias: number; texturas: number }; }; declare global { interface Window { __jogo?: EstadoDoJogo; } } const MAX_PASSOS_POR_QUADRO = 5; const INTERVALO_DO_RETRATO = 100; const ESPERA_DO_FIM = 2; /** Quadros guardados para o p95 de `window.__jogo`. */ const QUADROS_DO_P95 = 120; async function carregarRoupa(nome: string): Promise { try { const textura = await new TextureLoader().loadAsync( `/jogo/roupa-${nome}.jpg`, ); // como as texturas do glTF textura.flipY = false; textura.colorSpace = SRGBColorSpace; return textura; } catch { return null; } } async function carregarBloco(): Promise { const gltf = await new GLTFLoader().loadAsync("/3d/bloco.glb"); return (gltf.scene.children[0] as Mesh).geometry; } function retratar(p: Partida, pausado: boolean): Retrato { return { fase: p.fase, horda: p.horda, problemaAtual: p.hordas[p.horda], restantes: p.fila.length + p.zumbis.filter((z) => z.estado !== "morrendo").length, vida: Math.max(0, p.jogador.vida), arma: p.jogador.arma, municao: p.jogador.municao, placar: { ...p.placar }, feed: [...p.feed], drones: p.drones.map((d) => ({ problemaId: d.problemaId, eliminacoes: d.eliminacoes, })), pausado, }; } function mudou(a: Retrato, b: Retrato): boolean { return ( a.fase !== b.fase || a.horda !== b.horda || a.restantes !== b.restantes || a.vida !== b.vida || a.arma !== b.arma || a.municao !== b.municao || a.placar.jogador !== b.placar.jogador || a.placar.drones !== b.placar.drones || a.feed.length !== b.feed.length || a.feed[a.feed.length - 1]?.passo !== b.feed[b.feed.length - 1]?.passo || a.drones.length !== b.drones.length || a.drones.some((d, i) => d.eliminacoes !== b.drones[i].eliminacoes) || a.pausado !== b.pausado ); } function p95(quadros: readonly number[]): number { if (quadros.length === 0) return 0; const ordenados = [...quadros].sort((a, b) => a - b); return ordenados[Math.ceil(0.95 * ordenados.length) - 1]; } /** * A tabela de PBR (`DFG_LUT`) que o three cria uma vez no módulo e põe nos uniforms dos materiais padrão: o renderer * a envia para a GPU mas não a solta no `dispose`. Vem de um material já desenhado; null se nenhum usou. */ function tabelaDePbr(renderer: WebGLRenderer, cena: Scene): Texture | null { let achada: Texture | null = null; cena.traverse((objeto) => { const material = (objeto as Mesh).material; if (achada || !material || Array.isArray(material)) return; const propriedades = renderer.properties.get(material) as { uniforms?: { dfgLUT?: { value: Texture | null } }; }; achada = propriedades.uniforms?.dfgLUT?.value ?? null; }); return achada; } export async function criarCena(o: OpcoesDaCena): Promise { const velocidade = o.velocidade ?? 1; const [zumbi, bloco, roupaB, roupaC] = await Promise.all([ carregarZumbi(), carregarBloco(), carregarRoupa("b"), carregarRoupa("c"), ]); const roupas = [roupaB, roupaC].filter((t): t is Texture => t !== null); function soltarCarga() { bloco.dispose(); for (const textura of roupas) textura.dispose(); } // o jogador saiu durante a carga: nada de renderer nem cena if (o.cancelada()) { soltarCarga(); throw new Error("Carga cancelada"); } let renderer: WebGLRenderer; try { renderer = new WebGLRenderer({ canvas: o.canvas, antialias: true, powerPreference: "high-performance", }); } catch (erro) { soltarCarga(); throw erro; } renderer.shadowMap.enabled = true; const p = criarPartida({ semente: o.semente, ...o.escolha }); const cena = new Scene(); const camera = new PerspectiveCamera(75, 1, 0.05, 60); camera.rotation.order = "YXZ"; cena.add(camera); const ambiente = criarAmbiente(cena, p.mapa, { camera, movimentoReduzido: o.movimentoReduzido, }); const atores = criarAtores(cena, { zumbi, bloco, roupas }); const efeitos = criarEfeitos(cena, camera, { movimentoReduzido: o.movimentoReduzido, som: o.som, }); const regulador = criarRegulador(window.devicePixelRatio || 1); let nivel = -1; function aplicarNivel(novo: number) { if (novo === nivel) return; nivel = novo; const degrau = NIVEIS[novo]; renderer.setPixelRatio( Math.min(degrau.pixelRatio, window.devicePixelRatio || 1), ); ambiente.definirSombras(degrau.sombras); p.tetoAVista = degrau.teto; } function redimensionar() { const largura = o.canvas.clientWidth || window.innerWidth; const altura = o.canvas.clientHeight || window.innerHeight; renderer.setSize(largura, altura, false); camera.aspect = largura / altura; camera.updateProjectionMatrix(); } aplicarNivel(regulador.nivel); redimensionar(); // jogador no passo anterior, para interpolar a câmera const antes = { x: p.jogador.x, z: p.jogador.z, rumo: p.jogador.rumo }; let acumulado = 0; let ultimo = -1; let relogio = 0; let pausado = false; let raf = 0; let fimEm: number | null = null; let terminou = false; let mirando = false; const quadros: number[] = []; let retrato = retratar(p, false); let ultimoRetrato = -Infinity; function publicar(agora: number, forcar: boolean) { ultimoRetrato = agora; const novo = retratar(p, pausado); if (forcar || mudou(novo, retrato)) { retrato = novo; o.aoRetrato(novo); } const { memory, render } = renderer.info; window.__jogo = { fase: p.fase, horda: p.horda, passo: p.passo, vida: p.jogador.vida, placar: { ...p.placar }, x: p.jogador.x, z: p.jogador.z, rumo: p.jogador.rumo, nivel, p95: Math.round(p95(quadros) * 10) / 10, geometrias: memory.geometries, texturas: memory.textures, chamadas: render.calls, }; } function passo() { antes.x = p.jogador.x; antes.z = p.jogador.z; antes.rumo = p.jogador.rumo; avancar(p, o.piloto ? pilotar(p) : o.controle.ler()); atores.guardarPasso(p); for (const evento of p.eventos) efeitos.aoEvento(evento, p); } function terminar() { terminou = true; o.aoTerminar({ vitoria: p.fase === "vitoria", problemaId: p.problemaId, dificuldade: p.dificuldade, resolvidos: [...p.resolvidos], placar: { ...p.placar }, drones: p.drones.map((d) => ({ problemaId: d.problemaId, eliminacoes: d.eliminacoes, })), segundos: Math.round(p.passo * PASSO), }); } function desenhar(alfa: number, dt: number) { const t = Math.min(1, alfa); camera.position.set( antes.x + (p.jogador.x - antes.x) * t, JOGADOR.olhos, antes.z + (p.jogador.z - antes.z) * t, ); camera.rotation.set( p.jogador.inclinacao, antes.rumo + (p.jogador.rumo - antes.rumo) * t + Math.PI, 0, ); atores.sincronizar(p, t, dt * velocidade); ambiente.atualizar(relogio); efeitos.avancar(dt, p); renderer.render(cena, camera); } /** Roda os passos de lógica que cabem no tempo acumulado; devolve quantos rodaram. */ function rodarPassos(): number { const maximo = MAX_PASSOS_POR_QUADRO * velocidade; let passos = 0; while (acumulado >= PASSO && passos < maximo) { passo(); acumulado -= PASSO; passos++; } // atrasou demais (aba lenta): descarta o resto em vez de correr atrás if (passos === maximo) acumulado = Math.min(acumulado, PASSO); return passos; } function conferirMira() { const novaMira = alvoNaMira(p) !== null; if (novaMira === mirando) return; mirando = novaMira; o.aoMirar(mirando); } function quadro(agora: number) { const inicio = performance.now(); raf = requestAnimationFrame(quadro); const ms = ultimo < 0 ? 0 : agora - ultimo; ultimo = agora; const dt = Math.min(ms / 1000, 0.25); relogio += dt; acumulado += dt * velocidade; const fase = p.fase; // a mira só muda quando a lógica anda if (rodarPassos() > 0) conferirMira(); if (fimEm === null && (p.fase === "vitoria" || p.fase === "derrota")) fimEm = relogio; if (!terminou && fimEm !== null && relogio - fimEm >= ESPERA_DO_FIM) terminar(); desenhar(acumulado / PASSO, dt); if (ms > 0) { aplicarNivel(amostrar(regulador, ms, performance.now() - inicio)); quadros.push(ms); if (quadros.length > QUADROS_DO_P95) quadros.shift(); } if (p.fase !== fase || agora - ultimoRetrato >= INTERVALO_DO_RETRATO) publicar(agora, p.fase !== fase); } function pausar() { if (pausado) return; pausado = true; cancelAnimationFrame(raf); o.controle.soltar(); publicar(performance.now(), true); } function continuar() { if (!pausado || semContexto) return; pausado = false; ultimo = -1; raf = requestAnimationFrame(quadro); publicar(performance.now(), true); } function aoMudarVisibilidade() { if (document.hidden) pausar(); } function aoRedimensionar() { redimensionar(); // o canvas limpa ao mudar de tamanho; parado, redesenha o mesmo quadro if (pausado) renderer.render(cena, camera); } // Contexto WebGL perdido (GPU reiniciada, aba em segundo plano no celular): pausa até ele voltar. let semContexto = false; function aoPerderContexto(evento: Event) { // sem isto o navegador não devolve o contexto evento.preventDefault(); semContexto = true; pausar(); } function aoRestaurarContexto() { semContexto = false; // o three já refez o estado dele; o quadro parado volta e "Continuar" passa a valer renderer.render(cena, camera); } window.addEventListener("resize", aoRedimensionar); document.addEventListener("visibilitychange", aoMudarVisibilidade); o.canvas.addEventListener("webglcontextlost", aoPerderContexto); o.canvas.addEventListener("webglcontextrestored", aoRestaurarContexto); publicar(performance.now(), true); raf = requestAnimationFrame(quadro); return { pausar, continuar, descartar() { cancelAnimationFrame(raf); window.removeEventListener("resize", aoRedimensionar); document.removeEventListener("visibilitychange", aoMudarVisibilidade); o.canvas.removeEventListener("webglcontextlost", aoPerderContexto); o.canvas.removeEventListener("webglcontextrestored", aoRestaurarContexto); const tabela = tabelaDePbr(renderer, cena); efeitos.descartar(); atores.descartar(); ambiente.descartar(); // a próxima partida a envia de novo, num renderer novo tabela?.dispose(); renderer.dispose(); // CA9: o que este renderer ainda conta depois de soltar tudo; fica em `window.__jogo` para o teste ler const { geometries, textures } = renderer.info.memory; if (window.__jogo) window.__jogo.sobras = { geometrias: geometries, texturas: textures }; renderer.forceContextLoss(); }, }; }