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

238 lines
8.3 KiB
TypeScript

"use client";
import { type CSSProperties, useEffect, useRef } from "react";
import { problemaPorId } from "@/lib/jogo/problemas";
import { ARMAS, HORDAS, JOGADOR } from "@/lib/jogo/valores";
import type { Retrato } from "./contrato";
import { TOQUE_TOPO } from "./medidas";
const AREA_TOPO = "env(safe-area-inset-top)";
const AREA_ESQUERDA = "env(safe-area-inset-left)";
const AREA_DIREITA = "env(safe-area-inset-right)";
/** Centro do aviso: 22% da altura, no terço de cima, longe da mira. */
const AVISO_CENTRO = "22%";
/** Barra do topo (botão de 44 px entre duas folgas) onde o feed começa. */
const BARRA_TOPO = `calc(max(var(--sp-2), ${AREA_TOPO}) + 44px + var(--sp-2))`;
/** No toque o feed termina acima do botão mais alto, com uma folga. */
const FEED_ALTURA_MAXIMA = `calc(100dvh - ${BARRA_TOPO} - ${TOQUE_TOPO} - var(--sp-2))`;
type Props = {
retrato: Retrato;
mudo: boolean;
aoAlternarMudo(): void;
aoContinuar(): void;
aoSair(): void;
};
const fundo =
"bg-[rgba(10,10,10,.72)] font-[family-name:var(--font-mono)] tracking-[0.02em] text-[#f4f2ed]";
const painel = `${fundo} text-[13px]`;
const botao =
"pointer-events-auto inline-flex min-h-11 items-center justify-center border border-white/40 bg-[var(--ink)] px-sp-4 font-display text-[15px] font-medium text-[#f4f2ed] hover:bg-white/15";
function Alto({ retrato }: { retrato: Retrato }) {
const problema = problemaPorId(retrato.problemaAtual)?.nome ?? "";
if (retrato.fase === "chefao") return <>Chefão · {problema}</>;
return (
<>
Horda {retrato.horda + 1} de {HORDAS.tamanhos.length} · {problema} ·
faltam {retrato.restantes}
</>
);
}
function IconeSom({ mudo }: { mudo: boolean }) {
return (
<svg
aria-hidden="true"
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 9v6h4l5 4V5L8 9H4z" />
{mudo ? (
<path d="M17 9l5 6M22 9l-5 6" />
) : (
<path d="M17 8.5a5 5 0 0 1 0 7M19.5 6a8.5 8.5 0 0 1 0 12" />
)}
</svg>
);
}
export default function Hud({
retrato,
mudo,
aoAlternarMudo,
aoContinuar,
aoSair,
}: Props) {
const continuar = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (retrato.pausado) continuar.current?.focus();
}, [retrato.pausado]);
const arma = ARMAS[retrato.arma];
const municao =
retrato.arma === 0 ? "∞" : arma.gasta ? String(retrato.municao) : null;
const resolvido =
retrato.fase === "pausa" ? problemaPorId(retrato.problemaAtual) : undefined;
const emJogo = retrato.fase === "horda" || retrato.fase === "chefao";
const vida = Math.max(0, Math.min(JOGADOR.vida, Math.round(retrato.vida)));
return (
<div className="pointer-events-none absolute inset-0 overflow-hidden font-display">
<div
aria-hidden="true"
className="absolute left-1/2 top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--slash)] ring-1 ring-black/70"
/>
<div
className="absolute flex items-start justify-between gap-sp-2"
style={{
top: `max(var(--sp-2), ${AREA_TOPO})`,
left: `max(var(--sp-2), ${AREA_ESQUERDA})`,
right: `max(var(--sp-2), ${AREA_DIREITA})`,
}}
>
<p className={`${painel} px-sp-3 py-sp-2 leading-[1.4]`}>
{emJogo ? <Alto retrato={retrato} /> : "Expediente dos Mortos"}
</p>
<div className="flex min-w-0 shrink-0 flex-col items-end gap-sp-2">
<div className="flex items-start gap-sp-2">
<p className={`${painel} px-sp-3 py-sp-2 leading-[1.4]`}>
Você {retrato.placar.jogador} · Drones {retrato.placar.drones}
</p>
<button
type="button"
onClick={aoAlternarMudo}
aria-label={mudo ? "Ligar o som" : "Desligar o som"}
className={`${botao} w-11 px-0`}
>
<IconeSom mudo={mudo} />
</button>
</div>
<ul
aria-live="polite"
aria-label="Últimas eliminações"
style={{ "--feed-max": FEED_ALTURA_MAXIMA } as CSSProperties}
className={`${resolvido ? "opacity-0" : ""} ${fundo} grid w-max max-w-[40vw] gap-sp-1 overflow-hidden px-sp-3 py-sp-2 text-[12px] leading-4 empty:p-0 [&>li]:truncate [&>li:nth-last-child(n+6)]:hidden [@media(max-height:499px)]:[&>li:nth-last-child(n+4)]:hidden [@media(pointer:coarse)]:max-h-[var(--feed-max)]`}
>
{retrato.feed.map((linha) => (
<li key={`${linha.passo}-${linha.texto}`}>{linha.texto}</li>
))}
</ul>
</div>
</div>
{retrato.drones.length > 0 ? (
<ul
aria-label="Drones aliados"
className="absolute left-sp-2 top-20 grid w-36 gap-sp-1"
>
{retrato.drones.map((drone, i) => {
const solucao = problemaPorId(drone.problemaId)?.solucao ?? "";
return (
<li
key={`${drone.problemaId}-${i}`}
className={`${painel} px-sp-2 py-sp-1 text-[12px] leading-[1.3]`}
>
<span className="block">{solucao}</span>
<span className="block">
{drone.eliminacoes}{" "}
{drone.eliminacoes === 1 ? "eliminação" : "eliminações"}
</span>
</li>
);
})}
</ul>
) : null}
<div
className={`${painel} absolute bottom-sp-2 left-sp-2 grid w-48 gap-sp-1 px-sp-3 py-sp-2`}
>
<div className="flex items-center gap-sp-2">
<span>Vida</span>
<div
role="progressbar"
aria-label="Vida"
aria-valuemin={0}
aria-valuemax={JOGADOR.vida}
aria-valuenow={vida}
className="h-2 flex-1 bg-white/25"
>
<div
className="h-full bg-[var(--slash)]"
style={{ width: `${(vida / JOGADOR.vida) * 100}%` }}
/>
</div>
<span>{vida}</span>
</div>
<p>
{arma.nome}
{municao !== null ? ` · ${municao}` : ""}
</p>
</div>
{resolvido ? (
<div
className="absolute inset-x-0 flex -translate-y-1/2 justify-center"
style={{ top: AVISO_CENTRO }}
>
<div className="w-80 max-w-[calc(100%-2rem)] bg-[var(--ink)] p-sp-4 text-[#f4f2ed] ring-1 ring-[var(--slash)] [@media(max-height:499px)]:w-56 [@media(max-height:499px)]:p-sp-3">
<p className="text-[20px] font-semibold leading-[1.15] tracking-[-0.02em] [@media(max-height:499px)]:text-[16px]">
Resolvido: {resolvido.nome}
</p>
<p className="mt-sp-2 text-[15px] leading-[1.4] text-[rgba(244,242,237,.8)] [@media(max-height:499px)]:text-[13px]">
{resolvido.solucao} entrou para o seu time
</p>
</div>
</div>
) : null}
{retrato.pausado ? (
<div
role="dialog"
aria-modal="true"
aria-labelledby="hud-pausa"
className="pointer-events-auto absolute inset-0 flex items-center justify-center bg-black/70 p-sp-4"
>
<div className="w-full max-w-md bg-[var(--ink)] p-sp-6 text-[#f4f2ed] ring-1 ring-white/25">
<h2
id="hud-pausa"
className="text-[28px] font-semibold leading-none tracking-[-0.02em]"
>
Pausado
</h2>
<div className="mt-sp-4 flex flex-wrap gap-sp-2">
<button
ref={continuar}
type="button"
onClick={aoContinuar}
className={`${botao} bg-[var(--slash)] text-[var(--ink)] hover:bg-[var(--paper)]`}
>
Continuar
</button>
<button
type="button"
onClick={aoAlternarMudo}
aria-pressed={!mudo}
className={botao}
>
{mudo ? "Som desligado" : "Som ligado"}
</button>
<button type="button" onClick={aoSair} className={botao}>
Sair
</button>
</div>
</div>
</div>
) : null}
</div>
);
}