Files
portifolio-evandrodevbr/app/jogo/Jogo.tsx
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
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.
2026-11-10 19:37:10 -03:00

326 lines
10 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import { problemaPorId } from "@/lib/jogo/problemas";
import type { Dificuldade } from "@/lib/jogo/tipos";
import type { Cena } from "./cena";
import type { Controle, Escolha, Resultado, Retrato } from "./contrato";
import { criarControle } from "./controle";
import Hud from "./Hud";
import Inicio from "./Inicio";
import Relatorio from "./Relatorio";
import { criarSom, type Som } from "./som";
import { Toque } from "./Toque";
const ERRO = "Não deu para abrir o jogo neste navegador.";
const CHAVE_MUDO = "jogo-mudo";
const DIFICULDADES: readonly Dificuldade[] = ["facil", "normal", "dificil"];
type Ambiente = {
toque: boolean;
movimentoReduzido: boolean;
semWebgl: boolean;
};
/** Parâmetros da URL, só para teste e medição. */
type Teste = {
piloto: boolean;
velocidade?: number;
semente?: number;
escolha?: Escolha;
};
/** Tudo o que a cena recebe além do canvas, do controle, do som e dos avisos. */
type Partida = {
escolha: Escolha;
semente: number;
movimentoReduzido: boolean;
piloto: boolean;
velocidade?: number;
};
type Tela =
| { etapa: "inicio"; erro?: string }
| { etapa: "carregando" | "jogando"; partida: Partida }
| { etapa: "fim"; resultado: Resultado };
function lerUrl(busca: string): Teste {
const p = new URLSearchParams(busca);
const semente = Number.parseInt(p.get("semente") ?? "", 10);
const velocidade = Number(p.get("vel"));
const problemaId = p.get("problema") ?? "";
const dificuldade = DIFICULDADES.find((d) => d === p.get("dificuldade"));
return {
piloto: p.get("piloto") === "1",
semente: Number.isFinite(semente) ? semente : undefined,
velocidade: velocidade > 0 ? velocidade : undefined,
escolha: problemaPorId(problemaId)
? { problemaId, dificuldade: dificuldade ?? "facil" }
: undefined,
};
}
function temWebgl() {
const contexto = document.createElement("canvas").getContext("webgl2");
contexto?.getExtension("WEBGL_lose_context")?.loseContext();
return contexto !== null;
}
/** Tela cheia e paisagem no toque; cada navegador aceita uma parte, então as duas falham em silêncio. */
async function ocuparTela() {
try {
await document.documentElement.requestFullscreen();
} catch {}
try {
// `lock` saiu dos tipos do DOM do TypeScript, mas existe nos navegadores de celular
const orientacao = screen.orientation as ScreenOrientation & {
lock?(tipo: "landscape"): Promise<void>;
};
await orientacao.lock?.("landscape");
} catch {}
}
export default function Jogo() {
const [tela, setTela] = useState<Tela>({ etapa: "inicio" });
const [ambiente, setAmbiente] = useState<Ambiente>({
toque: false,
movimentoReduzido: false,
semWebgl: false,
});
const [retrato, setRetrato] = useState<Retrato | null>(null);
const [mirando, setMirando] = useState(false);
const [mudo, setMudo] = useState(false);
const [virar, setVirar] = useState(false);
const canvas = useRef<HTMLCanvasElement>(null);
const som = useRef<Som | null>(null);
const cena = useRef<Cena | null>(null);
const controle = useRef<Controle | null>(null);
const teste = useRef<Teste>({ piloto: false });
const partida =
tela.etapa === "carregando" || tela.etapa === "jogando"
? tela.partida
: null;
function pausar() {
cena.current?.pausar();
controle.current?.soltar();
}
function comecar(
escolha: Escolha,
amb: Ambiente,
semente = Math.floor(Math.random() * 2 ** 32),
) {
som.current?.liberar().catch(() => {});
if (amb.toque) void ocuparTela();
setRetrato(null);
setMirando(false);
setTela({
etapa: "carregando",
partida: {
escolha,
semente,
movimentoReduzido: amb.movimentoReduzido,
piloto: teste.current.piloto,
velocidade: teste.current.velocidade,
},
});
}
// biome-ignore lint/correctness/useExhaustiveDependencies: roda só ao montar; `comecar` só lê refs e o ambiente passado
useEffect(() => {
const amb: Ambiente = {
toque: matchMedia("(pointer: coarse)").matches,
movimentoReduzido: matchMedia("(prefers-reduced-motion: reduce)").matches,
semWebgl: !temWebgl(),
};
setAmbiente(amb);
setMudo(localStorage.getItem(CHAVE_MUDO) === "1");
som.current = criarSom();
teste.current = lerUrl(window.location.search);
const { piloto, escolha, semente } = teste.current;
if (piloto && escolha) comecar(escolha, amb, semente);
return () => {
som.current?.descartar();
som.current = null;
};
}, []);
useEffect(() => {
som.current?.definirMudo(mudo);
}, [mudo]);
// Uma partida: importa a cena (que traz o three) e a cria. Sair, terminar, falhar ou desmontar descarta tudo.
// biome-ignore lint/correctness/useExhaustiveDependencies: `pausar` só lê refs; a partida é a única dependência
useEffect(() => {
const alvo = canvas.current;
if (!partida || !alvo) return;
let viva = true;
controle.current = criarControle(alvo, {
aoMudarTrava: (travado) => {
if (!travado) pausar();
},
});
(async () => {
try {
// sob demanda: a cena traz o three, que não entra no carregamento inicial da página
const { criarCena } = await import("./cena");
if (!viva || !controle.current || !som.current) return;
const nova = await criarCena({
canvas: alvo,
escolha: partida.escolha,
semente: partida.semente,
controle: controle.current,
som: som.current,
movimentoReduzido: partida.movimentoReduzido,
piloto: partida.piloto,
velocidade: partida.velocidade,
cancelada: () => !viva,
aoRetrato: (novo) => {
if (viva) setRetrato(novo);
},
aoMirar: (sim) => {
if (viva) setMirando(sim);
},
aoTerminar: (resultado) => {
if (viva) setTela({ etapa: "fim", resultado });
},
});
if (!viva) {
nova.descartar();
return;
}
cena.current = nova;
setTela({ etapa: "jogando", partida });
} catch {
if (viva) setTela({ etapa: "inicio", erro: ERRO });
}
})();
return () => {
viva = false;
cena.current?.descartar();
cena.current = null;
controle.current?.descartar();
controle.current = null;
if (document.pointerLockElement) document.exitPointerLock();
if (document.fullscreenElement) document.exitFullscreen().catch(() => {});
};
}, [partida]);
// A página não rola por baixo da camada do jogo.
const emPartida = partida !== null;
useEffect(() => {
if (!emPartida) return;
const raiz = document.documentElement;
const antes = raiz.style.overflow;
raiz.style.overflow = "hidden";
return () => {
raiz.style.overflow = antes;
};
}, [emPartida]);
// Sair da aba pausa; no toque, o aparelho em retrato também.
const jogando = tela.etapa === "jogando";
const toque = ambiente.toque;
// biome-ignore lint/correctness/useExhaustiveDependencies: `pausar` só lê refs
useEffect(() => {
if (!jogando) return;
const aoTrocarAba = () => {
if (document.hidden) pausar();
};
document.addEventListener("visibilitychange", aoTrocarAba);
const retratoTela = toque ? matchMedia("(orientation: portrait)") : null;
const aoGirar = () => {
const deitado = retratoTela?.matches ?? false;
setVirar(deitado);
if (deitado) pausar();
};
aoGirar();
retratoTela?.addEventListener("change", aoGirar);
return () => {
document.removeEventListener("visibilitychange", aoTrocarAba);
retratoTela?.removeEventListener("change", aoGirar);
setVirar(false);
};
}, [jogando, toque]);
function alternarMudo() {
const novo = !mudo;
localStorage.setItem(CHAVE_MUDO, novo ? "1" : "0");
setMudo(novo);
}
if (tela.etapa === "fim") {
const { problemaId, dificuldade } = tela.resultado;
return (
<Relatorio
resultado={tela.resultado}
aoJogarDeNovo={() => comecar({ problemaId, dificuldade }, ambiente)}
/>
);
}
if (tela.etapa === "inicio") {
return (
<>
<Inicio
aoComecar={(escolha) => comecar(escolha, ambiente)}
toque={ambiente.toque}
movimentoReduzido={ambiente.movimentoReduzido}
semWebgl={ambiente.semWebgl}
/>
{tela.erro ? (
<p
role="alert"
className="bg-[var(--ink)] px-sp-6 pb-sp-10 font-display text-[17px] font-medium leading-[1.55] text-[#f4f2ed] lg:px-sp-12"
>
<span className="ds-section-shell block">{tela.erro}</span>
</p>
) : null}
</>
);
}
return (
<div className="fixed inset-0 z-[10000] overflow-hidden bg-[var(--ink)] font-display text-[#f4f2ed]">
<canvas ref={canvas} className="block h-full w-full" />
{tela.etapa === "carregando" ? (
<output className="absolute inset-0 flex items-center justify-center text-[20px] font-medium">
Carregando o escritório…
</output>
) : null}
{jogando && toque && controle.current && retrato ? (
<Toque
controle={controle.current}
arma={retrato.arma}
municao={retrato.municao}
mirando={mirando}
/>
) : null}
{jogando && retrato ? (
<Hud
retrato={retrato}
mudo={mudo}
aoAlternarMudo={alternarMudo}
aoContinuar={() => {
// dentro do gesto: no iOS o som só volta assim depois de sair da aba
som.current?.liberar().catch(() => {});
cena.current?.continuar();
if (!toque) canvas.current?.requestPointerLock?.();
}}
aoSair={() => setTela({ etapa: "inicio" })}
/>
) : null}
{virar ? (
<p
role="alert"
className="absolute inset-0 flex items-center justify-center bg-[var(--ink)] p-sp-6 text-center text-[24px] font-semibold leading-[1.2]"
>
Vire o aparelho para jogar
</p>
) : null}
</div>
);
}