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