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.
This commit is contained in:
1 parent
c7d09d63d4
commit
67b32fcab3
118 files changed
+16730
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user