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,435 @@
|
||||
/**
|
||||
* 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user