"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; }; await orientacao.lock?.("landscape"); } catch {} } export default function Jogo() { const [tela, setTela] = useState({ etapa: "inicio" }); const [ambiente, setAmbiente] = useState({ toque: false, movimentoReduzido: false, semWebgl: false, }); const [retrato, setRetrato] = useState(null); const [mirando, setMirando] = useState(false); const [mudo, setMudo] = useState(false); const [virar, setVirar] = useState(false); const canvas = useRef(null); const som = useRef(null); const cena = useRef(null); const controle = useRef(null); const teste = useRef({ 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 ( comecar({ problemaId, dificuldade }, ambiente)} /> ); } if (tela.etapa === "inicio") { return ( <> comecar(escolha, ambiente)} toque={ambiente.toque} movimentoReduzido={ambiente.movimentoReduzido} semWebgl={ambiente.semWebgl} /> {tela.erro ? (

{tela.erro}

) : null} ); } return (
{tela.etapa === "carregando" ? ( Carregando o escritório… ) : null} {jogando && toque && controle.current && retrato ? ( ) : null} {jogando && retrato ? ( { // 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 ? (

Vire o aparelho para jogar

) : null}
); }