feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s

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.
This commit is contained in:
evandro committed 2026-11-10 19:37:10 -03:00
1 parent c7d09d63d4
commit 67b32fcab3
118 files changed
+16730

No files matched your search

+458
View File
@@ -0,0 +1,458 @@
"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>
);
}