feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s

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:
evandro committed 2026-11-10 19:37:10 -03:00
1 parent c7d09d63d4
commit 67b32fcab3
118 files changed
+16730

No files matched your search

+458
View File
@@ -0,0 +1,458 @@
"use client";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import type { Material, Object3D, Texture } from "three";
export type ResultadoDaBancada =
| {
p50: number;
p95: number;
max: number;
quadros: number;
chamadas: number;
triangulos: number;
heapMB: number | null;
zumbis: number;
largura: number;
altura: number;
pixelRatio: number;
gpu: string | null;
trabalhoP50: number;
trabalhoP95: number;
geometrias: number;
texturas: number;
}
| { erro: "sem-webgl" };
declare global {
interface Window {
__bancada?: ResultadoDaBancada;
}
}
type Estado =
| { fase: "carregando" }
| { fase: "medindo"; faltam: number }
| { fase: "pronto"; resultado: ResultadoDaBancada };
const AQUECIMENTO_MS = 2000;
const VERDE = "#c5f04a";
/** Velocidade do zumbi comum (m/s), a mesma da lógica do jogo. */
const PASSO = 1.2;
function umaCasa(valor: number) {
return Math.round(valor * 10) / 10;
}
/** Percentil pelo posto mais próximo, sobre a lista já ordenada. */
function percentil(ordenados: number[], p: number) {
const indice = Math.ceil((p / 100) * ordenados.length) - 1;
return ordenados[Math.min(ordenados.length - 1, Math.max(0, indice))];
}
/** Descarta geometrias, materiais e as texturas deles em tudo o que está na cena. */
function descartarCena(raiz: Object3D) {
const texturas = new Set<Texture>();
const materiais = new Set<Material>();
raiz.traverse((objeto) => {
const malha = objeto as Object3D & {
geometry?: { dispose(): void };
material?: Material | Material[];
};
malha.geometry?.dispose();
const lista = Array.isArray(malha.material)
? malha.material
: malha.material
? [malha.material]
: [];
for (const material of lista) materiais.add(material);
});
for (const material of materiais) {
for (const valor of Object.values(material))
if ((valor as Texture | null)?.isTexture) texturas.add(valor as Texture);
material.dispose();
}
for (const textura of texturas) textura.dispose();
}
/**
* Monta a sala, os zumbis e os drones em `host`, aquece 2 s, mede `seg` segundos e chama `aoTerminar`. Devolve a
* função que desfaz tudo, ou `null` se `vivo()` virou falso no meio da carga.
*/
async function rodarBancada(
host: HTMLElement,
zumbis: number,
seg: number,
vivo: () => boolean,
aoMedir: (faltam: number) => void,
aoTerminar: (resultado: ResultadoDaBancada) => void,
) {
// carga sob demanda: o three e o modelo só baixam quando a bancada monta, fora do pacote da página
const [THREE, { carregarZumbi, criarZumbi }, { GLTFLoader }] =
await Promise.all([
import("three"),
import("./zumbis3d"),
import("three/examples/jsm/loaders/GLTFLoader.js"),
]);
if (!vivo()) return null;
let renderer: InstanceType<typeof THREE.WebGLRenderer>;
const teste = document.createElement("canvas").getContext("webgl2");
if (!teste) {
aoTerminar({ erro: "sem-webgl" });
return () => {};
}
teste.getExtension("WEBGL_lose_context")?.loseContext();
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance",
});
} catch {
aoTerminar({ erro: "sem-webgl" });
return () => {};
}
const pixelRatio = Math.min(window.devicePixelRatio || 1, 2);
renderer.setPixelRatio(pixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
const canvas = renderer.domElement;
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "display:block;width:100%;height:100%";
const [modelo, bloco] = await Promise.all([
carregarZumbi(),
new GLTFLoader().loadAsync("/3d/bloco.glb"),
]);
if (!vivo()) {
renderer.dispose();
renderer.forceContextLoss();
return null;
}
host.append(canvas);
const scene = new THREE.Scene();
const escuro = new THREE.Color("#0b0c0e");
scene.background = escuro;
scene.fog = new THREE.Fog(escuro, 6, 24);
// a sala: uma caixa vista por dentro, 28 × 3 × 17 m, piso no y = 0
const parede = new THREE.MeshStandardMaterial({
color: "#3a3d42",
roughness: 0.95,
side: THREE.BackSide,
});
const piso = new THREE.MeshStandardMaterial({
color: "#2a2b2d",
roughness: 1,
side: THREE.BackSide,
});
const sala = new THREE.Mesh(new THREE.BoxGeometry(28, 3, 17), [
parede,
parede,
parede,
piso,
parede,
parede,
]);
sala.position.y = 1.5;
sala.receiveShadow = true;
scene.add(sala);
scene.add(new THREE.AmbientLight("#ffffff", 0.25));
const sol = new THREE.DirectionalLight("#fff3e0", 1.6);
sol.position.set(4, 8, 3);
sol.castShadow = true;
sol.shadow.mapSize.set(1024, 1024);
Object.assign(sol.shadow.camera, {
left: -15,
right: 15,
top: 10,
bottom: -10,
near: 1,
far: 20,
});
sol.shadow.camera.updateProjectionMatrix();
scene.add(sol);
const camera = new THREE.PerspectiveCamera(
70,
window.innerWidth / window.innerHeight,
0.1,
40,
);
camera.position.set(0, 1.6, 0);
const andantes = Array.from({ length: zumbis }, (_, i) => {
const zumbi = criarZumbi(modelo);
zumbi.raiz.traverse((objeto) => {
objeto.castShadow = true;
});
zumbi.tocar("andar");
zumbi.avancar(i * 0.37);
scene.add(zumbi.raiz);
return {
zumbi,
raio: 3 + (6 * i) / Math.max(1, zumbis - 1),
angulo: i * 2.39996,
sentido: i % 2 ? -1 : 1,
};
});
const geometriaDoBloco = (
bloco.scene.children[0] as InstanceType<typeof THREE.Mesh>
).geometry;
const verde = new THREE.MeshStandardMaterial({
color: VERDE,
roughness: 0.6,
});
const drones = Array.from({ length: 3 }, () => {
const drone = new THREE.Group();
for (let i = 0; i < 8; i += 1) {
const peca = new THREE.Mesh(geometriaDoBloco, verde);
peca.scale.setScalar(0.12);
peca.position.set(
(i & 1 ? 1 : -1) * 0.07,
(i & 2 ? 1 : -1) * 0.07,
(i & 4 ? 1 : -1) * 0.07,
);
peca.castShadow = true;
drone.add(peca);
}
scene.add(drone);
return drone;
});
const redimensionar = () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
};
window.addEventListener("resize", redimensionar);
const duracoes: number[] = [];
const trabalhosBrutos: number[] = [];
let inicio = -1;
let anterior = -1;
let ultimoAviso = -1;
const quadro = (agora: number) => {
const comecoDoQuadro = performance.now();
if (inicio < 0) inicio = agora;
const dt = anterior < 0 ? 0 : Math.min(0.1, (agora - anterior) / 1000);
const decorrido = agora - inicio;
if (decorrido > AQUECIMENTO_MS && anterior >= 0)
duracoes.push(agora - anterior);
anterior = agora;
const t = decorrido / 1000;
camera.rotation.y = t * 0.15;
for (const andante of andantes) {
andante.angulo += (andante.sentido * PASSO * dt) / andante.raio;
const { raiz } = andante.zumbi;
raiz.position.set(
Math.sin(andante.angulo) * andante.raio,
0,
Math.cos(andante.angulo) * andante.raio,
);
raiz.rotation.y = andante.angulo + (andante.sentido * Math.PI) / 2;
andante.zumbi.avancar(dt);
}
drones.forEach((drone, i) => {
const angulo = camera.rotation.y + Math.PI + (i - 1) * 0.5;
drone.position.set(
Math.sin(angulo) * 2.5,
2.1 + Math.sin(t * 2 + i) * 0.1,
Math.cos(angulo) * 2.5,
);
drone.rotation.y = t + i;
});
renderer.render(scene, camera);
if (decorrido > AQUECIMENTO_MS)
trabalhosBrutos.push(performance.now() - comecoDoQuadro);
const medido = Math.max(0, decorrido - AQUECIMENTO_MS) / 1000;
const faltam = Math.max(0, Math.ceil(seg - medido));
if (faltam !== ultimoAviso) {
ultimoAviso = faltam;
aoMedir(faltam);
}
if (medido < seg) return;
renderer.setAnimationLoop(null);
const ordenados = [...duracoes].sort((a, b) => a - b);
const trabalhos = [...trabalhosBrutos].sort((a, b) => a - b);
const tamanho = renderer.getSize(new THREE.Vector2());
const gl = renderer.getContext();
const depuracao = gl.getExtension("WEBGL_debug_renderer_info");
const memoria = (
performance as Performance & { memory?: { usedJSHeapSize: number } }
).memory;
aoTerminar({
p50: umaCasa(percentil(ordenados, 50)),
p95: umaCasa(percentil(ordenados, 95)),
max: umaCasa(ordenados[ordenados.length - 1] ?? 0),
quadros: duracoes.length,
chamadas: renderer.info.render.calls,
triangulos: renderer.info.render.triangles,
heapMB: memoria ? umaCasa(memoria.usedJSHeapSize / 1048576) : null,
zumbis,
largura: tamanho.x,
altura: tamanho.y,
pixelRatio,
gpu: depuracao
? String(gl.getParameter(depuracao.UNMASKED_RENDERER_WEBGL))
: null,
trabalhoP50: umaCasa(percentil(trabalhos, 50)),
trabalhoP95: umaCasa(percentil(trabalhos, 95)),
geometrias: renderer.info.memory.geometries,
texturas: renderer.info.memory.textures,
});
};
renderer.setAnimationLoop(quadro);
return () => {
renderer.setAnimationLoop(null);
window.removeEventListener("resize", redimensionar);
descartarCena(scene);
for (const andante of andantes) andante.zumbi.descartar();
sol.dispose();
renderer.dispose();
renderer.forceContextLoss();
canvas.remove();
};
}
/** A bancada de desempenho: `zumbis` e `seg` já vêm conferidos e dentro dos limites pela página. */
export default function Bancada({
zumbis,
seg,
}: {
zumbis: number;
seg: number;
}) {
const palco = useRef<HTMLDivElement>(null);
const [rodada, setRodada] = useState(0);
const [estado, setEstado] = useState<Estado>({ fase: "carregando" });
// biome-ignore lint/correctness/useExhaustiveDependencies: `rodada` só existe para medir de novo
useEffect(() => {
const host = palco.current;
if (!host) return;
let ativo = true;
let desfazer: (() => void) | null = null;
delete window.__bancada;
setEstado({ fase: "carregando" });
rodarBancada(
host,
zumbis,
seg,
() => ativo,
(faltam) => {
if (ativo) setEstado({ fase: "medindo", faltam });
},
(resultado) => {
if (!ativo) return;
window.__bancada = resultado;
setEstado({ fase: "pronto", resultado });
},
).then((parar) => {
if (ativo) desfazer = parar;
else parar?.();
});
return () => {
ativo = false;
desfazer?.();
};
}, [zumbis, seg, rodada]);
// acima do cabeçalho (z-9000) e do WhatsApp, abaixo da barra de cookies (z-9980)
return (
<div className="fixed inset-0 z-[9500] bg-[#0b0c0e] text-[#f4f2ed]">
<div ref={palco} className="absolute inset-0" />
<div className="absolute inset-x-0 top-0 p-sp-4 font-mono">
<h1 className="text-[20px] font-semibold">Bancada do jogo</h1>
{estado.fase === "carregando" && (
<p className="text-[18px]">Carregando…</p>
)}
{estado.fase === "medindo" && (
<p className="text-[18px]" aria-live="polite">
Medindo… {estado.faltam} s
</p>
)}
</div>
{estado.fase === "pronto" && (
<Resultado
resultado={estado.resultado}
seg={seg}
medirDeNovo={() => setRodada((n) => n + 1)}
/>
)}
</div>
);
}
function Resultado({
resultado,
seg,
medirDeNovo,
}: {
resultado: ResultadoDaBancada;
seg: number;
medirDeNovo: () => void;
}) {
if ("erro" in resultado)
return (
<p className="absolute inset-0 grid place-items-center p-sp-6 text-center text-[24px]">
Este navegador não desenha 3D.
</p>
);
const linhas: Array<[string, string]> = [
["p50", `${resultado.p50} ms`],
["p95", `${resultado.p95} ms`],
["máx", `${resultado.max} ms`],
["quadros", String(resultado.quadros)],
["zumbis", String(resultado.zumbis)],
["chamadas", String(resultado.chamadas)],
["triângulos", resultado.triangulos.toLocaleString("pt-BR")],
["memória", resultado.heapMB === null ? "—" : `${resultado.heapMB} MB`],
[
"tela",
`${resultado.largura}×${resultado.altura} @${resultado.pixelRatio}`,
],
["GPU", resultado.gpu ?? "—"],
["Trabalho por quadro", `p95 ${resultado.trabalhoP95} ms`],
["trabalho p50", `${resultado.trabalhoP50} ms`],
["monitor (estimado)", `${Math.round(1000 / resultado.p50)} Hz`],
];
return (
<div className="absolute inset-0 overflow-auto bg-[rgba(11,12,14,.88)] p-sp-6 pt-[72px] font-mono">
<dl className="grid grid-cols-[auto_1fr] gap-x-sp-4 gap-y-sp-2 text-[clamp(20px,5vw,32px)] leading-tight">
{linhas.map(([nome, valor]) => (
<div key={nome} className="contents">
<dt className="text-[rgba(244,242,237,.6)]">{nome}</dt>
<dd className="break-words">{valor}</dd>
</div>
))}
</dl>
<div className="mt-sp-6 flex flex-wrap gap-sp-3 text-[20px]">
<button
type="button"
onClick={medirDeNovo}
className="rounded-md bg-[#c5f04a] px-sp-4 py-sp-2 font-semibold text-[#0b0c0e]"
>
Medir de novo
</button>
{[8, 16, 24].map((n) => (
<Link
key={n}
href={`/jogo/bancada?zumbis=${n}&seg=${seg}`}
className="rounded-md border border-[#c5f04a] px-sp-4 py-sp-2 text-[#c5f04a]"
>
{n} zumbis
</Link>
))}
</div>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { Retrato } from "./contrato";
import Hud from "./Hud";
const base: Retrato = {
fase: "horda",
horda: 1,
problemaAtual: "site-que-ninguem-acha",
restantes: 7,
vida: 80,
arma: 0,
municao: 30,
placar: { jogador: 4, drones: 2 },
feed: [{ passo: 1, texto: "Site que aparece no Google eliminou um zumbi" }],
drones: [{ problemaId: "site-que-ninguem-acha", eliminacoes: 2 }],
pausado: false,
};
function montar(parcial: Partial<Retrato> = {}, mudo = false) {
const acoes = {
aoAlternarMudo: jest.fn(),
aoContinuar: jest.fn(),
aoSair: jest.fn(),
};
render(<Hud retrato={{ ...base, ...parcial }} mudo={mudo} {...acoes} />);
return acoes;
}
describe("Hud", () => {
it("na horda mostra horda, problema, faltam, placar, vida, arma, feed e drones", () => {
montar();
expect(
screen.getByText(
"Horda 2 de 3 · Site que ninguém acha no Google · faltam 7",
),
).toBeInTheDocument();
expect(screen.getByText("Você 4 · Drones 2")).toBeInTheDocument();
expect(screen.getByRole("progressbar", { name: "Vida" })).toHaveAttribute(
"aria-valuenow",
"80",
);
expect(screen.getByText("Pistola · ∞")).toBeInTheDocument();
const feed = screen.getByRole("list", { name: "Últimas eliminações" });
expect(feed).toHaveAttribute("aria-live", "polite");
expect(feed).toHaveTextContent("eliminou um zumbi");
expect(screen.getByText("2 eliminações")).toBeInTheDocument();
expect(screen.queryByText(/Resolvido:/)).not.toBeInTheDocument();
expect(screen.queryByText("Pausado")).not.toBeInTheDocument();
});
it("mostra a munição da automática", () => {
montar({ arma: 1, municao: 17 });
expect(screen.getByText("Automática · 17")).toBeInTheDocument();
});
it("no chefão mostra Chefão e o nome", () => {
montar({ fase: "chefao", horda: 3 });
expect(
screen.getByText("Chefão · Site que ninguém acha no Google"),
).toBeInTheDocument();
});
it("na pausa entre hordas mostra o cartão Resolvido", () => {
montar({ fase: "pausa", restantes: 0 });
expect(
screen.getByText("Resolvido: Site que ninguém acha no Google"),
).toBeInTheDocument();
expect(
screen.getByText("Site que aparece no Google entrou para o seu time"),
).toBeInTheDocument();
});
it("no cartão Resolvido o feed fica apagado para não passar por baixo dele", () => {
montar({ fase: "pausa" });
const feed = screen.getByRole("list", { name: "Últimas eliminações" });
expect(feed).toHaveClass("opacity-0");
expect(feed).toHaveTextContent("eliminou um zumbi");
});
it.each(["inicio", "vitoria", "derrota"] as const)(
"na fase %s não mostra horda nem cartão",
(fase) => {
montar({ fase });
expect(screen.queryByText(/Horda \d/)).not.toBeInTheDocument();
expect(screen.queryByText(/Resolvido:/)).not.toBeInTheDocument();
},
);
it("pausado mostra o painel com Continuar, Som e Sair", async () => {
const acoes = montar({ pausado: true });
expect(screen.getByRole("dialog", { name: "Pausado" })).toBeInTheDocument();
const continuar = screen.getByRole("button", { name: "Continuar" });
expect(continuar).toHaveFocus();
await userEvent.click(continuar);
await userEvent.click(screen.getByRole("button", { name: "Som ligado" }));
await userEvent.click(screen.getByRole("button", { name: "Sair" }));
expect(acoes.aoContinuar).toHaveBeenCalledTimes(1);
expect(acoes.aoAlternarMudo).toHaveBeenCalledTimes(1);
expect(acoes.aoSair).toHaveBeenCalledTimes(1);
});
it("o botão de mudo do canto está sempre à vista", async () => {
const acoes = montar({}, true);
await userEvent.click(screen.getByRole("button", { name: "Ligar o som" }));
expect(acoes.aoAlternarMudo).toHaveBeenCalledTimes(1);
});
it("só os botões capturam o ponteiro", () => {
const { container } = render(
<Hud
retrato={base}
mudo={false}
aoAlternarMudo={jest.fn()}
aoContinuar={jest.fn()}
aoSair={jest.fn()}
/>,
);
expect(container.firstElementChild).toHaveClass("pointer-events-none");
expect(screen.getByRole("button")).toHaveClass("pointer-events-auto");
});
});
+237
View File
@@ -0,0 +1,237 @@
"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>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PROBLEMAS } from "@/lib/jogo/problemas";
import Inicio from "./Inicio";
function montar(extra: Partial<Parameters<typeof Inicio>[0]> = {}) {
const aoComecar = jest.fn();
render(
<Inicio
aoComecar={aoComecar}
toque={false}
movimentoReduzido={false}
semWebgl={false}
{...extra}
/>,
);
return aoComecar;
}
describe("Inicio", () => {
it("mostra o nome, a frase e o aviso", () => {
montar();
expect(
screen.getByRole("heading", { level: 1, name: "Expediente dos Mortos" }),
).toBeInTheDocument();
expect(
screen.getByText("Os problemas da sua empresa não morrem sozinhos."),
).toBeInTheDocument();
expect(
screen.getByText("Tem sangue e sustos. É um jogo de tiro."),
).toBeInTheDocument();
});
it("deixa Começar desabilitado, com o motivo, até escolher o problema", async () => {
const aoComecar = montar();
const botao = screen.getByRole("button", { name: "Começar" });
expect(botao).toBeDisabled();
expect(botao).toHaveAccessibleDescription(
"Escolha o problema do chefão para começar.",
);
expect(
screen
.getAllByRole("radio", { checked: true })
.map((r) => r.getAttribute("name")),
).toEqual(["dificuldade"]);
await userEvent.click(botao);
expect(aoComecar).not.toHaveBeenCalled();
});
it("passa o problema e a dificuldade ao começar (Fácil por padrão)", async () => {
const aoComecar = montar();
const alvo = PROBLEMAS[3];
await userEvent.click(screen.getByRole("radio", { name: alvo.nome }));
await userEvent.click(screen.getByRole("button", { name: "Começar" }));
expect(aoComecar).toHaveBeenCalledWith({
problemaId: alvo.id,
dificuldade: "facil",
});
});
it("envia a dificuldade escolhida", async () => {
const aoComecar = montar();
await userEvent.click(screen.getByRole("radio", { name: /Difícil/ }));
await userEvent.click(
screen.getByRole("radio", { name: PROBLEMAS[0].nome }),
);
await userEvent.click(screen.getByRole("button", { name: "Começar" }));
expect(aoComecar).toHaveBeenCalledWith({
problemaId: PROBLEMAS[0].id,
dificuldade: "dificil",
});
});
it("explica os controles de teclado e mouse ou os de toque", () => {
const { unmount } = render(
<Inicio
aoComecar={jest.fn()}
toque={false}
movimentoReduzido={false}
semWebgl={false}
/>,
);
expect(screen.getByText(/W A S D/)).toBeInTheDocument();
unmount();
montar({ toque: true });
expect(screen.getByText(/polegar esquerdo anda/i)).toBeInTheDocument();
expect(screen.queryByText(/W A S D/)).not.toBeInTheDocument();
});
it("sem WebGL troca o botão pelo aviso", () => {
montar({ semWebgl: true });
expect(
screen.queryByRole("button", { name: /Começar|Jogar mesmo assim/ }),
).not.toBeInTheDocument();
expect(
screen.getByText(
"Este navegador não desenha 3D. Veja os problemas e as soluções abaixo.",
),
).toBeInTheDocument();
});
it("com movimento reduzido avisa e o botão vira Jogar mesmo assim", () => {
montar({ movimentoReduzido: true });
expect(screen.getByText(/movimento intenso/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Jogar mesmo assim" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Começar" }),
).not.toBeInTheDocument();
});
it("mostra o resumo da escolha ao lado do Começar", async () => {
montar();
const alvo = PROBLEMAS[2];
await userEvent.click(screen.getByRole("radio", { name: /Normal/ }));
await userEvent.click(screen.getByRole("radio", { name: alvo.nome }));
expect(
screen.getByText(`${alvo.nome} · Normal`, { exact: false }),
).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Começar" })).toBeEnabled();
});
it("dá a cada dificuldade a sua explicação", () => {
montar();
expect(
screen.getByRole("radio", { name: "Difícil" }),
).toHaveAccessibleDescription(
"Zumbis mais rápidos, mais numerosos e que machucam mais.",
);
});
it("abre e fecha as áreas da sanfona, com a primeira aberta", async () => {
montar();
const primeira = screen.getByRole("button", {
name: "Vender e ser encontrado",
});
const segunda = screen.getByRole("button", { name: "Atender" });
expect(primeira).toHaveAttribute("aria-expanded", "true");
expect(segunda).toHaveAttribute("aria-expanded", "false");
await userEvent.click(segunda);
expect(segunda).toHaveAttribute("aria-expanded", "true");
await userEvent.click(primeira);
expect(primeira).toHaveAttribute("aria-expanded", "false");
});
it("usa a capa como imagem decorativa", () => {
const { container } = render(
<Inicio
aoComecar={jest.fn()}
toque={false}
movimentoReduzido={false}
semWebgl={false}
/>,
);
const capa = container.querySelector("img");
expect(capa).toHaveAttribute("alt", "");
expect(capa?.getAttribute("src")).toContain("capa.jpg");
});
});
+289
View File
@@ -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>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PROBLEMAS } from "@/lib/jogo/problemas";
import type { Cena, OpcoesDaCena } from "./cena";
import type { Resultado, Retrato } from "./contrato";
import Jogo from "./Jogo";
// a cena e o som são escritos em paralelo: virtual, para os dublês valerem mesmo antes de os arquivos existirem
jest.mock("./cena", () => ({ criarCena: jest.fn() }), { virtual: true });
jest.mock(
"./som",
() => ({
criarSom: jest.fn(() => ({
liberar: jest.fn(() => Promise.resolve()),
tocar: jest.fn(),
definirMudo: jest.fn(),
descartar: jest.fn(),
})),
}),
{ virtual: true },
);
const { criarCena } = jest.requireMock("./cena") as {
criarCena: jest.Mock<Promise<Cena>, [OpcoesDaCena]>;
};
const { criarSom } = jest.requireMock("./som") as { criarSom: jest.Mock };
const problema = PROBLEMAS[0];
const retrato: Retrato = {
fase: "horda",
horda: 0,
problemaAtual: problema.id,
restantes: 12,
vida: 100,
arma: 0,
municao: 0,
placar: { jogador: 3, drones: 1 },
feed: [],
drones: [],
pausado: false,
};
const resultado: Resultado = {
vitoria: true,
problemaId: problema.id,
dificuldade: "facil",
resolvidos: [problema.id],
placar: { jogador: 40, drones: 9 },
drones: [],
segundos: 300,
};
let cenas: { pausar: jest.Mock; continuar: jest.Mock; descartar: jest.Mock }[];
let midias: Record<string, boolean>;
function opcoes(i = 0) {
return criarCena.mock.calls[i][0];
}
function som() {
return criarSom.mock.results[0].value;
}
async function comecar() {
const usuario = userEvent.setup();
await usuario.click(screen.getByRole("radio", { name: problema.nome }));
await usuario.click(screen.getByRole("button", { name: "Começar" }));
await waitFor(() => expect(criarCena).toHaveBeenCalled());
return usuario;
}
beforeEach(() => {
cenas = [];
midias = {};
criarCena.mockReset().mockImplementation(async () => {
const cena = {
pausar: jest.fn(),
continuar: jest.fn(),
descartar: jest.fn(),
};
cenas.push(cena);
return cena;
});
criarSom.mockClear();
window.matchMedia = jest.fn((query: string) => ({
matches: midias[query] ?? false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
})) as unknown as typeof window.matchMedia;
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockReturnValue({ getExtension: () => null } as never);
localStorage.clear();
window.history.replaceState(null, "", "/jogo");
});
afterEach(() => jest.restoreAllMocks());
describe("Jogo", () => {
it("começa pela tela inicial, sem carregar a cena", () => {
render(<Jogo />);
expect(
screen.getByRole("heading", { level: 1, name: "Expediente dos Mortos" }),
).toBeInTheDocument();
expect(criarCena).not.toHaveBeenCalled();
});
it("ao começar libera o som e cria a cena com a escolha", async () => {
render(<Jogo />);
await comecar();
expect(som().liberar).toHaveBeenCalled();
expect(opcoes()).toMatchObject({
escolha: { problemaId: problema.id, dificuldade: "facil" },
piloto: false,
movimentoReduzido: false,
});
expect(opcoes().canvas).toBeInstanceOf(HTMLCanvasElement);
expect(document.documentElement.style.overflow).toBe("hidden");
});
it("mostra no HUD o retrato que a cena manda", async () => {
render(<Jogo />);
await comecar();
act(() => opcoes().aoRetrato(retrato));
expect(await screen.findByText("Você 3 · Drones 1")).toBeInTheDocument();
});
it("ao terminar mostra o relatório, descarta a cena e destrava a rolagem", async () => {
render(<Jogo />);
await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
act(() => opcoes().aoTerminar(resultado));
expect(
screen.getByRole("heading", { level: 1, name: "Problemas resolvidos" }),
).toBeInTheDocument();
expect(cenas[0].descartar).toHaveBeenCalled();
expect(document.documentElement.style.overflow).toBe("");
});
it("jogar de novo cria outra cena com a mesma escolha e outra semente", async () => {
render(<Jogo />);
const usuario = await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
act(() => opcoes().aoTerminar(resultado));
await usuario.click(screen.getByRole("button", { name: "Jogar de novo" }));
await waitFor(() => expect(criarCena).toHaveBeenCalledTimes(2));
expect(opcoes(1).escolha).toEqual(opcoes(0).escolha);
expect(opcoes(1).semente).not.toBe(opcoes(0).semente);
});
it("ao desmontar descarta a cena e o som", async () => {
const { unmount } = render(<Jogo />);
await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
unmount();
expect(cenas[0].descartar).toHaveBeenCalled();
expect(som().descartar).toHaveBeenCalled();
expect(document.documentElement.style.overflow).toBe("");
});
it("se a cena falhar, volta ao início com a mensagem", async () => {
criarCena.mockRejectedValueOnce(new Error("sem webgl2"));
render(<Jogo />);
await comecar();
expect(await screen.findByRole("alert")).toHaveTextContent(
"Não deu para abrir o jogo neste navegador.",
);
expect(
screen.getByRole("heading", { level: 1, name: "Expediente dos Mortos" }),
).toBeInTheDocument();
});
it("com piloto=1 e um problema válido pula o início", async () => {
window.history.replaceState(
null,
"",
`/jogo?piloto=1&semente=7&problema=${problema.id}&dificuldade=normal&vel=8`,
);
render(<Jogo />);
await waitFor(() => expect(criarCena).toHaveBeenCalled());
expect(opcoes()).toMatchObject({
escolha: { problemaId: problema.id, dificuldade: "normal" },
semente: 7,
piloto: true,
velocidade: 8,
});
expect(
screen.queryByRole("heading", { name: "Expediente dos Mortos" }),
).not.toBeInTheDocument();
});
it("no toque, em retrato, pede para virar o aparelho e pausa", async () => {
midias["(pointer: coarse)"] = true;
midias["(orientation: portrait)"] = true;
render(<Jogo />);
await comecar();
expect(
await screen.findByText("Vire o aparelho para jogar"),
).toBeInTheDocument();
expect(cenas[0].pausar).toHaveBeenCalled();
});
it("continuar depois da pausa libera o som de novo, no mesmo clique", async () => {
render(<Jogo />);
const usuario = await comecar();
await waitFor(() => expect(cenas).toHaveLength(1));
act(() => opcoes().aoRetrato({ ...retrato, pausado: true }));
som().liberar.mockClear();
await usuario.click(
await screen.findByRole("button", { name: "Continuar" }),
);
expect(som().liberar).toHaveBeenCalledTimes(1);
expect(cenas[0].continuar).toHaveBeenCalled();
});
it("a cena sabe que foi cancelada quando o jogador sai durante a carga", async () => {
const { unmount } = render(<Jogo />);
await comecar();
expect(opcoes().cancelada()).toBe(false);
unmount();
expect(opcoes().cancelada()).toBe(true);
});
});
+325
View File
@@ -0,0 +1,325 @@
"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<void>;
};
await orientacao.lock?.("landscape");
} catch {}
}
export default function Jogo() {
const [tela, setTela] = useState<Tela>({ etapa: "inicio" });
const [ambiente, setAmbiente] = useState<Ambiente>({
toque: false,
movimentoReduzido: false,
semWebgl: false,
});
const [retrato, setRetrato] = useState<Retrato | null>(null);
const [mirando, setMirando] = useState(false);
const [mudo, setMudo] = useState(false);
const [virar, setVirar] = useState(false);
const canvas = useRef<HTMLCanvasElement>(null);
const som = useRef<Som | null>(null);
const cena = useRef<Cena | null>(null);
const controle = useRef<Controle | null>(null);
const teste = useRef<Teste>({ 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 (
<Relatorio
resultado={tela.resultado}
aoJogarDeNovo={() => comecar({ problemaId, dificuldade }, ambiente)}
/>
);
}
if (tela.etapa === "inicio") {
return (
<>
<Inicio
aoComecar={(escolha) => comecar(escolha, ambiente)}
toque={ambiente.toque}
movimentoReduzido={ambiente.movimentoReduzido}
semWebgl={ambiente.semWebgl}
/>
{tela.erro ? (
<p
role="alert"
className="bg-[var(--ink)] px-sp-6 pb-sp-10 font-display text-[17px] font-medium leading-[1.55] text-[#f4f2ed] lg:px-sp-12"
>
<span className="ds-section-shell block">{tela.erro}</span>
</p>
) : null}
</>
);
}
return (
<div className="fixed inset-0 z-[10000] overflow-hidden bg-[var(--ink)] font-display text-[#f4f2ed]">
<canvas ref={canvas} className="block h-full w-full" />
{tela.etapa === "carregando" ? (
<output className="absolute inset-0 flex items-center justify-center text-[20px] font-medium">
Carregando o escritório…
</output>
) : null}
{jogando && toque && controle.current && retrato ? (
<Toque
controle={controle.current}
arma={retrato.arma}
municao={retrato.municao}
mirando={mirando}
/>
) : null}
{jogando && retrato ? (
<Hud
retrato={retrato}
mudo={mudo}
aoAlternarMudo={alternarMudo}
aoContinuar={() => {
// 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 ? (
<p
role="alert"
className="absolute inset-0 flex items-center justify-center bg-[var(--ink)] p-sp-6 text-center text-[24px] font-semibold leading-[1.2]"
>
Vire o aparelho para jogar
</p>
) : null}
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { render, screen, within } from "@testing-library/react";
import { AREAS, PROBLEMAS } from "@/lib/jogo/problemas";
import ListaDeProblemas from "./ListaDeProblemas";
describe("ListaDeProblemas", () => {
it("mostra os 27 problemas, cada um com a solução como link para o destino", () => {
render(<ListaDeProblemas />);
expect(PROBLEMAS).toHaveLength(27);
expect(screen.getAllByRole("listitem")).toHaveLength(27);
for (const problema of PROBLEMAS) {
expect(screen.getByText(problema.nome)).toBeInTheDocument();
const link = screen
.getAllByRole("link", { name: problema.solucao })
.find((a) => a.getAttribute("href") === problema.destino);
expect(link).toBeDefined();
}
});
it("agrupa pelas áreas", () => {
render(<ListaDeProblemas />);
for (const nome of Object.values(AREAS)) {
expect(
screen.getByRole("heading", { level: 3, name: nome }),
).toBeInTheDocument();
}
const primeira = screen.getByRole("heading", {
level: 3,
name: AREAS.vender,
}).parentElement as HTMLElement;
expect(within(primeira).getAllByRole("listitem").length).toBeGreaterThan(0);
});
});
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { textLinkDark } from "@/app/components/button-styles";
import { AREAS, type AreaId, problemasDaArea } from "@/lib/jogo/problemas";
const ORDEM = Object.keys(AREAS) as AreaId[];
/** Os 27 problemas e as soluções, em texto. É o que vê quem está sem JavaScript, sem WebGL ou com movimento reduzido. */
export default function ListaDeProblemas() {
return (
<section
aria-labelledby="lista-de-problemas"
className="bg-[var(--ink)] px-sp-6 py-sp-16 font-display text-[#f4f2ed] lg:px-sp-12"
>
<div className="ds-section-shell">
<h2
id="lista-de-problemas"
className="text-[clamp(24px,3.4vw,34px)] font-semibold leading-[1.15] tracking-[-0.02em]"
>
Os problemas e as soluções
</h2>
<div className="mt-sp-8 grid gap-sp-10 md:grid-cols-2">
{ORDEM.map((area) => (
<div key={area}>
<h3 className="text-[20px] font-semibold leading-[1.2] tracking-[-0.01em]">
{AREAS[area]}
</h3>
<ul className="mt-sp-3 grid gap-sp-3">
{problemasDaArea(area).map((problema) => (
<li key={problema.id} className="text-[16px] leading-[1.5]">
<span className="text-[rgba(244,242,237,.72)]">
{problema.nome}
</span>
{" → "}
<Link href={problema.destino} className={textLinkDark}>
{problema.solucao}
</Link>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</section>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { PROBLEMAS, problemaPorId } from "@/lib/jogo/problemas";
import type { Resultado } from "./contrato";
import Relatorio from "./Relatorio";
const [a, b, c] = PROBLEMAS;
const base: Resultado = {
vitoria: true,
problemaId: c.id,
dificuldade: "facil",
resolvidos: [a.id, b.id, c.id],
placar: { jogador: 9, drones: 5 },
drones: [],
segundos: 300,
};
describe("Relatorio", () => {
it("na vitória mostra o título, o placar e foca o título", () => {
render(<Relatorio resultado={base} aoJogarDeNovo={jest.fn()} />);
const titulo = screen.getByRole("heading", {
level: 1,
name: "Problemas resolvidos",
});
expect(titulo).toHaveFocus();
expect(screen.getByText("Você 9 · Drones 5")).toBeInTheDocument();
});
it("na derrota mostra o outro título", () => {
render(
<Relatorio
resultado={{ ...base, vitoria: false }}
aoJogarDeNovo={jest.fn()}
/>,
);
expect(
screen.getByRole("heading", {
level: 1,
name: "Os problemas venceram desta vez",
}),
).toBeInTheDocument();
});
it("cada cartão aponta para o destino do problema", () => {
render(<Relatorio resultado={base} aoJogarDeNovo={jest.fn()} />);
const links = screen.getAllByRole("link", {
name: /Ver como eu resolvo isso/,
});
expect(links).toHaveLength(3);
for (const [i, id] of base.resolvidos.entries()) {
expect(links[i]).toHaveAttribute("href", problemaPorId(id)?.destino);
}
});
it("o WhatsApp cita o problema escolhido, mesmo sem ter sido resolvido", () => {
render(
<Relatorio
resultado={{ ...base, vitoria: false, resolvidos: [a.id] }}
aoJogarDeNovo={jest.fn()}
/>,
);
const link = screen.getByRole("link", { name: "Chamar no WhatsApp" });
const href = link.getAttribute("href") ?? "";
expect(href).toMatch(/^https:\/\/wa\.me\/\d+\?text=/);
expect(href).toContain(encodeURIComponent(c.nome));
expect(
screen.getAllByRole("link", { name: /Ver como eu resolvo isso/ }),
).toHaveLength(1);
});
it("Jogar de novo chama a ação", async () => {
const aoJogarDeNovo = jest.fn();
render(<Relatorio resultado={base} aoJogarDeNovo={aoJogarDeNovo} />);
await userEvent.click(
screen.getByRole("button", { name: "Jogar de novo" }),
);
expect(aoJogarDeNovo).toHaveBeenCalledTimes(1);
});
});
+118
View File
@@ -0,0 +1,118 @@
"use client";
import Link from "next/link";
import { useEffect, useRef } from "react";
import {
pageTop,
primaryButton,
secondaryButtonDark,
textLinkDark,
} from "@/app/components/button-styles";
import { problemaPorId } from "@/lib/jogo/problemas";
import { buildWhatsAppUrl } from "@/lib/whatsapp";
import type { Resultado } from "./contrato";
type Props = {
resultado: Resultado;
aoJogarDeNovo(): void;
};
export default function Relatorio({ resultado, aoJogarDeNovo }: Props) {
const titulo = useRef<HTMLHeadingElement>(null);
useEffect(() => {
titulo.current?.focus();
}, []);
const escolhido = problemaPorId(resultado.problemaId);
const resolvidos = resultado.resolvidos.flatMap((id) => {
const problema = problemaPorId(id);
return problema ? [problema] : [];
});
const mensagem = escolhido
? `Oi, Evandro. Joguei o Expediente dos Mortos e o meu problema é: ${escolhido.nome}. Podemos conversar?`
: "";
return (
<section
aria-labelledby="relatorio-titulo"
className={`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}`}
>
<div className="ds-section-shell">
<h1
id="relatorio-titulo"
ref={titulo}
tabIndex={-1}
className="text-[clamp(32px,6vw,64px)] font-semibold leading-none tracking-[-0.04em] outline-none"
>
{resultado.vitoria
? "Problemas resolvidos"
: "Os problemas venceram desta vez"}
</h1>
<p className="mt-sp-6 text-[20px] font-medium leading-[1.3]">
Você {resultado.placar.jogador} · Drones {resultado.placar.drones}
</p>
{resolvidos.length > 0 ? (
<div className="mt-sp-10">
<h2 className="text-[24px] font-semibold leading-[1.15] tracking-[-0.02em]">
O que você resolveu
</h2>
<ul className="mt-sp-4 grid gap-sp-4 md:grid-cols-2">
{resolvidos.map((problema) => (
<li key={problema.id} className="border border-white/25 p-sp-4">
<h3 className="text-[18px] font-semibold leading-[1.25]">
{problema.nome}
</h3>
<p className="mt-sp-1 text-[16px] leading-[1.5] text-[rgba(244,242,237,.72)]">
{problema.solucao}
</p>
<Link
href={problema.destino}
className={`${textLinkDark} mt-sp-2`}
>
Ver como eu resolvo isso
<span className="sr-only">: {problema.nome}</span>
</Link>
</li>
))}
</ul>
</div>
) : null}
{escolhido ? (
<div className="mt-sp-10 max-w-[44rem] border border-[var(--slash)] p-sp-6">
<h2 className="text-[24px] font-semibold leading-[1.15] tracking-[-0.02em]">
O chefão era o seu problema
</h2>
<p className="mt-sp-3 text-[18px] font-medium leading-[1.4]">
{escolhido.nome}
</p>
<p className="mt-sp-1 text-[16px] leading-[1.5] text-[rgba(244,242,237,.72)]">
{escolhido.solucao}
</p>
<div className="mt-sp-6">
<a
href={buildWhatsAppUrl(mensagem)}
target="_blank"
rel="noopener noreferrer"
className={`${primaryButton} !bg-[var(--whatsapp)] rounded-full`}
>
Chamar no WhatsApp
</a>
</div>
</div>
) : null}
<div className="mt-sp-10">
<button
type="button"
onClick={aoJogarDeNovo}
className={secondaryButtonDark}
>
Jogar de novo
</button>
</div>
</div>
</section>
);
}
+251
View File
@@ -0,0 +1,251 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ArmaId } from "@/lib/jogo/tipos";
import type { Controle } from "./contrato";
import { Toque } from "./Toque";
function controleFalso() {
return {
ler: jest.fn(),
definirMover: jest.fn(),
somarOlhar: jest.fn(),
definirAtirar: jest.fn(),
definirArma: jest.fn(),
soltar: jest.fn(),
descartar: jest.fn(),
} satisfies Controle;
}
function montar(
c: Controle,
extra: { arma?: ArmaId; municao?: number; mirando?: boolean } = {},
) {
const props = { arma: 0 as ArmaId, municao: 30, mirando: false, ...extra };
const vista = render(<Toque controle={c} {...props} />);
return {
...vista,
camada: vista.container.firstElementChild as HTMLElement,
de: (novas: Partial<typeof props>) =>
vista.rerender(<Toque controle={c} {...props} {...novas} />),
};
}
// a tela do jsdom tem 1024 px de largura: a metade esquerda vai até 511
const ESQUERDA = 100;
const DIREITA = 800;
describe("Toque: direcional", () => {
it("aparece onde o dedo encosta, move e some ao soltar", () => {
const c = controleFalso();
const { camada } = montar(c);
expect(screen.queryByTestId("direcional")).toBeNull();
fireEvent.pointerDown(camada, {
pointerId: 1,
clientX: ESQUERDA,
clientY: 300,
});
expect(screen.getByTestId("direcional")).toHaveStyle({
left: "100px",
top: "300px",
});
fireEvent.pointerMove(camada, {
pointerId: 1,
clientX: ESQUERDA + 28,
clientY: 300 - 28,
});
const [direita, frente] = c.definirMover.mock.calls.at(-1) ?? [];
expect(direita).toBeCloseTo(0.5);
expect(frente).toBeCloseTo(0.5);
fireEvent.pointerUp(camada, { pointerId: 1 });
expect(c.definirMover).toHaveBeenLastCalledWith(0, 0);
expect(screen.queryByTestId("direcional")).toBeNull();
});
it("um segundo dedo na metade esquerda não rouba o direcional", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 100, clientY: 300 });
fireEvent.pointerDown(camada, { pointerId: 2, clientX: 200, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 2, clientX: 260, clientY: 200 });
expect(c.definirMover).not.toHaveBeenCalled();
});
it("cancelar o toque também solta o direcional", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 100, clientY: 300 });
fireEvent.pointerCancel(camada, { pointerId: 1 });
expect(c.definirMover).toHaveBeenLastCalledWith(0, 0);
});
});
describe("Toque: mira", () => {
it("arrastar na metade direita soma o giro", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, {
pointerId: 1,
clientX: DIREITA,
clientY: 200,
});
fireEvent.pointerMove(camada, {
pointerId: 1,
clientX: DIREITA + 40,
clientY: 210,
});
fireEvent.pointerMove(camada, {
pointerId: 1,
clientX: DIREITA + 60,
clientY: 210,
});
expect(c.somarOlhar).toHaveBeenCalledTimes(2);
const [a, b] = c.somarOlhar.mock.calls;
expect(a[0]).toBeCloseTo(-0.2);
expect(a[1]).toBeCloseTo(-0.05);
expect(b[0]).toBeCloseTo(-0.1);
expect(b[1]).toBeCloseTo(0);
expect(c.definirMover).not.toHaveBeenCalled();
});
it("depois de soltar, mover o ponteiro não gira mais", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 800, clientY: 200 });
fireEvent.pointerUp(camada, { pointerId: 1 });
fireEvent.pointerMove(camada, { pointerId: 1, clientX: 900, clientY: 200 });
expect(c.somarOlhar).not.toHaveBeenCalled();
});
it("dois dedos ao mesmo tempo: um anda e o outro mira", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 100, clientY: 300 });
fireEvent.pointerDown(camada, { pointerId: 2, clientX: 800, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 2, clientX: 810, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 1, clientX: 100, clientY: 250 });
expect(c.somarOlhar).toHaveBeenCalledTimes(1);
expect(c.definirMover).toHaveBeenCalledTimes(1);
expect(c.definirMover.mock.calls[0][1]).toBeCloseTo(0.89, 1);
});
it("um segundo dedo na metade direita não vira outra mira", () => {
const c = controleFalso();
const { camada } = montar(c);
fireEvent.pointerDown(camada, { pointerId: 1, clientX: 800, clientY: 200 });
fireEvent.pointerDown(camada, { pointerId: 2, clientX: 900, clientY: 200 });
fireEvent.pointerMove(camada, { pointerId: 2, clientX: 950, clientY: 200 });
expect(c.somarOlhar).not.toHaveBeenCalled();
});
});
describe("Toque: botão Atirar", () => {
it("segurar atira e soltar para", () => {
const c = controleFalso();
montar(c);
c.definirAtirar.mockClear();
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
fireEvent.pointerUp(botao, { pointerId: 1 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
it("arrastar o dedo sobre o botão também mira", () => {
const c = controleFalso();
montar(c);
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
fireEvent.pointerMove(botao, { pointerId: 1, clientX: 920, clientY: 330 });
const [rumo, inclinacao] = c.somarOlhar.mock.calls[0];
expect(rumo).toBeCloseTo(-0.1);
expect(inclinacao).toBeCloseTo(0.1);
});
it("tocar no botão não liga o direcional nem a mira da camada", () => {
const c = controleFalso();
const { camada } = montar(c);
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 100, clientY: 350 });
expect(screen.queryByTestId("direcional")).toBeNull();
fireEvent.pointerUp(camada, { pointerId: 1 });
expect(c.definirMover).not.toHaveBeenCalled();
});
});
describe("Toque: armas", () => {
it("mostra as três armas, com as balas da automática e a escolhida marcada", () => {
montar(controleFalso(), { arma: 1, municao: 42 });
expect(screen.getByRole("button", { name: "Pistola" })).toHaveAttribute(
"aria-pressed",
"false",
);
expect(
screen.getByRole("button", { name: "Automática 42" }),
).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Faca" })).toHaveAttribute(
"aria-pressed",
"false",
);
});
it("tocar numa arma a escolhe, sem mirar nem mover", () => {
const c = controleFalso();
montar(c);
const faca = screen.getByRole("button", { name: "Faca" });
fireEvent.pointerDown(faca, { pointerId: 1, clientX: 700, clientY: 300 });
fireEvent.click(faca);
fireEvent.pointerUp(faca, { pointerId: 1 });
expect(c.definirArma).toHaveBeenCalledWith(2);
expect(c.somarOlhar).not.toHaveBeenCalled();
});
});
describe("Toque: tiro automático", () => {
it("liga enquanto mira e desliga ao deixar de mirar", () => {
const c = controleFalso();
const { de } = montar(c, { mirando: true });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
de({ mirando: false });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
it("não solta o tiro se o botão Atirar está apertado", () => {
const c = controleFalso();
const { de } = montar(c, { mirando: true });
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
de({ mirando: false });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
fireEvent.pointerUp(botao, { pointerId: 1 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
it("soltar o botão mantém o tiro se ainda está mirando", () => {
const c = controleFalso();
montar(c, { mirando: true });
const botao = screen.getByRole("button", { name: "Atirar" });
fireEvent.pointerDown(botao, { pointerId: 1, clientX: 900, clientY: 350 });
fireEvent.pointerUp(botao, { pointerId: 1 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
});
it("não atira sozinho com a faca", () => {
const c = controleFalso();
const { de } = montar(c, { arma: 2, mirando: true });
expect(c.definirAtirar).not.toHaveBeenCalledWith(true);
de({ arma: 0 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(true);
de({ arma: 2 });
expect(c.definirAtirar).toHaveBeenLastCalledWith(false);
});
});
describe("Toque: camada", () => {
it("cobre a tela sem rolagem por gesto e bloqueia o menu de contexto", () => {
const { camada } = montar(controleFalso());
expect(camada.style.touchAction).toBe("none");
expect(fireEvent.contextMenu(camada)).toBe(false);
});
});
+169
View File
@@ -0,0 +1,169 @@
"use client";
import { type PointerEvent, useEffect, useRef, useState } from "react";
import { alavanca } from "@/lib/jogo/alavanca";
import type { ArmaId } from "@/lib/jogo/tipos";
import type { Controle } from "./contrato";
import {
TOQUE_ATIRAR,
TOQUE_BOTAO,
TOQUE_DIREITA,
TOQUE_EMBAIXO,
TOQUE_ESPACO,
} from "./medidas";
const RAIO = 56;
const MIRA = 0.005;
const FACA: ArmaId = 2;
// right/bottom e tamanhos dos botões vêm de ./medidas, que o Hud também lê.
/** "tiro" é o dedo no botão Atirar: segura o tiro e também mira. */
type Dedo = { papel: "mover" | "mirar" | "tiro"; x: number; y: number };
type Direcional = { x: number; y: number; kx: number; ky: number };
const BOTAO =
"pointer-events-auto flex min-w-14 flex-col items-center justify-center border bg-[color:var(--ink)]/30 px-3 font-[family-name:var(--font-mono)] text-[13px] leading-tight tracking-[0.02em] text-[color:var(--paper)] select-none";
export function Toque({
controle,
arma,
municao,
mirando,
}: {
controle: Controle;
arma: ArmaId;
municao: number;
mirando: boolean;
}) {
const dedos = useRef(new Map<number, Dedo>());
const apertado = useRef(false);
const automatico = useRef(false);
const [direcional, setDirecional] = useState<Direcional | null>(null);
useEffect(() => {
automatico.current = mirando && arma !== FACA;
controle.definirAtirar(apertado.current || automatico.current);
}, [controle, mirando, arma]);
function aoApertar(e: PointerEvent<HTMLDivElement>) {
const papel = (e.target as HTMLElement).closest<HTMLElement>("[data-papel]")
?.dataset.papel;
if (papel === "arma") return;
const { clientX: x, clientY: y, pointerId } = e;
if (papel === "atirar") {
apertado.current = true;
controle.definirAtirar(true);
dedos.current.set(pointerId, { papel: "tiro", x, y });
return;
}
const lado = x < window.innerWidth / 2 ? "mover" : "mirar";
const ocupado = [...dedos.current.values()].some((d) => d.papel === lado);
if (ocupado) return;
dedos.current.set(pointerId, { papel: lado, x, y });
if (lado === "mover") setDirecional({ x, y, kx: 0, ky: 0 });
}
function aoMover(e: PointerEvent<HTMLDivElement>) {
const dedo = dedos.current.get(e.pointerId);
if (!dedo) return;
const dx = e.clientX - dedo.x;
const dy = e.clientY - dedo.y;
if (dedo.papel !== "mover") {
controle.somarOlhar(-dx * MIRA, -dy * MIRA);
dedo.x = e.clientX;
dedo.y = e.clientY;
return;
}
const [direita, frente] = alavanca(dx, dy, RAIO);
controle.definirMover(direita, frente);
setDirecional({
x: dedo.x,
y: dedo.y,
kx: direita * RAIO,
ky: -frente * RAIO,
});
}
function aoSoltar(e: PointerEvent<HTMLDivElement>) {
const dedo = dedos.current.get(e.pointerId);
if (!dedo) return;
dedos.current.delete(e.pointerId);
if (dedo.papel === "tiro") {
apertado.current = false;
controle.definirAtirar(automatico.current);
}
if (dedo.papel === "mover") {
controle.definirMover(0, 0);
setDirecional(null);
}
}
const armas: { id: ArmaId; nome: string }[] = [
{ id: 0, nome: "Pistola" },
{ id: 1, nome: "Automática" },
{ id: 2, nome: "Faca" },
];
return (
<div
role="application"
aria-label="Controles de toque"
className="absolute inset-0 select-none"
style={{ touchAction: "none" }}
onPointerDown={aoApertar}
onPointerMove={aoMover}
onPointerUp={aoSoltar}
onPointerCancel={aoSoltar}
onContextMenu={(e) => e.preventDefault()}
>
{direcional && (
<div
data-testid="direcional"
className="pointer-events-none absolute size-28 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-[color:var(--slash)]/50"
style={{ left: direcional.x, top: direcional.y }}
>
<div
className="absolute top-1/2 left-1/2 size-12 rounded-full bg-[color:var(--slash)]/40"
style={{
transform: `translate(calc(-50% + ${direcional.kx}px), calc(-50% + ${direcional.ky}px))`,
}}
/>
</div>
)}
<div
className="pointer-events-none absolute flex flex-col items-end"
style={{
right: TOQUE_DIREITA,
bottom: TOQUE_EMBAIXO,
gap: TOQUE_ESPACO,
}}
>
<div className="flex" style={{ gap: TOQUE_ESPACO }}>
{armas.map(({ id, nome }) => (
<button
key={id}
type="button"
data-papel="arma"
aria-pressed={arma === id}
onClick={() => controle.definirArma(id)}
style={{ minHeight: TOQUE_BOTAO }}
className={`${BOTAO} ${arma === id ? "border-[color:var(--slash)] bg-[color:var(--slash)]/25" : "border-[color:var(--paper)]/40"}`}
>
{nome}
{id === 1 && <span>{municao}</span>}
</button>
))}
</div>
<button
type="button"
data-papel="atirar"
style={{ width: TOQUE_ATIRAR, height: TOQUE_ATIRAR }}
className={`${BOTAO} border-[color:var(--slash)]/70 active:bg-[color:var(--slash)]/30`}
>
Atirar
</button>
</div>
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import {
Light,
Line,
Mesh,
type Object3D,
PerspectiveCamera,
Points,
Scene,
Sprite,
} from "three";
import { lerMapa } from "@/lib/jogo/mapa";
import { ESCRITORIO } from "@/lib/jogo/valores";
import { criarAmbiente } from "./ambiente";
beforeEach(() => {
jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
jest.restoreAllMocks();
});
function montar() {
const cena = new Scene();
const camera = new PerspectiveCamera();
cena.add(camera);
const ambiente = criarAmbiente(cena, lerMapa(ESCRITORIO), {
camera,
movimentoReduzido: false,
});
return { cena, camera, ambiente };
}
test("o escritório cabe em 12 objetos desenháveis", () => {
const { cena } = montar();
let n = 0;
cena.traverse((o) => {
if (
o instanceof Mesh ||
o instanceof Sprite ||
o instanceof Line ||
o instanceof Points
)
n++;
});
expect(n).toBeGreaterThan(0);
expect(n).toBeLessThanOrEqual(12);
});
test("a lanterna presa à câmera é a única luz com sombra e desliga com o regulador", () => {
const { cena, ambiente } = montar();
const comSombra = () => {
const luzes: Object3D[] = [];
cena.traverse((o) => {
if (o instanceof Light && o.castShadow) luzes.push(o);
});
return luzes;
};
expect(comSombra()).toHaveLength(1);
expect(comSombra()[0].parent).toBeInstanceOf(PerspectiveCamera);
ambiente.definirSombras(false);
expect(comSombra()).toHaveLength(0);
});
test("descartar esvazia a cena e a câmera", () => {
const { cena, camera, ambiente } = montar();
ambiente.descartar();
expect(cena.children).toEqual([camera]);
expect(camera.children).toHaveLength(0);
expect(cena.fog).toBeNull();
});
+480
View File
@@ -0,0 +1,480 @@
/**
* O escritório à noite, montado a partir do `Mapa` da lógica (célula de 1 m). Dez chamadas de desenho: piso, forro,
* paredes, pilares, mesas, monitores, telas, painéis do teto, portas dos nascedouros e luzes de emergência.
* Texturas só de `<canvas>`.
*/
import {
BoxGeometry,
type Camera,
CanvasTexture,
Color,
Fog,
HemisphereLight,
InstancedMesh,
type Material,
Matrix4,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
Object3D,
PlaneGeometry,
PointLight,
RepeatWrapping,
type Scene,
SpotLight,
SRGBColorSpace,
type Texture,
} from "three";
import type { Mapa } from "@/lib/jogo/tipos";
export type Ambiente = {
definirSombras(ligadas: boolean): void;
/** Pisca o painel com defeito; `segundos` é o relógio da partida. */
atualizar(segundos: number): void;
descartar(): void;
};
const PE_DIREITO = 3;
const ALTURA_DA_MESA = 0.74;
const NOITE = "#06070a";
const LIMA = new Color("#ccff00").multiplyScalar(0.85);
const AZUL_FRIO = new Color("#3d6bff").multiplyScalar(0.55);
const PAINEL_ACESO = new Color("#dfe9ff");
const PAINEL_APAGADO = new Color("#2a2e35");
/** Um painel a cada 4 m nos dois eixos. */
const ESPACO_DOS_PAINEIS = 4;
/** Onde ficam os painéis acesos, em fração do mapa; o último pisca. */
const PAINEIS_ACESOS: readonly (readonly [number, number])[] = [
[0.5, 0.5],
[0.2, 0.2],
[0.8, 0.2],
[0.75, 0.85],
];
/** Porta do nascedouro, com a frente para +z: largura, altura, profundidade e centro (x, y, z). */
const PECAS_DA_PORTA: readonly (readonly [
number,
number,
number,
number,
number,
number,
])[] = [
[0.9, 2.05, 0.03, 0, 1.025, 0.015],
[0.06, 2.14, 0.05, -0.48, 1.07, 0.025],
[0.06, 2.14, 0.05, 0.48, 1.07, 0.025],
[1.02, 0.06, 0.05, 0, 2.11, 0.025],
[0.7, 0.04, 0.04, 0, 1.02, 0.05],
];
/** Folha escura, batente e verga cor de vinho, barra antipânico. */
const CORES_DA_PORTA = [
new Color("#1c1a1c"),
new Color("#4a1414"),
new Color("#4a1414"),
new Color("#4a1414"),
new Color("#8a8f96"),
];
/** Textura feita num `<canvas>`; sem contexto 2D (jsdom) sai em branco. */
export function texturaDeCanvas(
largura: number,
altura: number,
desenhar: (ctx: CanvasRenderingContext2D) => void,
): CanvasTexture {
const canvas = document.createElement("canvas");
canvas.width = largura;
canvas.height = altura;
const ctx = canvas.getContext("2d");
if (ctx) desenhar(ctx);
const textura = new CanvasTexture(canvas);
textura.colorSpace = SRGBColorSpace;
return textura;
}
/** Pontos claros e escuros espalhados, para a superfície não ficar lisa. */
function salpicar(
ctx: CanvasRenderingContext2D,
lado: number,
quantos: number,
semente: number,
) {
let s = semente;
const sortear = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
for (let i = 0; i < quantos; i++) {
ctx.fillStyle =
sortear() < 0.5 ? "rgba(0,0,0,0.25)" : "rgba(255,255,255,0.05)";
ctx.fillRect(sortear() * lado, sortear() * lado, 2, 2);
}
}
function repetir(textura: Texture, x: number, y: number): Texture {
textura.wrapS = RepeatWrapping;
textura.wrapT = RepeatWrapping;
textura.repeat.set(x, y);
return textura;
}
function texturaDoCarpete(): CanvasTexture {
return texturaDeCanvas(256, 256, (ctx) => {
ctx.fillStyle = "#24262b";
ctx.fillRect(0, 0, 256, 256);
salpicar(ctx, 256, 6000, 7);
ctx.strokeStyle = "rgba(0,0,0,0.35)";
ctx.lineWidth = 2;
ctx.strokeRect(0, 0, 256, 256);
});
}
/** Manchas largas e fracas, para o reboco ter variação sem parecer granulado de perto. */
function manchar(
ctx: CanvasRenderingContext2D,
largura: number,
altura: number,
quantas: number,
semente: number,
) {
let s = semente;
const sortear = () => {
s = (s * 16807) % 2147483647;
return s / 2147483647;
};
for (let i = 0; i < quantas; i++) {
const [cor, alfa] =
sortear() < 0.6 ? ["0,0,0", 0.05] : ["255,255,255", 0.025];
const x = sortear() * largura;
const y = sortear() * altura;
const raio = 20 + sortear() * 70;
// repete dos dois lados para a mancha continuar na célula vizinha sem costura
for (const deslocamento of [-largura, 0, largura]) {
const cx = x + deslocamento;
const borda = ctx.createRadialGradient(cx, y, 0, cx, y, raio);
borda.addColorStop(0, `rgba(${cor},${alfa})`);
borda.addColorStop(1, `rgba(${cor},0)`);
ctx.fillStyle = borda;
ctx.fillRect(cx - raio, y - raio, raio * 2, raio * 2);
}
}
}
/** 1 m × 3 m de parede em 256 × 768 px: o pixel fica quadrado na face. */
function texturaDaParede(): CanvasTexture {
return texturaDeCanvas(256, 768, (ctx) => {
ctx.fillStyle = "#3a3e46";
ctx.fillRect(0, 0, 256, 768);
manchar(ctx, 256, 768, 90, 11);
ctx.fillStyle = "rgba(0,0,0,0.3)";
ctx.fillRect(0, 0, 2, 768);
// rodapé de 10 cm
ctx.fillStyle = "#15171a";
ctx.fillRect(0, 742, 256, 26);
});
}
function texturaDoForro(): CanvasTexture {
return texturaDeCanvas(256, 256, (ctx) => {
ctx.fillStyle = "#30333a";
ctx.fillRect(0, 0, 256, 256);
salpicar(ctx, 256, 1500, 3);
ctx.strokeStyle = "#16181c";
ctx.lineWidth = 4;
ctx.strokeRect(0, 0, 128, 128);
ctx.strokeRect(128, 128, 128, 128);
ctx.strokeRect(128, 0, 128, 128);
ctx.strokeRect(0, 128, 128, 128);
});
}
function celula(mapa: Mapa, cx: number, cz: number): "#" | "M" | "." {
if (cx < 0 || cz < 0 || cx >= mapa.largura || cz >= mapa.altura) return ".";
const i = cz * mapa.largura + cx;
if (mapa.opaco[i]) return "#";
return mapa.solido[i] ? "M" : ".";
}
/** Parede de dentro com uma só vizinha de parede: é a ponta de um trecho, onde vai um pilar. */
function ehPilar(mapa: Mapa, cx: number, cz: number): boolean {
const borda =
cx === 0 || cz === 0 || cx === mapa.largura - 1 || cz === mapa.altura - 1;
if (borda) return false;
const vizinhas = [
celula(mapa, cx + 1, cz),
celula(mapa, cx - 1, cz),
celula(mapa, cx, cz + 1),
celula(mapa, cx, cz - 1),
].filter((c) => c === "#").length;
return vizinhas === 1;
}
function instancias(
geometria: BoxGeometry,
material: Material,
matrizes: readonly Matrix4[],
): InstancedMesh {
const malha = new InstancedMesh(geometria, material, matrizes.length);
matrizes.forEach((m, i) => {
malha.setMatrixAt(i, m);
});
return malha;
}
export function criarAmbiente(
cena: Scene,
mapa: Mapa,
opcoes: { camera: Camera; movimentoReduzido: boolean },
): Ambiente {
const objetos: Object3D[] = [];
const pos = new Object3D();
const matriz = (x: number, y: number, z: number, giro = 0) => {
pos.position.set(x, y, z);
pos.rotation.set(0, giro, 0);
pos.updateMatrix();
return pos.matrix.clone();
};
cena.background = new Color(NOITE);
// a neblina só engole o fundo: a 12 m ainda se vê a sala e quem vem nela
cena.fog = new Fog(NOITE, 9, 30);
const texturas = [
repetir(texturaDoCarpete(), mapa.largura / 2, mapa.altura / 2),
texturaDaParede(),
repetir(texturaDoForro(), mapa.largura / 1.2, mapa.altura / 1.2),
];
const [carpete, paredeTex, forroTex] = texturas;
const piso = new Mesh(
new PlaneGeometry(mapa.largura, mapa.altura),
new MeshStandardMaterial({ map: carpete, color: "#ffffff", roughness: 1 }),
);
piso.rotation.x = -Math.PI / 2;
piso.position.set(mapa.largura / 2, 0, mapa.altura / 2);
piso.receiveShadow = true;
const forro = new Mesh(
new PlaneGeometry(mapa.largura, mapa.altura),
new MeshStandardMaterial({ map: forroTex, roughness: 1 }),
);
forro.rotation.x = Math.PI / 2;
forro.position.set(mapa.largura / 2, PE_DIREITO, mapa.altura / 2);
const paredes: Matrix4[] = [];
const pilares: Matrix4[] = [];
const mesas: Matrix4[] = [];
const monitores: Matrix4[] = [];
const telas: Matrix4[] = [];
const coresDasTelas: Color[] = [];
const paineis: Matrix4[] = [];
const centrosDosPaineis: [number, number][] = [];
for (let cz = 0; cz < mapa.altura; cz++) {
for (let cx = 0; cx < mapa.largura; cx++) {
const c = celula(mapa, cx, cz);
const x = cx + 0.5;
const z = cz + 0.5;
if (c === "#") {
(ehPilar(mapa, cx, cz) ? pilares : paredes).push(
matriz(x, PE_DIREITO / 2, z),
);
continue;
}
if (cx % ESPACO_DOS_PAINEIS === 2 && cz % ESPACO_DOS_PAINEIS === 2) {
paineis.push(matriz(x, PE_DIREITO - 0.02, z));
centrosDosPaineis.push([x, z]);
}
if (c !== "M") continue;
mesas.push(matriz(x, ALTURA_DA_MESA / 2, z));
// Na fileira de cima do bloco de mesas a tela olha para −z; na de baixo, para +z.
const paraTras = celula(mapa, cx, cz + 1) === "M";
const sentido = paraTras ? -1 : 1;
const mz = z - sentido * 0.25;
const giro = paraTras ? Math.PI : 0;
monitores.push(matriz(x, ALTURA_DA_MESA + 0.25, mz, giro));
telas.push(matriz(x, ALTURA_DA_MESA + 0.26, mz + sentido * 0.03, giro));
coresDasTelas.push((cx * 7 + cz * 3) % 5 === 0 ? LIMA : AZUL_FRIO);
}
}
const paredeMat = new MeshStandardMaterial({
map: paredeTex,
roughness: 0.95,
});
const malhaDasParedes = instancias(
new BoxGeometry(1, PE_DIREITO, 1),
paredeMat,
paredes,
);
const malhaDosPilares = instancias(
new BoxGeometry(1.16, PE_DIREITO, 1.16),
new MeshStandardMaterial({ map: paredeTex, roughness: 0.85 }),
pilares,
);
const malhaDasMesas = instancias(
new BoxGeometry(0.98, ALTURA_DA_MESA, 0.98),
new MeshStandardMaterial({ color: "#3b342c", roughness: 0.7 }),
mesas,
);
const malhaDosMonitores = instancias(
new BoxGeometry(0.56, 0.36, 0.05),
new MeshStandardMaterial({ color: "#111214", roughness: 0.5 }),
monitores,
);
const malhaDasTelas = instancias(
new BoxGeometry(0.5, 0.3, 0.01),
new MeshBasicMaterial({ color: "#ffffff" }),
telas,
);
coresDasTelas.forEach((cor, i) => {
malhaDasTelas.setColorAt(i, cor);
});
for (const malha of [malhaDasParedes, malhaDosPilares, malhaDasMesas]) {
malha.castShadow = true;
malha.receiveShadow = true;
}
// Painéis do teto: a maioria apagada; os mais perto de `PAINEIS_ACESOS` acendem e ganham luz.
const malhaDosPaineis = instancias(
new BoxGeometry(1.2, 0.03, 0.6),
new MeshBasicMaterial({ color: "#ffffff" }),
paineis,
);
centrosDosPaineis.forEach((_, i) => {
malhaDosPaineis.setColorAt(i, PAINEL_APAGADO);
});
const luzesDosPaineis = PAINEIS_ACESOS.map(([fx, fz]) => {
const alvo = [fx * mapa.largura, fz * mapa.altura];
let melhor = 0;
centrosDosPaineis.forEach(([x, z], i) => {
const [mx, mz] = centrosDosPaineis[melhor];
if (
Math.hypot(x - alvo[0], z - alvo[1]) <
Math.hypot(mx - alvo[0], mz - alvo[1])
)
melhor = i;
});
malhaDosPaineis.setColorAt(melhor, PAINEL_ACESO);
const [x, z] = centrosDosPaineis[melhor];
const luz = new PointLight("#cfe0ff", 15, 15, 1.4);
luz.position.set(x, PE_DIREITO - 0.3, z);
return { luz, indice: melhor };
});
// Nascedouros: uma porta corta-fogo na parede do canto, com luminária vermelha em cima. É de lá que os zumbis vêm.
const portas: Matrix4[] = [];
const luminarias: Matrix4[] = [];
const luzesDeEmergencia = mapa.nascedouros.map(([x, z]) => {
const cx = Math.floor(x);
const cz = Math.floor(z);
// a face de parede vizinha e o giro que vira a frente da porta (+z) para dentro da sala
let [px, pz, giro] = [x, cz, 0];
if (celula(mapa, cx, cz + 1) === "#") [px, pz, giro] = [x, cz + 1, Math.PI];
else if (celula(mapa, cx - 1, cz) === "#")
[px, pz, giro] = [cx, z, Math.PI / 2];
else if (celula(mapa, cx + 1, cz) === "#")
[px, pz, giro] = [cx + 1, z, -Math.PI / 2];
const base = matriz(px, 0, pz, giro);
for (const [l, a, pr, ox, oy, oz] of PECAS_DA_PORTA)
portas.push(
base
.clone()
.multiply(new Matrix4().makeScale(l, a, pr).setPosition(ox, oy, oz)),
);
luminarias.push(
base
.clone()
.multiply(
new Matrix4().makeScale(0.56, 0.12, 0.1).setPosition(0, 2.3, 0.05),
),
);
const luz = new PointLight("#ff2020", 12, 10, 1.3);
luz.position.set(px + Math.sin(giro) * 0.6, 2.2, pz + Math.cos(giro) * 0.6);
return luz;
});
const malhaDasPortas = instancias(
new BoxGeometry(1, 1, 1),
new MeshStandardMaterial({ color: "#ffffff", roughness: 0.6 }),
portas,
);
portas.forEach((_, i) => {
malhaDasPortas.setColorAt(i, CORES_DA_PORTA[i % PECAS_DA_PORTA.length]);
});
const malhaDaEmergencia = instancias(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial({ color: "#ff1a1a" }),
luminarias,
);
// Luz de fundo fria, do forro para o chão: dá silhueta e forma à sala fora da lanterna, sem tirar a noite.
const ambiente = new HemisphereLight("#7d90c4", "#2a2622", 1.8);
// Lanterna presa à câmera, à esquerda da arma e abaixo dos olhos: a única luz com sombra. Daqui a arma fica na borda
// do facho e pega luz de lado, sem estourar de branco.
const lanterna = new SpotLight("#fff2dc", 40, 24, 0.5, 0.45, 1.5);
lanterna.position.set(-0.06, -0.14, 0.02);
lanterna.target.position.set(0, 0, -6);
lanterna.castShadow = true;
lanterna.shadow.mapSize.set(1024, 1024);
lanterna.shadow.bias = -0.0005;
lanterna.shadow.normalBias = 0.02;
lanterna.shadow.camera.near = 0.3;
opcoes.camera.add(lanterna, lanterna.target);
objetos.push(
piso,
forro,
malhaDasParedes,
malhaDosPilares,
malhaDasMesas,
malhaDosMonitores,
malhaDasTelas,
malhaDosPaineis,
malhaDasPortas,
malhaDaEmergencia,
ambiente,
...luzesDosPaineis.map((l) => l.luz),
...luzesDeEmergencia,
);
cena.add(...objetos);
const piscante = luzesDosPaineis[luzesDosPaineis.length - 1];
const intensidade = piscante.luz.intensity;
let aceso = true;
return {
definirSombras(ligadas) {
lanterna.castShadow = ligadas;
},
atualizar(segundos) {
if (opcoes.movimentoReduzido) return;
// apagões curtos e irregulares, como reator de lâmpada velha
const agora =
Math.sin(segundos * 23) * Math.sin(segundos * 7.3) +
Math.sin(segundos * 1.7) >
-0.6;
if (agora === aceso) return;
aceso = agora;
piscante.luz.intensity = aceso ? intensidade : 0;
malhaDosPaineis.setColorAt(
piscante.indice,
aceso ? PAINEL_ACESO : PAINEL_APAGADO,
);
if (malhaDosPaineis.instanceColor)
malhaDosPaineis.instanceColor.needsUpdate = true;
},
descartar() {
lanterna.shadow.dispose();
lanterna.removeFromParent();
lanterna.target.removeFromParent();
cena.remove(...objetos);
for (const objeto of objetos) {
if (!(objeto instanceof Mesh)) continue;
objeto.geometry.dispose();
(objeto.material as Material).dispose();
if (objeto instanceof InstancedMesh) objeto.dispose();
}
for (const textura of texturas) textura.dispose();
cena.fog = null;
cena.background = null;
},
};
}
+268
View File
@@ -0,0 +1,268 @@
import {
BoxGeometry,
Group,
Mesh,
MeshStandardMaterial,
type Object3D,
Scene,
Sprite,
Texture,
} from "three";
import { criarPartida } from "@/lib/jogo/partida";
import type { Partida, TipoZumbi, Zumbi } from "@/lib/jogo/tipos";
import { criarAtores } from "./atores";
import type { ModeloDoZumbi } from "./zumbis3d";
// O zumbis3d de verdade importa o GLTFLoader (ESM, que o Jest não transforma); aqui o clone é um `clone()` simples.
const mockTocados: string[] = [];
jest.mock("./zumbis3d", () => ({
criarZumbi: (modelo: ModeloDoZumbi) => {
const raiz = modelo.cena.clone();
return {
raiz,
tocar: (clipe: string, opcoes?: { umaVez?: boolean }) => {
mockTocados.push(opcoes?.umaVez ? `${clipe} uma vez` : clipe);
},
avancar: () => {},
descartar: () => raiz.removeFromParent(),
};
},
}));
/** Zumbi mínimo com as três peças do modelo. */
function modeloFalso(): ModeloDoZumbi {
const cena = new Group();
cena.name = "zumbi";
for (const nome of ["corpo", "roupa", "olhos"]) {
cena.add(
new Mesh(new BoxGeometry(), new MeshStandardMaterial({ name: nome })),
);
}
return { cena, clipes: [] };
}
function zumbi(id: number, x: number, z: number, tipo: TipoZumbi = "comum") {
const novo: Zumbi = {
id,
tipo,
problemaId: "site-lento",
x,
z,
rumo: 0,
vida: 100,
estado: "andando",
tempo: 0,
};
return novo;
}
function partidaCom(zumbis: Zumbi[], passo: number): Partida {
const p = criarPartida({
semente: 1,
dificuldade: "facil",
problemaId: "site-lento",
});
p.zumbis = zumbis;
p.passo = passo;
return p;
}
const ehZumbi = (o: Object3D) => o.name === "zumbi";
function zumbisNaCena(cena: Scene) {
const todos = cena.children.filter(ehZumbi);
return { total: todos.length, visiveis: todos.filter((o) => o.visible) };
}
beforeEach(() => {
jest.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
afterEach(() => {
jest.restoreAllMocks();
});
function montar() {
const cena = new Scene();
const atores = criarAtores(cena, {
zumbi: modeloFalso(),
bloco: new BoxGeometry(),
roupas: [new Texture(), new Texture()],
});
return { cena, atores };
}
test("cria, esconde e reaproveita zumbis sem a reserva passar do maior grupo", () => {
const { cena, atores } = montar();
const cinco = [1, 2, 3, 4, 5].map((id) => zumbi(id, 2 + id, 2));
atores.sincronizar(partidaCom(cinco, 1), 1, 0.016);
expect(zumbisNaCena(cena).total).toBe(5);
expect(zumbisNaCena(cena).visiveis).toHaveLength(5);
atores.sincronizar(partidaCom(cinco.slice(0, 2), 2), 1, 0.016);
expect(zumbisNaCena(cena).total).toBe(5);
expect(zumbisNaCena(cena).visiveis).toHaveLength(2);
const seis = [1, 2, 6, 7, 8, 9].map((id) => zumbi(id, 2 + id, 3));
atores.sincronizar(partidaCom(seis, 3), 1, 0.016);
const { total, visiveis } = zumbisNaCena(cena);
expect(total).toBe(6);
expect(visiveis).toHaveLength(6);
expect(visiveis.map((o) => o.position.x).sort((a, b) => a - b)).toEqual([
3, 4, 8, 9, 10, 11,
]);
});
test("toca o clipe do estado e veste roupa e escala do tipo", () => {
const { cena, atores } = montar();
mockTocados.length = 0;
const rapido = zumbi(1, 3, 3, "rapido");
const comum = zumbi(2, 4, 3);
const blindado = zumbi(3, 5, 3, "blindado");
atores.sincronizar(partidaCom([rapido, comum, blindado], 1), 1, 0);
expect(mockTocados).toEqual(["correr", "andar", "andar"]);
atores.sincronizar(
partidaCom(
[
{ ...rapido, estado: "atacando" },
{ ...comum, estado: "morrendo" },
blindado,
],
2,
),
1,
0,
);
expect(mockTocados.slice(3)).toEqual(["atacar", "morrer uma vez"]);
const [r, c, b] = cena.children.filter(ehZumbi);
const roupa = (o: Object3D) =>
(o.children[1] as Mesh).material as MeshStandardMaterial;
expect(r.scale.x).toBeCloseTo(0.95);
expect(b.scale.x).toBeCloseTo(1.08);
// ids 1, 2 e 3: roupa B, roupa C e a original escurecida e metálica
expect(new Set([roupa(r), roupa(c), roupa(b)]).size).toBe(3);
expect(roupa(b).metalness).toBeGreaterThan(0.5);
});
test("põe o zumbi no lugar e no rumo da lógica, interpolando entre passos", () => {
const { cena, atores } = montar();
const z = zumbi(1, 4, 4);
atores.sincronizar(partidaCom([z], 1), 1, 0);
const p2 = partidaCom([{ ...z, x: 5, rumo: 0.5 }], 2);
atores.guardarPasso(p2);
atores.sincronizar(p2, 0.5, 0);
const raiz = cena.children.find(ehZumbi);
expect(raiz?.position.x).toBeCloseTo(4.5);
expect(raiz?.position.z).toBeCloseTo(4);
expect(raiz?.rotation.y).toBeCloseTo(0.25);
});
test("dois passos no mesmo quadro: interpola entre o penúltimo e o último", () => {
const { cena, atores } = montar();
const z = zumbi(1, 4, 4);
atores.sincronizar(partidaCom([z], 1), 1, 0);
atores.guardarPasso(partidaCom([{ ...z, x: 5 }], 2));
const p3 = partidaCom([{ ...z, x: 6 }], 3);
atores.guardarPasso(p3);
atores.sincronizar(p3, 0.5, 0);
expect(cena.children.find(ehZumbi)?.position.x).toBeCloseTo(5.5);
});
test("com a partida parada, antes vira agora e nada treme", () => {
const { cena, atores } = montar();
const z = zumbi(1, 4, 4);
const drone = {
id: 9,
problemaId: "site-lento",
x: 13,
z: 9,
rumo: 0,
recarga: 0,
eliminacoes: 0,
};
const p1 = partidaCom([z], 1);
p1.drones = [drone];
atores.sincronizar(p1, 1, 0);
const p2 = partidaCom([{ ...z, x: 5 }], 2);
p2.drones = [{ ...drone, x: 14 }];
atores.guardarPasso(p2);
// terminada, a lógica não avança `passo`, mas a cena segue chamando a cada passo
atores.guardarPasso(p2);
for (const alfa of [0, 0.3, 0.9]) {
atores.sincronizar(p2, alfa, 0);
expect(cena.children.find(ehZumbi)?.position.x).toBeCloseTo(5);
const doDrone = cena.children.find(
(o) => o instanceof Sprite && o.name === "Site rápido",
);
expect(doDrone?.position.x).toBeCloseTo(14);
}
});
test("o rótulo do zumbi é o nome do problema e só aparece a até 10 m, menos no chefão", () => {
const { cena, atores } = montar();
// o jogador começa no centro do escritório (13,5; 8,5)
const perto = zumbi(1, 13.5, 5);
const longe = zumbi(2, 1.5, 1.5);
const chefao = zumbi(3, 26.5, 15.5, "chefao");
atores.sincronizar(partidaCom([perto, longe, chefao], 1), 1, 0);
const rotulos = cena.children.filter(
(o): o is Sprite => o instanceof Sprite && o.name !== "",
);
const em = (x: number) => rotulos.find((s) => s.position.x === x);
expect(em(13.5)?.name).toBe("Site lento");
expect(em(13.5)?.visible).toBe(true);
expect(em(1.5)?.visible).toBe(false);
expect(em(26.5)?.visible).toBe(true);
expect(em(26.5)?.material.depthTest).toBe(true);
});
test("só os 3 zumbis mais perto e o chefão levam rótulo, do mesmo tamanho na tela a qualquer distância", () => {
const { cena, atores } = montar();
const zumbis = [2, 3, 4, 5, 6].map((d, i) => zumbi(i + 1, 13.5, 8.5 + d));
zumbis.push(zumbi(9, 26.5, 15.5, "chefao"));
atores.sincronizar(partidaCom(zumbis, 1), 1, 0);
const rotulos = cena.children.filter(
(o): o is Sprite => o instanceof Sprite && o.visible,
);
expect(rotulos.map((s) => s.position.z).sort((a, b) => a - b)).toEqual([
10.5, 11.5, 12.5, 15.5,
]);
for (const s of rotulos) {
expect(s.material.sizeAttenuation).toBe(false);
expect(s.material.depthTest).toBe(true);
}
expect(rotulos[0].scale.y).toBeCloseTo(rotulos[1].scale.y, 10);
});
test("o drone leva o nome da solução, acima da cabeça e em rótulo menor que o do zumbi", () => {
const { cena, atores } = montar();
const p = partidaCom([zumbi(1, 13.5, 5)], 1);
p.drones = [
{
id: 9,
problemaId: "site-lento",
x: 13,
z: 9,
rumo: 0,
recarga: 0,
eliminacoes: 0,
},
];
atores.sincronizar(p, 1, 0);
const visivel = (nome: string) =>
cena.children.find(
(o): o is Sprite => o instanceof Sprite && o.visible && o.name === nome,
);
const doDrone = visivel("Site rápido");
expect(doDrone?.position.y).toBeGreaterThan(2.3);
expect(doDrone?.scale.y).toBeLessThan(visivel("Site lento")?.scale.y ?? 0);
expect(doDrone?.material.depthTest).toBe(false);
});
test("descartar tira tudo da cena", () => {
const { cena, atores } = montar();
atores.sincronizar(partidaCom([zumbi(1, 3, 3)], 1), 1, 0);
atores.descartar();
expect(cena.children).toHaveLength(0);
});
+577
View File
@@ -0,0 +1,577 @@
/**
* Zumbis, drones e caixas de munição desenhados a partir da `Partida`. Os zumbis vêm de uma reserva de clones que só
* cresce: quando um sai da partida, o clone se esconde e volta para outro. Cada zumbi leva o nome do problema que é,
* e cada drone o nome da solução.
*/
import {
BoxGeometry,
type BufferGeometry,
type CanvasTexture,
Color,
InstancedMesh,
type Material,
Matrix4,
type Mesh,
MeshStandardMaterial,
Object3D,
type Scene,
Sprite,
SpriteMaterial,
type Texture,
} from "three";
import { problemaPorId } from "@/lib/jogo/problemas";
import type { Drone, Partida, TipoZumbi, Zumbi } from "@/lib/jogo/tipos";
import { DIFICULDADES, HORDAS } from "@/lib/jogo/valores";
import { texturaDeCanvas } from "./ambiente";
import {
criarZumbi,
type ModeloDoZumbi,
type Zumbi as ZumbiNaTela,
} from "./zumbis3d";
/** O que os atores precisam já baixado. `roupas` são as variações B e C da roupa; a que faltar fica de fora. */
export type ModelosDosAtores = {
zumbi: ModeloDoZumbi;
bloco: BufferGeometry;
roupas: readonly Texture[];
};
export type Atores = {
/** Depois de cada passo da lógica: guarda posição e rumo de antes e de agora, para interpolar. */
guardarPasso(p: Partida): void;
/** `alfa` de 0 a 1 entre o passo anterior e o atual; `dt` em segundos do jogo, para as animações. */
sincronizar(p: Partida, alfa: number, dt: number): void;
descartar(): void;
};
const ESCALA: Record<TipoZumbi, number> = {
comum: 1,
rapido: 0.95,
blindado: 1.08,
chefao: 1.5,
};
/** Altura do modelo em escala 1, em metros. */
const ALTURA_DO_MODELO = 1.8;
const ALCANCE_DO_ROTULO = 10;
/** Só os zumbis mais perto (e o chefão) levam rótulo, para não empilhar nomes na tela. */
const ROTULOS_DE_ZUMBI = 3;
/** O rótulo do chefão não passa disto, para não entrar no forro (3 m). */
const TETO_DO_ROTULO = 2.8;
/** Metade do campo de visão vertical da câmera (75° em `cena.ts`), para medir o rótulo em pixels. */
const TANGENTE_DO_CAMPO = Math.tan((37.5 * Math.PI) / 180);
/** Rótulo do drone em relação ao do zumbi. */
const ESCALA_DO_ROTULO_DO_DRONE = 0.8;
/** Na tela, o drone fica acima da cabeça do jogador (olhos a 1,6 m); a lógica continua no chão. */
const ALTURA_DO_DRONE = 2.3;
const BLOCOS_POR_DRONE = 8;
const VERDE = "#c5f04a";
/** shortcut: no máximo 32 caixas desenhadas ao mesmo tempo; se o chão juntar mais, as velhas somem até alguém pegar. */
const MAX_CAIXAS = 32;
type Rotulo = {
material: SpriteMaterial;
textura: CanvasTexture;
aspecto: number;
texto: string;
};
type Trio = [number, number, number];
/** Posição e rumo no passo anterior e no atual. */
type Rastro = { antes: Trio; agora: Trio };
type Ator = Rastro & {
zumbi: ZumbiNaTela;
roupa: Mesh | null;
olhos: Mesh | null;
rotulo: Sprite;
aspecto: number;
id: number;
/** Último quadro em que o zumbi estava na partida. */
visto: number;
clipe: string;
};
function misturarAngulo(a: number, b: number, t: number): number {
const d = Math.atan2(Math.sin(b - a), Math.cos(b - a));
return a + d * t;
}
/** Põe antes e agora no mesmo lugar, sem alocar. */
function fixar(r: Rastro, x: number, z: number, rumo: number) {
r.antes[0] = r.agora[0] = x;
r.antes[1] = r.agora[1] = z;
r.antes[2] = r.agora[2] = rumo;
}
/** Um passo adiante sem alocar: o vetor de antes vira o de agora. Partida parada (passo igual): antes = agora. */
function seguir(
r: Rastro,
x: number,
z: number,
rumo: number,
parado: boolean,
) {
if (parado) {
fixar(r, r.agora[0], r.agora[1], r.agora[2]);
return;
}
const velho = r.antes;
r.antes = r.agora;
velho[0] = x;
velho[1] = z;
velho[2] = rumo;
r.agora = velho;
}
function malhaDoMaterial(raiz: Object3D, nome: string): Mesh | null {
let achada: Mesh | null = null;
raiz.traverse((objeto) => {
const malha = objeto as Mesh;
if (!achada && malha.isMesh && (malha.material as Material).name === nome)
achada = malha;
});
return achada;
}
function fonteMono(): string {
const raiz = getComputedStyle(document.documentElement);
return raiz.getPropertyValue("--font-mono").trim() || "monospace";
}
/** Etiqueta reta (cantos retos, como o site), texto em mono. */
function texturaDoRotulo(texto: string, fundo: string, tinta: string) {
const fonte = `500 46px ${fonteMono()}`;
const medida = document.createElement("canvas").getContext("2d");
if (medida) medida.font = fonte;
const largura = Math.ceil(
(medida?.measureText(texto).width ?? texto.length * 28) + 32,
);
const textura = texturaDeCanvas(largura, 64, (ctx) => {
ctx.fillStyle = fundo;
ctx.fillRect(0, 0, largura, 64);
ctx.font = fonte;
ctx.fillStyle = tinta;
ctx.textBaseline = "middle";
ctx.fillText(texto, 16, 34);
});
return { textura, aspecto: largura / 64 };
}
/** Altura do rótulo em unidades de `Sprite` sem atenuação: ~18 px no computador, ~14 px no celular. */
function alturaDoRotulo(): number {
const altura = window.innerHeight || 900;
const pixels = Math.min(18, Math.max(14, altura * 0.02));
return (pixels * 2 * TANGENTE_DO_CAMPO) / altura;
}
function clipeDo(z: Zumbi): string {
if (z.estado === "atacando") return "atacar";
if (z.estado === "morrendo") return "morrer";
return z.tipo === "rapido" ? "correr" : "andar";
}
export function criarAtores(cena: Scene, modelo: ModelosDosAtores): Atores {
const reserva: Ator[] = [];
const porId = new Map<number, Ator>();
const rotulos = new Map<string, Rotulo>();
const materiais = new Map<string, Material>();
let passoVisto = -1;
let relogio = 0;
let quadro = 0;
// os zumbis mais perto deste quadro, em reserva fixa
const maisPerto: (Ator | null)[] = Array(ROTULOS_DE_ZUMBI).fill(null);
const distanciasMaisPerto = new Float64Array(ROTULOS_DE_ZUMBI);
const roupaOriginal = malhaDoMaterial(modelo.zumbi.cena, "roupa")?.material as
| MeshStandardMaterial
| undefined;
const olhosOriginais = malhaDoMaterial(modelo.zumbi.cena, "olhos")
?.material as MeshStandardMaterial | undefined;
/** Material derivado, criado uma vez e guardado: variação de roupa, blindado escurecido, olhos do chefão. */
function derivado(
chave: string,
base: MeshStandardMaterial,
ajustar: (m: MeshStandardMaterial) => void,
): Material {
let material = materiais.get(chave);
if (!material) {
const novo = base.clone();
ajustar(novo);
materiais.set(chave, novo);
material = novo;
}
return material;
}
function roupaPara(z: Zumbi): Material | null {
if (!roupaOriginal) return null;
const variacao = z.id % 3;
const mapa = variacao > 0 ? modelo.roupas[variacao - 1] : undefined;
const base = mapa
? (derivado(`roupa-${variacao}`, roupaOriginal, (m) => {
m.map = mapa;
}) as MeshStandardMaterial)
: roupaOriginal;
if (z.tipo !== "blindado") return base;
return derivado(`blindado-${variacao}`, base, (m) => {
m.color.multiplyScalar(0.35);
m.metalness = 0.75;
m.roughness = 0.35;
});
}
function olhosPara(z: Zumbi): Material | null {
if (!olhosOriginais) return null;
if (z.tipo !== "chefao") return olhosOriginais;
return derivado("olhos-chefao", olhosOriginais, (m) => {
m.color.set("#ff2a1a");
m.emissive = new Color("#ff1a0a");
m.emissiveIntensity = 4;
});
}
function rotuloDo(
chave: string,
texto: string,
fundo: string,
tinta: string,
discreto = false,
): Rotulo {
let rotulo = rotulos.get(chave);
if (!rotulo) {
const { textura, aspecto } = texturaDoRotulo(texto, fundo, tinta);
// sem atenuação: o rótulo tem o mesmo tamanho na tela a 1 m ou a 10 m. O do zumbi testa profundidade para
// não atravessar parede; o do drone, discreto, não, senão o próprio drone come o texto.
const material = new SpriteMaterial({
map: textura,
fog: false,
sizeAttenuation: false,
opacity: discreto ? 0.85 : 1,
depthTest: !discreto,
});
rotulo = { material, textura, aspecto, texto };
rotulos.set(chave, rotulo);
}
return rotulo;
}
function aplicarRotulo(sprite: Sprite, rotulo: Rotulo) {
sprite.material = rotulo.material;
sprite.name = rotulo.texto;
}
function novoAtor(): Ator {
const zumbi = criarZumbi(modelo.zumbi);
zumbi.raiz.traverse((objeto) => {
objeto.castShadow = true;
});
// os olhos são pequenos demais para sombra; poupa uma chamada por zumbi
const olhos = malhaDoMaterial(zumbi.raiz, "olhos");
if (olhos) olhos.castShadow = false;
const rotulo = new Sprite();
rotulo.renderOrder = 10;
// ancorado embaixo: o rótulo sobe a partir do ponto acima da cabeça
rotulo.center.set(0.5, 0);
cena.add(zumbi.raiz, rotulo);
const ator: Ator = {
zumbi,
roupa: malhaDoMaterial(zumbi.raiz, "roupa"),
olhos,
rotulo,
aspecto: 1,
id: -1,
visto: 0,
clipe: "",
antes: [0, 0, 0],
agora: [0, 0, 0],
};
reserva.push(ator);
return ator;
}
/** Prepara um clone livre (ou novo) para o zumbi `z`. */
function vestir(z: Zumbi): Ator {
const ator = reserva.find((a) => a.id < 0) ?? novoAtor();
ator.id = z.id;
fixar(ator, z.x, z.z, z.rumo);
ator.zumbi.raiz.scale.setScalar(ESCALA[z.tipo]);
ator.zumbi.raiz.visible = true;
const roupa = roupaPara(z);
if (ator.roupa && roupa) ator.roupa.material = roupa;
const olhos = olhosPara(z);
if (ator.olhos && olhos) ator.olhos.material = olhos;
const problema = problemaPorId(z.problemaId);
const chefao = z.tipo === "chefao";
const rotulo = rotuloDo(
`${chefao ? "chefao" : "zumbi"}:${z.problemaId}`,
problema?.nome ?? z.problemaId,
"rgba(10,10,10,0.78)",
chefao ? "#ff5a4a" : "#f4f2ed",
);
aplicarRotulo(ator.rotulo, rotulo);
ator.aspecto = rotulo.aspecto;
// degraus por id, na tela: rótulos de zumbis juntos não se sobrepõem por inteiro
ator.rotulo.center.y = -(z.id % 3) * 1.15;
ator.clipe = clipeDo(z);
ator.zumbi.tocar(ator.clipe, { umaVez: ator.clipe === "morrer" });
// fases diferentes, para a horda não andar em marcha
ator.zumbi.avancar((z.id * 0.37) % 1.2);
porId.set(z.id, ator);
return ator;
}
function soltar(ator: Ator) {
porId.delete(ator.id);
ator.id = -1;
ator.zumbi.raiz.visible = false;
ator.rotulo.visible = false;
}
// Drones: os 8 blocos de cada um numa malha só.
const maxDrones = HORDAS.tamanhos.length;
const verde = new MeshStandardMaterial({
color: VERDE,
emissive: VERDE,
emissiveIntensity: 0.45,
roughness: 0.5,
});
const blocos = new InstancedMesh(
modelo.bloco,
verde,
maxDrones * BLOCOS_POR_DRONE,
);
blocos.count = 0;
blocos.frustumCulled = false;
const rotulosDosDrones = Array.from({ length: maxDrones }, () => {
const sprite = new Sprite();
sprite.renderOrder = 10;
sprite.center.set(0.5, 0);
sprite.visible = false;
return sprite;
});
const problemasDosRotulos: string[] = Array(maxDrones).fill("");
const aspectosDosRotulos = new Float64Array(maxDrones);
const posicoesDosDrones = new Map<number, Rastro>();
const materialDaCaixa = new MeshStandardMaterial({
color: "#d9a400",
emissive: "#d9a400",
emissiveIntensity: 0.6,
roughness: 0.6,
});
const caixas = new InstancedMesh(
new BoxGeometry(0.32, 0.2, 0.24),
materialDaCaixa,
MAX_CAIXAS,
);
caixas.count = 0;
caixas.frustumCulled = false;
cena.add(blocos, caixas, ...rotulosDosDrones);
const peca = new Object3D();
const corpo = new Object3D();
const m = new Matrix4();
function guardarPasso(p: Partida) {
// terminada, a partida não anda mais: sem isto antes ≠ agora e a interpolação treme
const parado = p.passo === passoVisto;
passoVisto = p.passo;
for (const z of p.zumbis) {
const ator = porId.get(z.id);
if (ator) seguir(ator, z.x, z.z, z.rumo, parado);
}
for (const d of p.drones) {
const pos = posicoesDosDrones.get(d.id);
if (pos) seguir(pos, d.x, d.z, d.rumo, parado);
}
}
/** O rastro do drone; o que acabou de nascer começa parado onde está. */
function rastroDo(d: Drone): Rastro {
let pos = posicoesDosDrones.get(d.id);
if (!pos) {
pos = { antes: [d.x, d.z, d.rumo], agora: [d.x, d.z, d.rumo] };
posicoesDosDrones.set(d.id, pos);
}
return pos;
}
/** Guarda `ator` entre os mais perto deste quadro, em ordem de distância. */
function guardarMaisPerto(ator: Ator, distancia: number) {
let i = ROTULOS_DE_ZUMBI - 1;
if (distancia >= distanciasMaisPerto[i]) return;
while (i > 0 && distanciasMaisPerto[i - 1] > distancia) {
distanciasMaisPerto[i] = distanciasMaisPerto[i - 1];
maisPerto[i] = maisPerto[i - 1];
i--;
}
distanciasMaisPerto[i] = distancia;
maisPerto[i] = ator;
}
function sincronizarZumbis(
p: Partida,
alfa: number,
dt: number,
alturaDoTexto: number,
) {
quadro++;
const ritmo = DIFICULDADES[p.dificuldade].velocidade;
const { x: jx, z: jz } = p.jogador;
distanciasMaisPerto.fill(Number.POSITIVE_INFINITY);
maisPerto.fill(null);
for (const z of p.zumbis) {
const ator = porId.get(z.id) ?? vestir(z);
ator.visto = quadro;
const clipe = clipeDo(z);
if (clipe !== ator.clipe) {
ator.clipe = clipe;
ator.zumbi.tocar(clipe, { umaVez: clipe === "morrer" });
}
const [x0, z0, r0] = ator.antes;
const [x1, z1, r1] = ator.agora;
const x = x0 + (x1 - x0) * alfa;
const zz = z0 + (z1 - z0) * alfa;
const raiz = ator.zumbi.raiz;
raiz.position.set(x, 0, zz);
raiz.rotation.y = misturarAngulo(r0, r1, alfa);
// andar e correr no ritmo da dificuldade, para o pé não deslizar no chão
ator.zumbi.avancar(z.estado === "andando" ? dt * ritmo : dt);
const altura = ALTURA_DO_MODELO * ESCALA[z.tipo] + 0.25;
const rotulo = ator.rotulo;
rotulo.position.set(x, Math.min(altura, TETO_DO_ROTULO), zz);
rotulo.scale.set(alturaDoTexto * ator.aspecto, alturaDoTexto, 1);
rotulo.visible = z.tipo === "chefao" && z.estado !== "morrendo";
if (z.tipo === "chefao" || z.estado === "morrendo") continue;
const distancia = Math.hypot(x - jx, zz - jz);
if (distancia <= ALCANCE_DO_ROTULO) guardarMaisPerto(ator, distancia);
}
for (let i = 0; i < ROTULOS_DE_ZUMBI; i++) {
const ator = maisPerto[i];
if (ator) ator.rotulo.visible = true;
}
for (const ator of reserva)
if (ator.id >= 0 && ator.visto !== quadro) soltar(ator);
}
function sincronizarDrones(p: Partida, alfa: number, alturaDoTexto: number) {
const n = Math.min(p.drones.length, maxDrones);
blocos.count = n * BLOCOS_POR_DRONE;
for (let i = 0; i < n; i++) {
const d = p.drones[i];
const pos = rastroDo(d);
const [x0, z0, r0] = pos.antes;
const [x1, z1, r1] = pos.agora;
const x = x0 + (x1 - x0) * alfa;
const z = z0 + (z1 - z0) * alfa;
const y = ALTURA_DO_DRONE + Math.sin(relogio * 2 + d.id) * 0.06;
corpo.position.set(x, y, z);
corpo.rotation.set(
Math.sin(relogio * 1.3 + d.id) * 0.08,
misturarAngulo(r0, r1, alfa),
0,
);
corpo.updateMatrix();
for (let b = 0; b < BLOCOS_POR_DRONE; b++) {
peca.position.set(
(b & 1 ? 1 : -1) * 0.07,
(b & 2 ? 1 : -1) * 0.07,
(b & 4 ? 1 : -1) * 0.07,
);
peca.scale.setScalar(0.12);
peca.updateMatrix();
m.multiplyMatrices(corpo.matrix, peca.matrix);
blocos.setMatrixAt(i * BLOCOS_POR_DRONE + b, m);
}
const sprite = rotulosDosDrones[i];
// troca a etiqueta só quando muda o drone da vaga: nada de texto novo por quadro
if (problemasDosRotulos[i] !== d.problemaId) {
problemasDosRotulos[i] = d.problemaId;
const problema = problemaPorId(d.problemaId);
const rotulo = rotuloDo(
`drone:${d.problemaId}`,
problema?.solucao ?? d.problemaId,
"rgba(10,10,10,0.55)",
"#ccff00",
true,
);
aplicarRotulo(sprite, rotulo);
aspectosDosRotulos[i] = rotulo.aspecto;
}
const alturaDoRotuloDoDrone = alturaDoTexto * ESCALA_DO_ROTULO_DO_DRONE;
sprite.scale.set(
alturaDoRotuloDoDrone * aspectosDosRotulos[i],
alturaDoRotuloDoDrone,
1,
);
// acima do topo do drone visto de baixo, para o texto não cair sobre os blocos
sprite.position.set(x, y + 0.34, z);
sprite.visible = true;
}
for (let i = n; i < maxDrones; i++) rotulosDosDrones[i].visible = false;
blocos.instanceMatrix.needsUpdate = true;
}
function sincronizarCaixas(p: Partida) {
const n = Math.min(p.caixas.length, MAX_CAIXAS);
caixas.count = n;
for (let i = 0; i < n; i++) {
const c = p.caixas[i];
peca.position.set(c.x, 0.22 + Math.sin(relogio * 3 + c.id) * 0.05, c.z);
peca.rotation.set(0, relogio * 1.5 + c.id, 0);
peca.scale.setScalar(1);
peca.updateMatrix();
caixas.setMatrixAt(i, peca.matrix);
}
peca.rotation.set(0, 0, 0);
caixas.instanceMatrix.needsUpdate = true;
}
return {
guardarPasso,
sincronizar(p, alfa, dt) {
relogio += dt;
const alturaDoTexto = alturaDoRotulo();
sincronizarZumbis(p, alfa, dt, alturaDoTexto);
sincronizarDrones(p, alfa, alturaDoTexto);
sincronizarCaixas(p);
},
descartar() {
for (const ator of reserva) {
ator.zumbi.descartar();
ator.rotulo.removeFromParent();
}
reserva.length = 0;
porId.clear();
cena.remove(blocos, caixas, ...rotulosDosDrones);
blocos.dispose();
caixas.dispose();
verde.dispose();
materialDaCaixa.dispose();
caixas.geometry.dispose();
modelo.bloco.dispose();
// a geometria dos sprites é uma só, do módulo do three: soltar libera o renderer desta partida
rotulosDosDrones[0].geometry.dispose();
for (const { material, textura } of rotulos.values()) {
material.dispose();
textura.dispose();
}
rotulos.clear();
for (const material of materiais.values()) material.dispose();
materiais.clear();
for (const textura of modelo.roupas) textura.dispose();
// o modelo fica em cache para a próxima partida; dispose só solta a GPU (e os ouvintes) deste renderer
modelo.zumbi.cena.traverse((objeto) => {
const malha = objeto as Mesh;
if (!malha.isMesh) return;
malha.geometry.dispose();
const material = malha.material as MeshStandardMaterial;
for (const valor of Object.values(material))
if ((valor as Texture | null)?.isTexture)
(valor as Texture).dispose();
material.dispose();
});
},
};
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import Bancada from "../Bancada";
export const metadata: Metadata = {
title: "Bancada do jogo",
robots: { index: false, follow: false },
};
/** Inteiro da busca, dentro de `[min, max]`; ausente ou inválido vira `padrao`. */
function inteiro(
valor: string | string[] | undefined,
padrao: number,
min: number,
max: number,
) {
const numero = Number.parseInt(String(valor), 10);
return Number.isFinite(numero)
? Math.min(max, Math.max(min, numero))
: padrao;
}
export default async function BancadaPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const busca = await searchParams;
return (
<Bancada
zumbis={inteiro(busca.zumbis, 16, 1, 64)}
seg={inteiro(busca.seg, 20, 2, 120)}
/>
);
}
+435
View File
@@ -0,0 +1,435 @@
/**
* A partida na tela: renderer, câmera, laço de passo fixo, regulador de qualidade e a ligação entre a lógica
* (`lib/jogo`) e o que se desenha (`ambiente`, `atores`, `efeitos`).
*/
import {
type BufferGeometry,
type Mesh,
PerspectiveCamera,
Scene,
SRGBColorSpace,
type Texture,
TextureLoader,
WebGLRenderer,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { alvoNaMira } from "@/lib/jogo/armas";
import { avancar, criarPartida } from "@/lib/jogo/partida";
import { pilotar } from "@/lib/jogo/piloto";
import { amostrar, criarRegulador } from "@/lib/jogo/regulador";
import type { Partida } from "@/lib/jogo/tipos";
import { JOGADOR, NIVEIS, PASSO } from "@/lib/jogo/valores";
import { criarAmbiente } from "./ambiente";
import { criarAtores } from "./atores";
import type { Controle, Escolha, Resultado, Retrato } from "./contrato";
import { criarEfeitos } from "./efeitos";
import type { Som } from "./som";
import { carregarZumbi } from "./zumbis3d";
export type OpcoesDaCena = {
canvas: HTMLCanvasElement;
escolha: Escolha;
semente: number;
controle: Controle;
som: Som;
movimentoReduzido: boolean;
/** true: quem joga é `pilotar` de lib/jogo/piloto (testes e medição). */
piloto?: boolean;
/** Passos da lógica por passo de tempo real; padrão 1 (testes usam 8). */
velocidade?: number;
/** true se quem pediu a cena desistiu (saiu antes de carregar): conferido depois de cada `await`. */
cancelada(): boolean;
/** No máximo 10 vezes por segundo, e sempre que fase ou pausa mudar. */
aoRetrato(retrato: Retrato): void;
/** Só quando muda: a mira está ou não num zumbi. */
aoMirar(mirando: boolean): void;
/** Uma vez, 2 s depois de a fase virar vitoria ou derrota. */
aoTerminar(resultado: Resultado): void;
};
export type Cena = { pausar(): void; continuar(): void; descartar(): void };
/** O que os testes de ponta a ponta leem da partida. */
export type EstadoDoJogo = {
fase: Partida["fase"];
horda: number;
passo: number;
vida: number;
placar: { jogador: number; drones: number };
x: number;
z: number;
rumo: number;
nivel: number;
/** p95 dos últimos quadros, em ms. */
p95: number;
geometrias: number;
texturas: number;
chamadas: number;
/** Só depois de `descartar`: o que o renderer ainda contava como vivo. O esperado é 0 e 0. */
sobras?: { geometrias: number; texturas: number };
};
declare global {
interface Window {
__jogo?: EstadoDoJogo;
}
}
const MAX_PASSOS_POR_QUADRO = 5;
const INTERVALO_DO_RETRATO = 100;
const ESPERA_DO_FIM = 2;
/** Quadros guardados para o p95 de `window.__jogo`. */
const QUADROS_DO_P95 = 120;
async function carregarRoupa(nome: string): Promise<Texture | null> {
try {
const textura = await new TextureLoader().loadAsync(
`/jogo/roupa-${nome}.jpg`,
);
// como as texturas do glTF
textura.flipY = false;
textura.colorSpace = SRGBColorSpace;
return textura;
} catch {
return null;
}
}
async function carregarBloco(): Promise<BufferGeometry> {
const gltf = await new GLTFLoader().loadAsync("/3d/bloco.glb");
return (gltf.scene.children[0] as Mesh).geometry;
}
function retratar(p: Partida, pausado: boolean): Retrato {
return {
fase: p.fase,
horda: p.horda,
problemaAtual: p.hordas[p.horda],
restantes:
p.fila.length + p.zumbis.filter((z) => z.estado !== "morrendo").length,
vida: Math.max(0, p.jogador.vida),
arma: p.jogador.arma,
municao: p.jogador.municao,
placar: { ...p.placar },
feed: [...p.feed],
drones: p.drones.map((d) => ({
problemaId: d.problemaId,
eliminacoes: d.eliminacoes,
})),
pausado,
};
}
function mudou(a: Retrato, b: Retrato): boolean {
return (
a.fase !== b.fase ||
a.horda !== b.horda ||
a.restantes !== b.restantes ||
a.vida !== b.vida ||
a.arma !== b.arma ||
a.municao !== b.municao ||
a.placar.jogador !== b.placar.jogador ||
a.placar.drones !== b.placar.drones ||
a.feed.length !== b.feed.length ||
a.feed[a.feed.length - 1]?.passo !== b.feed[b.feed.length - 1]?.passo ||
a.drones.length !== b.drones.length ||
a.drones.some((d, i) => d.eliminacoes !== b.drones[i].eliminacoes) ||
a.pausado !== b.pausado
);
}
function p95(quadros: readonly number[]): number {
if (quadros.length === 0) return 0;
const ordenados = [...quadros].sort((a, b) => a - b);
return ordenados[Math.ceil(0.95 * ordenados.length) - 1];
}
/**
* A tabela de PBR (`DFG_LUT`) que o three cria uma vez no módulo e põe nos uniforms dos materiais padrão: o renderer
* a envia para a GPU mas não a solta no `dispose`. Vem de um material já desenhado; null se nenhum usou.
*/
function tabelaDePbr(renderer: WebGLRenderer, cena: Scene): Texture | null {
let achada: Texture | null = null;
cena.traverse((objeto) => {
const material = (objeto as Mesh).material;
if (achada || !material || Array.isArray(material)) return;
const propriedades = renderer.properties.get(material) as {
uniforms?: { dfgLUT?: { value: Texture | null } };
};
achada = propriedades.uniforms?.dfgLUT?.value ?? null;
});
return achada;
}
export async function criarCena(o: OpcoesDaCena): Promise<Cena> {
const velocidade = o.velocidade ?? 1;
const [zumbi, bloco, roupaB, roupaC] = await Promise.all([
carregarZumbi(),
carregarBloco(),
carregarRoupa("b"),
carregarRoupa("c"),
]);
const roupas = [roupaB, roupaC].filter((t): t is Texture => t !== null);
function soltarCarga() {
bloco.dispose();
for (const textura of roupas) textura.dispose();
}
// o jogador saiu durante a carga: nada de renderer nem cena
if (o.cancelada()) {
soltarCarga();
throw new Error("Carga cancelada");
}
let renderer: WebGLRenderer;
try {
renderer = new WebGLRenderer({
canvas: o.canvas,
antialias: true,
powerPreference: "high-performance",
});
} catch (erro) {
soltarCarga();
throw erro;
}
renderer.shadowMap.enabled = true;
const p = criarPartida({ semente: o.semente, ...o.escolha });
const cena = new Scene();
const camera = new PerspectiveCamera(75, 1, 0.05, 60);
camera.rotation.order = "YXZ";
cena.add(camera);
const ambiente = criarAmbiente(cena, p.mapa, {
camera,
movimentoReduzido: o.movimentoReduzido,
});
const atores = criarAtores(cena, { zumbi, bloco, roupas });
const efeitos = criarEfeitos(cena, camera, {
movimentoReduzido: o.movimentoReduzido,
som: o.som,
});
const regulador = criarRegulador(window.devicePixelRatio || 1);
let nivel = -1;
function aplicarNivel(novo: number) {
if (novo === nivel) return;
nivel = novo;
const degrau = NIVEIS[novo];
renderer.setPixelRatio(
Math.min(degrau.pixelRatio, window.devicePixelRatio || 1),
);
ambiente.definirSombras(degrau.sombras);
p.tetoAVista = degrau.teto;
}
function redimensionar() {
const largura = o.canvas.clientWidth || window.innerWidth;
const altura = o.canvas.clientHeight || window.innerHeight;
renderer.setSize(largura, altura, false);
camera.aspect = largura / altura;
camera.updateProjectionMatrix();
}
aplicarNivel(regulador.nivel);
redimensionar();
// jogador no passo anterior, para interpolar a câmera
const antes = { x: p.jogador.x, z: p.jogador.z, rumo: p.jogador.rumo };
let acumulado = 0;
let ultimo = -1;
let relogio = 0;
let pausado = false;
let raf = 0;
let fimEm: number | null = null;
let terminou = false;
let mirando = false;
const quadros: number[] = [];
let retrato = retratar(p, false);
let ultimoRetrato = -Infinity;
function publicar(agora: number, forcar: boolean) {
ultimoRetrato = agora;
const novo = retratar(p, pausado);
if (forcar || mudou(novo, retrato)) {
retrato = novo;
o.aoRetrato(novo);
}
const { memory, render } = renderer.info;
window.__jogo = {
fase: p.fase,
horda: p.horda,
passo: p.passo,
vida: p.jogador.vida,
placar: { ...p.placar },
x: p.jogador.x,
z: p.jogador.z,
rumo: p.jogador.rumo,
nivel,
p95: Math.round(p95(quadros) * 10) / 10,
geometrias: memory.geometries,
texturas: memory.textures,
chamadas: render.calls,
};
}
function passo() {
antes.x = p.jogador.x;
antes.z = p.jogador.z;
antes.rumo = p.jogador.rumo;
avancar(p, o.piloto ? pilotar(p) : o.controle.ler());
atores.guardarPasso(p);
for (const evento of p.eventos) efeitos.aoEvento(evento, p);
}
function terminar() {
terminou = true;
o.aoTerminar({
vitoria: p.fase === "vitoria",
problemaId: p.problemaId,
dificuldade: p.dificuldade,
resolvidos: [...p.resolvidos],
placar: { ...p.placar },
drones: p.drones.map((d) => ({
problemaId: d.problemaId,
eliminacoes: d.eliminacoes,
})),
segundos: Math.round(p.passo * PASSO),
});
}
function desenhar(alfa: number, dt: number) {
const t = Math.min(1, alfa);
camera.position.set(
antes.x + (p.jogador.x - antes.x) * t,
JOGADOR.olhos,
antes.z + (p.jogador.z - antes.z) * t,
);
camera.rotation.set(
p.jogador.inclinacao,
antes.rumo + (p.jogador.rumo - antes.rumo) * t + Math.PI,
0,
);
atores.sincronizar(p, t, dt * velocidade);
ambiente.atualizar(relogio);
efeitos.avancar(dt, p);
renderer.render(cena, camera);
}
/** Roda os passos de lógica que cabem no tempo acumulado; devolve quantos rodaram. */
function rodarPassos(): number {
const maximo = MAX_PASSOS_POR_QUADRO * velocidade;
let passos = 0;
while (acumulado >= PASSO && passos < maximo) {
passo();
acumulado -= PASSO;
passos++;
}
// atrasou demais (aba lenta): descarta o resto em vez de correr atrás
if (passos === maximo) acumulado = Math.min(acumulado, PASSO);
return passos;
}
function conferirMira() {
const novaMira = alvoNaMira(p) !== null;
if (novaMira === mirando) return;
mirando = novaMira;
o.aoMirar(mirando);
}
function quadro(agora: number) {
const inicio = performance.now();
raf = requestAnimationFrame(quadro);
const ms = ultimo < 0 ? 0 : agora - ultimo;
ultimo = agora;
const dt = Math.min(ms / 1000, 0.25);
relogio += dt;
acumulado += dt * velocidade;
const fase = p.fase;
// a mira só muda quando a lógica anda
if (rodarPassos() > 0) conferirMira();
if (fimEm === null && (p.fase === "vitoria" || p.fase === "derrota"))
fimEm = relogio;
if (!terminou && fimEm !== null && relogio - fimEm >= ESPERA_DO_FIM)
terminar();
desenhar(acumulado / PASSO, dt);
if (ms > 0) {
aplicarNivel(amostrar(regulador, ms, performance.now() - inicio));
quadros.push(ms);
if (quadros.length > QUADROS_DO_P95) quadros.shift();
}
if (p.fase !== fase || agora - ultimoRetrato >= INTERVALO_DO_RETRATO)
publicar(agora, p.fase !== fase);
}
function pausar() {
if (pausado) return;
pausado = true;
cancelAnimationFrame(raf);
o.controle.soltar();
publicar(performance.now(), true);
}
function continuar() {
if (!pausado || semContexto) return;
pausado = false;
ultimo = -1;
raf = requestAnimationFrame(quadro);
publicar(performance.now(), true);
}
function aoMudarVisibilidade() {
if (document.hidden) pausar();
}
function aoRedimensionar() {
redimensionar();
// o canvas limpa ao mudar de tamanho; parado, redesenha o mesmo quadro
if (pausado) renderer.render(cena, camera);
}
// Contexto WebGL perdido (GPU reiniciada, aba em segundo plano no celular): pausa até ele voltar.
let semContexto = false;
function aoPerderContexto(evento: Event) {
// sem isto o navegador não devolve o contexto
evento.preventDefault();
semContexto = true;
pausar();
}
function aoRestaurarContexto() {
semContexto = false;
// o three já refez o estado dele; o quadro parado volta e "Continuar" passa a valer
renderer.render(cena, camera);
}
window.addEventListener("resize", aoRedimensionar);
document.addEventListener("visibilitychange", aoMudarVisibilidade);
o.canvas.addEventListener("webglcontextlost", aoPerderContexto);
o.canvas.addEventListener("webglcontextrestored", aoRestaurarContexto);
publicar(performance.now(), true);
raf = requestAnimationFrame(quadro);
return {
pausar,
continuar,
descartar() {
cancelAnimationFrame(raf);
window.removeEventListener("resize", aoRedimensionar);
document.removeEventListener("visibilitychange", aoMudarVisibilidade);
o.canvas.removeEventListener("webglcontextlost", aoPerderContexto);
o.canvas.removeEventListener("webglcontextrestored", aoRestaurarContexto);
const tabela = tabelaDePbr(renderer, cena);
efeitos.descartar();
atores.descartar();
ambiente.descartar();
// a próxima partida a envia de novo, num renderer novo
tabela?.dispose();
renderer.dispose();
// CA9: o que este renderer ainda conta depois de soltar tudo; fica em `window.__jogo` para o teste ler
const { geometries, textures } = renderer.info.memory;
if (window.__jogo)
window.__jogo.sobras = { geometrias: geometries, texturas: textures };
renderer.forceContextLoss();
},
};
}
+62
View File
@@ -0,0 +1,62 @@
import type {
ArmaId,
Dificuldade,
Entrada,
Fase,
LinhaFeed,
} from "@/lib/jogo/tipos";
/** O que a pessoa escolhe na tela inicial. */
export type Escolha = {
problemaId: string;
dificuldade: Dificuldade;
};
/** O que a tela mostra por cima da cena. A cena manda um novo quando algum valor muda, no máximo 10 vezes por segundo. */
export type Retrato = {
fase: Fase;
/** Índice da horda atual: 0 a 2, e 3 no chefão. */
horda: number;
/** Id do problema da horda atual. */
problemaAtual: string;
/** Zumbis que ainda faltam cair nesta horda (na fila e à vista). */
restantes: number;
vida: number;
arma: ArmaId;
municao: number;
placar: { jogador: number; drones: number };
feed: readonly LinhaFeed[];
/** Um por drone vivo, na ordem em que nasceram. */
drones: readonly { problemaId: string; eliminacoes: number }[];
pausado: boolean;
};
/** O que sobra da partida para o relatório. */
export type Resultado = {
vitoria: boolean;
problemaId: string;
dificuldade: Dificuldade;
/** Ids dos problemas resolvidos, na ordem. */
resolvidos: readonly string[];
placar: { jogador: number; drones: number };
drones: readonly { problemaId: string; eliminacoes: number }[];
segundos: number;
};
/**
* Junta teclado, mouse e toque numa `Entrada` por passo da lógica.
* Convenção de giro (a mesma da lógica): `olhar[0]` positivo vira à esquerda, `olhar[1]` positivo olha para cima.
*/
export type Controle = {
/** A entrada deste passo. O giro acumulado desde a última leitura é devolvido e zerado. */
ler(): Entrada;
/** Direcional de toque: x para a direita, y para a frente, cada um de -1 a 1. */
definirMover(x: number, y: number): void;
/** Soma giro em radianos (toque ou mouse). */
somarOlhar(rumo: number, inclinacao: number): void;
definirAtirar(atirando: boolean): void;
definirArma(arma: ArmaId): void;
/** Solta tudo: sem movimento, sem tiro, sem giro pendente. Usado ao pausar. */
soltar(): void;
descartar(): void;
};
+243
View File
@@ -0,0 +1,243 @@
import { fireEvent } from "@testing-library/react";
import { criarControle } from "./controle";
let alvo: HTMLDivElement;
let travaDo: Element | null;
beforeEach(() => {
alvo = document.createElement("div");
document.body.append(alvo);
travaDo = null;
Object.defineProperty(document, "pointerLockElement", {
configurable: true,
get: () => travaDo,
});
});
afterEach(() => {
alvo.remove();
});
function travar() {
travaDo = alvo;
document.dispatchEvent(new Event("pointerlockchange"));
}
function mover(movementX: number, movementY: number) {
const e = new MouseEvent("mousemove");
Object.defineProperty(e, "movementX", { value: movementX });
Object.defineProperty(e, "movementY", { value: movementY });
document.dispatchEvent(e);
}
describe("criarControle: teclado", () => {
it("W e as setas movem; teclas opostas se anulam", () => {
const c = criarControle(alvo);
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.keyDown(window, { code: "ArrowRight" });
expect(c.ler().mover).toEqual([1, 1]);
fireEvent.keyDown(window, { code: "KeyS" });
fireEvent.keyDown(window, { code: "KeyA" });
expect(c.ler().mover).toEqual([0, 0]);
fireEvent.keyUp(window, { code: "KeyS" });
fireEvent.keyUp(window, { code: "KeyA" });
expect(c.ler().mover).toEqual([1, 1]);
c.descartar();
});
it("soma o direcional de toque ao teclado", () => {
const c = criarControle(alvo);
c.definirMover(-1, 0.5);
fireEvent.keyDown(window, { code: "KeyD" });
expect(c.ler().mover).toEqual([0, 0.5]);
c.descartar();
});
it("1, 2 e 3 escolhem a arma", () => {
const c = criarControle(alvo);
expect(c.ler().arma).toBe(0);
fireEvent.keyDown(window, { code: "Digit3" });
expect(c.ler().arma).toBe(2);
fireEvent.keyDown(window, { code: "Digit2" });
expect(c.ler().arma).toBe(1);
fireEvent.keyDown(window, { code: "Digit1" });
expect(c.ler().arma).toBe(0);
c.descartar();
});
it("a roda troca para a próxima e para a anterior, em volta", () => {
const c = criarControle(alvo);
fireEvent.wheel(window, { deltaY: 100 });
expect(c.ler().arma).toBe(1);
fireEvent.wheel(window, { deltaY: -100 });
fireEvent.wheel(window, { deltaY: -100 });
expect(c.ler().arma).toBe(2);
fireEvent.wheel(window, { deltaY: 100 });
expect(c.ler().arma).toBe(0);
c.descartar();
});
it("perder o foco da janela solta as teclas", () => {
const c = criarControle(alvo);
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.blur(window);
expect(c.ler().mover).toEqual([0, 0]);
c.descartar();
});
it("ignora tecla com o foco num campo de texto ou botão", () => {
const c = criarControle(alvo);
const campo = document.createElement("input");
const botao = document.createElement("button");
document.body.append(campo, botao);
fireEvent.keyDown(campo, { code: "KeyW" });
fireEvent.keyDown(botao, { code: "Digit3" });
const entrada = c.ler();
expect(entrada.mover).toEqual([0, 0]);
expect(entrada.arma).toBe(0);
campo.remove();
botao.remove();
c.descartar();
});
});
describe("criarControle: mouse", () => {
it("clicar no alvo pede a trava do ponteiro", () => {
alvo.requestPointerLock = jest.fn();
const c = criarControle(alvo);
fireEvent.click(alvo);
expect(alvo.requestPointerLock).toHaveBeenCalledTimes(1);
c.descartar();
});
it("clicar não falha onde não há trava do ponteiro", () => {
const c = criarControle(alvo);
expect(() => fireEvent.click(alvo)).not.toThrow();
c.descartar();
});
it("avisa quando a trava muda", () => {
const aoMudarTrava = jest.fn();
const c = criarControle(alvo, { aoMudarTrava });
travar();
travaDo = null;
document.dispatchEvent(new Event("pointerlockchange"));
expect(aoMudarTrava.mock.calls).toEqual([[true], [false]]);
c.descartar();
});
it("sem a trava, o mouse não gira nem atira", () => {
const c = criarControle(alvo);
mover(100, 100);
fireEvent.mouseDown(document, { button: 0 });
const entrada = c.ler();
expect(entrada.olhar).toEqual([0, 0]);
expect(entrada.atirar).toBe(false);
c.descartar();
});
it("com a trava, soma o giro e o ler o zera", () => {
const c = criarControle(alvo);
travar();
mover(100, -50);
mover(100, 0);
const [rumo, inclinacao] = c.ler().olhar;
expect(rumo).toBeCloseTo(-200 * 0.0022);
expect(inclinacao).toBeCloseTo(50 * 0.0022);
expect(c.ler().olhar).toEqual([0, 0]);
c.descartar();
});
it("botão esquerdo pressionado atira; outro botão e soltar não", () => {
const c = criarControle(alvo);
travar();
fireEvent.mouseDown(document, { button: 2 });
expect(c.ler().atirar).toBe(false);
fireEvent.mouseDown(document, { button: 0 });
expect(c.ler().atirar).toBe(true);
fireEvent.mouseUp(document, { button: 2 });
expect(c.ler().atirar).toBe(true);
fireEvent.mouseUp(document, { button: 0 });
expect(c.ler().atirar).toBe(false);
c.descartar();
});
it("clique inteiro entre duas leituras ainda dá um tiro, e só um", () => {
const c = criarControle(alvo);
travar();
fireEvent.mouseDown(document, { button: 0 });
fireEvent.mouseUp(document, { button: 0 });
expect(c.ler().atirar).toBe(true);
expect(c.ler().atirar).toBe(false);
c.descartar();
});
});
describe("criarControle: toque, soltar e descartar", () => {
it("junta o giro, o tiro e a arma vindos do toque", () => {
const c = criarControle(alvo);
c.somarOlhar(0.1, 0.2);
c.somarOlhar(0.1, 0);
c.definirAtirar(true);
c.definirArma(2);
expect(c.ler()).toEqual({
mover: [0, 0],
olhar: [0.2, 0.2],
atirar: true,
arma: 2,
});
c.descartar();
});
it("toque no botão de tiro entre duas leituras ainda dá um tiro, e só um", () => {
const c = criarControle(alvo);
c.definirAtirar(true);
c.definirAtirar(false);
expect(c.ler().atirar).toBe(true);
expect(c.ler().atirar).toBe(false);
c.descartar();
});
it("soltar zera movimento, tiro e giro, e mantém a arma", () => {
const c = criarControle(alvo);
travar();
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.mouseDown(document, { button: 0 });
c.definirMover(1, 1);
c.definirAtirar(true);
c.somarOlhar(1, 1);
c.definirArma(1);
c.soltar();
expect(c.ler()).toEqual({
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 1,
});
c.descartar();
});
it("descartar remove todos os ouvintes", () => {
alvo.requestPointerLock = jest.fn();
const aoMudarTrava = jest.fn();
const c = criarControle(alvo, { aoMudarTrava });
travar();
aoMudarTrava.mockClear();
c.descartar();
fireEvent.keyDown(window, { code: "KeyW" });
fireEvent.keyDown(window, { code: "Digit3" });
fireEvent.wheel(window, { deltaY: 100 });
fireEvent.mouseDown(document, { button: 0 });
mover(100, 100);
fireEvent.click(alvo);
document.dispatchEvent(new Event("pointerlockchange"));
expect(c.ler()).toEqual({
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 0,
});
expect(alvo.requestPointerLock).not.toHaveBeenCalled();
expect(aoMudarTrava).not.toHaveBeenCalled();
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { ArmaId, Entrada } from "@/lib/jogo/tipos";
import type { Controle } from "./contrato";
const SENSIBILIDADE = 0.0022;
const CAMPOS = "input, textarea, select, button, [contenteditable]";
const ARMAS_NA_ORDEM = 3;
/** Teclas de movimento: [direita, frente]. */
const DIRECAO: Record<string, readonly [number, number]> = {
KeyW: [0, 1],
ArrowUp: [0, 1],
KeyS: [0, -1],
ArrowDown: [0, -1],
KeyD: [1, 0],
ArrowRight: [1, 0],
KeyA: [-1, 0],
ArrowLeft: [-1, 0],
};
const ARMA_DA_TECLA: Record<string, ArmaId> = {
Digit1: 0,
Digit2: 1,
Digit3: 2,
};
export function criarControle(
alvo: HTMLElement,
opcoes: { aoMudarTrava?: (travado: boolean) => void } = {},
): Controle {
const teclas = new Set<string>();
let toque: [number, number] = [0, 0];
let giro: [number, number] = [0, 0];
let mouseAtira = false;
let toqueAtira = false;
// apertou desde a última leitura: um clique inteiro entre dois passos ainda vira um tiro
let apertou = false;
let arma: ArmaId = 0;
const travado = () => document.pointerLockElement === alvo;
const aoApertar = (e: KeyboardEvent) => {
if (e.target instanceof Element && e.target.closest(CAMPOS)) return;
if (e.code in ARMA_DA_TECLA) arma = ARMA_DA_TECLA[e.code];
if (e.code in DIRECAO) {
teclas.add(e.code);
e.preventDefault();
}
};
const aoSoltar = (e: KeyboardEvent) => {
teclas.delete(e.code);
};
const aoPerderFoco = () => teclas.clear();
const aoRolar = (e: WheelEvent) => {
const passo = e.deltaY > 0 ? 1 : -1;
arma = ((arma + passo + ARMAS_NA_ORDEM) % ARMAS_NA_ORDEM) as ArmaId;
};
const aoClicar = () => alvo.requestPointerLock?.();
const aoMover = (e: MouseEvent) => {
if (!travado()) return;
giro = [
giro[0] - e.movementX * SENSIBILIDADE,
giro[1] - e.movementY * SENSIBILIDADE,
];
};
const aoApertarMouse = (e: MouseEvent) => {
if (e.button !== 0 || !travado()) return;
mouseAtira = true;
apertou = true;
};
const aoSoltarMouse = (e: MouseEvent) => {
if (e.button === 0) mouseAtira = false;
};
const aoMudarTrava = () => opcoes.aoMudarTrava?.(travado());
window.addEventListener("keydown", aoApertar);
window.addEventListener("keyup", aoSoltar);
window.addEventListener("blur", aoPerderFoco);
window.addEventListener("wheel", aoRolar);
alvo.addEventListener("click", aoClicar);
document.addEventListener("mousemove", aoMover);
document.addEventListener("mousedown", aoApertarMouse);
document.addEventListener("mouseup", aoSoltarMouse);
document.addEventListener("pointerlockchange", aoMudarTrava);
const soltar = () => {
teclas.clear();
toque = [0, 0];
giro = [0, 0];
mouseAtira = false;
toqueAtira = false;
apertou = false;
};
return {
ler(): Entrada {
let x = toque[0];
let y = toque[1];
for (const t of teclas) {
x += DIRECAO[t][0];
y += DIRECAO[t][1];
}
const olhar = giro;
giro = [0, 0];
const atirar = mouseAtira || toqueAtira || apertou;
apertou = false;
return { mover: [x, y], olhar, atirar, arma };
},
definirMover: (x, y) => {
toque = [x, y];
},
somarOlhar: (rumo, inclinacao) => {
giro = [giro[0] + rumo, giro[1] + inclinacao];
},
definirAtirar: (atirando) => {
if (atirando && !toqueAtira) apertou = true;
toqueAtira = atirando;
},
definirArma: (nova) => {
arma = nova;
},
soltar,
descartar: () => {
window.removeEventListener("keydown", aoApertar);
window.removeEventListener("keyup", aoSoltar);
window.removeEventListener("blur", aoPerderFoco);
window.removeEventListener("wheel", aoRolar);
alvo.removeEventListener("click", aoClicar);
document.removeEventListener("mousemove", aoMover);
document.removeEventListener("mousedown", aoApertarMouse);
document.removeEventListener("mouseup", aoSoltarMouse);
document.removeEventListener("pointerlockchange", aoMudarTrava);
},
};
}
+314
View File
@@ -0,0 +1,314 @@
import {
BufferGeometry,
type InstancedMesh,
Matrix4,
type Mesh,
type Object3D,
PerspectiveCamera,
type PointLight,
Scene,
} from "three";
import { criarPartida } from "@/lib/jogo/partida";
import { PROBLEMAS } from "@/lib/jogo/problemas";
import type { Evento, Partida, Zumbi } from "@/lib/jogo/tipos";
import { criarEfeitos } from "./efeitos";
import type { Som } from "./som";
// O jsdom não desenha em <canvas>: um contexto falso aceita qualquer chamada.
const contextoFalso: unknown = new Proxy(
{},
{
get: () => () => ({ addColorStop() {} }),
set: () => true,
},
);
beforeAll(() => {
jest
.spyOn(HTMLCanvasElement.prototype, "getContext")
.mockImplementation((() => contextoFalso) as never);
});
afterAll(() => jest.restoreAllMocks());
function somFalso(): Som & { tocar: jest.Mock } {
return {
liberar: jest.fn(() => Promise.resolve()),
tocar: jest.fn(),
definirMudo: jest.fn(),
descartar: jest.fn(),
};
}
function montar(movimentoReduzido = false) {
const cena = new Scene();
const camera = new PerspectiveCamera(70, 1, 0.05, 100);
cena.add(camera);
const p: Partida = criarPartida({
semente: 1,
dificuldade: "normal",
problemaId: PROBLEMAS[0].id,
});
camera.position.set(p.jogador.x, 1.6, p.jogador.z);
const som = somFalso();
const efeitos = criarEfeitos(cena, camera, { movimentoReduzido, som });
return { cena, camera, p, som, efeitos };
}
function zumbi(p: Partida): Zumbi {
const z: Zumbi = {
id: 7,
tipo: "comum",
problemaId: PROBLEMAS[0].id,
x: p.jogador.x,
z: p.jogador.z + 4,
rumo: Math.PI,
vida: 100,
estado: "andando",
tempo: 0,
};
p.zumbis.push(z);
return z;
}
function acerto(z: Zumbi, cabeca = false): Evento {
return { tipo: "acerto", zumbiId: z.id, cabeca, x: z.x, y: 1.2, z: z.z };
}
const tiro: Evento = { tipo: "tiro", autor: "jogador", arma: 0, distancia: 4 };
/** Instâncias com escala diferente de zero. */
function visiveis(malha: InstancedMesh): number {
const m = new Matrix4();
let n = 0;
for (let i = 0; i < malha.count; i++) {
malha.getMatrixAt(i, m);
if (m.determinant() !== 0) n++;
}
return n;
}
function achar<T extends Object3D>(cena: Scene, nome: string): T {
const o = cena.getObjectByName(nome);
if (!o) throw new Error(`sem ${nome}`);
return o as T;
}
function desenhados(raiz: Object3D): number {
let n = 0;
raiz.traverseVisible((o) => {
if ((o as Mesh).isMesh) n++;
});
return n;
}
it("200 acertos seguidos não passam de 64 gotas nem de 64 marcas", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
for (let i = 0; i < 200; i++) efeitos.aoEvento(acerto(z, i % 2 === 0), p);
const gotas = achar<InstancedMesh>(cena, "efeitos-gotas");
const marcas = achar<InstancedMesh>(cena, "efeitos-marcas");
efeitos.avancar(0.016, p);
expect(gotas.count).toBe(64);
expect(visiveis(gotas)).toBe(64);
expect(marcas.count).toBe(64);
expect(visiveis(marcas)).toBe(64);
});
it("as gotas somem depois de 0,5 s; tiro na cabeça espirra o dobro", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const gotas = achar<InstancedMesh>(cena, "efeitos-gotas");
efeitos.aoEvento(acerto(z), p);
efeitos.avancar(0.3, p);
expect(visiveis(gotas)).toBe(6);
efeitos.avancar(0.25, p);
expect(visiveis(gotas)).toBe(0);
efeitos.aoEvento(acerto(z, true), p);
efeitos.avancar(0.016, p);
expect(visiveis(gotas)).toBe(12);
});
it("a marca fica no chão embaixo do zumbi e a morte deixa uma poça maior", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const marcas = achar<InstancedMesh>(cena, "efeitos-marcas");
efeitos.aoEvento(acerto(z), p);
efeitos.aoEvento({ tipo: "morte", zumbiId: z.id, autor: "jogador" }, p);
efeitos.avancar(2, p);
const m = new Matrix4();
marcas.getMatrixAt(0, m);
const e = m.elements;
expect(e[13]).toBeLessThan(0.05);
expect(Math.abs(e[12] - z.x)).toBeLessThan(0.3);
expect(Math.abs(e[14] - z.z)).toBeLessThan(0.3);
const tamanho = (i: number) => {
marcas.getMatrixAt(i, m);
return Math.hypot(m.elements[0], m.elements[2]);
};
expect(tamanho(1)).toBeGreaterThan(tamanho(0));
});
it("tiro do jogador liga o clarão, a luz e o traçante, que apagam depois de 60 ms", () => {
const { cena, p, som, efeitos } = montar();
const clarao = achar<Mesh>(cena, "efeitos-clarao");
const luz = achar<PointLight>(cena, "efeitos-luz");
const tracantes = achar<InstancedMesh>(cena, "efeitos-tracantes");
expect(clarao.visible).toBe(false);
efeitos.aoEvento(tiro, p);
expect(som.tocar).toHaveBeenCalledWith("pistola", { variar: true });
efeitos.avancar(0.03, p);
expect(clarao.visible).toBe(true);
expect(luz.intensity).toBeGreaterThan(0);
expect(visiveis(tracantes)).toBe(1);
efeitos.avancar(0.04, p);
expect(clarao.visible).toBe(false);
expect(luz.intensity).toBe(0);
expect(visiveis(tracantes)).toBe(0);
});
it("o traçante do drone sai dele, a 2,3 m de altura, com som baixo", () => {
const { cena, p, som, efeitos } = montar();
p.drones.push({
id: 3,
problemaId: PROBLEMAS[0].id,
x: 2,
z: 2,
rumo: 0,
recarga: 0,
eliminacoes: 0,
});
efeitos.aoEvento({ tipo: "tiro", autor: 3, arma: "drone", distancia: 6 }, p);
expect(som.tocar).toHaveBeenCalledWith("drone", {
volume: 0.35,
variar: true,
});
const tracantes = achar<InstancedMesh>(cena, "efeitos-tracantes");
const m = new Matrix4();
tracantes.getMatrixAt(0, m);
// O meio da faixa fica entre o drone (z = 2) e o ponto do tiro (z = 8).
expect(m.elements[14]).toBeCloseTo(5, 5);
expect(m.elements[13]).toBeCloseTo((2.3 + 1.2) / 2, 5);
});
it("no máximo 8 traçantes e 6 chamadas de desenho somando tudo", () => {
const { cena, p, efeitos } = montar();
const z = zumbi(p);
const antes = desenhados(cena);
for (let i = 0; i < 20; i++) {
efeitos.aoEvento(tiro, p);
efeitos.aoEvento(acerto(z), p);
}
efeitos.aoEvento({ tipo: "morte", zumbiId: z.id, autor: "jogador" }, p);
efeitos.avancar(0.016, p);
expect(visiveis(achar<InstancedMesh>(cena, "efeitos-tracantes"))).toBe(8);
expect(desenhados(cena)).toBeLessThanOrEqual(6);
expect(antes).toBeLessThanOrEqual(6);
});
it("troca de arma troca o modelo visível", () => {
const { cena, p, efeitos } = montar();
const visivel = () =>
["pistola", "automatica", "faca"].filter(
(nome) => achar<Mesh>(cena, `arma-${nome}`).visible,
);
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["pistola"]);
p.jogador.arma = 1;
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["automatica"]);
p.jogador.arma = 2;
efeitos.avancar(0.016, p);
expect(visivel()).toEqual(["faca"]);
});
it("o golpe dá um tranco na câmera por 150 ms e toca o som", () => {
const { camera, p, som, efeitos } = montar();
const x = camera.position.x;
const y = camera.position.y;
efeitos.aoEvento({ tipo: "golpe", dano: 10, zumbiId: 0 }, p);
expect(som.tocar).toHaveBeenCalledWith("golpe");
efeitos.avancar(0.016, p);
expect(
camera.position.x !== x ||
camera.position.y !== y ||
camera.rotation.z !== 0,
).toBe(true);
efeitos.avancar(0.2, p);
expect(camera.position.x).toBeCloseTo(x, 10);
expect(camera.position.y).toBeCloseTo(y, 10);
expect(camera.rotation.z).toBeCloseTo(0, 10);
});
it("movimento reduzido não mexe a câmera no golpe nem balança a arma", () => {
const { cena, camera, p, efeitos } = montar(true);
camera.updateMatrix();
const inicial = camera.matrix.clone();
efeitos.aoEvento({ tipo: "golpe", dano: 10, zumbiId: 0 }, p);
const arma = achar<Object3D>(cena, "efeitos-arma");
const posicaoDaArma = arma.position.clone();
for (let i = 0; i < 20; i++) {
p.jogador.z += 0.07;
efeitos.avancar(0.016, p);
camera.updateMatrix();
expect(camera.matrix.equals(inicial)).toBe(true);
}
expect(arma.position.equals(posicaoDaArma)).toBe(true);
});
it("os eventos tocam os sons certos", () => {
const { p, som, efeitos } = montar();
const z = zumbi(p);
efeitos.aoEvento(
{ tipo: "tiro", autor: "jogador", arma: 2, distancia: 1 },
p,
);
efeitos.aoEvento(acerto(z), p);
efeitos.aoEvento({ tipo: "resolvido", problemaId: "x", droneId: 1 }, p);
efeitos.aoEvento({ tipo: "caixa", balas: 30 }, p);
const nomes = som.tocar.mock.calls.map((c) => c[0]);
expect(nomes).toEqual(["faca", "acerto", "resolvido", "caixa"]);
});
it("um zumbi a até 12 m geme a cada 2 a 5 s, mais baixo quando longe", () => {
const { p, som, efeitos } = montar();
const z = zumbi(p);
z.z = p.jogador.z + 9;
for (let i = 0; i < 300; i++) efeitos.avancar(0.02, p);
const gemidos = som.tocar.mock.calls.filter((c) =>
String(c[0]).startsWith("gemido"),
);
expect(gemidos.length).toBeGreaterThanOrEqual(1);
expect(gemidos.length).toBeLessThanOrEqual(3);
expect(gemidos[0][1].volume).toBeCloseTo(0.25, 5);
som.tocar.mockClear();
z.z = p.jogador.z + 13;
for (let i = 0; i < 300; i++) efeitos.avancar(0.02, p);
expect(som.tocar).not.toHaveBeenCalled();
});
it("descartar tira tudo da cena e da câmera e libera a memória", () => {
const { cena, camera, p, efeitos } = montar();
const z = zumbi(p);
efeitos.aoEvento(tiro, p);
efeitos.aoEvento(acerto(z), p);
const liberadas = jest.spyOn(BufferGeometry.prototype, "dispose");
efeitos.descartar();
expect(camera.children).toHaveLength(0);
expect(cena.children).toEqual([camera]);
expect(liberadas.mock.calls.length).toBeGreaterThanOrEqual(7);
liberadas.mockRestore();
});
it("sem câmera na cena, coloca e depois retira", () => {
const cena = new Scene();
const camera = new PerspectiveCamera();
const efeitos = criarEfeitos(cena, camera, {
movimentoReduzido: false,
som: somFalso(),
});
expect(camera.parent).toBe(cena);
efeitos.descartar();
expect(cena.children).toHaveLength(0);
expect(camera.parent).toBeNull();
});
+714
View File
@@ -0,0 +1,714 @@
/**
* Efeitos da cena: arma na tela, clarão do cano, traçante, sangue e os sons ligados aos eventos da lógica.
* Tudo em reserva fixa e sem alocação por quadro em `avancar`. São 5 chamadas de desenho no total:
* arma, clarão, traçantes, gotas e marcas.
*/
import {
AdditiveBlending,
BoxGeometry,
BufferGeometry,
CanvasTexture,
Color,
DynamicDrawUsage,
Float32BufferAttribute,
Group,
InstancedMesh,
Matrix4,
Mesh,
MeshBasicMaterial,
MeshStandardMaterial,
type PerspectiveCamera,
PlaneGeometry,
PointLight,
Quaternion,
type Scene,
Vector3,
} from "three";
import type { ArmaId, Evento, Partida } from "@/lib/jogo/tipos";
import { JOGADOR } from "@/lib/jogo/valores";
import type { Som } from "./som";
export type Efeitos = {
aoEvento(evento: Evento, p: Partida): void;
/** `dt` em segundos. */
avancar(dt: number, p: Partida): void;
descartar(): void;
};
/** Durações em segundos. */
const CLARAO = 0.06;
const TRACANTE = 0.06;
const VIDA_DA_GOTA = 0.5;
const TRANCO = 0.15;
const CRESCIMENTO_DA_POCA = 1.2;
const MAX_TRACANTES = 8;
const MAX_GOTAS = 64;
const MAX_MARCAS = 64;
const GRAVIDADE = 9.8;
/** A mesma altura do modelo do drone em `atores.ts`. */
const ALTURA_DO_DRONE = 2.3;
/** A mesma altura de acerto do drone em `lib/jogo/drones.ts`. */
const ALTURA_DO_ACERTO_DO_DRONE = 1.2;
const ALCANCE_DO_GEMIDO = 12;
const ESPESSURA_DO_TRACANTE = 0.012;
const COR_DO_FERROLHO = 0x464a51;
const COR_DA_ARMACAO = 0x2c2f34;
const COR_ESCURA = 0x17181b;
const COR_DA_LAMINA = 0xb4bac2;
const COR_DO_FIO = 0xdde2e8;
const COR_DA_GOTA = 0x6a0a0a;
const COR_DA_MARCA = 0x3b0505;
const COR_DO_FOGO = 0xffc46b;
/** Onde a arma fica no espaço da câmera (x à direita, y para cima, −z para a frente): baixa, no canto direito. */
const POSICAO_DA_ARMA = new Vector3(0.2, -0.21, -0.44);
/** Giro de repouso em y: o cano aponta um pouco para dentro, na direção da mira. */
const GIRO_DA_ARMA = 0.07;
/** Uma caixa do modelo: largura, altura, profundidade, centro (x, y, z), cor e giro em x. */
type Peca = readonly [
number,
number,
number,
number,
number,
number,
number,
number?,
];
/** Pistola de 19 cm: ferrolho, cano, armação, guarda-mato e cabo inclinado. */
const PISTOLA: readonly Peca[] = [
[0.026, 0.03, 0.186, 0, 0.024, -0.068, COR_DO_FERROLHO],
[0.012, 0.012, 0.008, 0, 0.026, -0.163, COR_ESCURA],
[0.002, 0.011, 0.04, 0.0132, 0.029, -0.04, COR_ESCURA],
[0.018, 0.006, 0.006, 0, 0.042, 0.018, COR_ESCURA],
[0.004, 0.006, 0.005, 0, 0.042, -0.154, COR_ESCURA],
[0.024, 0.018, 0.16, 0, 0, -0.06, COR_DA_ARMACAO],
[0.012, 0.006, 0.052, 0, -0.03, -0.058, COR_DA_ARMACAO],
[0.012, 0.03, 0.007, 0, -0.016, -0.084, COR_DA_ARMACAO],
[0.005, 0.018, 0.005, 0, -0.014, -0.05, COR_ESCURA, 0.2],
[0.028, 0.105, 0.048, 0, -0.055, 0.008, COR_DA_ARMACAO, -0.32],
[0.026, 0.012, 0.02, 0, -0.004, 0.03, COR_DA_ARMACAO],
[0.03, 0.01, 0.052, 0, -0.108, 0.027, COR_ESCURA, -0.32],
];
/** Automática: mais comprida, com carregador e coronha curta vazada. */
const AUTOMATICA: readonly Peca[] = [
[0.034, 0.05, 0.24, 0, 0.02, -0.08, COR_DO_FERROLHO],
[0.014, 0.008, 0.22, 0, 0.049, -0.1, COR_ESCURA],
[0.02, 0.016, 0.02, 0, 0.06, 0.01, COR_ESCURA],
[0.006, 0.02, 0.008, 0, 0.056, -0.29, COR_ESCURA],
[0.032, 0.04, 0.11, 0, 0.015, -0.255, COR_DA_ARMACAO],
[0.014, 0.014, 0.06, 0, 0.02, -0.34, COR_ESCURA],
[0.02, 0.02, 0.03, 0, 0.02, -0.385, COR_ESCURA],
[0.022, 0.12, 0.04, 0, -0.06, -0.13, COR_ESCURA, 0.15],
[0.028, 0.09, 0.04, 0, -0.04, 0, COR_DA_ARMACAO, -0.3],
[0.012, 0.006, 0.05, 0, -0.022, -0.045, COR_DA_ARMACAO],
[0.008, 0.008, 0.12, 0, 0.03, 0.1, COR_ESCURA],
[0.008, 0.008, 0.12, 0, -0.01, 0.1, COR_ESCURA],
[0.03, 0.07, 0.012, 0, 0.01, 0.16, COR_DA_ARMACAO],
];
/** Faca: lâmina de aço com fio mais claro, guarda e cabo escuro. */
const FACA: readonly Peca[] = [
[0.024, 0.03, 0.11, 0, -0.005, 0.02, COR_ESCURA],
[0.012, 0.05, 0.012, 0, 0, -0.04, COR_DO_FERROLHO],
[0.004, 0.03, 0.16, 0, 0.002, -0.126, COR_DA_LAMINA],
[0.0042, 0.006, 0.16, 0, -0.011, -0.126, COR_DO_FIO],
[0.004, 0.018, 0.04, 0, 0.008, -0.222, COR_DA_LAMINA],
];
const MODELOS = [PISTOLA, AUTOMATICA, FACA] as const;
const NOMES_DOS_MODELOS = ["pistola", "automatica", "faca"] as const;
/** Boca do cano de cada arma, no espaço do grupo da arma. */
const BOCAIS: readonly Vector3[] = [
new Vector3(0, 0.026, -0.17),
new Vector3(0, 0.02, -0.405),
new Vector3(0, 0, -0.25),
];
const TAMANHO_DO_CLARAO = [0.1, 0.13, 0];
/** Coice por arma: recuo em z, giro em x e em y. A faca avança e corta de lado. */
const COICES: readonly (readonly [number, number, number])[] = [
[0.045, 0.16, 0],
[0.02, 0.05, 0],
[-0.09, 0.1, 0.6],
];
const SONS_DAS_ARMAS = ["pistola", "automatica", "faca"] as const;
/** Junta caixas numa geometria só, com a cor em cada vértice: uma chamada de desenho por arma. */
function montarModelo(pecas: readonly Peca[]): BufferGeometry {
const posicoes: number[] = [];
const normais: number[] = [];
const cores: number[] = [];
const indices: number[] = [];
const cor = new Color();
for (const [l, a, pr, x, y, z, hex, giro] of pecas) {
const caixa = new BoxGeometry(l, a, pr);
if (giro) caixa.rotateX(giro);
caixa.translate(x, y, z);
const base = posicoes.length / 3;
const posicao = caixa.getAttribute("position");
posicoes.push(...(posicao.array as Float32Array));
normais.push(...(caixa.getAttribute("normal").array as Float32Array));
cor.setHex(hex);
for (let i = 0; i < posicao.count; i++) cores.push(cor.r, cor.g, cor.b);
const indice = caixa.getIndex();
if (indice) for (const i of indice.array) indices.push(base + i);
caixa.dispose();
}
const geometria = new BufferGeometry();
geometria.setAttribute("position", new Float32BufferAttribute(posicoes, 3));
geometria.setAttribute("normal", new Float32BufferAttribute(normais, 3));
geometria.setAttribute("color", new Float32BufferAttribute(cores, 3));
geometria.setIndex(indices);
return geometria;
}
function criarTela(): [HTMLCanvasElement, CanvasRenderingContext2D | null] {
const tela = document.createElement("canvas");
tela.width = 64;
tela.height = 64;
return [tela, tela.getContext("2d")];
}
/** Mancha irregular branca com gotas em volta; a cor vem do material. */
function texturaDeRespingo(): CanvasTexture {
const [tela, ctx] = criarTela();
if (ctx) {
ctx.fillStyle = "#ffffff";
for (let i = 0; i < 10; i++) {
const angulo = (i / 10) * Math.PI * 2;
const r = 4 + Math.random() * 6;
ctx.beginPath();
ctx.arc(
32 + Math.cos(angulo) * r,
32 + Math.sin(angulo) * r,
9 + Math.random() * 6,
0,
Math.PI * 2,
);
ctx.fill();
}
for (let i = 0; i < 8; i++) {
const angulo = Math.random() * Math.PI * 2;
const r = 18 + Math.random() * 9;
ctx.beginPath();
ctx.arc(
32 + Math.cos(angulo) * r,
32 + Math.sin(angulo) * r,
1.5 + Math.random() * 2.5,
0,
Math.PI * 2,
);
ctx.fill();
}
}
return new CanvasTexture(tela);
}
/** Brilho redondo com uma cruz fina, para o clarão do cano. */
function texturaDeClarao(): CanvasTexture {
const [tela, ctx] = criarTela();
if (ctx) {
const brilho = ctx.createRadialGradient(32, 32, 0, 32, 32, 32);
brilho.addColorStop(0, "rgba(255,255,255,1)");
brilho.addColorStop(0.3, "rgba(255,255,255,0.45)");
brilho.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = brilho;
ctx.fillRect(0, 0, 64, 64);
ctx.fillStyle = "rgba(255,255,255,0.8)";
ctx.fillRect(4, 31, 56, 2);
ctx.fillRect(31, 4, 2, 56);
}
return new CanvasTexture(tela);
}
function criarReserva(
geometria: BufferGeometry,
material: MeshBasicMaterial,
quantidade: number,
nome: string,
): InstancedMesh<BufferGeometry, MeshBasicMaterial> {
const malha = new InstancedMesh(geometria, material, quantidade);
malha.name = nome;
malha.instanceMatrix.setUsage(DynamicDrawUsage);
// As instâncias andam pelo mapa todo; a esfera calculada uma vez cortaria as que saem dela.
malha.frustumCulled = false;
for (let i = 0; i < quantidade; i++) malha.setMatrixAt(i, ESCONDIDA);
return malha;
}
const ESCONDIDA = new Matrix4().makeScale(0, 0, 0);
const EIXO_Y = new Vector3(0, 1, 0);
const EIXO_Z = new Vector3(0, 0, 1);
/** Reaproveitado a cada gemido para não alocar dentro de `avancar`. */
const OPCOES_DO_GEMIDO = { volume: 1, variar: true };
export function criarEfeitos(
cena: Scene,
camera: PerspectiveCamera,
opcoes: { movimentoReduzido: boolean; som: Som },
): Efeitos {
const { movimentoReduzido, som } = opcoes;
// Filhos da câmera só aparecem se a câmera estiver na cena.
const colocouCamera = camera.parent === null;
if (colocouCamera) cena.add(camera);
// Arma na tela.
const materialDaArma = new MeshStandardMaterial({
vertexColors: true,
metalness: 0.45,
roughness: 0.62,
});
const arma = new Group();
arma.name = "efeitos-arma";
arma.position.copy(POSICAO_DA_ARMA);
const modelos = MODELOS.map((pecas, i) => {
const modelo = new Mesh(montarModelo(pecas), materialDaArma);
modelo.name = `arma-${NOMES_DOS_MODELOS[i]}`;
modelo.visible = i === 0;
arma.add(modelo);
return modelo;
});
let armaAtual: ArmaId = 0;
const texturaDoClarao = texturaDeClarao();
const clarao = new Mesh(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({
map: texturaDoClarao,
color: COR_DO_FOGO,
blending: AdditiveBlending,
transparent: true,
depthWrite: false,
toneMapped: false,
}),
);
clarao.name = "efeitos-clarao";
clarao.visible = false;
// A luz fica sempre na cena e só muda de intensidade: ligar e desligar luzes recompila os materiais.
const luz = new PointLight(COR_DO_FOGO, 0, 6, 2);
luz.name = "efeitos-luz";
arma.add(clarao, luz);
camera.add(arma);
// Efeitos no mundo.
const mundo = new Group();
mundo.name = "efeitos";
cena.add(mundo);
const tracantes = criarReserva(
new BoxGeometry(1, 1, 1),
new MeshBasicMaterial({
color: 0xfff0b0,
blending: AdditiveBlending,
transparent: true,
depthWrite: false,
toneMapped: false,
}),
MAX_TRACANTES,
"efeitos-tracantes",
);
const respingo = texturaDeRespingo();
const gotas = criarReserva(
new PlaneGeometry(1, 1),
new MeshBasicMaterial({
map: respingo,
color: COR_DA_GOTA,
transparent: true,
depthWrite: false,
}),
MAX_GOTAS,
"efeitos-gotas",
);
const marcas = criarReserva(
new PlaneGeometry(1, 1).rotateX(-Math.PI / 2),
new MeshBasicMaterial({
map: respingo,
color: COR_DA_MARCA,
transparent: true,
opacity: 0.9,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -1,
polygonOffsetUnits: -4,
}),
MAX_MARCAS,
"efeitos-marcas",
);
const cor = new Color();
for (let i = 0; i < MAX_TRACANTES; i++)
tracantes.setColorAt(i, cor.setScalar(1));
mundo.add(marcas, gotas, tracantes);
// Estado em reserva fixa.
const vidaDoTracante = new Float32Array(MAX_TRACANTES);
let proximoTracante = 0;
let tracantesVivos = 0;
const posicaoDaGota = new Float32Array(MAX_GOTAS * 3);
const velocidadeDaGota = new Float32Array(MAX_GOTAS * 3);
const vidaDaGota = new Float32Array(MAX_GOTAS);
const tamanhoDaGota = new Float32Array(MAX_GOTAS);
let proximaGota = 0;
let gotasVivas = 0;
/** x, z, ângulo e tamanho final de cada marca; `crescimento` vai de 0 a 1. */
const marca = new Float32Array(MAX_MARCAS * 4);
const crescimento = new Float32Array(MAX_MARCAS).fill(1);
let proximaMarca = 0;
let marcasCrescendo = 0;
let restoDoClarao = 0;
let coice = 0;
let tranco = 0;
let fase = 0;
let amplitude = 0;
let ultimoX = Number.NaN;
let ultimoZ = Number.NaN;
let origemX = 0;
let origemZ = 0;
let proximoGemido = 2 + Math.random() * 3;
const trancoAplicado = new Vector3();
let rolAplicado = 0;
// Temporários reaproveitados.
const a = new Vector3();
const b = new Vector3();
const escala = new Vector3();
const direcao = new Vector3();
const meio = new Vector3();
const giro = new Quaternion();
const matriz = new Matrix4();
function trocarArma(nova: ArmaId): void {
modelos[armaAtual].visible = false;
modelos[nova].visible = true;
armaAtual = nova;
clarao.position.copy(BOCAIS[nova]);
luz.position.copy(BOCAIS[nova]);
}
trocarArma(0);
function lancarTracante(de: Vector3, ate: Vector3): void {
const i = proximoTracante;
proximoTracante = (i + 1) % MAX_TRACANTES;
if (vidaDoTracante[i] <= 0) tracantesVivos++;
vidaDoTracante[i] = TRACANTE;
direcao.subVectors(ate, de);
const comprimento = direcao.length();
if (comprimento < 1e-3) tracantes.setMatrixAt(i, ESCONDIDA);
else {
giro.setFromUnitVectors(EIXO_Z, direcao.divideScalar(comprimento));
escala.set(ESPESSURA_DO_TRACANTE, ESPESSURA_DO_TRACANTE, comprimento);
meio.addVectors(de, ate).multiplyScalar(0.5);
tracantes.setMatrixAt(i, matriz.compose(meio, giro, escala));
}
tracantes.setColorAt(i, cor.setScalar(1));
tracantes.instanceMatrix.needsUpdate = true;
if (tracantes.instanceColor) tracantes.instanceColor.needsUpdate = true;
}
function pintarMarca(i: number): void {
const k = i * 4;
const s = marca[k + 3] * (0.25 + 0.75 * crescimento[i]);
a.set(marca[k], 0.012, marca[k + 1]);
giro.setFromAxisAngle(EIXO_Y, marca[k + 2]);
escala.set(s, 1, s);
marcas.setMatrixAt(i, matriz.compose(a, giro, escala));
marcas.instanceMatrix.needsUpdate = true;
}
function deixarMarca(
x: number,
z: number,
tamanho: number,
crescer: boolean,
): void {
const i = proximaMarca;
proximaMarca = (i + 1) % MAX_MARCAS;
const k = i * 4;
marca[k] = x;
marca[k + 1] = z;
marca[k + 2] = Math.random() * Math.PI * 2;
marca[k + 3] = tamanho;
if (crescimento[i] < 1) marcasCrescendo--;
crescimento[i] = crescer ? 0 : 1;
if (crescer) marcasCrescendo++;
pintarMarca(i);
}
function zumbiPorId(p: Partida, id: number) {
for (const zumbi of p.zumbis) if (zumbi.id === id) return zumbi;
return undefined;
}
function espirrar(x: number, y: number, z: number, quantidade: number): void {
let dx = x - origemX;
let dz = z - origemZ;
const d = Math.hypot(dx, dz);
if (d > 1e-3) {
dx /= d;
dz /= d;
}
for (let n = 0; n < quantidade; n++) {
const i = proximaGota;
proximaGota = (i + 1) % MAX_GOTAS;
if (vidaDaGota[i] <= 0) gotasVivas++;
vidaDaGota[i] = VIDA_DA_GOTA;
tamanhoDaGota[i] = 0.03 + Math.random() * 0.05;
const velocidade = 1.2 + Math.random() * 2.3;
const k = i * 3;
posicaoDaGota[k] = x;
posicaoDaGota[k + 1] = y;
posicaoDaGota[k + 2] = z;
velocidadeDaGota[k] = dx * velocidade + (Math.random() - 0.5) * 1.6;
velocidadeDaGota[k + 1] = 0.6 + Math.random() * 1.8;
velocidadeDaGota[k + 2] = dz * velocidade + (Math.random() - 0.5) * 1.6;
}
}
function atirou(evento: Extract<Evento, { tipo: "tiro" }>, p: Partida): void {
if (evento.autor === "jogador") {
const j = p.jogador;
origemX = j.x;
origemZ = j.z;
const qual = evento.arma === "drone" ? armaAtual : evento.arma;
if (qual !== armaAtual) trocarArma(qual);
coice = 1;
som.tocar(SONS_DAS_ARMAS[qual], { variar: true });
if (qual === 2) return;
restoDoClarao = CLARAO;
clarao.visible = true;
clarao.rotation.z = Math.random() * Math.PI;
clarao.scale.setScalar(
TAMANHO_DO_CLARAO[qual] * (0.8 + Math.random() * 0.4),
);
luz.intensity = 4;
camera.updateMatrixWorld();
a.copy(BOCAIS[qual]).applyMatrix4(arma.matrixWorld);
const t = evento.distancia;
b.set(
j.x + Math.sin(j.rumo) * t,
JOGADOR.olhos + Math.tan(j.inclinacao) * t,
j.z + Math.cos(j.rumo) * t,
);
lancarTracante(a, b);
return;
}
som.tocar("drone", { volume: 0.35, variar: true });
const drone = p.drones.find((d) => d.id === evento.autor);
if (!drone) return;
origemX = drone.x;
origemZ = drone.z;
const t = evento.distancia;
a.set(drone.x, ALTURA_DO_DRONE, drone.z);
b.set(
drone.x + Math.sin(drone.rumo) * t,
ALTURA_DO_ACERTO_DO_DRONE,
drone.z + Math.cos(drone.rumo) * t,
);
lancarTracante(a, b);
}
function avancarTracantes(dt: number): void {
if (tracantesVivos === 0) return;
for (let i = 0; i < MAX_TRACANTES; i++) {
if (vidaDoTracante[i] <= 0) continue;
vidaDoTracante[i] -= dt;
if (vidaDoTracante[i] <= 0) {
tracantesVivos--;
tracantes.setMatrixAt(i, ESCONDIDA);
} else
tracantes.setColorAt(i, cor.setScalar(vidaDoTracante[i] / TRACANTE));
}
tracantes.instanceMatrix.needsUpdate = true;
if (tracantes.instanceColor) tracantes.instanceColor.needsUpdate = true;
}
function avancarGotas(dt: number): void {
if (gotasVivas === 0) return;
for (let i = 0; i < MAX_GOTAS; i++) {
if (vidaDaGota[i] <= 0) continue;
vidaDaGota[i] -= dt;
if (vidaDaGota[i] <= 0) {
gotasVivas--;
gotas.setMatrixAt(i, ESCONDIDA);
continue;
}
const k = i * 3;
velocidadeDaGota[k + 1] -= GRAVIDADE * dt;
posicaoDaGota[k] += velocidadeDaGota[k] * dt;
posicaoDaGota[k + 1] += velocidadeDaGota[k + 1] * dt;
posicaoDaGota[k + 2] += velocidadeDaGota[k + 2] * dt;
if (posicaoDaGota[k + 1] < 0.02) {
posicaoDaGota[k + 1] = 0.02;
velocidadeDaGota[k] = 0;
velocidadeDaGota[k + 1] = 0;
velocidadeDaGota[k + 2] = 0;
}
const s = tamanhoDaGota[i] * (0.5 + vidaDaGota[i] / VIDA_DA_GOTA);
a.set(posicaoDaGota[k], posicaoDaGota[k + 1], posicaoDaGota[k + 2]);
escala.set(s, s, s);
gotas.setMatrixAt(i, matriz.compose(a, camera.quaternion, escala));
}
gotas.instanceMatrix.needsUpdate = true;
}
function avancarMarcas(dt: number): void {
if (marcasCrescendo === 0) return;
for (let i = 0; i < MAX_MARCAS; i++) {
if (crescimento[i] >= 1) continue;
crescimento[i] = Math.min(1, crescimento[i] + dt / CRESCIMENTO_DA_POCA);
if (crescimento[i] >= 1) marcasCrescendo--;
pintarMarca(i);
}
}
function avancarArma(dt: number, p: Partida): void {
if (p.jogador.arma !== armaAtual) trocarArma(p.jogador.arma);
if (restoDoClarao > 0) {
restoDoClarao -= dt;
if (restoDoClarao <= 0) {
clarao.visible = false;
luz.intensity = 0;
}
}
const { x, z } = p.jogador;
const andou = Math.hypot(x - ultimoX, z - ultimoZ);
ultimoX = x;
ultimoZ = z;
const andando =
!movimentoReduzido && dt > 0 && andou / dt > 0.5 && andou < 1;
amplitude += ((andando ? 1 : 0) - amplitude) * Math.min(1, dt * 8);
if (andando) fase += dt * 9;
coice *= Math.exp(-dt * 14);
const [recuo, giroX, giroY] = COICES[armaAtual];
arma.position.set(
POSICAO_DA_ARMA.x + Math.sin(fase) * 0.012 * amplitude,
POSICAO_DA_ARMA.y - Math.abs(Math.cos(fase)) * 0.01 * amplitude,
POSICAO_DA_ARMA.z + recuo * coice,
);
arma.rotation.set(giroX * coice, GIRO_DA_ARMA + giroY * coice, 0);
}
/** O tranco é somado por cima de quem posiciona a câmera e retirado no quadro seguinte. */
function avancarTranco(dt: number): void {
if (tranco <= 0 && rolAplicado === 0 && trancoAplicado.lengthSq() === 0)
return;
camera.position.sub(trancoAplicado);
camera.rotation.z -= rolAplicado;
tranco -= dt;
if (tranco > 0) {
const forca = tranco / TRANCO;
trancoAplicado.set(
(Math.random() - 0.5) * 0.08 * forca,
(Math.random() - 0.5) * 0.06 * forca,
0,
);
rolAplicado = (Math.random() - 0.5) * 0.06 * forca;
} else {
trancoAplicado.set(0, 0, 0);
rolAplicado = 0;
}
camera.position.add(trancoAplicado);
camera.rotation.z += rolAplicado;
}
function avancarGemidos(dt: number, p: Partida): void {
proximoGemido -= dt;
if (proximoGemido > 0) return;
proximoGemido = 2 + Math.random() * 3;
const total = p.zumbis.length;
const inicio = Math.floor(Math.random() * total);
for (let n = 0; n < total; n++) {
const zumbi = p.zumbis[(inicio + n) % total];
if (zumbi.estado === "morrendo") continue;
const d = Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z);
if (d > ALCANCE_DO_GEMIDO) continue;
OPCOES_DO_GEMIDO.volume = 1 - d / ALCANCE_DO_GEMIDO;
som.tocar(
Math.random() < 0.5 ? "gemido-1" : "gemido-2",
OPCOES_DO_GEMIDO,
);
return;
}
}
return {
aoEvento(evento, p) {
switch (evento.tipo) {
case "tiro":
atirou(evento, p);
break;
case "acerto": {
som.tocar("acerto", { volume: 0.7, variar: true });
espirrar(evento.x, evento.y, evento.z, evento.cabeca ? 12 : 6);
const zumbi = zumbiPorId(p, evento.zumbiId);
const x = zumbi ? zumbi.x : evento.x;
const z = zumbi ? zumbi.z : evento.z;
deixarMarca(
x + (Math.random() - 0.5) * 0.4,
z + (Math.random() - 0.5) * 0.4,
0.25 + Math.random() * 0.25,
false,
);
break;
}
case "morte": {
const zumbi = zumbiPorId(p, evento.zumbiId);
if (zumbi)
deixarMarca(zumbi.x, zumbi.z, 1 + Math.random() * 0.5, true);
break;
}
case "golpe":
som.tocar("golpe");
if (!movimentoReduzido) tranco = TRANCO;
break;
case "resolvido":
som.tocar("resolvido");
break;
case "caixa":
som.tocar("caixa");
break;
}
},
avancar(dt, p) {
avancarArma(dt, p);
avancarTracantes(dt);
avancarGotas(dt);
avancarMarcas(dt);
avancarTranco(dt);
avancarGemidos(dt, p);
},
descartar() {
camera.position.sub(trancoAplicado);
camera.rotation.z -= rolAplicado;
trancoAplicado.set(0, 0, 0);
rolAplicado = 0;
arma.removeFromParent();
mundo.removeFromParent();
if (colocouCamera) camera.removeFromParent();
for (const modelo of modelos) modelo.geometry.dispose();
materialDaArma.dispose();
for (const malha of [clarao, tracantes, gotas, marcas]) {
malha.geometry.dispose();
malha.material.dispose();
}
for (const malha of [tracantes, gotas, marcas]) malha.dispose();
texturaDoClarao.dispose();
respingo.dispose();
luz.dispose();
},
};
}
+10
View File
@@ -0,0 +1,10 @@
/** Medidas dos botões de toque. O Toque posiciona os botões com elas e o Hud usa as mesmas para o feed não invadir a área deles. */
export const TOQUE_FOLGA = "16px";
export const TOQUE_ESPACO = "8px";
export const TOQUE_BOTAO = "56px";
export const TOQUE_ATIRAR = "80px";
export const TOQUE_DIREITA = `calc(${TOQUE_FOLGA} + env(safe-area-inset-right))`;
export const TOQUE_EMBAIXO = `calc(${TOQUE_FOLGA} + env(safe-area-inset-bottom))`;
/** Distância da borda de baixo até o topo do botão mais alto (a fileira de armas). */
export const TOQUE_TOPO = `calc(${TOQUE_EMBAIXO} + ${TOQUE_ATIRAR} + ${TOQUE_ESPACO} + ${TOQUE_BOTAO})`;
+19
View File
@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import JogoPage, { metadata } from "./page";
// o Jogo tem os próprios testes; aqui só importa o que a página põe em volta dele
jest.mock("./Jogo", () => ({ __esModule: true, default: () => null }));
describe("página /jogo", () => {
it("fica fora da busca", () => {
expect(metadata.robots).toEqual({ index: false, follow: false });
expect(metadata.title).toBe("Expediente dos Mortos");
});
it("sempre mostra a lista de problemas e soluções", () => {
render(<JogoPage />);
expect(
screen.getByRole("heading", { name: "Os problemas e as soluções" }),
).toBeInTheDocument();
});
});
+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import Jogo from "./Jogo";
import ListaDeProblemas from "./ListaDeProblemas";
export const metadata: Metadata = {
title: "Expediente dos Mortos",
description:
"Um jogo de tiro em que cada horda de zumbis é um problema de empresa. Os problemas da sua empresa não morrem sozinhos.",
robots: { index: false, follow: false },
};
// O h1 vem do Inicio e do Relatorio, dentro do Jogo; durante a partida a camada fixa cobre a página.
export default function JogoPage() {
return (
<main className="bg-[var(--ink)]">
<Jogo />
<noscript>
<p className="px-sp-6 py-sp-8 font-display text-[17px] font-medium leading-[1.55] text-[#f4f2ed] lg:px-sp-12">
O jogo precisa de JavaScript. Os problemas e as soluções estão abaixo.
</p>
</noscript>
<ListaDeProblemas />
</main>
);
}
+183
View File
@@ -0,0 +1,183 @@
import { criarSom } from "./som";
class GanhoFalso {
gain = { value: 1 };
connect = jest.fn();
disconnect = jest.fn();
}
class FonteFalsa {
buffer: unknown = null;
playbackRate = { value: 1 };
onended: (() => void) | null = null;
connect = jest.fn();
start = jest.fn();
stop = jest.fn();
}
class ContextoFalso {
static ultimo: ContextoFalso | null = null;
destination = {};
ganhos: GanhoFalso[] = [];
fontes: FonteFalsa[] = [];
close = jest.fn(() => Promise.resolve());
constructor() {
ContextoFalso.ultimo = this;
}
state = "running";
resume = jest.fn(() => Promise.resolve());
decodeAudioData() {
return Promise.resolve({});
}
createGain() {
const g = new GanhoFalso();
this.ganhos.push(g);
return g;
}
createBufferSource() {
const f = new FonteFalsa();
this.fontes.push(f);
return f;
}
}
const g = globalThis as unknown as { AudioContext?: unknown; fetch?: unknown };
function respostaOk() {
return Promise.resolve({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
});
}
beforeEach(() => {
ContextoFalso.ultimo = null;
g.AudioContext = ContextoFalso;
g.fetch = jest.fn(respostaOk);
});
afterEach(() => {
delete g.AudioContext;
delete g.fetch;
});
it("tocar antes de liberar não quebra nem cria contexto", () => {
const som = criarSom();
expect(() => som.tocar("pistola")).not.toThrow();
expect(ContextoFalso.ultimo).toBeNull();
expect(g.fetch).not.toHaveBeenCalled();
});
it("liberar cria o contexto e baixa os dez sons", async () => {
const som = criarSom();
await som.liberar();
expect(ContextoFalso.ultimo).not.toBeNull();
expect(g.fetch).toHaveBeenCalledTimes(10);
expect(g.fetch).toHaveBeenCalledWith("/jogo/sons/gemido-1.mp3");
som.tocar("acerto", { volume: 0.4 });
const ctx = ContextoFalso.ultimo as ContextoFalso;
expect(ctx.fontes).toHaveLength(1);
expect(ctx.fontes[0].start).toHaveBeenCalled();
expect(ctx.ganhos.at(-1)?.gain.value).toBe(0.4);
});
it("liberar de novo retoma o contexto parado, sem lançar se a retomada falhar", async () => {
const som = criarSom();
await som.liberar();
const ctx = ContextoFalso.ultimo as ContextoFalso;
ctx.resume.mockClear();
await som.liberar();
expect(ctx.resume).not.toHaveBeenCalled();
for (const parado of ["suspended", "interrupted"]) {
ctx.state = parado;
ctx.resume.mockImplementationOnce(() => Promise.reject(new Error("não")));
await expect(som.liberar()).resolves.toBeUndefined();
}
expect(ctx.resume).toHaveBeenCalledTimes(2);
expect(ContextoFalso.ultimo).toBe(ctx);
});
it("no máximo 8 vozes: a mais antiga é cortada", async () => {
const som = criarSom();
await som.liberar();
for (let i = 0; i < 10; i++) som.tocar("automatica");
const fontes = (ContextoFalso.ultimo as ContextoFalso).fontes;
expect(fontes).toHaveLength(10);
expect(fontes.filter((f) => f.stop.mock.calls.length > 0)).toEqual([
fontes[0],
fontes[1],
]);
});
it("voz que terminou libera a vaga", async () => {
const som = criarSom();
await som.liberar();
for (let i = 0; i < 8; i++) som.tocar("pistola");
const fontes = (ContextoFalso.ultimo as ContextoFalso).fontes;
fontes[3].onended?.();
som.tocar("pistola");
expect(fontes.some((f) => f.stop.mock.calls.length > 0)).toBe(false);
});
it("variar muda a velocidade em até 6%", async () => {
const som = criarSom();
await som.liberar();
for (let i = 0; i < 20; i++) som.tocar("pistola", { variar: true });
for (const f of (ContextoFalso.ultimo as ContextoFalso).fontes) {
expect(f.playbackRate.value).toBeGreaterThanOrEqual(0.94);
expect(f.playbackRate.value).toBeLessThanOrEqual(1.06);
}
});
it("mudo zera o ganho geral, antes ou depois de liberar", async () => {
const som = criarSom();
som.definirMudo(true);
await som.liberar();
const geral = (ContextoFalso.ultimo as ContextoFalso).ganhos[0];
expect(geral.gain.value).toBe(0);
som.definirMudo(false);
expect(geral.gain.value).toBe(1);
som.definirMudo(true);
expect(geral.gain.value).toBe(0);
});
it("falha de download não lança erro e o som que faltou não toca", async () => {
const erro = jest.spyOn(console, "error").mockImplementation(() => {});
g.fetch = jest.fn((url: string) => {
if (url.includes("pistola")) return Promise.reject(new Error("rede"));
if (url.includes("faca")) return Promise.resolve({ ok: false });
return respostaOk();
});
const som = criarSom();
await expect(som.liberar()).resolves.toBeUndefined();
som.tocar("pistola");
som.tocar("faca");
som.tocar("golpe");
expect((ContextoFalso.ultimo as ContextoFalso).fontes).toHaveLength(1);
expect(erro).not.toHaveBeenCalled();
erro.mockRestore();
});
it("sem AudioContext tudo vira no-op", async () => {
delete g.AudioContext;
const som = criarSom();
await expect(som.liberar()).resolves.toBeUndefined();
expect(() => {
som.tocar("drone");
som.definirMudo(true);
som.descartar();
}).not.toThrow();
expect(g.fetch).not.toHaveBeenCalled();
});
it("descartar fecha o contexto e cala o resto", async () => {
const som = criarSom();
await som.liberar();
som.tocar("caixa");
som.descartar();
const ctx = ContextoFalso.ultimo as ContextoFalso;
expect(ctx.close).toHaveBeenCalled();
expect(ctx.fontes[0].stop).toHaveBeenCalled();
som.tocar("caixa");
expect(ctx.fontes).toHaveLength(1);
});
+127
View File
@@ -0,0 +1,127 @@
/** Sons do jogo com Web Audio. Sem `AudioContext` no navegador, tudo vira no-op. */
export type NomeDoSom =
| "pistola"
| "automatica"
| "faca"
| "acerto"
| "gemido-1"
| "gemido-2"
| "golpe"
| "drone"
| "resolvido"
| "caixa";
export type Som = {
/** Cria o contexto e baixa os sons; depois, retoma o contexto parado. Chamar a cada toque ou clique que começa ou volta ao jogo. */
liberar(): Promise<void>;
tocar(nome: NomeDoSom, opcoes?: { volume?: number; variar?: boolean }): void;
definirMudo(mudo: boolean): void;
descartar(): void;
};
const NOMES: readonly NomeDoSom[] = [
"pistola",
"automatica",
"faca",
"acerto",
"gemido-1",
"gemido-2",
"golpe",
"drone",
"resolvido",
"caixa",
];
const MAXIMO_DE_VOZES = 8;
/** `variar` muda a velocidade em até 6% para cima ou para baixo. */
const VARIACAO = 0.06;
type Voz = { fonte: AudioBufferSourceNode; ganho: GainNode };
export function criarSom(): Som {
let contexto: AudioContext | null = null;
let geral: GainNode | null = null;
let carga: Promise<void> | null = null;
let mudo = false;
let descartado = false;
const buffers = new Map<NomeDoSom, AudioBuffer>();
const vozes: Voz[] = [];
async function baixar(ctx: AudioContext, nome: NomeDoSom): Promise<void> {
try {
const resposta = await fetch(`/jogo/sons/${nome}.mp3`);
if (!resposta.ok) return;
const buffer = await ctx.decodeAudioData(await resposta.arrayBuffer());
buffers.set(nome, buffer);
} catch {
// Som que não carregou simplesmente não toca.
}
}
function soltar(voz: Voz): void {
const i = vozes.indexOf(voz);
if (i >= 0) vozes.splice(i, 1);
voz.ganho.disconnect();
}
return {
liberar() {
const Contexto = globalThis.AudioContext;
if (descartado || typeof Contexto !== "function")
return Promise.resolve();
if (carga) {
// no iOS o contexto fica `interrupted` ou `suspended` ao sair da aba
if (contexto && contexto.state !== "running")
contexto.resume().catch(() => {});
return carga;
}
const ctx = new Contexto();
contexto = ctx;
geral = ctx.createGain();
geral.gain.value = mudo ? 0 : 1;
geral.connect(ctx.destination);
carga = Promise.all([
ctx.resume().catch(() => {}),
...NOMES.map((nome) => baixar(ctx, nome)),
]).then(() => {});
return carga;
},
tocar(nome, opcoes) {
const buffer = buffers.get(nome);
if (!contexto || !geral || !buffer) return;
if (vozes.length >= MAXIMO_DE_VOZES) {
const antiga = vozes[0];
antiga.fonte.stop();
soltar(antiga);
}
const fonte = contexto.createBufferSource();
fonte.buffer = buffer;
if (opcoes?.variar)
fonte.playbackRate.value = 1 + (Math.random() * 2 - 1) * VARIACAO;
const ganho = contexto.createGain();
ganho.gain.value = opcoes?.volume ?? 1;
fonte.connect(ganho);
ganho.connect(geral);
const voz = { fonte, ganho };
vozes.push(voz);
fonte.onended = () => soltar(voz);
fonte.start();
},
definirMudo(valor) {
mudo = valor;
if (geral) geral.gain.value = mudo ? 0 : 1;
},
descartar() {
descartado = true;
for (const voz of vozes.splice(0)) voz.fonte.stop();
buffers.clear();
contexto?.close().catch(() => {});
contexto = null;
geral = null;
},
};
}
+90
View File
@@ -0,0 +1,90 @@
import {
type AnimationAction,
AnimationClip,
AnimationMixer,
LoopOnce,
LoopRepeat,
type Object3D,
type Skeleton,
type SkinnedMesh,
} from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { clone } from "three/examples/jsm/utils/SkeletonUtils.js";
/** O zumbi baixado: a cena original (nunca vai para a tela, só é clonada) e os clipes de animação. */
export type ModeloDoZumbi = { cena: Object3D; clipes: AnimationClip[] };
export type Zumbi = {
raiz: Object3D;
tocar(clipe: string, opcoes?: { umaVez?: boolean }): void;
avancar(dt: number): void;
descartar(): void;
};
/** Duração da transição entre um clipe e o seguinte, em segundos. */
const TRANSICAO = 0.2;
let carga: Promise<ModeloDoZumbi> | null = null;
/** Baixa `public/jogo/zumbi.glb` uma vez só; se a carga falhar, a próxima chamada tenta de novo. */
export function carregarZumbi(): Promise<ModeloDoZumbi> {
carga ??= new GLTFLoader()
.loadAsync("/jogo/zumbi.glb")
.then((gltf) => ({ cena: gltf.scene, clipes: gltf.animations }))
.catch((erro) => {
carga = null;
throw erro;
});
return carga;
}
/**
* Um zumbi novo: esqueleto próprio e um `AnimationMixer` só dele; geometria, materiais e texturas são os do modelo,
* compartilhados entre todos. `SkeletonUtils.clone` dá um esqueleto a cada malha; as malhas do clone passam a usar o
* da primeira, para os ossos serem calculados e enviados uma vez por quadro. `descartar` solta só o que é do clone
* (mixer e textura dos ossos); quem descarta a geometria e os materiais é a cena.
*/
export function criarZumbi(modelo: ModeloDoZumbi): Zumbi {
const raiz = clone(modelo.cena);
let esqueleto: Skeleton | null = null;
raiz.traverse((objeto) => {
const malha = objeto as SkinnedMesh;
if (!malha.isSkinnedMesh) return;
// os esqueletos descartados ainda não têm textura: ela só nasce no primeiro desenho
if (esqueleto) malha.bind(esqueleto, malha.bindMatrix);
else esqueleto = malha.skeleton;
});
const mixer = new AnimationMixer(raiz);
let atual: AnimationAction | null = null;
return {
raiz,
tocar(nome, opcoes) {
const clipe = AnimationClip.findByName(modelo.clipes, nome);
if (!clipe) throw new Error(`O zumbi não tem o clipe "${nome}"`);
const acao = mixer.clipAction(clipe);
const umaVez = opcoes?.umaVez ?? false;
if (acao === atual && !umaVez) return;
// "morrer" é o último clipe de um zumbi: o próximo é de um clone que voltou da reserva, e começa do zero
if (atual?.loop === LoopOnce) {
mixer.stopAllAction();
mixer.setTime(0);
atual = null;
}
acao.reset();
acao.setLoop(umaVez ? LoopOnce : LoopRepeat, Infinity);
acao.clampWhenFinished = umaVez;
acao.play();
if (atual && atual !== acao) atual.crossFadeTo(acao, TRANSICAO, false);
atual = acao;
},
avancar(dt) {
mixer.update(dt);
},
descartar() {
mixer.stopAllAction();
mixer.uncacheRoot(raiz);
esqueleto?.dispose();
raiz.removeFromParent();
},
};
}
+4
View File
@@ -31,6 +31,10 @@ describe("sitemap", () => {
expect(urls.some((url) => /not-found|nada/.test(url))).toBe(false);
});
it("never lists the game (/jogo) or its benchmark (/jogo/bancada)", () => {
expect(urls.some((url) => url.startsWith(`${siteUrl}/jogo`))).toBe(false);
});
it("gives every route the modification date of its own content, equal on every build", () => {
const day = (value: Date | string | undefined) =>
new Date(value ?? "").toISOString().slice(0, 10);