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.
436 lines
13 KiB
TypeScript
436 lines
13 KiB
TypeScript
/**
|
|
* 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();
|
|
},
|
|
};
|
|
}
|