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.
326 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|