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,325 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user