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.
238 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|