Files
portifolio-evandrodevbr/app/jogo/cena.ts
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
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.
2026-11-10 19:37:10 -03:00

436 lines
13 KiB
TypeScript

/**
* 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<Texture | null> {
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<BufferGeometry> {
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<Cena> {
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();
},
};
}