Files
portifolio-evandrodevbr/app/jogo/Bancada.tsx
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

459 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import type { Material, Object3D, Texture } from "three";
export type ResultadoDaBancada =
| {
p50: number;
p95: number;
max: number;
quadros: number;
chamadas: number;
triangulos: number;
heapMB: number | null;
zumbis: number;
largura: number;
altura: number;
pixelRatio: number;
gpu: string | null;
trabalhoP50: number;
trabalhoP95: number;
geometrias: number;
texturas: number;
}
| { erro: "sem-webgl" };
declare global {
interface Window {
__bancada?: ResultadoDaBancada;
}
}
type Estado =
| { fase: "carregando" }
| { fase: "medindo"; faltam: number }
| { fase: "pronto"; resultado: ResultadoDaBancada };
const AQUECIMENTO_MS = 2000;
const VERDE = "#c5f04a";
/** Velocidade do zumbi comum (m/s), a mesma da lógica do jogo. */
const PASSO = 1.2;
function umaCasa(valor: number) {
return Math.round(valor * 10) / 10;
}
/** Percentil pelo posto mais próximo, sobre a lista já ordenada. */
function percentil(ordenados: number[], p: number) {
const indice = Math.ceil((p / 100) * ordenados.length) - 1;
return ordenados[Math.min(ordenados.length - 1, Math.max(0, indice))];
}
/** Descarta geometrias, materiais e as texturas deles em tudo o que está na cena. */
function descartarCena(raiz: Object3D) {
const texturas = new Set<Texture>();
const materiais = new Set<Material>();
raiz.traverse((objeto) => {
const malha = objeto as Object3D & {
geometry?: { dispose(): void };
material?: Material | Material[];
};
malha.geometry?.dispose();
const lista = Array.isArray(malha.material)
? malha.material
: malha.material
? [malha.material]
: [];
for (const material of lista) materiais.add(material);
});
for (const material of materiais) {
for (const valor of Object.values(material))
if ((valor as Texture | null)?.isTexture) texturas.add(valor as Texture);
material.dispose();
}
for (const textura of texturas) textura.dispose();
}
/**
* Monta a sala, os zumbis e os drones em `host`, aquece 2 s, mede `seg` segundos e chama `aoTerminar`. Devolve a
* função que desfaz tudo, ou `null` se `vivo()` virou falso no meio da carga.
*/
async function rodarBancada(
host: HTMLElement,
zumbis: number,
seg: number,
vivo: () => boolean,
aoMedir: (faltam: number) => void,
aoTerminar: (resultado: ResultadoDaBancada) => void,
) {
// carga sob demanda: o three e o modelo só baixam quando a bancada monta, fora do pacote da página
const [THREE, { carregarZumbi, criarZumbi }, { GLTFLoader }] =
await Promise.all([
import("three"),
import("./zumbis3d"),
import("three/examples/jsm/loaders/GLTFLoader.js"),
]);
if (!vivo()) return null;
let renderer: InstanceType<typeof THREE.WebGLRenderer>;
const teste = document.createElement("canvas").getContext("webgl2");
if (!teste) {
aoTerminar({ erro: "sem-webgl" });
return () => {};
}
teste.getExtension("WEBGL_lose_context")?.loseContext();
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance",
});
} catch {
aoTerminar({ erro: "sem-webgl" });
return () => {};
}
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(pixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
const canvas = renderer.domElement;
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "display:block;width:100%;height:100%";
const [modelo, bloco] = await Promise.all([
carregarZumbi(),
new GLTFLoader().loadAsync("/3d/bloco.glb"),
]);
if (!vivo()) {
renderer.dispose();
renderer.forceContextLoss();
return null;
}
host.append(canvas);
const scene = new THREE.Scene();
const escuro = new THREE.Color("#0b0c0e");
scene.background = escuro;
scene.fog = new THREE.Fog(escuro, 6, 24);
// a sala: uma caixa vista por dentro, 28 × 3 × 17 m, piso no y = 0
const parede = new THREE.MeshStandardMaterial({
color: "#3a3d42",
roughness: 0.95,
side: THREE.BackSide,
});
const piso = new THREE.MeshStandardMaterial({
color: "#2a2b2d",
roughness: 1,
side: THREE.BackSide,
});
const sala = new THREE.Mesh(new THREE.BoxGeometry(28, 3, 17), [
parede,
parede,
parede,
piso,
parede,
parede,
]);
sala.position.y = 1.5;
sala.receiveShadow = true;
scene.add(sala);
scene.add(new THREE.AmbientLight("#ffffff", 0.25));
const sol = new THREE.DirectionalLight("#fff3e0", 1.6);
sol.position.set(4, 8, 3);
sol.castShadow = true;
sol.shadow.mapSize.set(1024, 1024);
Object.assign(sol.shadow.camera, {
left: -15,
right: 15,
top: 10,
bottom: -10,
near: 1,
far: 20,
});
sol.shadow.camera.updateProjectionMatrix();
scene.add(sol);
const camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1,
40,
);
camera.position.set(0, 1.6, 0);
const andantes = Array.from({ length: zumbis }, (_, i) => {
const zumbi = criarZumbi(modelo);
zumbi.raiz.traverse((objeto) => {
objeto.castShadow = true;
});
zumbi.tocar("andar");
zumbi.avancar(i * 0.37);
scene.add(zumbi.raiz);
return {
zumbi,
raio: 3 + (6 * i) / Math.max(1, zumbis - 1),
angulo: i * 2.39996,
sentido: i % 2 ? -1 : 1,
};
});
const geometriaDoBloco = (
bloco.scene.children[0] as InstanceType<typeof THREE.Mesh>
).geometry;
const verde = new THREE.MeshStandardMaterial({
color: VERDE,
roughness: 0.6,
});
const drones = Array.from({ length: 3 }, () => {
const drone = new THREE.Group();
for (let i = 0; i < 8; i += 1) {
const peca = new THREE.Mesh(geometriaDoBloco, verde);
peca.scale.setScalar(0.12);
peca.position.set(
(i & 1 ? 1 : -1) * 0.07,
(i & 2 ? 1 : -1) * 0.07,
(i & 4 ? 1 : -1) * 0.07,
);
peca.castShadow = true;
drone.add(peca);
}
scene.add(drone);
return drone;
});
const redimensionar = () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
window.addEventListener("resize", redimensionar);
const duracoes: number[] = [];
const trabalhosBrutos: number[] = [];
let inicio = -1;
let anterior = -1;
let ultimoAviso = -1;
const quadro = (agora: number) => {
const comecoDoQuadro = performance.now();
if (inicio < 0) inicio = agora;
const dt = anterior < 0 ? 0 : Math.min(0.1, (agora - anterior) / 1000);
const decorrido = agora - inicio;
if (decorrido > AQUECIMENTO_MS && anterior >= 0)
duracoes.push(agora - anterior);
anterior = agora;
const t = decorrido / 1000;
camera.rotation.y = t * 0.15;
for (const andante of andantes) {
andante.angulo += (andante.sentido * PASSO * dt) / andante.raio;
const { raiz } = andante.zumbi;
raiz.position.set(
Math.sin(andante.angulo) * andante.raio,
0,
Math.cos(andante.angulo) * andante.raio,
);
raiz.rotation.y = andante.angulo + (andante.sentido * Math.PI) / 2;
andante.zumbi.avancar(dt);
}
drones.forEach((drone, i) => {
const angulo = camera.rotation.y + Math.PI + (i - 1) * 0.5;
drone.position.set(
Math.sin(angulo) * 2.5,
2.1 + Math.sin(t * 2 + i) * 0.1,
Math.cos(angulo) * 2.5,
);
drone.rotation.y = t + i;
});
renderer.render(scene, camera);
if (decorrido > AQUECIMENTO_MS)
trabalhosBrutos.push(performance.now() - comecoDoQuadro);
const medido = Math.max(0, decorrido - AQUECIMENTO_MS) / 1000;
const faltam = Math.max(0, Math.ceil(seg - medido));
if (faltam !== ultimoAviso) {
ultimoAviso = faltam;
aoMedir(faltam);
}
if (medido < seg) return;
renderer.setAnimationLoop(null);
const ordenados = [...duracoes].sort((a, b) => a - b);
const trabalhos = [...trabalhosBrutos].sort((a, b) => a - b);
const tamanho = renderer.getSize(new THREE.Vector2());
const gl = renderer.getContext();
const depuracao = gl.getExtension("WEBGL_debug_renderer_info");
const memoria = (
performance as Performance & { memory?: { usedJSHeapSize: number } }
).memory;
aoTerminar({
p50: umaCasa(percentil(ordenados, 50)),
p95: umaCasa(percentil(ordenados, 95)),
max: umaCasa(ordenados[ordenados.length - 1] ?? 0),
quadros: duracoes.length,
chamadas: renderer.info.render.calls,
triangulos: renderer.info.render.triangles,
heapMB: memoria ? umaCasa(memoria.usedJSHeapSize / 1048576) : null,
zumbis,
largura: tamanho.x,
altura: tamanho.y,
pixelRatio,
gpu: depuracao
? String(gl.getParameter(depuracao.UNMASKED_RENDERER_WEBGL))
: null,
trabalhoP50: umaCasa(percentil(trabalhos, 50)),
trabalhoP95: umaCasa(percentil(trabalhos, 95)),
geometrias: renderer.info.memory.geometries,
texturas: renderer.info.memory.textures,
});
};
renderer.setAnimationLoop(quadro);
return () => {
renderer.setAnimationLoop(null);
window.removeEventListener("resize", redimensionar);
descartarCena(scene);
for (const andante of andantes) andante.zumbi.descartar();
sol.dispose();
renderer.dispose();
renderer.forceContextLoss();
canvas.remove();
};
}
/** A bancada de desempenho: `zumbis` e `seg` já vêm conferidos e dentro dos limites pela página. */
export default function Bancada({
zumbis,
seg,
}: {
zumbis: number;
seg: number;
}) {
const palco = useRef<HTMLDivElement>(null);
const [rodada, setRodada] = useState(0);
const [estado, setEstado] = useState<Estado>({ fase: "carregando" });
// biome-ignore lint/correctness/useExhaustiveDependencies: `rodada` só existe para medir de novo
useEffect(() => {
const host = palco.current;
if (!host) return;
let ativo = true;
let desfazer: (() => void) | null = null;
delete window.__bancada;
setEstado({ fase: "carregando" });
rodarBancada(
host,
zumbis,
seg,
() => ativo,
(faltam) => {
if (ativo) setEstado({ fase: "medindo", faltam });
},
(resultado) => {
if (!ativo) return;
window.__bancada = resultado;
setEstado({ fase: "pronto", resultado });
},
).then((parar) => {
if (ativo) desfazer = parar;
else parar?.();
});
return () => {
ativo = false;
desfazer?.();
};
}, [zumbis, seg, rodada]);
// acima do cabeçalho (z-9000) e do WhatsApp, abaixo da barra de cookies (z-9980)
return (
<div className="fixed inset-0 z-[9500] bg-[#0b0c0e] text-[#f4f2ed]">
<div ref={palco} className="absolute inset-0" />
<div className="absolute inset-x-0 top-0 p-sp-4 font-mono">
<h1 className="text-[20px] font-semibold">Bancada do jogo</h1>
{estado.fase === "carregando" && (
<p className="text-[18px]">Carregando…</p>
)}
{estado.fase === "medindo" && (
<p className="text-[18px]" aria-live="polite">
Medindo… {estado.faltam} s
</p>
)}
</div>
{estado.fase === "pronto" && (
<Resultado
resultado={estado.resultado}
seg={seg}
medirDeNovo={() => setRodada((n) => n + 1)}
/>
)}
</div>
);
}
function Resultado({
resultado,
seg,
medirDeNovo,
}: {
resultado: ResultadoDaBancada;
seg: number;
medirDeNovo: () => void;
}) {
if ("erro" in resultado)
return (
<p className="absolute inset-0 grid place-items-center p-sp-6 text-center text-[24px]">
Este navegador não desenha 3D.
</p>
);
const linhas: Array<[string, string]> = [
["p50", `${resultado.p50} ms`],
["p95", `${resultado.p95} ms`],
["máx", `${resultado.max} ms`],
["quadros", String(resultado.quadros)],
["zumbis", String(resultado.zumbis)],
["chamadas", String(resultado.chamadas)],
["triângulos", resultado.triangulos.toLocaleString("pt-BR")],
["memória", resultado.heapMB === null ? "—" : `${resultado.heapMB} MB`],
[
"tela",
`${resultado.largura}×${resultado.altura} @${resultado.pixelRatio}`,
],
["GPU", resultado.gpu ?? "—"],
["Trabalho por quadro", `p95 ${resultado.trabalhoP95} ms`],
["trabalho p50", `${resultado.trabalhoP50} ms`],
["monitor (estimado)", `${Math.round(1000 / resultado.p50)} Hz`],
];
return (
<div className="absolute inset-0 overflow-auto bg-[rgba(11,12,14,.88)] p-sp-6 pt-[72px] font-mono">
<dl className="grid grid-cols-[auto_1fr] gap-x-sp-4 gap-y-sp-2 text-[clamp(20px,5vw,32px)] leading-tight">
{linhas.map(([nome, valor]) => (
<div key={nome} className="contents">
<dt className="text-[rgba(244,242,237,.6)]">{nome}</dt>
<dd className="break-words">{valor}</dd>
</div>
))}
</dl>
<div className="mt-sp-6 flex flex-wrap gap-sp-3 text-[20px]">
<button
type="button"
onClick={medirDeNovo}
className="rounded-md bg-[#c5f04a] px-sp-4 py-sp-2 font-semibold text-[#0b0c0e]"
>
Medir de novo
</button>
{[8, 16, 24].map((n) => (
<Link
key={n}
href={`/jogo/bancada?zumbis=${n}&seg=${seg}`}
className="rounded-md border border-[#c5f04a] px-sp-4 py-sp-2 text-[#c5f04a]"
>
{n} zumbis
</Link>
))}
</div>
</div>
);
}