Files
portifolio-evandrodevbr/app/jogo/Inicio.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

290 lines
12 KiB
TypeScript

"use client";
import Image from "next/image";
import { useId, useState } from "react";
import { pageTop, primaryButton } from "@/app/components/button-styles";
import {
AREAS,
type AreaId,
problemaPorId,
problemasDaArea,
} from "@/lib/jogo/problemas";
import type { Dificuldade } from "@/lib/jogo/tipos";
import type { Escolha } from "./contrato";
type Props = {
aoComecar(escolha: Escolha): void;
toque: boolean;
movimentoReduzido: boolean;
semWebgl: boolean;
};
const ORDEM = Object.keys(AREAS) as AreaId[];
const NIVEIS: readonly { id: Dificuldade; nome: string; linha: string }[] = [
{
id: "facil",
nome: "Fácil",
linha: "Zumbis lentos, poucos e de dano baixo. Bom para a primeira vez.",
},
{
id: "normal",
nome: "Normal",
linha: "Zumbis em velocidade, quantidade e dano normais.",
},
{
id: "dificil",
nome: "Difícil",
linha: "Zumbis mais rápidos, mais numerosos e que machucam mais.",
},
];
// Botão de opção com cara de ficha: o rádio fica só para leitor de tela e teclado; o quadrado cheio marca a escolha
// sem depender só da cor.
const ficha =
"group flex min-h-11 cursor-pointer items-center gap-sp-3 border border-white/20 bg-[rgba(10,10,10,.86)] px-sp-3 py-sp-2 text-[15px] leading-[1.3] transition-colors duration-200 hover:border-white/50 has-[:checked]:border-[var(--slash)] has-[:checked]:bg-[var(--slash-dim)] has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-[var(--slash)] motion-reduce:transition-none";
function Marca() {
return (
<span
aria-hidden="true"
className="size-2.5 shrink-0 border border-white/50 transition-colors duration-200 group-has-[:checked]:border-[var(--slash)] group-has-[:checked]:bg-[var(--slash)] motion-reduce:transition-none"
/>
);
}
const rotulo = "text-[15px] font-semibold leading-[1.3]";
const secundario = "text-[rgba(244,242,237,.72)]";
export default function Inicio({
aoComecar,
toque,
movimentoReduzido,
semWebgl,
}: Props) {
const [problemaId, setProblemaId] = useState<string | null>(null);
const [dificuldade, setDificuldade] = useState<Dificuldade>("facil");
// sanfona do celular; no computador todas as áreas ficam abertas
const [abertas, setAbertas] = useState<readonly AreaId[]>([ORDEM[0]]);
const [capaPronta, setCapaPronta] = useState(false);
const base = useId();
const motivo = `${base}-motivo`;
const escolhido = problemaId ? problemaPorId(problemaId) : undefined;
const nivel = NIVEIS.find((n) => n.id === dificuldade) ?? NIVEIS[0];
return (
<section
aria-labelledby="jogo-titulo"
className={`relative isolate bg-[var(--ink)] px-sp-6 pb-sp-16 pt-[calc(var(--site-header-height)+var(--sp-12))] font-display text-[#f4f2ed] lg:px-sp-12 ${pageTop}`}
>
{/* Capa decorativa (public/jogo/capa.jpg), escurecida até --ink. Entra com fade a partir de opacidade zero, como o
pôster do hero: o título continua o maior elemento pintado primeiro (LCP), e sem o arquivo a faixa fica só escura. */}
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 -z-10 h-[340px] w-full lg:h-[520px] lg:w-[45%]"
>
<Image
src="/jogo/capa.jpg"
alt=""
fill
sizes="100vw"
loading="eager"
onLoad={() => setCapaPronta(true)}
className={`object-cover transition-opacity duration-700 ease-[var(--ease-out)] motion-reduce:transition-none ${capaPronta ? "opacity-100" : "opacity-0"}`}
/>
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(10,10,10,.55)_0%,rgba(10,10,10,.7)_45%,var(--ink)_95%)] lg:bg-[linear-gradient(180deg,rgba(10,10,10,0)_40%,var(--ink)_95%),linear-gradient(90deg,rgba(10,10,10,.2)_0%,var(--ink)_92%)]" />
</div>
<form
className="ds-section-shell grid gap-sp-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:grid-rows-[auto_1fr] lg:gap-x-sp-16 lg:gap-y-sp-8"
onSubmit={(evento) => {
evento.preventDefault();
if (problemaId) aoComecar({ problemaId, dificuldade });
}}
>
<div className="lg:col-start-1 lg:row-start-1">
<h1
id="jogo-titulo"
className="text-[clamp(40px,8vw,64px)] font-semibold leading-[0.95] tracking-[-0.04em] text-balance xl:text-[72px]"
>
Expediente dos Mortos
</h1>
<p className="mt-sp-4 text-[clamp(20px,2.6vw,26px)] font-medium leading-[1.2] tracking-[-0.02em] text-[var(--slash)]">
Os problemas da sua empresa não morrem sozinhos.
</p>
<p
className={`mt-sp-5 max-w-[34em] text-[17px] leading-[1.55] ${secundario}`}
>
Cada horda de zumbis é um problema de empresa. Quando você resolve
um, ele vira um drone aliado com o nome da solução. No fim vem o
chefão: o problema que você escolher.
</p>
</div>
<fieldset className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:pt-sp-2">
<legend className="mb-sp-4 text-[20px] font-semibold leading-[1.2] lg:mb-sp-6">
Qual problema é o chefão?
</legend>
<div className="grid lg:gap-sp-6">
{ORDEM.map((area) => {
const aberta = abertas.includes(area);
const painel = `${base}-${area}`;
return (
<fieldset
key={area}
className="min-w-0 border-t border-white/15 last:border-b lg:border-0 lg:last:border-0"
>
{/* no celular o nome da área é o botão da sanfona; a legenda fica para o leitor de tela */}
<legend
className={`max-lg:sr-only lg:mb-sp-3 ${rotulo} ${secundario}`}
>
{AREAS[area]}
</legend>
<button
type="button"
aria-expanded={aberta}
aria-controls={painel}
onClick={() =>
setAbertas((atual) =>
aberta
? atual.filter((a) => a !== area)
: [...atual, area],
)
}
className="flex min-h-13 w-full cursor-pointer items-center justify-between gap-sp-4 py-sp-3 text-left text-[17px] font-semibold leading-[1.3] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] lg:hidden"
>
{AREAS[area]}
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className={`size-4 shrink-0 ${aberta ? "rotate-180" : ""}`}
>
<path
d="M3 6l5 5 5-5"
fill="none"
stroke="currentColor"
strokeWidth="1.75"
/>
</svg>
</button>
<div
id={painel}
className={`flex flex-wrap gap-sp-2 pb-sp-5 lg:grid lg:grid-cols-2 lg:pb-0 xl:grid-cols-3 ${aberta ? "" : "max-lg:hidden"}`}
>
{problemasDaArea(area).map((problema) => (
<label key={problema.id} className={ficha}>
<input
type="radio"
name="problema"
value={problema.id}
checked={problemaId === problema.id}
onChange={() => setProblemaId(problema.id)}
className="sr-only"
/>
<Marca />
{problema.nome}
</label>
))}
</div>
</fieldset>
);
})}
</div>
</fieldset>
{/* No computador, a coluna da esquerda fica presa no topo ao rolar; no celular este invólucro some (contents)
e a barra do "Começar" vira filha direta do formulário, para grudar no pé da tela enquanto a escolha rola. */}
<div className="grid gap-sp-8 max-lg:contents lg:sticky lg:top-[calc(var(--site-header-height)+var(--sp-6))] lg:col-start-1 lg:row-start-2 lg:self-start">
<div className="grid gap-sp-6">
<fieldset className="min-w-0">
<legend className={`mb-sp-3 ${rotulo}`}>Dificuldade</legend>
<div className="grid grid-cols-3 gap-sp-2">
{NIVEIS.map((n) => (
<label key={n.id} className={`${ficha} justify-center`}>
<input
type="radio"
name="dificuldade"
value={n.id}
checked={dificuldade === n.id}
onChange={() => setDificuldade(n.id)}
aria-describedby={`${base}-${n.id}`}
className="sr-only"
/>
<Marca />
<span className="font-semibold">{n.nome}</span>
</label>
))}
</div>
{NIVEIS.map((n) => (
<p
key={n.id}
id={`${base}-${n.id}`}
hidden={dificuldade !== n.id}
className={`mt-sp-2 text-[15px] leading-[1.45] ${secundario}`}
>
{n.linha}
</p>
))}
</fieldset>
<div className="grid gap-sp-2">
<h2 className={rotulo}>Como jogar</h2>
<p
className={`max-w-[38em] text-[15px] leading-[1.5] ${secundario}`}
>
{toque
? "O polegar esquerdo anda. O direito mira. A arma atira sozinha quando a mira está no zumbi."
: "W A S D andam. O mouse mira e o clique atira. As teclas 1, 2 e 3 trocam de arma. Esc pausa."}
</p>
<p className="text-[15px] font-medium leading-[1.5]">
Tem sangue e sustos. É um jogo de tiro.
</p>
</div>
{semWebgl ? (
<p className="max-w-[34em] text-[17px] font-medium leading-[1.55]">
Este navegador não desenha 3D. Veja os problemas e as soluções
abaixo.
</p>
) : movimentoReduzido ? (
<p className="max-w-[34em] text-[15px] leading-[1.5]">
O jogo tem movimento intenso: zumbis correndo e a imagem sempre
em mudança.
</p>
) : null}
</div>
{semWebgl ? null : (
// celular: barra presa no pé (acima da barra de cookies) e com a direita livre para o botão do WhatsApp,
// que abaixo de 768 px flutua ali quando a barra de cookies está fechada
<div className="z-10 flex flex-col-reverse gap-sp-2 max-lg:sticky max-lg:bottom-[var(--cookie-bar-height,0px)] max-lg:-mx-sp-6 max-lg:border-t max-lg:border-white/15 max-lg:bg-[var(--ink)] max-lg:px-sp-6 max-lg:pt-sp-3 max-lg:pb-[max(var(--sp-3),env(safe-area-inset-bottom))] max-md:pr-[calc(var(--sp-4)+56px+var(--sp-4))] sm:flex-row-reverse sm:items-center sm:gap-sp-4 max-lg:[[data-cookie-bar=open]_&]:pr-sp-6 max-lg:[[data-cookie-bar=open]_&]:pb-sp-3 lg:flex-row">
<button
type="submit"
disabled={problemaId === null}
aria-describedby={problemaId === null ? motivo : undefined}
className={`${primaryButton} shrink-0 disabled:cursor-not-allowed disabled:bg-white/15 disabled:text-[rgba(244,242,237,.72)] disabled:before:hidden`}
>
{movimentoReduzido ? "Jogar mesmo assim" : "Começar"}
</button>
{escolhido ? (
<p className="min-w-0 flex-1 text-[15px] leading-[1.35]">
<span className={secundario}>Chefão:</span>{" "}
{`${escolhido.nome} · ${nivel.nome}`}
</p>
) : (
<p
id={motivo}
className={`min-w-0 flex-1 text-[15px] leading-[1.35] ${secundario}`}
>
Escolha o problema do chefão para começar.
</p>
)}
</div>
)}
</div>
</form>
</section>
);
}