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.
459 lines
14 KiB
TypeScript
459 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|