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

+27
View File
@@ -0,0 +1,27 @@
import { alavanca } from "./alavanca";
describe("alavanca", () => {
it("fica parada dentro da zona morta", () => {
expect(alavanca(4, -4, 56)).toEqual([0, 0]);
expect(alavanca(0, 0, 56)).toEqual([0, 0]);
});
it("empurrar para cima anda para a frente", () => {
const [x, y] = alavanca(0, -28, 56);
expect(x).toBeCloseTo(0);
expect(y).toBeCloseTo(0.5);
});
it("empurrar para baixo e para a direita anda para trás e para a direita", () => {
const [x, y] = alavanca(28, 28, 56);
expect(x).toBeCloseTo(0.5);
expect(y).toBeCloseTo(-0.5);
});
it("limita o comprimento a 1, mantendo a direção", () => {
const [x, y] = alavanca(-300, -300, 56);
expect(Math.hypot(x, y)).toBeCloseTo(1);
expect(x).toBeCloseTo(-Math.SQRT1_2);
expect(y).toBeCloseTo(Math.SQRT1_2);
});
});
+14
View File
@@ -0,0 +1,14 @@
/** Deslocamento do dedo em pixels (dy cresce para baixo) em [direita, frente], de -1 a 1. */
const ZONA_MORTA = 0.12;
export function alavanca(
dx: number,
dy: number,
raio: number,
): [number, number] {
const comprimento = Math.hypot(dx, dy) / raio;
if (comprimento < ZONA_MORTA) return [0, 0];
const escala = Math.min(1, comprimento) / comprimento / raio;
return [dx * escala, 0 - dy * escala];
}
+375
View File
@@ -0,0 +1,375 @@
import {
alvoNaMira,
atirar,
raioNoCirculo,
recolherCaixas,
resfriar,
trocarArma,
} from "./armas";
import { lerMapa } from "./mapa";
import type { ArmaId, Partida, TipoZumbi, Zumbi } from "./tipos";
import { ARMAS, JOGADOR, ZUMBIS } from "./valores";
const LARGURA = 7;
function mapaAberto() {
const linhas = ["#######", "#N...J#"];
for (let i = 0; i < 13; i++) linhas.push("#.....#");
linhas.push("#######");
return lerMapa(linhas);
}
function zumbi(
id: number,
x: number,
z: number,
tipo: TipoZumbi = "comum",
): Zumbi {
return {
id,
tipo,
problemaId: "site-lento",
x,
z,
rumo: 0,
vida: ZUMBIS[tipo].vida,
estado: "andando",
tempo: 0,
};
}
/** Jogador em (3.5, 2.5) olhando para +z, com a pistola. */
function partida(arma: ArmaId = 0): Partida {
return {
passo: 0,
rng: 1,
dificuldade: "normal",
problemaId: "site-lento",
hordas: [],
horda: 0,
fase: "horda",
tempoFase: 0,
tetoAVista: 16,
mapa: mapaAberto(),
jogador: {
x: 3.5,
z: 2.5,
rumo: 0,
inclinacao: 0,
vida: JOGADOR.vida,
arma,
municao: 10,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 100,
};
}
describe("alvoNaMira", () => {
it("é nulo com a faca", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 3.5)];
expect(alvoNaMira(p)).toBeNull();
});
it("é nulo sem zumbi na linha", () => {
const p = partida();
p.zumbis = [zumbi(1, 1.5, 5.5)];
expect(alvoNaMira(p)).toBeNull();
});
it("devolve o corpo, com a distância e a altura", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-0.2);
const alvo = alvoNaMira(p);
expect(alvo?.zumbi.id).toBe(1);
expect(alvo?.cabeca).toBe(false);
expect(alvo?.distancia).toBeCloseTo(2.65);
expect(alvo?.y).toBeCloseTo(1.6 - 0.2 * 2.65);
});
it("devolve a cabeça e não fere ninguém", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
expect(alvoNaMira(p)?.cabeca).toBe(true);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(0);
});
});
describe("raioNoCirculo", () => {
it("devolve a distância até a primeira interseção", () => {
expect(raioNoCirculo(0, 0, 0, 1, 0, 5, 1)).toBeCloseTo(4);
});
it("devolve null quando o raio passa ao lado", () => {
expect(raioNoCirculo(0, 0, 0, 1, 3, 5, 1)).toBeNull();
});
it("devolve null quando o círculo está atrás", () => {
expect(raioNoCirculo(0, 0, 0, 1, 0, -5, 1)).toBeNull();
});
it("devolve 0 com a origem dentro do círculo", () => {
expect(raioNoCirculo(0, 0.2, 0, 1, 0, 0, 1)).toBe(0);
});
});
describe("trocarArma", () => {
it("troca e põe a recarga em 10", () => {
const p = partida(0);
trocarArma(p, 1);
expect(p.jogador.arma).toBe(1);
expect(p.jogador.recarga).toBe(10);
});
it("não faz nada para a mesma arma", () => {
const p = partida(0);
trocarArma(p, 0);
expect(p.jogador.recarga).toBe(0);
});
});
describe("resfriar", () => {
it("desconta 1 da recarga", () => {
const p = partida();
p.jogador.recarga = 3;
resfriar(p);
expect(p.jogador.recarga).toBe(2);
});
it("para em 0", () => {
const p = partida();
resfriar(p);
expect(p.jogador.recarga).toBe(0);
});
});
describe("recolherCaixas", () => {
it("recolhe a caixa perto e deixa a longe", () => {
const p = partida();
p.caixas = [
{ id: 1, x: 3.5, z: 3.0 },
{ id: 2, x: 3.5, z: 8.5 },
];
recolherCaixas(p);
expect(p.caixas.map((c) => c.id)).toEqual([2]);
expect(p.jogador.municao).toBe(40);
expect(p.eventos).toEqual([{ tipo: "caixa", balas: 30 }]);
});
});
describe("atirar: regras gerais", () => {
it("não faz nada com recarga pendente", () => {
const p = partida();
p.jogador.recarga = 1;
atirar(p);
expect(p.eventos).toEqual([]);
expect(p.jogador.recarga).toBe(1);
});
it("automática sem bala não atira e não gasta recarga", () => {
const p = partida(1);
p.jogador.municao = 0;
atirar(p);
expect(p.eventos).toEqual([]);
expect(p.jogador.recarga).toBe(0);
});
it("automática gasta uma bala e arma a recarga", () => {
const p = partida(1);
atirar(p);
expect(p.jogador.municao).toBe(9);
expect(p.jogador.recarga).toBe(ARMAS[1].intervalo);
});
it("pistola nunca gasta bala, nem com a munição zerada", () => {
const p = partida(0);
p.jogador.municao = 0;
atirar(p);
expect(p.jogador.municao).toBe(0);
expect(p.jogador.recarga).toBe(ARMAS[0].intervalo);
expect(p.eventos).toHaveLength(1);
});
});
describe("atirar: tiro por raio", () => {
it("sem zumbi, o tiro vai até a parede", () => {
const p = partida();
atirar(p);
expect(p.eventos).toHaveLength(1);
const e = p.eventos[0];
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(12.5);
});
it("acerta o corpo com o dano da arma", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-0.2);
atirar(p);
expect(p.zumbis[0].vida).toBe(100 - ARMAS[0].dano);
const [tiro, acerto] = p.eventos;
expect(tiro).toMatchObject({ tipo: "tiro", autor: "jogador", arma: 0 });
expect(tiro.tipo === "tiro" && tiro.distancia).toBeCloseTo(2.65);
expect(acerto).toMatchObject({
tipo: "acerto",
zumbiId: 1,
cabeca: false,
});
expect(acerto.tipo === "acerto" && acerto.y).toBeCloseTo(1.6 - 0.2 * 2.65);
expect(acerto.tipo === "acerto" && acerto.x).toBeCloseTo(3.5);
expect(acerto.tipo === "acerto" && acerto.z).toBeCloseTo(5.15);
});
it("acerta a cabeça com o dano dobrado", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100 - ARMAS[0].dano * 2);
expect(p.eventos[1]).toMatchObject({ tipo: "acerto", cabeca: true });
});
it("erra por cima com a inclinação alta", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = 1;
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(1);
const e = p.eventos[0];
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(12.5);
});
it("erra por baixo, no chão", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-1);
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(1);
});
it("o raio que passa ao lado não acerta", () => {
const p = partida();
p.zumbis = [zumbi(1, 5.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
});
it("parede na frente do zumbi o protege", () => {
const p = partida();
p.mapa.opaco[8 * LARGURA + 3] = 1;
p.zumbis = [zumbi(1, 3.5, 10.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
const e = p.eventos[0];
expect(p.eventos).toHaveLength(1);
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(5.5);
});
it("o zumbi da frente protege o de trás", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 9.5), zumbi(2, 3.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.zumbis[1].vida).toBe(32);
});
it("zumbi morrendo é atravessado", () => {
const p = partida();
const morto = zumbi(1, 3.5, 5.5);
morto.estado = "morrendo";
p.zumbis = [morto, zumbi(2, 3.5, 9.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.zumbis[1].vida).toBe(32);
});
it("o tiro que passa por cima do da frente pode acertar o de trás", () => {
const p = partida();
p.zumbis = [zumbi(1, 3.5, 5.5, "rapido"), zumbi(2, 3.5, 9.5, "chefao")];
p.jogador.inclinacao = Math.atan(0.05);
atirar(p);
expect(p.zumbis[0].vida).toBe(50);
expect(p.zumbis[1].vida).toBeLessThan(1500);
});
it("matar registra a morte pelo ferir", () => {
const p = partida();
const fraco = zumbi(1, 3.5, 5.5);
fraco.vida = 10;
p.zumbis = [fraco];
atirar(p);
expect(fraco.estado).toBe("morrendo");
expect(p.eventos.some((e) => e.tipo === "morte")).toBe(true);
});
it("a automática usa o dano da automática", () => {
const p = partida(1);
p.zumbis = [zumbi(1, 3.5, 5.5)];
p.jogador.inclinacao = Math.atan(-0.2);
atirar(p);
expect(p.zumbis[0].vida).toBe(100 - ARMAS[1].dano);
});
});
describe("atirar: faca", () => {
it("fere o mais próximo dentro do alcance e do arco", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 3.9), zumbi(2, 3.5, 3.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.zumbis[1].vida).toBe(100 - ARMAS[2].dano);
expect(p.eventos[0]).toMatchObject({ tipo: "tiro", arma: 2 });
const acerto = p.eventos.find((e) => e.tipo === "acerto");
expect(acerto).toMatchObject({
zumbiId: 2,
cabeca: false,
y: ZUMBIS.comum.altura / 2,
});
});
it("não acerta fora do arco", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 1.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toEqual([
{ tipo: "tiro", autor: "jogador", arma: 2, distancia: ARMAS[2].alcance },
]);
});
it("não acerta fora do alcance", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 5.5)];
atirar(p);
expect(p.zumbis[0].vida).toBe(100);
expect(p.eventos).toHaveLength(1);
});
it("ignora o zumbi morrendo", () => {
const p = partida(2);
const morto = zumbi(1, 3.5, 3.5);
morto.estado = "morrendo";
p.zumbis = [morto];
atirar(p);
expect(p.eventos).toHaveLength(1);
});
it("a distância do tiro é a do zumbi ferido", () => {
const p = partida(2);
p.zumbis = [zumbi(1, 3.5, 3.5)];
atirar(p);
const e = p.eventos[0];
expect(e.tipo === "tiro" && e.distancia).toBeCloseTo(0.65);
});
});
+206
View File
@@ -0,0 +1,206 @@
/** Armas do jogador: tiro por raio, faca, troca, recarga e coleta de caixas. Sem three, React nem DOM. */
import { raioAteParede } from "./mapa";
import type { ArmaId, Partida, Zumbi } from "./tipos";
import {
ARCO_DA_FACA,
ARMAS,
FRACAO_DA_CABECA,
JOGADOR,
MULTIPLICADOR_DA_CABECA,
MUNICAO,
ZUMBIS,
} from "./valores";
import { ferir } from "./zumbis";
const FACA: ArmaId = 2;
const RECARGA_DA_TROCA = 10;
/** Distância ao longo do raio até o círculo; `null` se não cruza ou fica atrás; 0 se a origem está dentro. */
export function raioNoCirculo(
ox: number,
oz: number,
dx: number,
dz: number,
cx: number,
cz: number,
r: number,
): number | null {
const fx = ox - cx;
const fz = oz - cz;
const fora = fx * fx + fz * fz - r * r;
if (fora <= 0) return 0;
const b = fx * dx + fz * dz;
const delta = b * b - fora;
if (delta < 0) return null;
const t = -b - Math.sqrt(delta);
return t < 0 ? null : t;
}
export function trocarArma(p: Partida, arma: ArmaId): void {
if (arma === p.jogador.arma) return;
p.jogador.arma = arma;
p.jogador.recarga = RECARGA_DA_TROCA;
}
export function resfriar(p: Partida): void {
p.jogador.recarga = Math.max(0, p.jogador.recarga - 1);
}
export function recolherCaixas(p: Partida): void {
const { x, z } = p.jogador;
const perto = (c: { x: number; z: number }) =>
Math.hypot(c.x - x, c.z - z) <= MUNICAO.raioDeColeta;
const recolhidas = p.caixas.filter(perto);
for (let i = 0; i < recolhidas.length; i++) {
p.jogador.municao += MUNICAO.porCaixa;
p.eventos.push({ tipo: "caixa", balas: MUNICAO.porCaixa });
}
p.caixas = p.caixas.filter((c) => !perto(c));
}
type Cruzamento = { zumbi: Zumbi; t: number };
/** Zumbis vivos cruzados pelo raio antes do limite, do mais perto para o mais longe. */
function cruzamentos(
p: Partida,
dx: number,
dz: number,
limite: number,
): Cruzamento[] {
const lista: Cruzamento[] = [];
for (const zumbi of p.zumbis) {
if (zumbi.estado === "morrendo") continue;
const { x, z } = p.jogador;
const t = raioNoCirculo(
x,
z,
dx,
dz,
zumbi.x,
zumbi.z,
ZUMBIS[zumbi.tipo].raio,
);
if (t !== null && t < limite) lista.push({ zumbi, t });
}
return lista.sort((a, b) => a.t - b.t);
}
/** O primeiro zumbi cuja altura contém a altura do tiro, com essa altura. */
function primeiroAtingido(
p: Partida,
lista: Cruzamento[],
): (Cruzamento & { y: number }) | null {
const subida = Math.tan(p.jogador.inclinacao);
for (const c of lista) {
const y = JOGADOR.olhos + subida * c.t;
if (y >= 0 && y <= ZUMBIS[c.zumbi.tipo].altura) return { ...c, y };
}
return null;
}
/** Distância até a parede (ou o alcance da arma) no rumo atual. */
function limiteDoRaio(p: Partida): number {
const { x, z, rumo, arma } = p.jogador;
return raioAteParede(p.mapa, x, z, rumo, ARMAS[arma].alcance);
}
/** O zumbi que um tiro de raio acertaria agora; `null` com a faca ou sem alvo. */
export function alvoNaMira(p: Partida): {
zumbi: Zumbi;
distancia: number;
cabeca: boolean;
y: number;
} | null {
const { rumo, arma } = p.jogador;
if (arma === FACA) return null;
const lista = cruzamentos(p, Math.sin(rumo), Math.cos(rumo), limiteDoRaio(p));
const alvo = primeiroAtingido(p, lista);
if (!alvo) return null;
const { zumbi, t, y } = alvo;
const cabeca = y >= FRACAO_DA_CABECA * ZUMBIS[zumbi.tipo].altura;
return { zumbi, distancia: t, cabeca, y };
}
function atirarPorRaio(p: Partida): void {
const { x, z, rumo, arma } = p.jogador;
const alvo = alvoNaMira(p);
p.eventos.push({
tipo: "tiro",
autor: "jogador",
arma,
distancia: alvo ? alvo.distancia : limiteDoRaio(p),
});
if (!alvo) return;
const { zumbi, distancia: t, cabeca, y } = alvo;
const dx = Math.sin(rumo);
const dz = Math.cos(rumo);
ferir(
p,
zumbi,
cabeca ? ARMAS[arma].dano * MULTIPLICADOR_DA_CABECA : ARMAS[arma].dano,
"jogador",
);
p.eventos.push({
tipo: "acerto",
zumbiId: zumbi.id,
cabeca,
x: x + dx * t,
y,
z: z + dz * t,
});
}
/** Ângulo, de 0 a π, entre o rumo do jogador e a direção até o ponto. */
function desvio(p: Partida, zumbi: Zumbi): number {
const d =
Math.atan2(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z) - p.jogador.rumo;
return Math.abs(Math.atan2(Math.sin(d), Math.cos(d)));
}
/** Zumbi vivo mais próximo ao alcance da faca e dentro do arco, com a folga até a borda dele. */
function alvoDaFaca(p: Partida): { zumbi: Zumbi; folga: number } | null {
let melhor: { zumbi: Zumbi; folga: number } | null = null;
for (const zumbi of p.zumbis) {
if (zumbi.estado === "morrendo") continue;
const folga =
Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z) -
ZUMBIS[zumbi.tipo].raio;
const vale =
folga <= ARMAS[FACA].alcance && desvio(p, zumbi) <= ARCO_DA_FACA;
if (vale && (melhor === null || folga < melhor.folga))
melhor = { zumbi, folga };
}
return melhor;
}
function golpearComFaca(p: Partida): void {
const alvo = alvoDaFaca(p);
p.eventos.push({
tipo: "tiro",
autor: "jogador",
arma: FACA,
distancia: alvo ? alvo.folga : ARMAS[FACA].alcance,
});
if (!alvo) return;
const { zumbi } = alvo;
ferir(p, zumbi, ARMAS[FACA].dano, "jogador");
p.eventos.push({
tipo: "acerto",
zumbiId: zumbi.id,
cabeca: false,
x: zumbi.x,
y: ZUMBIS[zumbi.tipo].altura / 2,
z: zumbi.z,
});
}
export function atirar(p: Partida): void {
const j = p.jogador;
const arma = ARMAS[j.arma];
if (j.recarga > 0 || (arma.gasta && j.municao === 0)) return;
j.recarga = arma.intervalo;
if (arma.gasta) j.municao -= 1;
if (j.arma === FACA) golpearComFaca(p);
else atirarPorRaio(p);
}
+160
View File
@@ -0,0 +1,160 @@
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import ts from "typescript";
const LIMITE = 10;
const PASTA = __dirname;
type Medida = { nome: string; linha: number; complexidade: number };
const OPERADORES = new Set([
ts.SyntaxKind.AmpersandAmpersandToken,
ts.SyntaxKind.BarBarToken,
ts.SyntaxKind.QuestionQuestionToken,
]);
function ehDecisao(no: ts.Node): boolean {
if (ts.isBinaryExpression(no)) return OPERADORES.has(no.operatorToken.kind);
return (
ts.isIfStatement(no) ||
ts.isConditionalExpression(no) ||
ts.isForStatement(no) ||
ts.isForInStatement(no) ||
ts.isForOfStatement(no) ||
ts.isWhileStatement(no) ||
ts.isDoStatement(no) ||
ts.isCaseClause(no) ||
ts.isCatchClause(no)
);
}
function ehFuncao(no: ts.Node): no is ts.FunctionLikeDeclaration {
return (
ts.isFunctionDeclaration(no) ||
ts.isFunctionExpression(no) ||
ts.isArrowFunction(no) ||
ts.isMethodDeclaration(no) ||
ts.isConstructorDeclaration(no) ||
ts.isGetAccessorDeclaration(no) ||
ts.isSetAccessorDeclaration(no)
);
}
function nomeDaFuncao(no: ts.FunctionLikeDeclaration): string {
if (ts.isConstructorDeclaration(no)) return "constructor";
if (no.name) return no.name.getText();
const pai = no.parent;
if (ts.isVariableDeclaration(pai) || ts.isPropertyAssignment(pai)) {
return pai.name.getText();
}
return "(anônima)";
}
/** Complexidade ciclomática de cada função do código-fonte (cada uma medida à parte). */
export function medirComplexidade(codigo: string): Medida[] {
const fonte = ts.createSourceFile(
"medida.ts",
codigo,
ts.ScriptTarget.Latest,
true,
);
const medidas: Medida[] = [];
function medir(funcao: ts.FunctionLikeDeclaration): void {
let complexidade = 1;
const visitar = (no: ts.Node): void => {
if (ehFuncao(no)) {
medir(no);
return;
}
if (ehDecisao(no)) complexidade++;
ts.forEachChild(no, visitar);
};
ts.forEachChild(funcao, visitar);
const { line } = fonte.getLineAndCharacterOfPosition(funcao.getStart());
medidas.push({
nome: nomeDaFuncao(funcao),
linha: line + 1,
complexidade,
});
}
const percorrer = (no: ts.Node): void => {
if (ehFuncao(no)) medir(no);
else ts.forEachChild(no, percorrer);
};
percorrer(fonte);
return medidas.sort((a, b) => a.linha - b.linha);
}
function comDecisoes(n: number): string {
const ifs = Array.from({ length: n }, (_, i) => `if (x > ${i}) y++;`);
return `function f(x: number) { let y = 0; ${ifs.join(" ")} return y; }`;
}
describe("medirComplexidade", () => {
it("função sem decisão vale 1", () => {
expect(
medirComplexidade("function f() { return 1; }")[0].complexidade,
).toBe(1);
});
it("10 decisões valem 11 e são reprovadas", () => {
const [medida] = medirComplexidade(comDecisoes(10));
expect(medida.complexidade).toBe(11);
expect(medida.complexidade > LIMITE).toBe(true);
});
it("9 decisões valem 10 e passam", () => {
const [medida] = medirComplexidade(comDecisoes(9));
expect(medida.complexidade).toBe(10);
expect(medida.complexidade > LIMITE).toBe(false);
});
it("decisões de função aninhada não contam na de fora", () => {
const medidas = medirComplexidade(
"function fora(a: number) { const dentro = () => { if (a) return 1; return a ? 2 : 3; }; return dentro; }",
);
expect(medidas.find((m) => m.nome === "fora")?.complexidade).toBe(1);
expect(medidas.find((m) => m.nome === "dentro")?.complexidade).toBe(3);
});
it("default não conta, case conta", () => {
const [medida] = medirComplexidade(
"function f(x: number) { switch (x) { case 1: return 1; case 2: return 2; default: return 0; } }",
);
expect(medida.complexidade).toBe(3);
});
it("&&, || e ?? contam", () => {
const [medida] = medirComplexidade(
"function f(a: any, b: any, c: any) { return (a && b) || (c ?? a); }",
);
expect(medida.complexidade).toBe(4);
});
it("laços, catch, do e ternário contam; métodos são medidos", () => {
const medidas = medirComplexidade(
"class A { m(l: number[]) { for (const i of l) {} for (const k in l) {} for (;;) {} while (1) {} do {} while (0); try {} catch {} return l ? 1 : 2; } }",
);
expect(medidas[0]).toMatchObject({ nome: "m", complexidade: 8 });
});
});
describe("complexidade ciclomática de lib/jogo", () => {
it(`nenhuma função passa de ${LIMITE}`, () => {
const reprovadas: string[] = [];
const arquivos = readdirSync(PASTA).filter(
(f) => f.endsWith(".ts") && !f.endsWith(".test.ts"),
);
for (const arquivo of arquivos) {
const codigo = readFileSync(join(PASTA, arquivo), "utf8");
for (const m of medirComplexidade(codigo)) {
if (m.complexidade > LIMITE) {
reprovadas.push(`${arquivo}:${m.linha} ${m.nome} ${m.complexidade}`);
}
}
}
expect(reprovadas).toEqual([]);
});
});
+87
View File
@@ -0,0 +1,87 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { type Credito, validarCreditos } from "./creditos";
const ok: Credito = {
arquivo: "som/tiro.ogg",
origem: "https://exemplo.org/tiro",
autor: "Fulano",
licenca: "CC0-1.0",
};
describe("validarCreditos", () => {
it("não devolve erro quando está tudo certo", () => {
const propria: Credito = {
arquivo: "a.png",
origem: "interna",
autor: "Evandro",
licenca: "propria",
};
expect(validarCreditos(["som/tiro.ogg", "a.png"], [ok, propria])).toEqual(
[],
);
});
it("aponta arquivo sem crédito", () => {
expect(validarCreditos(["som/tiro.ogg", "b.png"], [ok])).toEqual([
"b.png: arquivo sem crédito.",
]);
});
it("aponta crédito de arquivo que não existe", () => {
expect(validarCreditos([], [ok])).toEqual([
"som/tiro.ogg: crédito de arquivo que não existe.",
]);
});
it("aponta licença não aceita", () => {
const erros = validarCreditos(
["som/tiro.ogg"],
[{ ...ok, licenca: "CC-BY-4.0" }],
);
expect(erros).toEqual(["som/tiro.ogg: licença não aceita (CC-BY-4.0)."]);
});
it("exige origem https:// para CC0-1.0", () => {
const erros = validarCreditos(
["som/tiro.ogg"],
[{ ...ok, origem: "http://exemplo.org" }],
);
expect(erros).toEqual([
"som/tiro.ogg: a origem de CC0-1.0 deve começar com https://.",
]);
});
it("aponta autor vazio", () => {
expect(validarCreditos(["som/tiro.ogg"], [{ ...ok, autor: " " }])).toEqual(
["som/tiro.ogg: autor vazio."],
);
});
it("aponta arquivo creditado duas vezes", () => {
expect(validarCreditos(["som/tiro.ogg"], [ok, ok])).toEqual([
"som/tiro.ogg: creditado mais de uma vez.",
]);
});
});
function listar(pasta: string, base = ""): string[] {
if (!existsSync(pasta)) return [];
return readdirSync(pasta).flatMap((nome) => {
const caminho = join(pasta, nome);
const rel = base ? `${base}/${nome}` : nome;
if (statSync(caminho).isDirectory()) return listar(caminho, rel);
return rel === "CREDITOS.json" ? [] : [rel];
});
}
describe("public/jogo", () => {
it("tem crédito válido para cada arquivo", () => {
const pasta = join(process.cwd(), "public", "jogo");
const json = join(pasta, "CREDITOS.json");
const creditos: Credito[] = existsSync(json)
? JSON.parse(readFileSync(json, "utf8"))
: [];
expect(validarCreditos(listar(pasta), creditos)).toEqual([]);
});
});
+45
View File
@@ -0,0 +1,45 @@
export type Credito = {
arquivo: string;
origem: string;
autor: string;
licenca: string;
};
export const LICENCAS_ACEITAS = ["CC0-1.0", "propria"] as const;
function errosDoCredito({
arquivo,
origem,
autor,
licenca,
}: Credito): string[] {
const erros: string[] = [];
if (!(LICENCAS_ACEITAS as readonly string[]).includes(licenca)) {
erros.push(`${arquivo}: licença não aceita (${licenca}).`);
}
if (licenca === "CC0-1.0" && !origem.startsWith("https://")) {
erros.push(`${arquivo}: a origem de CC0-1.0 deve começar com https://.`);
}
if (autor.trim() === "") erros.push(`${arquivo}: autor vazio.`);
return erros;
}
export function validarCreditos(
arquivos: string[],
creditos: Credito[],
): string[] {
const erros: string[] = [];
const vistos = new Set<string>();
for (const credito of creditos) {
if (vistos.has(credito.arquivo))
erros.push(`${credito.arquivo}: creditado mais de uma vez.`);
vistos.add(credito.arquivo);
if (!arquivos.includes(credito.arquivo))
erros.push(`${credito.arquivo}: crédito de arquivo que não existe.`);
erros.push(...errosDoCredito(credito));
}
for (const arquivo of arquivos) {
if (!vistos.has(arquivo)) erros.push(`${arquivo}: arquivo sem crédito.`);
}
return erros;
}
+23
View File
@@ -0,0 +1,23 @@
import { ajustar } from "./dificuldade";
import { ZUMBIS } from "./valores";
describe("ajustar", () => {
it("na dificuldade normal devolve os valores do tipo", () => {
expect(ajustar("blindado", "normal")).toEqual(ZUMBIS.blindado);
});
it("multiplica velocidade e dano no fácil", () => {
const r = ajustar("comum", "facil");
expect(r.velocidade).toBeCloseTo(1.2 * 0.6);
expect(r.dano).toBeCloseTo(10 * 0.4);
});
it("multiplica velocidade e dano no difícil sem mexer em vida, raio e altura", () => {
const r = ajustar("rapido", "dificil");
expect(r.velocidade).toBeCloseTo(2.4 * 1.3);
expect(r.dano).toBeCloseTo(6 * 1.5);
expect(r.vida).toBe(ZUMBIS.rapido.vida);
expect(r.raio).toBe(ZUMBIS.rapido.raio);
expect(r.altura).toBe(ZUMBIS.rapido.altura);
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { Dificuldade, TipoZumbi } from "./tipos";
import { DIFICULDADES, ZUMBIS } from "./valores";
/** Os valores do tipo com velocidade e dano multiplicados pela dificuldade. */
export function ajustar(
tipo: TipoZumbi,
dificuldade: Dificuldade,
): {
vida: number;
velocidade: number;
dano: number;
raio: number;
altura: number;
} {
const base = ZUMBIS[tipo];
const nivel = DIFICULDADES[dificuldade];
return {
...base,
velocidade: base.velocidade * nivel.velocidade,
dano: base.dano * nivel.dano,
};
}
+235
View File
@@ -0,0 +1,235 @@
import { avancarDrones, criarDrone } from "./drones";
import { lerMapa } from "./mapa";
import { PROBLEMAS } from "./problemas";
import type { Drone, Partida, Zumbi } from "./tipos";
import { DRONE, ESCRITORIO, PASSO } from "./valores";
const PASSO_MAX = DRONE.velocidade * PASSO;
const [PROBLEMA_DRONE, PROBLEMA_ZUMBI] = PROBLEMAS;
function partida(x: number, z: number, rumo = 0): Partida {
return {
passo: 0,
rng: 1,
dificuldade: "normal",
problemaId: PROBLEMA_DRONE.id,
hordas: [],
horda: 0,
fase: "horda",
tempoFase: 0,
tetoAVista: 16,
mapa: lerMapa(ESCRITORIO),
jogador: {
x,
z,
rumo,
inclinacao: 0,
vida: 100,
arma: 0,
municao: 90,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
};
}
function zumbi(p: Partida, x: number, z: number, vida = 100): Zumbi {
const z1: Zumbi = {
id: p.proximoId++,
tipo: "comum",
problemaId: PROBLEMA_ZUMBI.id,
x,
z,
rumo: 0,
vida,
estado: "andando",
tempo: 0,
};
p.zumbis.push(z1);
return z1;
}
function drone(p: Partida, recarga = 1): Drone {
const d = criarDrone(p, PROBLEMA_DRONE.id);
d.recarga = recarga;
return d;
}
describe("criarDrone", () => {
it("nasce no jogador, com o rumo dele, e entra na lista", () => {
const p = partida(5.5, 8.5, 1.25);
const d = criarDrone(p, PROBLEMA_DRONE.id);
expect(d).toEqual({
id: 1,
problemaId: PROBLEMA_DRONE.id,
x: 5.5,
z: 8.5,
rumo: 1.25,
recarga: DRONE.intervalo,
eliminacoes: 0,
});
expect(p.drones).toEqual([d]);
expect(p.proximoId).toBe(2);
});
});
describe("avancarDrones: movimento", () => {
it("anda no máximo DRONE.velocidade * PASSO por passo e chega ao lugar", () => {
const p = partida(13.5, 8.5);
const d = drone(p, 1000);
for (let i = 0; i < 100; i++) {
const [x, z] = [d.x, d.z];
avancarDrones(p);
expect(Math.hypot(d.x - x, d.z - z)).toBeLessThanOrEqual(
PASSO_MAX + 1e-12,
);
}
expect(d.x).toBeCloseTo(15.5, 9);
expect(d.z).toBeCloseTo(8.5, 9);
});
it("dá um passo cheio quando o lugar está longe", () => {
const p = partida(13.5, 8.5);
const d = drone(p, 1000);
avancarDrones(p);
expect(d.x - 13.5).toBeCloseTo(PASSO_MAX, 9);
expect(d.z).toBeCloseTo(8.5, 9);
});
it("não atravessa parede", () => {
const p = partida(13.5, 15.5, -Math.PI / 2);
const d = drone(p, 1000);
for (let i = 0; i < 100; i++) avancarDrones(p);
expect(d.z).toBeLessThanOrEqual(16 - 0.2);
});
it("três drones ocupam ângulos diferentes em volta do jogador", () => {
const p = partida(24.5, 8.5, 0.4);
const drones = [drone(p, 1000), drone(p, 1000), drone(p, 1000)];
for (let i = 0; i < 200; i++) avancarDrones(p);
drones.forEach((d, i) => {
const a = p.jogador.rumo + Math.PI / 2 + i * ((2 * Math.PI) / 3);
expect(d.x).toBeCloseTo(24.5 + Math.sin(a) * DRONE.distancia, 6);
expect(d.z).toBeCloseTo(8.5 + Math.cos(a) * DRONE.distancia, 6);
});
expect(
Math.hypot(drones[0].x - drones[1].x, drones[0].z - drones[1].z),
).toBeGreaterThan(1);
expect(
Math.hypot(drones[1].x - drones[2].x, drones[1].z - drones[2].z),
).toBeGreaterThan(1);
});
});
describe("avancarDrones: tiro", () => {
it("respeita a recarga: desconta até 0, atira e recarrega", () => {
const p = partida(5.5, 8.5);
const d = drone(p, 3);
const alvo = zumbi(p, 11.5, 8.5);
avancarDrones(p);
avancarDrones(p);
expect(d.recarga).toBe(1);
expect(p.eventos).toEqual([]);
avancarDrones(p);
expect(d.recarga).toBe(DRONE.intervalo);
expect(p.eventos.map((e) => e.tipo)).toEqual(["tiro", "acerto"]);
expect(alvo.vida).toBe(100 - DRONE.dano);
avancarDrones(p);
expect(d.recarga).toBe(DRONE.intervalo - 1);
expect(p.eventos).toHaveLength(2);
expect(p.feed).toEqual([]);
});
it("vira para o zumbi e registra tiro e acerto", () => {
const p = partida(5.5, 8.5);
const d = drone(p);
const alvo = zumbi(p, 5.5, 14.5);
avancarDrones(p);
const distancia = Math.hypot(alvo.x - d.x, alvo.z - d.z);
expect(d.rumo).toBeCloseTo(Math.atan2(alvo.x - d.x, alvo.z - d.z), 9);
expect(p.eventos).toEqual([
{ tipo: "tiro", autor: d.id, arma: "drone", distancia },
{
tipo: "acerto",
zumbiId: alvo.id,
cabeca: false,
x: 5.5,
y: 1.2,
z: 14.5,
},
]);
});
it("escolhe o zumbi mais próximo", () => {
const p = partida(5.5, 8.5);
drone(p);
const longe = zumbi(p, 15.5, 8.5);
const perto = zumbi(p, 9.5, 8.5);
avancarDrones(p);
expect(perto.vida).toBe(100 - DRONE.dano);
expect(longe.vida).toBe(100);
});
it("mantém o mais próximo quando o seguinte está mais longe", () => {
const p = partida(5.5, 8.5);
drone(p);
const perto = zumbi(p, 9.5, 8.5);
const longe = zumbi(p, 15.5, 8.5);
avancarDrones(p);
expect(perto.vida).toBe(100 - DRONE.dano);
expect(longe.vida).toBe(100);
});
it("ignora zumbi morrendo", () => {
const p = partida(5.5, 8.5);
drone(p);
const morto = zumbi(p, 8.5, 8.5);
morto.estado = "morrendo";
const vivo = zumbi(p, 15.5, 8.5);
avancarDrones(p);
expect(morto.vida).toBe(100);
expect(vivo.vida).toBe(100 - DRONE.dano);
});
it("não atira com parede no meio e deixa a recarga em 0", () => {
const p = partida(9.5, 2.5);
const d = drone(p);
const alvo = zumbi(p, 12.5, 2.5);
avancarDrones(p);
expect(alvo.vida).toBe(100);
expect(p.eventos).toEqual([]);
expect(d.recarga).toBe(0);
});
it("não atira além do alcance", () => {
const p = partida(2.5, 8.5);
const d = drone(p);
const alvo = zumbi(p, 2.5 + DRONE.alcance + 1, 8.5);
avancarDrones(p);
expect(alvo.vida).toBe(100);
expect(p.eventos).toEqual([]);
expect(d.recarga).toBe(0);
});
it("a morte gera a linha do feed com a solução do drone e o nome do zumbi", () => {
const p = partida(5.5, 8.5);
drone(p);
const alvo = zumbi(p, 9.5, 8.5, DRONE.dano);
avancarDrones(p);
expect(alvo.estado).toBe("morrendo");
expect(p.feed).toEqual([
{
passo: 0,
texto: `${PROBLEMA_DRONE.solucao} eliminou ${PROBLEMA_ZUMBI.nome}`,
},
]);
});
});
+94
View File
@@ -0,0 +1,94 @@
/** Drones aliados: seguem o jogador em órbita e atiram no zumbi mais próximo à vista. */
import { registrarEliminacao } from "./feed";
import { moverComColisao, temLinhaDeVisao } from "./mapa";
import type { Drone, Partida, Zumbi } from "./tipos";
import { DRONE, PASSO } from "./valores";
import { ferir } from "./zumbis";
const RAIO_DO_DRONE = 0.2;
const PASSO_MAXIMO = DRONE.velocidade * PASSO;
const ANGULO_ENTRE_DRONES = (2 * Math.PI) / 3;
const ALTURA_DO_ACERTO = 1.2;
export function criarDrone(p: Partida, problemaId: string): Drone {
const drone: Drone = {
id: p.proximoId++,
problemaId,
x: p.jogador.x,
z: p.jogador.z,
rumo: p.jogador.rumo,
recarga: DRONE.intervalo,
eliminacoes: 0,
};
p.drones.push(drone);
return drone;
}
function seguirJogador(p: Partida, drone: Drone, indice: number): void {
const angulo = p.jogador.rumo + Math.PI / 2 + indice * ANGULO_ENTRE_DRONES;
const dx = p.jogador.x + Math.sin(angulo) * DRONE.distancia - drone.x;
const dz = p.jogador.z + Math.cos(angulo) * DRONE.distancia - drone.z;
const fracao = Math.min(1, PASSO_MAXIMO / Math.hypot(dx, dz));
[drone.x, drone.z] = moverComColisao(
p.mapa,
drone.x,
drone.z,
dx * fracao,
dz * fracao,
RAIO_DO_DRONE,
);
}
function zumbiMaisProximo(p: Partida, drone: Drone): Zumbi | undefined {
let alvo: Zumbi | undefined;
let melhor = Number.POSITIVE_INFINITY;
for (const zumbi of p.zumbis) {
const d = Math.hypot(zumbi.x - drone.x, zumbi.z - drone.z);
if (
zumbi.estado !== "morrendo" &&
d <= DRONE.alcance &&
d < melhor &&
temLinhaDeVisao(p.mapa, drone.x, drone.z, zumbi.x, zumbi.z)
) {
alvo = zumbi;
melhor = d;
}
}
return alvo;
}
function atirar(p: Partida, drone: Drone, alvo: Zumbi): void {
const dx = alvo.x - drone.x;
const dz = alvo.z - drone.z;
drone.rumo = Math.atan2(dx, dz);
p.eventos.push(
{
tipo: "tiro",
autor: drone.id,
arma: "drone",
distancia: Math.hypot(dx, dz),
},
{
tipo: "acerto",
zumbiId: alvo.id,
cabeca: false,
x: alvo.x,
y: ALTURA_DO_ACERTO,
z: alvo.z,
},
);
const matou = ferir(p, alvo, DRONE.dano, drone.id);
drone.recarga = DRONE.intervalo;
if (matou) registrarEliminacao(p, drone.problemaId, alvo.problemaId);
}
export function avancarDrones(p: Partida): void {
p.drones.forEach((drone, indice) => {
seguirJogador(p, drone, indice);
drone.recarga = Math.max(0, drone.recarga - 1);
if (drone.recarga > 0) return;
const alvo = zumbiMaisProximo(p, drone);
if (alvo) atirar(p, drone, alvo);
});
}
+48
View File
@@ -0,0 +1,48 @@
import { registrar, registrarEliminacao, registrarResolvido } from "./feed";
import type { Partida } from "./tipos";
const nova = (): Pick<Partida, "feed" | "passo"> => ({ feed: [], passo: 42 });
describe("registrar", () => {
it("acrescenta a linha com o passo atual", () => {
const p = nova();
registrar(p, "olá");
expect(p.feed).toEqual([{ passo: 42, texto: "olá" }]);
});
it("mantém cinco linhas e corta na sexta", () => {
const p = nova();
for (let i = 1; i <= 5; i++) registrar(p, `l${i}`);
expect(p.feed).toHaveLength(5);
registrar(p, "l6");
expect(p.feed.map((l) => l.texto)).toEqual(["l2", "l3", "l4", "l5", "l6"]);
});
});
describe("registrarEliminacao", () => {
it("usa a solução do drone e o nome do zumbi", () => {
const p = nova();
registrarEliminacao(p, "site-lento", "site-lento");
expect(p.feed[0].texto).toBe("Site rápido eliminou Site lento");
});
it("usa o próprio id quando desconhecido", () => {
const p = nova();
registrarEliminacao(p, "a", "b");
expect(p.feed[0].texto).toBe("a eliminou b");
});
});
describe("registrarResolvido", () => {
it("registra o nome do problema", () => {
const p = nova();
registrarResolvido(p, "site-lento");
expect(p.feed[0].texto).toBe("Resolvido: Site lento");
});
it("usa o próprio id quando desconhecido", () => {
const p = nova();
registrarResolvido(p, "zzz");
expect(p.feed[0].texto).toBe("Resolvido: zzz");
});
});
+26
View File
@@ -0,0 +1,26 @@
import { problemaPorId } from "./problemas";
import type { Partida } from "./tipos";
import { LINHAS_DO_FEED } from "./valores";
type ComFeed = Pick<Partida, "feed" | "passo">;
export function registrar(p: ComFeed, texto: string): void {
p.feed.push({ passo: p.passo, texto });
if (p.feed.length > LINHAS_DO_FEED) {
p.feed.splice(0, p.feed.length - LINHAS_DO_FEED);
}
}
export function registrarEliminacao(
p: ComFeed,
problemaDoDrone: string,
problemaDoZumbi: string,
): void {
const solucao = problemaPorId(problemaDoDrone)?.solucao ?? problemaDoDrone;
const nome = problemaPorId(problemaDoZumbi)?.nome ?? problemaDoZumbi;
registrar(p, `${solucao} eliminou ${nome}`);
}
export function registrarResolvido(p: ComFeed, problemaId: string): void {
registrar(p, `Resolvido: ${problemaPorId(problemaId)?.nome ?? problemaId}`);
}
+6
View File
@@ -0,0 +1,6 @@
{
"duracao": 1.4,
"impacto": 0.833,
"mao": "direita",
"alcance": 0.79
}
+113
View File
@@ -0,0 +1,113 @@
import { escolherHordas, montarFila, tamanhoDaHorda } from "./hordas";
import { problemaPorId, problemasDaArea } from "./problemas";
import { sortearInteiro } from "./rng";
import type { Dificuldade } from "./tipos";
describe("tamanhoDaHorda", () => {
it.each([
[0, "facil", 8],
[1, "facil", 11],
[2, "facil", 14],
[0, "normal", 12],
[1, "normal", 16],
[2, "normal", 20],
[0, "dificil", 16],
[1, "dificil", 21],
[2, "dificil", 26],
] as const)("horda %i no %s tem %i", (indice, dificuldade, esperado) => {
expect(tamanhoDaHorda(indice, dificuldade)).toBe(esperado);
});
it("lança erro para índice fora de 0 a 2", () => {
expect(() => tamanhoDaHorda(3, "normal")).toThrow(Error);
expect(() => tamanhoDaHorda(-1, "normal")).toThrow(Error);
});
});
describe("escolherHordas", () => {
const id = "site-lento";
const area = problemaPorId(id)?.area ?? "vender";
it("devolve 3 outros problemas da área, sem repetir, e o próprio id por último", () => {
for (let rng = 1; rng <= 30; rng++) {
const ids = escolherHordas({ rng }, id);
expect(ids).toHaveLength(4);
expect(ids[3]).toBe(id);
expect(new Set(ids).size).toBe(4);
for (const outro of ids) {
expect(problemaPorId(outro)?.area).toBe(area);
}
}
});
it("usa sortearInteiro sobre os candidatos restantes", () => {
const p = { rng: 7 };
const copia = { rng: 7 };
const outros = problemasDaArea(area)
.map((problema) => problema.id)
.filter((outro) => outro !== id);
const esperado: string[] = [];
for (let i = 0; i < 3; i++) {
const [escolhido] = outros.splice(
sortearInteiro(copia, outros.length),
1,
);
esperado.push(escolhido);
}
expect(escolherHordas(p, id)).toEqual([...esperado, id]);
});
it("lança erro para id inexistente", () => {
expect(() => escolherHordas({ rng: 1 }, "nao-existe")).toThrow(Error);
});
});
describe("montarFila", () => {
const tipos = (indice: number, dificuldade: Dificuldade = "normal") =>
montarFila(indice, "x", dificuldade).map((n) => n.tipo);
it("horda 0: todos comuns", () => {
expect(tipos(0)).toEqual(Array(12).fill("comum"));
});
it("horda 1: alterna comum e rápido", () => {
const t = tipos(1);
expect(t).toHaveLength(16);
expect(t.slice(0, 4)).toEqual(["comum", "rapido", "comum", "rapido"]);
expect(t.filter((x) => x === "comum")).toHaveLength(8);
expect(t.filter((x) => x === "rapido")).toHaveLength(8);
});
it("horda 2: posição módulo 5", () => {
const t = tipos(2);
expect(t).toHaveLength(20);
expect(t.slice(0, 5)).toEqual([
"comum",
"comum",
"rapido",
"rapido",
"blindado",
]);
expect(t.slice(15)).toEqual(t.slice(0, 5));
});
it("chefão: um chefão e a escolta fixa, sem mudar com a dificuldade", () => {
const esperado = ["chefao", ...Array(6).fill("comum")];
expect(tipos(3)).toEqual(esperado);
expect(tipos(3, "dificil")).toEqual(esperado);
});
it("o tamanho segue a dificuldade e todos levam o problemaId", () => {
const fila = montarFila(0, "abc", "facil");
expect(fila).toHaveLength(8);
expect(fila.every((n) => n.problemaId === "abc")).toBe(true);
expect(
montarFila(3, "abc", "facil").every((n) => n.problemaId === "abc"),
).toBe(true);
});
it("lança erro para índice fora de 0 a 3", () => {
expect(() => montarFila(4, "x", "normal")).toThrow(Error);
expect(() => montarFila(-1, "x", "normal")).toThrow(Error);
});
});
+60
View File
@@ -0,0 +1,60 @@
import { problemaPorId, problemasDaArea } from "./problemas";
import { sortearInteiro } from "./rng";
import type { Dificuldade, NaFila, TipoZumbi } from "./tipos";
import { DIFICULDADES, HORDAS } from "./valores";
export function tamanhoDaHorda(
indice: number,
dificuldade: Dificuldade,
): number {
if (indice !== 0 && indice !== 1 && indice !== 2) {
throw new Error(`Índice de horda inválido: ${indice}`);
}
return Math.max(
1,
Math.round(HORDAS.tamanhos[indice] * DIFICULDADES[dificuldade].quantidade),
);
}
export function escolherHordas(
p: { rng: number },
problemaId: string,
): string[] {
const problema = problemaPorId(problemaId);
if (!problema) throw new Error(`Problema desconhecido: ${problemaId}`);
const outros = problemasDaArea(problema.area)
.map((candidato) => candidato.id)
.filter((id) => id !== problemaId);
const escolhidos: string[] = [];
for (let i = 0; i < 3; i++) {
escolhidos.push(...outros.splice(sortearInteiro(p, outros.length), 1));
}
return [...escolhidos, problemaId];
}
function tipoDaPosicao(indice: number, posicao: number): TipoZumbi {
if (indice === 0) return "comum";
if (indice === 1) return posicao % 2 === 0 ? "comum" : "rapido";
const resto = posicao % 5;
if (resto < 2) return "comum";
return resto < 4 ? "rapido" : "blindado";
}
export function montarFila(
indice: number,
problemaId: string,
dificuldade: Dificuldade,
): NaFila[] {
if (indice === 3) {
const tipos: TipoZumbi[] = [
"chefao",
...Array<TipoZumbi>(HORDAS.escoltaDoChefao).fill("comum"),
];
return tipos.map((tipo) => ({ tipo, problemaId }));
}
const total = tamanhoDaHorda(indice, dificuldade);
return Array.from({ length: total }, (_, posicao) => ({
tipo: tipoDaPosicao(indice, posicao),
problemaId,
}));
}
+309
View File
@@ -0,0 +1,309 @@
import {
caminhoLivre,
campoDeFluxo,
direcaoNoCampo,
ehOpaco,
ehSolido,
lerMapa,
moverComColisao,
raioAteParede,
temLinhaDeVisao,
} from "./mapa";
import { ESCRITORIO } from "./valores";
const SALA = lerMapa(["#######", "#J...N#", "#.....#", "#..M..#", "#######"]);
const DIVIDIDA = lerMapa(["#########", "#J..#..N#", "#...#...#", "#########"]);
describe("lerMapa", () => {
it("lê dimensões, células, início e nascedouros", () => {
const m = lerMapa(["#####", "#J.N#", "#.M.#", "#####"]);
expect(m.largura).toBe(5);
expect(m.altura).toBe(4);
expect(m.solido[2 * 5 + 2]).toBe(1);
expect(m.opaco[2 * 5 + 2]).toBe(0);
expect(m.solido[1 * 5 + 1]).toBe(0);
expect(m.opaco[0]).toBe(1);
expect(m.inicio).toEqual([1.5, 1.5]);
expect(m.nascedouros).toEqual([[3.5, 1.5]]);
});
it("recusa lista vazia", () => {
expect(() => lerMapa([])).toThrow(/ao menos uma linha/);
});
it("recusa linhas de larguras diferentes", () => {
expect(() => lerMapa(["JN", "N"])).toThrow(/larguras diferentes/);
});
it("recusa mapa sem início", () => {
expect(() => lerMapa(["N."])).toThrow(/exatamente um início/);
});
it("recusa mapa com dois inícios", () => {
expect(() => lerMapa(["JJN"])).toThrow(/exatamente um início/);
});
it("recusa mapa sem nascedouro", () => {
expect(() => lerMapa(["J."])).toThrow(/nascedouro/);
});
it("recusa caractere desconhecido", () => {
expect(() => lerMapa(["JNX"])).toThrow(/Caractere desconhecido "X"/);
});
it("lê o ESCRITORIO: 28×17, 4 nascedouros, todos com caminho até o início", () => {
const m = lerMapa(ESCRITORIO);
expect(m.largura).toBe(28);
expect(m.altura).toBe(17);
expect(m.nascedouros).toHaveLength(4);
const campo = campoDeFluxo(m, m.inicio[0], m.inicio[1]);
for (const [x, z] of m.nascedouros) {
expect(
campo[Math.floor(z) * m.largura + Math.floor(x)],
).toBeGreaterThanOrEqual(0);
}
});
});
describe("ehSolido e ehOpaco", () => {
it("parede é sólida e opaca", () => {
expect(ehSolido(SALA, 0.5, 0.5)).toBe(true);
expect(ehOpaco(SALA, 0.5, 0.5)).toBe(true);
});
it("mesa é sólida e não opaca", () => {
expect(ehSolido(SALA, 3.5, 3.5)).toBe(true);
expect(ehOpaco(SALA, 3.5, 3.5)).toBe(false);
});
it("chão é livre", () => {
expect(ehSolido(SALA, 2.5, 2.5)).toBe(false);
expect(ehOpaco(SALA, 2.5, 2.5)).toBe(false);
});
it.each([
[-0.1, 2.5],
[2.5, -0.1],
[7.1, 2.5],
[2.5, 5.1],
])("fora do mapa (%f, %f) é sólido e opaco", (x, z) => {
expect(ehSolido(SALA, x, z)).toBe(true);
expect(ehOpaco(SALA, x, z)).toBe(true);
});
});
describe("raioAteParede", () => {
it("anda em +x", () => {
expect(raioAteParede(SALA, 1.5, 2.5, Math.PI / 2, 99)).toBeCloseTo(4.5, 6);
});
it("anda em -x", () => {
expect(raioAteParede(SALA, 1.5, 2.5, -Math.PI / 2, 99)).toBeCloseTo(0.5, 6);
});
it("anda em +z atravessando a mesa", () => {
expect(raioAteParede(SALA, 3.5, 1.5, 0, 99)).toBeCloseTo(2.5, 6);
});
it("anda em -z", () => {
expect(raioAteParede(SALA, 3.5, 3.5, Math.PI, 99)).toBeCloseTo(2.5, 6);
});
it("anda em diagonal, trocando de eixo", () => {
expect(raioAteParede(SALA, 1.5, 1.5, 0.5, 99)).toBeCloseTo(
2.5 / Math.cos(0.5),
6,
);
});
it("devolve o alcance se não bater antes", () => {
expect(raioAteParede(SALA, 1.5, 2.5, Math.PI / 2, 2)).toBe(2);
});
it("devolve 0 se já começa em célula opaca", () => {
expect(raioAteParede(SALA, 0.5, 0.5, 1, 5)).toBe(0);
});
});
describe("temLinhaDeVisao", () => {
it("vê em campo aberto", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 3.5, 2.5)).toBe(true);
});
it("não vê através de parede", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 6.5, 1.5)).toBe(false);
});
it("vê através de mesa", () => {
expect(temLinhaDeVisao(SALA, 1.5, 3.5, 5.5, 3.5)).toBe(true);
});
it("não vê um ponto dentro da parede", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 4.5, 1.5)).toBe(false);
});
it("não vê a partir de dentro da parede", () => {
expect(temLinhaDeVisao(DIVIDIDA, 0.5, 0.5, 1.5, 1.5)).toBe(false);
});
it("o mesmo ponto livre se vê; o mesmo ponto em parede, não", () => {
expect(temLinhaDeVisao(DIVIDIDA, 1.5, 1.5, 1.5, 1.5)).toBe(true);
expect(temLinhaDeVisao(DIVIDIDA, 0.5, 0.5, 0.5, 0.5)).toBe(false);
});
});
/** Mesa em x de 3 a 4, z de 3 a 4. */
const COM_MESA = lerMapa([
"#######",
"#J....#",
"#.....#",
"#..M..#",
"#....N#",
"#######",
]);
describe("caminhoLivre", () => {
it("livre em campo aberto, com folga dos dois lados", () => {
expect(caminhoLivre(COM_MESA, 1.5, 1.5, 5.5, 1.5, 0.35)).toBe(true);
});
it("a mesa no meio do segmento bloqueia, mesmo sem bloquear a visão", () => {
expect(caminhoLivre(COM_MESA, 1.5, 3.5, 5.5, 3.5, 0.35)).toBe(false);
expect(temLinhaDeVisao(COM_MESA, 1.5, 3.5, 5.5, 3.5)).toBe(true);
});
it("bloqueia quando só o paralelo de um lado cruza a mesa", () => {
expect(caminhoLivre(COM_MESA, 1.5, 2.7, 5.5, 2.7, 0.35)).toBe(false);
expect(caminhoLivre(COM_MESA, 1.5, 2.7, 5.5, 2.7, 0.25)).toBe(true);
});
it("bloqueia quando só o paralelo do outro lado cruza a mesa", () => {
expect(caminhoLivre(COM_MESA, 1.5, 4.3, 5.5, 4.3, 0.35)).toBe(false);
expect(caminhoLivre(COM_MESA, 1.5, 4.3, 5.5, 4.3, 0.25)).toBe(true);
});
it("o mesmo ponto: livre em chão, bloqueado em célula sólida", () => {
expect(caminhoLivre(COM_MESA, 1.5, 1.5, 1.5, 1.5, 0.35)).toBe(true);
expect(caminhoLivre(COM_MESA, 3.5, 3.5, 3.5, 3.5, 0.35)).toBe(false);
});
});
describe("moverComColisao", () => {
it("anda livre nos dois eixos", () => {
const [x, z] = moverComColisao(SALA, 2.5, 2.5, 0.5, 0.2, 0.25);
expect(x).toBeCloseTo(3.0, 9);
expect(z).toBeCloseTo(2.7, 9);
});
it("desliza em z quando x bate na parede (canto de trás, em cima)", () => {
expect(moverComColisao(SALA, 1.5, 1.5, -0.5, 0.3, 0.25)).toEqual([
1.5, 1.8,
]);
});
it("desliza em x quando z bate na parede", () => {
const [x, z] = moverComColisao(SALA, 1.5, 1.5, 0.3, -0.5, 0.25);
expect(x).toBeCloseTo(1.8, 9);
expect(z).toBe(1.5);
});
it("para quando os dois eixos batem", () => {
expect(moverComColisao(SALA, 1.5, 1.5, -0.5, -0.5, 0.25)).toEqual([
1.5, 1.5,
]);
});
it("confere o canto da frente, em cima (parede à direita)", () => {
expect(moverComColisao(SALA, 5.5, 2.5, 0.5, 0.1, 0.25)).toEqual([5.5, 2.6]);
});
it("confere o canto de trás, embaixo (mesa por baixo)", () => {
expect(moverComColisao(SALA, 3.5, 2.5, 0, 0.3, 0.25)).toEqual([3.5, 2.5]);
});
it("confere o canto da frente, embaixo (mesa na diagonal)", () => {
const [x, z] = moverComColisao(SALA, 2.5, 2.5, 0.3, 0.3, 0.25);
expect(x).toBeCloseTo(2.8, 9);
expect(z).toBe(2.5);
});
});
const FLUXO = lerMapa(["######", "#J.#N#", "#..#.#", "#M.###", "######"]);
describe("campoDeFluxo", () => {
it("guarda distâncias, -1 em sólidas e inalcançáveis", () => {
const campo = campoDeFluxo(FLUXO, 1.5, 1.5);
expect(Array.from(campo)).toEqual([
...[-1, -1, -1, -1, -1, -1],
...[-1, 0, 1, -1, -1, -1],
...[-1, 1, 2, -1, -1, -1],
...[-1, -1, 3, -1, -1, -1],
...[-1, -1, -1, -1, -1, -1],
]);
expect(campo).toBeInstanceOf(Int16Array);
});
it("tudo -1 se o alvo está em célula sólida", () => {
expect(
Array.from(campoDeFluxo(FLUXO, 1.5, 3.5)).every((v) => v === -1),
).toBe(true);
});
it("tudo -1 se o alvo está fora do mapa", () => {
expect(
Array.from(campoDeFluxo(FLUXO, -3, 1.5)).every((v) => v === -1),
).toBe(true);
});
});
const ABERTO = lerMapa(["J..", "...", "..N"]);
describe("direcaoNoCampo", () => {
const campo = campoDeFluxo(ABERTO, 0.5, 0.5);
it("vai na diagonal quando livre", () => {
const [dx, dz] = direcaoNoCampo(ABERTO, campo, 2.5, 2.5);
expect(dx).toBeCloseTo(-Math.SQRT1_2, 9);
expect(dz).toBeCloseTo(-Math.SQRT1_2, 9);
});
it("aponta para o centro da melhor vizinha a partir da posição real", () => {
const [dx, dz] = direcaoNoCampo(ABERTO, campo, 2.9, 2.1);
const tamanho = Math.hypot(1.4, 0.6);
expect(dx).toBeCloseTo(-1.4 / tamanho, 9);
expect(dz).toBeCloseTo(-0.6 / tamanho, 9);
});
it("vai em linha reta na borda do mapa", () => {
expect(direcaoNoCampo(ABERTO, campo, 1.5, 0.5)).toEqual([-1, 0]);
});
it("devolve [0, 0] na célula do alvo", () => {
expect(direcaoNoCampo(ABERTO, campo, 0.5, 0.5)).toEqual([0, 0]);
});
it("devolve [0, 0] fora do mapa", () => {
expect(direcaoNoCampo(ABERTO, campo, -1, 0.5)).toEqual([0, 0]);
expect(direcaoNoCampo(ABERTO, campo, 0.5, -1)).toEqual([0, 0]);
expect(direcaoNoCampo(ABERTO, campo, 3.5, 0.5)).toEqual([0, 0]);
expect(direcaoNoCampo(ABERTO, campo, 0.5, 3.5)).toEqual([0, 0]);
});
it("devolve [0, 0] em célula sem caminho", () => {
const campoF = campoDeFluxo(FLUXO, 1.5, 1.5);
expect(direcaoNoCampo(FLUXO, campoF, 4.5, 1.5)).toEqual([0, 0]);
});
it("não corta quina: parede em cima da diagonal", () => {
const m = lerMapa(["J#.", "...", "..N"]);
const c = campoDeFluxo(m, 0.5, 0.5);
expect(direcaoNoCampo(m, c, 1.5, 1.5)).toEqual([-1, 0]);
});
it("não corta quina: parede ao lado da diagonal", () => {
const m = lerMapa(["J..", "#..", "..N"]);
const c = campoDeFluxo(m, 0.5, 0.5);
expect(direcaoNoCampo(m, c, 1.5, 1.5)).toEqual([0, -1]);
});
});
+308
View File
@@ -0,0 +1,308 @@
/** Grade do escritório: leitura, colisão, visão e caminho dos zumbis. Sem three, React nem DOM. */
import type { Mapa } from "./tipos";
type Ponto = readonly [number, number];
type Camada = "solido" | "opaco";
const CARACTERES = "#M.JN";
const SOLIDOS = "#M";
/** Tolerância de ponto flutuante na comparação de distâncias da visão. */
const FOLGA = 1e-9;
const VIZINHOS_4: readonly Ponto[] = [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
];
const VIZINHOS_8: readonly Ponto[] = [
...VIZINHOS_4,
[1, 1],
[1, -1],
[-1, 1],
[-1, -1],
];
function validarLinhas(linhas: readonly string[]): void {
if (linhas.length === 0) {
throw new Error("O mapa precisa de ao menos uma linha.");
}
if (linhas.some((linha) => linha.length !== linhas[0].length)) {
throw new Error("As linhas do mapa têm larguras diferentes.");
}
}
export function lerMapa(linhas: readonly string[]): Mapa {
validarLinhas(linhas);
const largura = linhas[0].length;
const altura = linhas.length;
const solido = new Uint8Array(largura * altura);
const opaco = new Uint8Array(largura * altura);
const inicios: Ponto[] = [];
const nascedouros: Ponto[] = [];
for (let z = 0; z < altura; z++) {
for (let x = 0; x < largura; x++) {
const c = linhas[z].charAt(x);
if (!CARACTERES.includes(c)) {
throw new Error(
`Caractere desconhecido "${c}" no mapa (coluna ${x}, linha ${z}).`,
);
}
solido[z * largura + x] = SOLIDOS.includes(c) ? 1 : 0;
opaco[z * largura + x] = c === "#" ? 1 : 0;
if (c === "J") inicios.push([x + 0.5, z + 0.5]);
if (c === "N") nascedouros.push([x + 0.5, z + 0.5]);
}
}
if (inicios.length !== 1) {
throw new Error("O mapa precisa de exatamente um início (J).");
}
if (nascedouros.length === 0) {
throw new Error("O mapa precisa de ao menos um nascedouro (N).");
}
return { largura, altura, solido, opaco, inicio: inicios[0], nascedouros };
}
function dentro(mapa: Mapa, cx: number, cz: number): boolean {
return cx >= 0 && cz >= 0 && cx < mapa.largura && cz < mapa.altura;
}
function consultar(mapa: Mapa, camada: Camada, x: number, z: number): boolean {
const cx = Math.floor(x);
const cz = Math.floor(z);
if (!dentro(mapa, cx, cz)) return true;
return mapa[camada][cz * mapa.largura + cx] === 1;
}
/** Coordenadas em metros; fora do mapa é sólido. */
export function ehSolido(mapa: Mapa, x: number, z: number): boolean {
return consultar(mapa, "solido", x, z);
}
/** Coordenadas em metros; fora do mapa é opaco. */
export function ehOpaco(mapa: Mapa, x: number, z: number): boolean {
return consultar(mapa, "opaco", x, z);
}
type Eixo = { passo: number; delta: number; t: number };
/** Estado do DDA em um eixo: sentido, distância por célula e distância até a próxima borda. */
function prepararEixo(pos: number, d: number): Eixo {
if (d === 0) return { passo: 0, delta: Infinity, t: Infinity };
const base = Math.floor(pos);
const ate = d > 0 ? base + 1 - pos : pos - base;
return {
passo: d > 0 ? 1 : -1,
delta: 1 / Math.abs(d),
t: ate / Math.abs(d),
};
}
/** Distância até a primeira célula da camada na direção (sin(rumo), cos(rumo)); `alcance` se não bater antes. */
function distanciaAteCamada(
mapa: Mapa,
camada: Camada,
x: number,
z: number,
rumo: number,
alcance: number,
): number {
const ex = prepararEixo(x, Math.sin(rumo));
const ez = prepararEixo(z, Math.cos(rumo));
let cx = Math.floor(x);
let cz = Math.floor(z);
let t = 0;
while (t < alcance) {
if (consultar(mapa, camada, cx, cz)) return t;
if (ex.t < ez.t) {
t = ex.t;
ex.t += ex.delta;
cx += ex.passo;
} else {
t = ez.t;
ez.t += ez.delta;
cz += ez.passo;
}
}
return alcance;
}
/** Distância até a primeira célula opaca na direção (sin(rumo), cos(rumo)); `alcance` se não bater antes. */
export function raioAteParede(
mapa: Mapa,
x: number,
z: number,
rumo: number,
alcance: number,
): number {
return distanciaAteCamada(mapa, "opaco", x, z, rumo, alcance);
}
/** Verdadeiro se nenhuma célula da camada fica entre os dois pontos (inclusive o de chegada). */
function segmentoLivre(
mapa: Mapa,
camada: Camada,
x0: number,
z0: number,
x1: number,
z1: number,
): boolean {
if (consultar(mapa, camada, x1, z1)) return false;
const dx = x1 - x0;
const dz = z1 - z0;
const distancia = Math.hypot(dx, dz);
const rumo = Math.atan2(dx, dz);
return (
distanciaAteCamada(mapa, camada, x0, z0, rumo, distancia) >=
distancia - FOLGA
);
}
/** Verdadeiro se não há célula opaca entre os dois pontos (inclusive o de chegada). */
export function temLinhaDeVisao(
mapa: Mapa,
x0: number,
z0: number,
x1: number,
z1: number,
): boolean {
return segmentoLivre(mapa, "opaco", x0, z0, x1, z1);
}
/** Verdadeiro se o segmento e os dois paralelos deslocados `raio` para cada lado não cruzam célula sólida. */
export function caminhoLivre(
mapa: Mapa,
x0: number,
z0: number,
x1: number,
z1: number,
raio: number,
): boolean {
const distancia = Math.hypot(x1 - x0, z1 - z0);
const escala = distancia === 0 ? 0 : raio / distancia;
const nx = (z0 - z1) * escala;
const nz = (x1 - x0) * escala;
return [0, 1, -1].every((lado) =>
segmentoLivre(
mapa,
"solido",
x0 + nx * lado,
z0 + nz * lado,
x1 + nx * lado,
z1 + nz * lado,
),
);
}
function circuloLivre(mapa: Mapa, x: number, z: number, raio: number): boolean {
return !(
ehSolido(mapa, x - raio, z - raio) ||
ehSolido(mapa, x + raio, z - raio) ||
ehSolido(mapa, x - raio, z + raio) ||
ehSolido(mapa, x + raio, z + raio)
);
}
/** Tenta x e depois z, separadamente, então desliza na parede. */
export function moverComColisao(
mapa: Mapa,
x: number,
z: number,
dx: number,
dz: number,
raio: number,
): [number, number] {
const nx = circuloLivre(mapa, x + dx, z, raio) ? x + dx : x;
const nz = circuloLivre(mapa, nx, z + dz, raio) ? z + dz : z;
return [nx, nz];
}
function visitar(
mapa: Mapa,
campo: Int16Array,
fila: number[],
cx: number,
cz: number,
distancia: number,
): void {
if (ehSolido(mapa, cx, cz)) return;
const i = cz * mapa.largura + cx;
if (campo[i] !== -1) return;
campo[i] = distancia;
fila.push(i);
}
/** Distância em células até o alvo (BFS de 4 vizinhos); -1 para sólida ou inalcançável. */
export function campoDeFluxo(mapa: Mapa, x: number, z: number): Int16Array {
const campo = new Int16Array(mapa.largura * mapa.altura).fill(-1);
const fila: number[] = [];
visitar(mapa, campo, fila, Math.floor(x), Math.floor(z), 0);
for (let k = 0; k < fila.length; k++) {
const cx = fila[k] % mapa.largura;
const cz = Math.floor(fila[k] / mapa.largura);
for (const [ox, oz] of VIZINHOS_4) {
visitar(mapa, campo, fila, cx + ox, cz + oz, campo[fila[k]] + 1);
}
}
return campo;
}
function distanciaEm(
mapa: Mapa,
campo: Int16Array,
cx: number,
cz: number,
): number {
return dentro(mapa, cx, cz) ? campo[cz * mapa.largura + cx] : -1;
}
/** Na diagonal, só passa se as duas células ortogonais vizinhas estão livres. */
function quinaLivre(
mapa: Mapa,
cx: number,
cz: number,
ox: number,
oz: number,
): boolean {
return !(ehSolido(mapa, cx + ox, cz) || ehSolido(mapa, cx, cz + oz));
}
/** Vizinho (8 direções, sem cortar quina) de menor distância; undefined na célula do alvo ou sem caminho. */
function melhorVizinho(
mapa: Mapa,
campo: Int16Array,
cx: number,
cz: number,
): Ponto | undefined {
let melhorDistancia = distanciaEm(mapa, campo, cx, cz);
if (melhorDistancia <= 0) return undefined;
let melhor: Ponto = [0, 0];
for (const [ox, oz] of VIZINHOS_8) {
const d = distanciaEm(mapa, campo, cx + ox, cz + oz);
if (d < 0 || d >= melhorDistancia) continue;
if (!quinaLivre(mapa, cx, cz, ox, oz)) continue;
melhorDistancia = d;
melhor = [ox, oz];
}
return melhor;
}
/** Vetor unitário da posição real até o centro do melhor vizinho; [0, 0] na célula do alvo ou sem caminho. */
export function direcaoNoCampo(
mapa: Mapa,
campo: Int16Array,
x: number,
z: number,
): [number, number] {
const cx = Math.floor(x);
const cz = Math.floor(z);
const vizinho = melhorVizinho(mapa, campo, cx, cz);
if (!vizinho) return [0, 0];
const dx = cx + vizinho[0] + 0.5 - x;
const dz = cz + vizinho[1] + 0.5 - z;
const tamanho = Math.hypot(dx, dz);
return [dx / tamanho, dz / tamanho];
}
+131
View File
@@ -0,0 +1,131 @@
import { campoDeFluxo, direcaoNoCampo, lerMapa } from "../mapa";
import { AMBIENTES, ESCRITORIO, PORTAS } from "./escritorio";
const MAPA = lerMapa(ESCRITORIO);
const { largura, altura } = MAPA;
const andavel = (bloqueio: Uint8Array, x: number, z: number) =>
x >= 0 &&
z >= 0 &&
x < largura &&
z < altura &&
bloqueio[z * largura + x] === 0;
/** Quantas células andáveis a busca em largura alcança a partir do início. */
function alcancaveis(bloqueio: Uint8Array): number {
const inicio =
Math.floor(MAPA.inicio[1]) * largura + Math.floor(MAPA.inicio[0]);
const visto = new Uint8Array(largura * altura);
const fila = [inicio];
visto[inicio] = 1;
for (let k = 0; k < fila.length; k++) {
const x = fila[k] % largura;
const z = Math.floor(fila[k] / largura);
for (const [dx, dz] of [
[1, 0],
[-1, 0],
[0, 1],
[0, -1],
]) {
const i = (z + dz) * largura + x + dx;
if (andavel(bloqueio, x + dx, z + dz) && !visto[i]) {
visto[i] = 1;
fila.push(i);
}
}
}
return fila.length;
}
const totalAndavel = MAPA.solido.filter((c) => c === 0).length;
describe("mapa do escritório", () => {
it("tem pelo menos 40 × 28 células e é lido por lerMapa", () => {
expect(largura).toBeGreaterThanOrEqual(40);
expect(altura).toBeGreaterThanOrEqual(28);
expect(() => lerMapa(ESCRITORIO)).not.toThrow();
});
it("tem os 7 ambientes com nome", () => {
const nomes = AMBIENTES.map((a) => a.nome);
for (const nome of [
"recepção",
"área de mesas",
"sala de reunião",
"copa",
"sala do servidor",
"diretoria",
"almoxarifado",
]) {
expect(nomes).toContain(nome);
}
});
it("tem 6 ou mais nascedouros, todos a 8 m ou mais do início", () => {
expect(MAPA.nascedouros.length).toBeGreaterThanOrEqual(6);
for (const [x, z] of MAPA.nascedouros) {
expect(
Math.hypot(x - MAPA.inicio[0], z - MAPA.inicio[1]),
).toBeGreaterThanOrEqual(8);
}
});
it("alcança todo o chão a partir do início", () => {
expect(alcancaveis(MAPA.solido)).toBe(totalAndavel);
});
it("continua alcançando todo o chão sem qualquer uma das portas", () => {
expect(PORTAS.length).toBeGreaterThan(0);
for (const porta of PORTAS) {
const bloqueio = MAPA.solido.slice();
for (let z = porta.z; z < porta.z + porta.altura; z++) {
for (let x = porta.x; x < porta.x + porta.largura; x++) {
expect(andavel(bloqueio, x, z)).toBe(true);
bloqueio[z * largura + x] = 1;
}
}
expect(alcancaveis(bloqueio)).toBe(
totalAndavel - porta.largura * porta.altura,
);
}
});
it("não tem passagem com menos de 2 células: toda célula andável está num 2 × 2 andável", () => {
const livre = (x: number, z: number) => andavel(MAPA.solido, x, z);
for (let z = 0; z < altura; z++) {
for (let x = 0; x < largura; x++) {
if (!livre(x, z)) continue;
const emQuadrado = [
[-1, -1],
[-1, 0],
[0, -1],
[0, 0],
].some(
([ox, oz]) =>
livre(x + ox, z + oz) &&
livre(x + ox + 1, z + oz) &&
livre(x + ox, z + oz + 1) &&
livre(x + ox + 1, z + oz + 1),
);
expect([x, z, emQuadrado]).toEqual([x, z, true]);
}
}
});
it("o campo de fluxo leva cada nascedouro ao início em menos de 400 passos", () => {
const campo = campoDeFluxo(MAPA, MAPA.inicio[0], MAPA.inicio[1]);
const alvo = [Math.floor(MAPA.inicio[0]), Math.floor(MAPA.inicio[1])];
for (const [nx, nz] of MAPA.nascedouros) {
let x = Math.floor(nx);
let z = Math.floor(nz);
let passos = 0;
while ((x !== alvo[0] || z !== alvo[1]) && passos < 400) {
const [dx, dz] = direcaoNoCampo(MAPA, campo, x + 0.5, z + 0.5);
x += Math.round(dx);
z += Math.round(dz);
passos++;
}
expect([x, z]).toEqual(alvo);
expect(passos).toBeLessThan(400);
}
});
});
+89
View File
@@ -0,0 +1,89 @@
// Gerado por `bash 3d/mapa.sh` (`python3 3d/mapa.py converter`) a partir de 3d/mapa/escritorio.tmx: não edite à mão.
/** Planta do escritório para `lerMapa`: `#` parede ou móvel alto, `M` móvel baixo, `.` chão, `J` início, `N` nascedouro. */
export const ESCRITORIO: readonly string[] = [
"############################################",
"#..MMM.........#............#MMM..........##",
"#..............#............#.............##",
"#M......MMMM...#..MMMMMMMM.........MMMM...##",
"#M.............#..MMMMMMMM.........MMMM...##",
"#M.....J.......#............#..............#",
"#M.............#............#..............#",
"####..######..####..####..####..############",
"#........#........................#........#",
"#........#........................#........#",
"#........#..#########..#####..##..#..#######",
"#M..MM......#.................##...........#",
"#M..MM......#.................##...........#",
"#M.......#..#..MMMMM...MMMMM..##..#..#######",
"#M.......#..#..MMMMM...MMMMM...#..#........#",
"#MMMM..###........................#........#",
"##########........................####..####",
"###..MMMM#..#..MMMMM...MMMMM...#..#.......##",
"###......#..#..MMMMM...MMMMM...#..#.......##",
"###.........#..................#..........##",
"###.........#..................#.....###...#",
"###...N..#..#########..#########..#........#",
"#........#........................#......N.#",
"#........#........................###......#",
"####..#######..#####..######..########..####",
"#................#......#..................#",
"#................#......#..................#",
"#.N....N.............####...............MMM#",
"#....................####...............MMM#",
"####..###........#.N.####.N................#",
"####..###..MMMM..#...####..######........N.#",
"############################################",
];
/** Salas com nome, em células (x para a direita, z para baixo). */
export const AMBIENTES: readonly {
nome: string;
x: number;
z: number;
largura: number;
altura: number;
}[] = [
{ nome: "recepção", x: 1, z: 1, largura: 14, altura: 6 },
{ nome: "sala de reunião", x: 16, z: 1, largura: 12, altura: 6 },
{ nome: "diretoria", x: 29, z: 1, largura: 14, altura: 6 },
{ nome: "copa", x: 1, z: 8, largura: 8, altura: 8 },
{ nome: "banheiros", x: 1, z: 17, largura: 8, altura: 7 },
{ nome: "área de mesas", x: 13, z: 11, largura: 18, altura: 10 },
{ nome: "sala do servidor", x: 35, z: 8, largura: 8, altura: 8 },
{ nome: "almoxarifado", x: 35, z: 17, largura: 8, altura: 7 },
{ nome: "hall dos elevadores", x: 1, z: 25, largura: 16, altura: 6 },
{ nome: "escada", x: 18, z: 25, largura: 6, altura: 6 },
{ nome: "área de serviço", x: 25, z: 25, largura: 18, altura: 6 },
];
/** Células abertas nas paredes, em células. */
export const PORTAS: readonly {
x: number;
z: number;
largura: number;
altura: number;
}[] = [
{ x: 4, z: 7, largura: 2, altura: 1 },
{ x: 12, z: 7, largura: 2, altura: 1 },
{ x: 18, z: 7, largura: 2, altura: 1 },
{ x: 24, z: 7, largura: 2, altura: 1 },
{ x: 28, z: 3, largura: 1, altura: 2 },
{ x: 30, z: 7, largura: 2, altura: 1 },
{ x: 9, z: 11, largura: 1, altura: 2 },
{ x: 9, z: 19, largura: 1, altura: 2 },
{ x: 4, z: 24, largura: 2, altura: 1 },
{ x: 13, z: 24, largura: 2, altura: 1 },
{ x: 17, z: 27, largura: 1, altura: 2 },
{ x: 20, z: 24, largura: 2, altura: 1 },
{ x: 28, z: 24, largura: 2, altura: 1 },
{ x: 38, z: 24, largura: 2, altura: 1 },
{ x: 34, z: 19, largura: 1, altura: 2 },
{ x: 38, z: 16, largura: 2, altura: 1 },
{ x: 34, z: 11, largura: 1, altura: 2 },
{ x: 21, z: 10, largura: 2, altura: 1 },
{ x: 28, z: 10, largura: 2, altura: 1 },
{ x: 21, z: 21, largura: 2, altura: 1 },
{ x: 12, z: 15, largura: 1, altura: 2 },
{ x: 31, z: 15, largura: 1, altura: 2 },
];
+192
View File
@@ -0,0 +1,192 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const RAIZ = process.cwd();
const PASTA = join(RAIZ, "public/jogo");
const BLOCO = join(RAIZ, "public/3d/bloco.glb");
const MB = 1024 * 1024;
function arquivos(pasta: string): string[] {
if (!existsSync(pasta)) return [];
return readdirSync(pasta, { withFileTypes: true }).flatMap((e) =>
e.isDirectory() ? arquivos(join(pasta, e.name)) : [join(pasta, e.name)],
);
}
const nome = (caminho: string) => relative(RAIZ, caminho);
const doJogo = arquivos(PASTA).filter((a) => !a.endsWith("CREDITOS.json"));
const glbs = [...doJogo, BLOCO].filter((a) => a.endsWith(".glb"));
/** Maior lado de um JPEG, PNG ou WebP, lido do cabeçalho; `null` se não for nenhum deles. */
function lado(b: Buffer): number | null {
if (
b.length > 30 &&
b.toString("latin1", 0, 4) === "RIFF" &&
b.toString("latin1", 8, 15) === "WEBPVP8"
) {
const tipo = b.toString("latin1", 15, 16);
if (tipo === "X")
return Math.max(b.readUIntLE(24, 3), b.readUIntLE(27, 3)) + 1;
if (tipo === " ")
return Math.max(b.readUInt16LE(26) & 0x3fff, b.readUInt16LE(28) & 0x3fff);
if (tipo === "L") {
const v = b.readUInt32LE(21);
return Math.max((v & 0x3fff) + 1, ((v >> 14) & 0x3fff) + 1);
}
}
if (b.length > 24 && b.readUInt32BE(0) === 0x89504e47)
return Math.max(b.readUInt32BE(16), b.readUInt32BE(20));
if (b[0] === 0xff && b[1] === 0xd8) {
let i = 2;
while (i + 9 < b.length) {
if (b[i] !== 0xff) {
i += 1;
continue;
}
const marcador = b[i + 1];
if (marcador === 0xff) {
i += 1;
continue;
}
if (marcador === 0xd8 || (marcador >= 0xd0 && marcador <= 0xd7)) {
i += 2;
continue;
}
const sof =
marcador >= 0xc0 &&
marcador <= 0xcf &&
marcador !== 0xc4 &&
marcador !== 0xc8 &&
marcador !== 0xcc;
if (sof) return Math.max(b.readUInt16BE(i + 5), b.readUInt16BE(i + 7));
i += 2 + b.readUInt16BE(i + 2);
}
}
return null;
}
type Gltf = {
accessors?: Array<{ count: number }>;
meshes?: Array<{ primitives: Array<{ indices?: number }> }>;
images?: Array<{ bufferView?: number; uri?: string }>;
bufferViews?: Array<{ byteOffset?: number; byteLength: number }>;
};
function lerGlb(caminho: string) {
const b = readFileSync(caminho);
const tamanhoJson = b.readUInt32LE(12);
const json = JSON.parse(
b.subarray(20, 20 + tamanhoJson).toString("utf8"),
) as Gltf;
const fimJson = 20 + tamanhoJson;
const bin =
fimJson + 8 <= b.length
? b.subarray(fimJson + 8, fimJson + 8 + b.readUInt32LE(fimJson))
: Buffer.alloc(0);
return { json, bin };
}
describe("orçamento de peso do jogo", () => {
it("public/jogo + bloco.glb somam até 6 MB", () => {
const total = [...doJogo, BLOCO].reduce((s, a) => s + statSync(a).size, 0);
expect({
total: `${(total / MB).toFixed(2)} MB`,
ok: total <= 6 * MB,
}).toEqual({ total: `${(total / MB).toFixed(2)} MB`, ok: true });
});
it.each(glbs.map((g) => [nome(g), g]))("%s tem até 1,5 MB", (n, g) => {
const tamanho = statSync(g).size;
expect({
arquivo: n,
MB: +(tamanho / MB).toFixed(2),
ok: tamanho <= 1.5 * MB,
}).toEqual({ arquivo: n, MB: +(tamanho / MB).toFixed(2), ok: true });
});
it("zumbi.glb tem até 12.000 triângulos", () => {
const { json } = lerGlb(join(PASTA, "zumbi.glb"));
let triangulos = 0;
for (const malha of json.meshes ?? [])
for (const p of malha.primitives)
if (p.indices !== undefined)
triangulos += (json.accessors?.[p.indices]?.count ?? 0) / 3;
expect({ arquivo: "public/jogo/zumbi.glb", triangulos }).toEqual({
arquivo: "public/jogo/zumbi.glb",
triangulos: Math.min(triangulos, 12000),
});
});
it("imagens de public/jogo têm lado até 1024 px", () => {
for (const a of doJogo.filter(
(f) => /\.(jpe?g|png|webp)$/i.test(f) && !f.endsWith("capa.jpg"),
)) {
const l = lado(readFileSync(a));
expect({ arquivo: nome(a), lado: l }).toEqual({
arquivo: nome(a),
lado: Math.min(l ?? 0, 1024),
});
}
});
it("itens.webp tem 512 px de lado e até 120 KB", () => {
const a = join(PASTA, "itens.webp");
const kb = statSync(a).size / 1024;
expect({
arquivo: "public/jogo/itens.webp",
lado: lado(readFileSync(a)),
ok: kb <= 120,
}).toEqual({ arquivo: "public/jogo/itens.webp", lado: 512, ok: true });
});
it("capa.jpg tem lado até 1600 px e até 220 KB", () => {
const a = join(PASTA, "capa.jpg");
const l = lado(readFileSync(a));
const kb = statSync(a).size / 1024;
expect({
arquivo: "public/jogo/capa.jpg",
lado: l,
KB: Math.round(kb),
}).toEqual({
arquivo: "public/jogo/capa.jpg",
lado: Math.min(l ?? 0, 1600),
KB: Math.min(Math.round(kb), 220),
});
});
it("imagens embutidas nos GLB têm lado até 1024 px", () => {
for (const g of glbs) {
const { json, bin } = lerGlb(g);
(json.images ?? []).forEach((img, i) => {
let dados: Buffer | null = null;
if (img.bufferView !== undefined) {
const v = json.bufferViews?.[img.bufferView];
if (v)
dados = bin.subarray(
v.byteOffset ?? 0,
(v.byteOffset ?? 0) + v.byteLength,
);
} else if (img.uri?.startsWith("data:"))
dados = Buffer.from(img.uri.split(",")[1] ?? "", "base64");
const l = dados ? lado(dados) : null;
const rotulo = `${nome(g)}#imagem${i}`;
expect({ arquivo: rotulo, lado: l }).toEqual({
arquivo: rotulo,
lado: Math.min(l ?? 0, 1024),
});
});
}
});
it("cada .mp3 de public/jogo/sons tem até 60 KB", () => {
for (const a of arquivos(join(PASTA, "sons")).filter((f) =>
f.endsWith(".mp3"),
)) {
const kb = statSync(a).size / 1024;
expect({ arquivo: nome(a), KB: Math.round(kb) }).toEqual({
arquivo: nome(a),
KB: Math.min(Math.round(kb), 60),
});
}
});
});
+379
View File
@@ -0,0 +1,379 @@
import { criarDrone } from "./drones";
import { montarFila } from "./hordas";
import * as mapaModulo from "./mapa";
import { avancar, criarPartida } from "./partida";
import { pilotar } from "./piloto";
import { semear } from "./rng";
import type { Dificuldade, Entrada, Fase, Partida } from "./tipos";
import { ESCRITORIO, HORDAS, JOGADOR, MUNICAO, PASSO } from "./valores";
const MAPA = ["#########", "#N......#", "#.......#", "#......J#", "#########"];
const PARADO: Entrada = {
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 0,
};
function nova(extra: Partial<Parameters<typeof criarPartida>[0]> = {}) {
return criarPartida({
semente: 1,
dificuldade: "facil",
problemaId: "site-lento",
tetoAVista: 2,
mapa: MAPA,
...extra,
});
}
function passo(p: Partida, parcial: Partial<Entrada> = {}) {
avancar(p, { ...PARADO, ...parcial });
}
function em(p: Partida, fase: Fase, extra: Partial<Partida> = {}) {
p.fase = fase;
p.tempoFase = 0;
p.jogador.x = 4.5;
p.jogador.z = 2.5;
Object.assign(p, extra);
}
const FILA_UM = [{ tipo: "comum" as const, problemaId: "site-lento" }];
describe("criarPartida", () => {
it("monta o estado inicial no mapa do escritório por padrão", () => {
const p = criarPartida({
semente: 7,
dificuldade: "normal",
problemaId: "site-lento",
});
expect(p.fase).toBe("inicio");
expect(p.tetoAVista).toBe(16);
expect(p.mapa.largura).toBe(ESCRITORIO[0].length);
expect(p.jogador).toEqual({
x: p.mapa.inicio[0],
z: p.mapa.inicio[1],
rumo: 0,
inclinacao: 0,
vida: JOGADOR.vida,
arma: 0,
municao: MUNICAO.inicial,
recarga: 0,
});
expect(p.hordas).toHaveLength(4);
expect(p.hordas[3]).toBe("site-lento");
expect(p).toMatchObject({
passo: 0,
horda: 0,
tempoFase: 0,
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
problemaId: "site-lento",
dificuldade: "normal",
});
});
it("usa o mapa e o teto informados e consome o sorteio da própria partida", () => {
const p = nova({ tetoAVista: 3 });
expect(p.tetoAVista).toBe(3);
expect(p.mapa.largura).toBe(9);
expect(p.jogador.x).toBe(7.5);
expect(p.rng).not.toBe(semear(1));
expect(nova().hordas).toEqual(p.hordas);
});
});
describe("avancar: jogador", () => {
it("anda para a frente em +z com rumo 0", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [0, 1] });
expect(p.jogador.x).toBeCloseTo(4.5);
expect(p.jogador.z).toBeCloseTo(2.5 + JOGADOR.velocidade * PASSO);
});
it("anda para a direita em −x com rumo 0", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [1, 0] });
expect(p.jogador.x).toBeCloseTo(4.5 - JOGADOR.velocidade * PASSO);
expect(p.jogador.z).toBeCloseTo(2.5);
});
it("gira o rumo e anda para a frente na direção nova", () => {
const p = nova();
em(p, "pausa");
passo(p, { olhar: [Math.PI / 2, 0], mover: [0, 1] });
expect(p.jogador.rumo).toBeCloseTo(Math.PI / 2);
expect(p.jogador.x).toBeCloseTo(4.5 + JOGADOR.velocidade * PASSO);
expect(p.jogador.z).toBeCloseTo(2.5);
});
it("limita o movimento diagonal a comprimento 1", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [1, 1] });
const andou = Math.hypot(p.jogador.x - 4.5, p.jogador.z - 2.5);
expect(andou).toBeCloseTo(JOGADOR.velocidade * PASSO);
});
it("não aumenta um movimento menor que 1", () => {
const p = nova();
em(p, "pausa");
passo(p, { mover: [0, 0.5] });
expect(p.jogador.z).toBeCloseTo(2.5 + 0.5 * JOGADOR.velocidade * PASSO);
});
it("soma a inclinação e a limita para cima e para baixo", () => {
const p = nova();
em(p, "pausa");
passo(p, { olhar: [0, 0.1] });
expect(p.jogador.inclinacao).toBeCloseTo(0.1);
passo(p, { olhar: [0, 5] });
expect(p.jogador.inclinacao).toBe(JOGADOR.inclinacaoMax);
passo(p, { olhar: [0, -9] });
expect(p.jogador.inclinacao).toBe(-JOGADOR.inclinacaoMax);
});
it("troca de arma, mas só atira se pedido", () => {
const p = nova();
em(p, "pausa");
passo(p, { arma: 1 });
expect(p.jogador.arma).toBe(1);
expect(p.eventos).toEqual([]);
p.jogador.recarga = 0;
passo(p, { arma: 1, atirar: true });
expect(p.eventos[0]).toMatchObject({ tipo: "tiro", autor: "jogador" });
});
it("recolhe as caixas ao alcance", () => {
const p = nova();
em(p, "pausa", { caixas: [{ id: 99, x: 4.5, z: 2.5 }] });
passo(p);
expect(p.caixas).toEqual([]);
expect(p.jogador.municao).toBe(MUNICAO.inicial + MUNICAO.porCaixa);
expect(p.eventos).toEqual([{ tipo: "caixa", balas: MUNICAO.porCaixa }]);
});
});
describe("avancar: fases", () => {
it("esvazia os eventos e conta passos", () => {
const p = nova();
p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 });
passo(p);
expect(p.eventos).toEqual([]);
expect(p.passo).toBe(1);
expect(p.tempoFase).toBe(1);
});
it("fica em inicio até o preparo acabar", () => {
const p = nova();
p.tempoFase = HORDAS.preparo - 2;
passo(p);
expect(p.fase).toBe("inicio");
expect(p.eventos).toEqual([]);
});
it("começa a horda 0 ao fim do preparo", () => {
const p = nova();
p.tempoFase = HORDAS.preparo - 1;
passo(p);
expect(p.fase).toBe("horda");
expect(p.tempoFase).toBe(0);
expect(p.horda).toBe(0);
expect(p.fila).toEqual(montarFila(0, p.hordas[0], "facil"));
expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]);
});
it("não termina a horda com zumbis na fila", () => {
const p = nova();
em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 });
passo(p);
expect(p.fase).toBe("horda");
expect(p.resolvidos).toEqual([]);
});
it("não termina a horda com zumbis no mapa", () => {
const p = nova();
em(p, "horda", {
zumbis: [
{
id: 50,
tipo: "comum",
problemaId: "site-lento",
x: 1.5,
z: 1.5,
rumo: 0,
vida: 100,
estado: "andando",
tempo: 0,
},
],
});
passo(p);
expect(p.fase).toBe("horda");
expect(p.resolvidos).toEqual([]);
});
it("termina a horda: resolve, cria o drone, cura e vai para a pausa", () => {
const p = nova();
em(p, "horda", { horda: 1 });
p.jogador.vida = 50;
passo(p);
const resolvido = p.hordas[1];
expect(p.resolvidos).toEqual([resolvido]);
expect(p.drones).toHaveLength(1);
expect(p.drones[0].problemaId).toBe(resolvido);
expect(p.jogador.vida).toBe(50 + JOGADOR.curaPorHorda);
expect(p.fase).toBe("pausa");
expect(p.tempoFase).toBe(0);
expect(p.feed[0].texto).toMatch(/^Resolvido: /);
expect(p.eventos).toEqual([
{ tipo: "resolvido", problemaId: resolvido, droneId: p.drones[0].id },
{ tipo: "fase", fase: "pausa" },
]);
});
it("a cura não passa da vida máxima", () => {
const p = nova();
em(p, "horda");
p.jogador.vida = 90;
passo(p);
expect(p.jogador.vida).toBe(JOGADOR.vida);
});
it("vai para a derrota com a vida zerada, antes de resolver a horda", () => {
const p = nova();
em(p, "horda");
p.jogador.vida = 0;
passo(p);
expect(p.fase).toBe("derrota");
expect(p.resolvidos).toEqual([]);
expect(p.drones).toEqual([]);
expect(p.eventos).toEqual([{ tipo: "fase", fase: "derrota" }]);
});
it("fica na pausa até o tempo acabar, e os drones seguem o jogador", () => {
const p = nova();
em(p, "pausa", { horda: 0 });
const drone = criarDrone(p, "site-lento");
drone.x = 1.5;
drone.z = 1.5;
passo(p);
expect(p.fase).toBe("pausa");
expect(p.horda).toBe(0);
expect(drone.x).toBeGreaterThan(1.5);
expect(p.eventos).toEqual([]);
});
it("sai da pausa para a horda seguinte", () => {
const p = nova();
em(p, "pausa", { horda: 0 });
p.tempoFase = HORDAS.pausa - 1;
passo(p);
expect(p.horda).toBe(1);
expect(p.fase).toBe("horda");
expect(p.tempoFase).toBe(0);
expect(p.fila).toEqual(montarFila(1, p.hordas[1], "facil"));
expect(p.eventos).toEqual([{ tipo: "fase", fase: "horda" }]);
});
it("sai da pausa da horda 2 para o chefão", () => {
const p = nova();
em(p, "pausa", { horda: 2 });
p.tempoFase = HORDAS.pausa - 1;
passo(p);
expect(p.horda).toBe(3);
expect(p.fase).toBe("chefao");
expect(p.fila).toEqual(montarFila(3, p.hordas[3], "facil"));
expect(p.eventos).toEqual([{ tipo: "fase", fase: "chefao" }]);
});
it("vence ao derrotar o chefão, sem criar drone", () => {
const p = nova();
em(p, "chefao", { horda: 3 });
passo(p);
expect(p.fase).toBe("vitoria");
expect(p.resolvidos).toEqual(["site-lento"]);
expect(p.drones).toEqual([]);
expect(p.eventos).toEqual([
{ tipo: "resolvido", problemaId: "site-lento", droneId: -1 },
{ tipo: "fase", fase: "vitoria" },
]);
});
it("perde no chefão com a vida zerada", () => {
const p = nova();
em(p, "chefao", { horda: 3 });
p.jogador.vida = -5;
passo(p);
expect(p.fase).toBe("derrota");
});
it.each(["vitoria", "derrota"] as const)(
"depois da %s nada mais muda, só os eventos esvaziam",
(fase) => {
const p = nova();
em(p, fase);
p.eventos.push({ tipo: "golpe", dano: 1, zumbiId: 0 });
const antes = JSON.stringify({ ...p, eventos: [] });
passo(p, { mover: [1, 1], olhar: [1, 1], atirar: true, arma: 1 });
expect(p.eventos).toEqual([]);
expect(JSON.stringify(p)).toBe(antes);
},
);
});
describe("avancar: campo de fluxo", () => {
it("só recalcula quando o jogador muda de célula", () => {
const espiao = jest.spyOn(mapaModulo, "campoDeFluxo");
const p = nova();
em(p, "horda", { fila: [...FILA_UM], tetoAVista: 0 });
passo(p);
passo(p);
expect(espiao).toHaveBeenCalledTimes(1);
p.jogador.x -= 1;
passo(p);
expect(espiao).toHaveBeenCalledTimes(2);
passo(p);
expect(espiao).toHaveBeenCalledTimes(2);
espiao.mockRestore();
});
});
describe("partida inteira com o piloto", () => {
const TETO_DE_PASSOS = 60_000;
// regressão: na fácil, as sementes 4, 6, 13, 20, 23, 24 e 38 empacavam na horda 3 com "faltam 2"
it.each<Dificuldade>(["facil", "normal", "dificil"])(
"40 sementes na %s terminam em vitória ou derrota",
(dificuldade) => {
const fins: [number, Fase][] = [];
for (let semente = 1; semente <= 40; semente++) {
const p = criarPartida({
semente,
dificuldade,
problemaId: "site-lento",
});
for (
let i = 0;
i < TETO_DE_PASSOS && p.fase !== "vitoria" && p.fase !== "derrota";
i++
) {
avancar(p, pilotar(p));
}
fins.push([semente, p.fase]);
}
const presas = fins.filter(([, f]) => f !== "vitoria" && f !== "derrota");
expect(presas).toEqual([]);
},
);
});
+164
View File
@@ -0,0 +1,164 @@
/** Integra a lógica do jogo: cria a partida e avança um passo por vez. Sem three, React nem DOM. */
import { atirar, recolherCaixas, resfriar, trocarArma } from "./armas";
import { avancarDrones, criarDrone } from "./drones";
import { registrarResolvido } from "./feed";
import { escolherHordas, montarFila } from "./hordas";
import { campoDeFluxo, lerMapa, moverComColisao } from "./mapa";
import { semear } from "./rng";
import type { Dificuldade, Entrada, Fase, Partida } from "./tipos";
import {
ESCRITORIO,
HORDAS,
JOGADOR,
MUNICAO,
PASSO,
TETO_A_VISTA,
} from "./valores";
import { avancarZumbis, nascer } from "./zumbis";
const HORDA_DO_CHEFAO = 3;
export function criarPartida(opcoes: {
semente: number;
dificuldade: Dificuldade;
problemaId: string;
tetoAVista?: number;
mapa?: readonly string[];
}): Partida {
const sorteio = { rng: semear(opcoes.semente) };
const hordas = escolherHordas(sorteio, opcoes.problemaId);
const mapa = lerMapa(opcoes.mapa ?? ESCRITORIO);
return {
passo: 0,
rng: sorteio.rng,
dificuldade: opcoes.dificuldade,
problemaId: opcoes.problemaId,
hordas,
horda: 0,
fase: "inicio",
tempoFase: 0,
tetoAVista: opcoes.tetoAVista ?? TETO_A_VISTA,
mapa,
jogador: {
x: mapa.inicio[0],
z: mapa.inicio[1],
rumo: 0,
inclinacao: 0,
vida: JOGADOR.vida,
arma: 0,
municao: MUNICAO.inicial,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
};
}
const campos = new WeakMap<Partida, { celula: number; campo: Int16Array }>();
/** Só recalcula o campo de fluxo quando o jogador muda de célula. */
function campoAtual(p: Partida): Int16Array {
const { x, z } = p.jogador;
const celula = Math.floor(z) * p.mapa.largura + Math.floor(x);
const guardado = campos.get(p);
if (guardado?.celula === celula) return guardado.campo;
const campo = campoDeFluxo(p.mapa, x, z);
campos.set(p, { celula, campo });
return campo;
}
function moverJogador(p: Partida, entrada: Entrada): void {
const j = p.jogador;
j.rumo += entrada.olhar[0];
j.inclinacao = Math.min(
JOGADOR.inclinacaoMax,
Math.max(-JOGADOR.inclinacaoMax, j.inclinacao + entrada.olhar[1]),
);
const [lado, frente] = entrada.mover;
const escala = Math.min(1, 1 / Math.hypot(lado, frente));
const passo = escala * JOGADOR.velocidade * PASSO;
const dx = (Math.sin(j.rumo) * frente - Math.cos(j.rumo) * lado) * passo;
const dz = (Math.cos(j.rumo) * frente + Math.sin(j.rumo) * lado) * passo;
[j.x, j.z] = moverComColisao(p.mapa, j.x, j.z, dx, dz, JOGADOR.raio);
}
function usarArma(p: Partida, entrada: Entrada): void {
trocarArma(p, entrada.arma);
resfriar(p);
if (entrada.atirar) atirar(p);
recolherCaixas(p);
}
function mudarFase(p: Partida, fase: Fase): void {
p.fase = fase;
p.eventos.push({ tipo: "fase", fase });
}
/** Começa a horda `p.horda`: a última é a do chefão. */
function comecarHorda(p: Partida): void {
p.tempoFase = 0;
p.fila = montarFila(p.horda, p.hordas[p.horda], p.dificuldade);
mudarFase(p, p.horda === HORDA_DO_CHEFAO ? "chefao" : "horda");
}
function fecharHorda(p: Partida): void {
const problemaId = p.hordas[p.horda];
p.resolvidos.push(problemaId);
registrarResolvido(p, problemaId);
if (p.fase === "chefao") {
p.eventos.push({ tipo: "resolvido", problemaId, droneId: -1 });
mudarFase(p, "vitoria");
return;
}
const drone = criarDrone(p, problemaId);
p.eventos.push({ tipo: "resolvido", problemaId, droneId: drone.id });
p.jogador.vida = Math.min(
JOGADOR.vida,
p.jogador.vida + JOGADOR.curaPorHorda,
);
p.tempoFase = 0;
mudarFase(p, "pausa");
}
function combater(p: Partida): void {
nascer(p);
avancarZumbis(p, campoAtual(p));
avancarDrones(p);
if (p.jogador.vida <= 0) {
mudarFase(p, "derrota");
} else if (p.fila.length === 0 && p.zumbis.length === 0) {
fecharHorda(p);
}
}
function avancarFase(p: Partida): void {
if (p.fase === "inicio") {
if (p.tempoFase >= HORDAS.preparo) comecarHorda(p);
} else if (p.fase === "pausa") {
avancarDrones(p);
if (p.tempoFase >= HORDAS.pausa) {
p.horda++;
comecarHorda(p);
}
} else {
combater(p);
}
}
export function avancar(p: Partida, entrada: Entrada): void {
p.eventos.length = 0;
if (p.fase === "vitoria" || p.fase === "derrota") return;
p.passo++;
p.tempoFase++;
moverJogador(p, entrada);
usarArma(p, entrada);
avancarFase(p);
}
+226
View File
@@ -0,0 +1,226 @@
import { avancar, criarPartida } from "./partida";
import { pilotar } from "./piloto";
import type { Partida, TipoZumbi, Zumbi } from "./tipos";
import { ESCRITORIO, ZUMBIS } from "./valores";
/** 21 x 15, parede por dentro em x = 10, de z = 5 a z = 9. */
const MAPA = Array.from({ length: 15 }, (_, z) => {
if (z === 0 || z === 14) return "#".repeat(21);
const miolo = Array.from({ length: 19 }, (_, i) => {
if (z === 1 && i === 0) return "N";
if (z === 2 && i === 0) return "J";
return i === 9 && z >= 5 && z <= 9 ? "#" : ".";
});
return `#${miolo.join("")}#`;
});
const JX = 5.5;
const JZ = 7.5;
function zumbi(
id: number,
x: number,
z: number,
tipo: TipoZumbi = "comum",
estado: Zumbi["estado"] = "andando",
): Zumbi {
return {
id,
tipo,
problemaId: "site-lento",
x,
z,
rumo: 0,
vida: ZUMBIS[tipo].vida,
estado,
tempo: 0,
};
}
/** Jogador em (5.5, 7.5), rumo 0, com os zumbis dados. */
function cena(...zumbis: Zumbi[]): Partida {
const p = criarPartida({
semente: 1,
dificuldade: "facil",
problemaId: "site-lento",
mapa: MAPA,
});
p.jogador.x = JX;
p.jogador.z = JZ;
p.zumbis = zumbis;
return p;
}
/** Zumbi a `distancia` metros do jogador, no rumo dado. */
function aoRumo(
id: number,
rumo: number,
distancia: number,
tipo: TipoZumbi = "comum",
): Zumbi {
return zumbi(
id,
JX + Math.sin(rumo) * distancia,
JZ + Math.cos(rumo) * distancia,
tipo,
);
}
describe("pilotar: sem alvo", () => {
it("fica parado sem zumbis", () => {
expect(pilotar(cena())).toEqual({
mover: [0, 0],
olhar: [0, 0],
atirar: false,
arma: 0,
});
});
it("ignora zumbis morrendo", () => {
const p = cena(zumbi(1, 5.5, 9.5, "comum", "morrendo"));
expect(pilotar(p).atirar).toBe(false);
expect(pilotar(p).mover).toEqual([0, 0]);
});
});
describe("pilotar: escolha do alvo", () => {
it("prefere o mais próximo com linha de visão", () => {
const atras = zumbi(1, 11.5, 7.5);
const livre = zumbi(2, 1.5, 1.5);
expect(pilotar(cena(atras, livre)).olhar[0]).toBe(-0.12);
});
it("sem visão de ninguém, escolhe o mais próximo", () => {
const perto = zumbi(1, 11.5, 7.5);
const longe = zumbi(2, 11.5, 5.5);
expect(pilotar(cena(longe, perto)).olhar[0]).toBe(0.12);
});
it("não atira sem linha de visão, mesmo alinhado", () => {
const p = cena(zumbi(1, 11.5, 7.5));
p.jogador.rumo = Math.PI / 2;
expect(pilotar(p).atirar).toBe(false);
});
});
describe("pilotar: mira", () => {
it("gira no máximo 0,12 rad por passo", () => {
expect(pilotar(cena(aoRumo(1, 1, 5))).olhar[0]).toBe(0.12);
expect(pilotar(cena(aoRumo(1, -1, 5))).olhar[0]).toBe(-0.12);
});
it("gira só o que falta quando o erro é menor que o limite", () => {
const p = cena(aoRumo(1, 0.03, 5));
expect(pilotar(p).olhar[0]).toBeCloseTo(0.03);
});
it("normaliza a diferença de rumo para −π a π", () => {
const p = cena(aoRumo(1, -3, 5));
p.jogador.rumo = 3;
expect(pilotar(p).olhar[0]).toBe(0.12);
});
it("inclina no máximo 0,05 rad por passo, para baixo e para cima", () => {
const p = cena(aoRumo(1, 0, 5));
expect(pilotar(p).olhar[1]).toBe(-0.05);
p.jogador.inclinacao = -1;
expect(pilotar(p).olhar[1]).toBe(0.05);
});
it("mira a 65% da altura do tipo", () => {
const p = cena(aoRumo(1, 0, 5));
p.jogador.inclinacao = Math.atan2(0.65 * ZUMBIS.comum.altura - 1.6, 5);
expect(pilotar(p).olhar[1]).toBeCloseTo(0);
});
it("atira quando o erro de rumo é menor que 0,05 e há visão", () => {
expect(pilotar(cena(aoRumo(1, 0.04, 5))).atirar).toBe(true);
});
it("não atira com erro de rumo de 0,05 ou mais", () => {
expect(pilotar(cena(aoRumo(1, 0.06, 5))).atirar).toBe(false);
});
});
describe("pilotar: arma", () => {
it("usa a faca quando a borda está a 1,3 m ou menos", () => {
const comum = ZUMBIS.comum.raio;
expect(pilotar(cena(aoRumo(1, 0, 1.25 + comum))).arma).toBe(2);
expect(pilotar(cena(aoRumo(1, 0, 1.4 + comum))).arma).toBe(0);
});
it("usa a automática com 3 zumbis a até 12 m", () => {
const tres = cena(aoRumo(1, 0, 5), aoRumo(2, 1, 6), aoRumo(3, 2, 12));
expect(pilotar(tres).arma).toBe(1);
});
it("fica na pistola com só 2 zumbis a até 12 m", () => {
const dois = cena(aoRumo(1, 0, 5), aoRumo(2, 1, 6), aoRumo(3, 2, 12.5));
expect(pilotar(dois).arma).toBe(0);
});
it("usa a automática contra blindado e contra chefão", () => {
expect(pilotar(cena(aoRumo(1, 0, 6, "blindado"))).arma).toBe(1);
expect(pilotar(cena(aoRumo(1, 0, 6, "chefao"))).arma).toBe(1);
});
it("fica na pistola contra um comum ou um rápido", () => {
expect(pilotar(cena(aoRumo(1, 0, 6, "rapido"))).arma).toBe(0);
});
it("sem bala não usa a automática", () => {
const p = cena(aoRumo(1, 0, 6, "blindado"));
p.jogador.municao = 0;
expect(pilotar(p).arma).toBe(0);
});
});
describe("pilotar: distância", () => {
it("anda para trás com o alvo a menos de 4 m", () => {
expect(pilotar(cena(aoRumo(1, 0, 3.9))).mover).toEqual([0, -1]);
});
it("fica parado com o alvo a 4 m ou mais", () => {
expect(pilotar(cena(aoRumo(1, 0, 4))).mover).toEqual([0, 0]);
});
});
describe("pilotar: partida inteira no escritório", () => {
function jogar(dificuldade: Partida["dificuldade"] = "facil") {
const p = criarPartida({
semente: 1,
dificuldade,
problemaId: "site-lento",
mapa: ESCRITORIO,
});
while (p.fase !== "vitoria" && p.fase !== "derrota" && p.passo < 36000) {
avancar(p, pilotar(p));
}
return p;
}
it("vence em menos de 36.000 passos, com 3 drones e 4 problemas resolvidos", () => {
const p = jogar();
expect(p.fase).toBe("vitoria");
expect(p.passo).toBeLessThan(36000);
expect(p.drones).toHaveLength(3);
expect(p.resolvidos).toHaveLength(4);
expect(p.placar.drones).toBeGreaterThan(0);
});
it.each(["normal", "dificil"] as const)(
"termina em vitória ou derrota antes de 36.000 passos no %s",
(dificuldade) => {
const p = jogar(dificuldade);
expect(["vitoria", "derrota"]).toContain(p.fase);
expect(p.passo).toBeLessThan(36000);
},
);
it("é determinística: mesma partida, mesmo passo e mesmo placar", () => {
const a = jogar();
const b = jogar();
expect(b.passo).toBe(a.passo);
expect(b.placar).toEqual(a.placar);
});
});
+75
View File
@@ -0,0 +1,75 @@
/** Jogador automático para testes e medição: função pura do estado, sem memória própria. */
import { temLinhaDeVisao } from "./mapa";
import type { ArmaId, Entrada, Partida, Zumbi } from "./tipos";
import { JOGADOR, ZUMBIS } from "./valores";
const GIRO_MAXIMO = 0.12;
const INCLINACAO_MAXIMA = 0.05;
const ALTURA_DA_MIRA = 0.65;
const ALCANCE_DA_FACA = 1.3;
const ERRO_PARA_ATIRAR = 0.05;
const DISTANCIA_DE_RECUO = 4;
const RAIO_DA_MULTIDAO = 12;
const TAMANHO_DA_MULTIDAO = 3;
const PISTOLA: ArmaId = 0;
const AUTOMATICA: ArmaId = 1;
const FACA: ArmaId = 2;
type Alvo = { zumbi: Zumbi; vivos: Zumbi[]; visao: boolean };
function distanciaAte(p: Partida, zumbi: Zumbi): number {
return Math.hypot(zumbi.x - p.jogador.x, zumbi.z - p.jogador.z);
}
function escolherAlvo(p: Partida): Alvo | undefined {
const { x, z } = p.jogador;
const vivos = p.zumbis.filter((zumbi) => zumbi.estado !== "morrendo");
if (vivos.length === 0) return undefined;
const vistos = vivos.filter((zumbi) =>
temLinhaDeVisao(p.mapa, x, z, zumbi.x, zumbi.z),
);
const lista = vistos.length > 0 ? vistos : vivos;
const zumbi = lista.reduce((a, b) =>
distanciaAte(p, b) < distanciaAte(p, a) ? b : a,
);
return { zumbi, vivos, visao: vistos.length > 0 };
}
function limitar(valor: number, maximo: number): number {
return Math.max(-maximo, Math.min(maximo, valor));
}
function escolherArma(p: Partida, alvo: Alvo): ArmaId {
const { tipo } = alvo.zumbi;
if (distanciaAte(p, alvo.zumbi) - ZUMBIS[tipo].raio <= ALCANCE_DA_FACA) {
return FACA;
}
const perto = alvo.vivos.filter(
(zumbi) => distanciaAte(p, zumbi) <= RAIO_DA_MULTIDAO,
);
const exige =
perto.length >= TAMANHO_DA_MULTIDAO ||
tipo === "blindado" ||
tipo === "chefao";
return p.jogador.municao > 0 && exige ? AUTOMATICA : PISTOLA;
}
export function pilotar(p: Partida): Entrada {
const alvo = escolherAlvo(p);
if (!alvo) return { mover: [0, 0], olhar: [0, 0], atirar: false, arma: 0 };
const { zumbi } = alvo;
const { x, z, rumo, inclinacao } = p.jogador;
const distancia = distanciaAte(p, zumbi);
const desvio = Math.atan2(zumbi.x - x, zumbi.z - z) - rumo;
const erro = Math.atan2(Math.sin(desvio), Math.cos(desvio));
const altura = ZUMBIS[zumbi.tipo].altura * ALTURA_DA_MIRA - JOGADOR.olhos;
const subida = Math.atan2(altura, distancia) - inclinacao;
return {
mover: [0, distancia < DISTANCIA_DE_RECUO ? -1 : 0],
olhar: [limitar(erro, GIRO_MAXIMO), limitar(subida, INCLINACAO_MAXIMA)],
atirar: Math.abs(erro) < ERRO_PARA_ATIRAR && alvo.visao,
arma: escolherArma(p, alvo),
};
}
+78
View File
@@ -0,0 +1,78 @@
import { getServicePage } from "@/lib/site-content";
import {
AREAS,
type AreaId,
PROBLEMAS,
problemaPorId,
problemasDaArea,
} from "./problemas";
const areas = Object.keys(AREAS) as AreaId[];
describe("PROBLEMAS", () => {
it("tem 27 problemas", () => {
expect(PROBLEMAS).toHaveLength(27);
});
it("tem ids únicos em kebab-case sem acento", () => {
const ids = PROBLEMAS.map(({ id }) => id);
expect(new Set(ids).size).toBe(ids.length);
for (const id of ids) expect(id).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/);
});
it("tem os rótulos das seis áreas", () => {
expect(AREAS).toEqual({
vender: "Vender e ser encontrado",
atender: "Atender",
operar: "Operar",
fiscal: "Fiscal e financeiro",
sistemas: "Sistemas e integrações",
seguranca: "Segurança e dados",
});
});
it("dá a toda área ao menos 4 problemas", () => {
for (const area of areas)
expect(problemasDaArea(area).length).toBeGreaterThanOrEqual(4);
});
it("aponta cada destino para uma página de serviço que existe", () => {
const fora = PROBLEMAS.filter(
({ destino }) => !destino.startsWith("/servicos/"),
);
expect(fora.map(({ destino }) => destino)).toEqual(["/ia"]);
for (const { destino } of PROBLEMAS.filter((p) =>
p.destino.startsWith("/servicos/"),
)) {
expect(getServicePage(destino.replace("/servicos/", ""))).toBeDefined();
}
});
it("não cita nome de cliente", () => {
const clientes = /SEV|Lavação|NutriScan|SC Plus|AS Serviços|Julia/;
for (const { nome, solucao } of PROBLEMAS) {
expect(nome).not.toMatch(clientes);
expect(solucao).not.toMatch(clientes);
}
});
});
describe("problemaPorId", () => {
it("acha pelo id", () => {
expect(problemaPorId(PROBLEMAS[0].id)).toBe(PROBLEMAS[0]);
});
it("devolve undefined para id inexistente", () => {
expect(problemaPorId("nao-existe")).toBeUndefined();
});
});
describe("problemasDaArea", () => {
it("devolve só os da área", () => {
for (const area of areas) {
const lista = problemasDaArea(area);
expect(lista.every((p) => p.area === area)).toBe(true);
}
expect(areas.reduce((n, a) => n + problemasDaArea(a).length, 0)).toBe(27);
});
});
+192
View File
@@ -0,0 +1,192 @@
export type AreaId =
| "vender"
| "atender"
| "operar"
| "fiscal"
| "sistemas"
| "seguranca";
export type Problema = {
id: string;
area: AreaId;
nome: string;
solucao: string;
destino: string;
};
export const AREAS: Record<AreaId, string> = {
vender: "Vender e ser encontrado",
atender: "Atender",
operar: "Operar",
fiscal: "Fiscal e financeiro",
sistemas: "Sistemas e integrações",
seguranca: "Segurança e dados",
};
const DESTINO_DA_AREA: Record<AreaId, string> = {
vender: "/servicos/criacao-de-sites",
atender: "/servicos/atendimento-whatsapp-ia",
operar: "/servicos/engenharia-de-software",
fiscal: "/servicos/nota-fiscal-reforma-tributaria",
sistemas: "/servicos/arquitetura-de-software",
seguranca: "/servicos/arquitetura-de-software",
};
function p(
area: AreaId,
id: string,
nome: string,
solucao: string,
destino = DESTINO_DA_AREA[area],
): Problema {
return { id, area, nome, solucao, destino };
}
export const PROBLEMAS: readonly Problema[] = [
p(
"vender",
"site-que-ninguem-acha",
"Site que ninguém acha no Google",
"Site que aparece no Google",
),
p(
"vender",
"site-ruim-no-celular",
"Site que não abre direito no celular",
"Site que funciona no celular",
),
p("vender", "site-lento", "Site lento", "Site rápido"),
p(
"vender",
"catalogo-desatualizado",
"Catálogo desatualizado",
"Catálogo em dia",
),
p(
"vender",
"pedido-perdido-no-whatsapp",
"Pedido perdido no WhatsApp",
"Pedido organizado",
),
p(
"vender",
"pagamento-que-falha",
"Pagamento que falha",
"Pagamento funcionando",
),
p(
"atender",
"atraso-no-atendimento",
"Atraso no atendimento",
"Atendimento na hora",
),
p(
"atender",
"mesma-pergunta-cem-vezes",
"Mesma pergunta respondida cem vezes",
"Resposta automática",
),
p(
"atender",
"agenda-duplicada",
"Agenda com horário duplicado",
"Agenda organizada",
),
p("atender", "cliente-sem-retorno", "Cliente sem retorno", "Retorno em dia"),
p(
"operar",
"planilha-duplicada",
"Planilha duplicada",
"Sistema no lugar da planilha",
),
p(
"operar",
"digitar-duas-vezes",
"Digitar a mesma coisa em dois lugares",
"Cadastro único",
),
p("operar", "estoque-furado", "Estoque furado", "Estoque em dia"),
p(
"operar",
"processo-de-uma-pessoa",
"Processo que só uma pessoa sabe fazer",
"Processo no sistema",
),
p("operar", "controle-no-papel", "Controle no papel", "Controle digital"),
p(
"fiscal",
"nota-fiscal-rejeitada",
"Nota fiscal rejeitada",
"Notas fiscais em dia",
),
p(
"fiscal",
"fechamento-que-nao-bate",
"Fechamento do mês que não bate",
"Fechamento conferido",
),
p(
"fiscal",
"cobranca-esquecida",
"Cobrança esquecida",
"Cobrança automática",
),
p(
"fiscal",
"relatorio-demorado",
"Relatório que demora dias",
"Relatório na hora",
),
p(
"sistemas",
"sistemas-que-nao-conversam",
"Sistemas que não conversam",
"Sistemas integrados",
),
p(
"sistemas",
"maquina-que-nao-conversa",
"Máquina que não conversa com o sistema",
"Equipamento integrado",
"/servicos/integracao-hardware-software-iot",
),
p(
"sistemas",
"sistema-antigo",
"Sistema antigo em que ninguém mexe",
"Sistema atualizado",
),
p(
"sistemas",
"sistema-que-cai-no-pico",
"Sistema que cai na hora do pico",
"Sistema estável",
),
p(
"seguranca",
"senha-compartilhada",
"Senha compartilhada",
"Acesso por pessoa",
),
p("seguranca", "dados-sem-backup", "Dados sem backup", "Backup automático"),
p(
"seguranca",
"acesso-de-ex-funcionario",
"Acesso de ex-funcionário",
"Acesso revogado",
),
p(
"seguranca",
"app-de-ia-com-falha",
"App feito por IA cheio de falha",
"App revisado",
"/ia",
),
];
export function problemaPorId(id: string): Problema | undefined {
return PROBLEMAS.find((problema) => problema.id === id);
}
export function problemasDaArea(area: AreaId): Problema[] {
return PROBLEMAS.filter((problema) => problema.area === area);
}
+157
View File
@@ -0,0 +1,157 @@
import {
amostrar,
criarRegulador,
nivelAtual,
type Regulador,
} from "./regulador";
import { NIVEIS } from "./valores";
const ULTIMO = NIVEIS.length - 1;
const HZ_60 = 1000 / 60;
const HZ_30 = 1000 / 30;
/** Alimenta `n` janelas inteiras (2 s cada) de quadros iguais; para antes se o regulador travar. */
function janelas(
r: Regulador,
intervalo: number,
trabalho: number,
n: number,
): number {
for (let i = 0; i < n && !r.travado; i++) {
do amostrar(r, intervalo, trabalho);
while (r.somaDaJanela > 0);
}
return r.nivel;
}
/** Regulador com o topo no nível 0 (tela de pixelRatio 2), já no nível `nivel`. */
function reguladorEm(nivel: number): Regulador {
const r = criarRegulador(2);
r.nivel = nivel;
return r;
}
describe("regulador", () => {
it("começa no primeiro nível cujo pixelRatio cabe no do aparelho", () => {
expect(criarRegulador(3).nivel).toBe(0);
expect(criarRegulador(2).nivel).toBe(0);
expect(criarRegulador(1.5).nivel).toBe(1);
expect(criarRegulador(1.25).nivel).toBe(2);
expect(criarRegulador(1).nivel).toBe(2);
// zoom de afastar: abaixo de 1 vale o primeiro degrau de pixelRatio 1
expect(criarRegulador(0.8).nivel).toBe(2);
});
it("nunca sobe acima do nível inicial do aparelho", () => {
const r = criarRegulador(1);
expect(janelas(r, 8, 2, 20)).toBe(2);
r.nivel = 4;
expect(janelas(r, 8, 2, 20)).toBe(2);
});
it("nivelAtual devolve o degrau de NIVEIS", () => {
expect(nivelAtual(reguladorEm(2))).toBe(NIVEIS[2]);
});
it("60 Hz com folga nunca muda o nível 0", () => {
expect(janelas(criarRegulador(2), HZ_60, 3, 30)).toBe(0);
});
it("60 Hz saudável sobe depois de cair", () => {
const r = criarRegulador(2);
expect(janelas(r, 30, 25, 1)).toBe(1);
// a janela depois da descida já ganhou e conta como folga: 5 janelas (10 s) sobem
expect(janelas(r, HZ_60, 3, 4)).toBe(1);
expect(janelas(r, HZ_60, 3, 1)).toBe(0);
expect(r.travado).toBe(false);
});
it("60 Hz com trabalho pesado não conta folga", () => {
const r = reguladorEm(2);
expect(janelas(r, HZ_60, 8, 10)).toBe(2);
});
it("tela limitada a 30 Hz desce 2, volta 2 e trava", () => {
const r = criarRegulador(2);
expect(janelas(r, HZ_30, 3, 1)).toBe(1);
expect(janelas(r, HZ_30, 3, 1)).toBe(2);
expect(janelas(r, HZ_30, 3, 1)).toBe(0);
expect(r.travado).toBe(true);
expect(janelas(r, HZ_30, 3, 1)).toBe(0);
expect(amostrar(r, HZ_30, 3)).toBe(0);
expect(r.intervalos).toHaveLength(0);
});
it("carga real desce e fica", () => {
const r = criarRegulador(2);
expect(janelas(r, 30, 25, 1)).toBe(1);
// cada descida melhora o intervalo em mais de 10%
expect(janelas(r, 24, 20, 1)).toBe(2);
expect(janelas(r, 18, 15, 15)).toBe(2);
expect(r.travado).toBe(false);
});
it("descida com ganho zera a conta de descidas sem ganho", () => {
const r = criarRegulador(2);
janelas(r, 30, 25, 1);
janelas(r, 30, 25, 1);
expect(r.descidasSemGanho).toBe(1);
expect(janelas(r, 25, 20, 1)).toBe(3);
expect(r.descidasSemGanho).toBe(0);
});
it("descida sem ganho que não desce de novo não trava, e a volta não passa do topo", () => {
const r = criarRegulador(2);
janelas(r, 21, 3, 1);
// 20 ms: sem ganho, mas dentro do limite; sobe de volta depois da folga
expect(janelas(r, 20, 3, 5)).toBe(0);
expect(r.descidasSemGanho).toBe(1);
janelas(r, 21, 3, 1);
expect(janelas(r, 21, 3, 1)).toBe(0);
expect(r.travado).toBe(true);
});
it("não passa do último degrau", () => {
const r = reguladorEm(ULTIMO);
expect(janelas(r, 30, 25, 5)).toBe(ULTIMO);
});
it("janela mediana zera a folga acumulada", () => {
const r = reguladorEm(1);
janelas(r, 8, 2, 4);
janelas(r, 16, 8, 1);
expect(janelas(r, 8, 2, 4)).toBe(1);
});
it("descer logo depois de subir trava a subida: depois disso 20 s de folga não sobem", () => {
const r = reguladorEm(2);
expect(janelas(r, 8, 2, 5)).toBe(1);
expect(janelas(r, 30, 25, 1)).toBe(2);
expect(janelas(r, 8, 2, 10)).toBe(2);
expect(r.subidaTravada).toBe(true);
});
it("descer muito depois de subir não trava", () => {
const r = reguladorEm(2);
expect(janelas(r, 8, 2, 5)).toBe(1);
janelas(r, 16, 8, 15);
expect(janelas(r, 30, 25, 1)).toBe(2);
expect(janelas(r, 8, 2, 5)).toBe(1);
});
it("quadro de 300 ms é ignorado", () => {
const r = reguladorEm(1);
for (let i = 0; i < 20; i++) expect(amostrar(r, 300, 3)).toBe(1);
expect(r.intervalos).toHaveLength(0);
});
it("o p95 decide, não a média", () => {
const r = criarRegulador(2);
let nivel = 0;
for (let g = 0; g < 16; g++) {
for (let i = 0; i < 9; i++) nivel = amostrar(r, 10, 2);
nivel = amostrar(r, 40, 2);
}
expect(nivel).toBe(1);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* Regulador de qualidade: numa janela, mede o p95 do intervalo entre quadros e o do trabalho de JS de cada quadro,
* e sobe/desce degraus de `NIVEIS`. Sem estado no módulo.
*/
import { NIVEIS, REGULADOR } from "./valores";
export type Regulador = {
nivel: number;
/** Primeiro nível cujo `pixelRatio` cabe no do aparelho; o regulador nunca sobe acima dele. */
topo: number;
/** Intervalos entre quadros da janela atual, em ms. */
intervalos: number[];
/** Tempo de JS de cada quadro da janela atual (lógica + chamada de render), em ms. */
trabalhos: number[];
/** Soma dos intervalos da janela atual, em ms. */
somaDaJanela: number;
/** Tempo seguido de janelas com folga, em ms. */
folga: number;
/** Soma dos intervalos aceitos desde o início, em ms. */
tempo: number;
/** Valor de `tempo` na última subida; null se nunca subiu. */
tempoDaSubida: number | null;
/** Desceu logo depois de subir: não sobe mais. */
subidaTravada: boolean;
/** Descer não adiantou duas vezes seguidas: o limite é da tela, não da nossa carga. Não muda mais de nível. */
travado: boolean;
/** p95 do intervalo na janela que acabou de descer; null se a janela anterior não desceu. */
p95DaDescida: number | null;
descidasSemGanho: number;
};
const ULTIMO = NIVEIS.length - 1;
export function criarRegulador(pixelRatio: number): Regulador {
// abaixo do menor `pixelRatio` (zoom de afastar), vale o primeiro degrau com o menor
const tela = Math.max(pixelRatio, NIVEIS[ULTIMO].pixelRatio);
const topo = NIVEIS.findIndex((n) => n.pixelRatio <= tela);
return {
nivel: topo,
topo,
intervalos: [],
trabalhos: [],
somaDaJanela: 0,
folga: 0,
tempo: 0,
tempoDaSubida: null,
subidaTravada: false,
travado: false,
p95DaDescida: null,
descidasSemGanho: 0,
};
}
export function nivelAtual(r: Regulador): (typeof NIVEIS)[number] {
return NIVEIS[r.nivel];
}
/** Ordena a própria janela: ela é esvaziada logo depois. */
function p95(quadros: number[]): number {
quadros.sort((a, b) => a - b);
return quadros[Math.ceil(0.95 * quadros.length) - 1];
}
function descer(r: Regulador, intervalo: number): void {
r.folga = 0;
if (r.nivel >= ULTIMO) return;
r.nivel += 1;
r.p95DaDescida = intervalo;
const subiuHaPouco =
r.tempoDaSubida !== null && r.tempo - r.tempoDaSubida < REGULADOR.trava;
if (subiuHaPouco) r.subidaTravada = true;
}
function acumularFolga(r: Regulador, duracao: number): void {
r.folga += duracao;
if (r.folga < REGULADOR.subida) return;
if (!r.subidaTravada && r.nivel > r.topo) {
r.nivel -= 1;
r.tempoDaSubida = r.tempo;
}
r.folga = 0;
}
/**
* Na janela seguinte a uma descida: se o intervalo não melhorou `REGULADOR.ganho`, conta uma descida sem ganho; na
* segunda seguida, volta os degraus e trava. Devolve true se travou.
*/
function conferirGanho(r: Regulador, intervalo: number): boolean {
const antes = r.p95DaDescida;
r.p95DaDescida = null;
if (antes === null) return false;
if (intervalo <= antes * (1 - REGULADOR.ganho)) {
r.descidasSemGanho = 0;
return false;
}
r.descidasSemGanho += 1;
if (r.descidasSemGanho < REGULADOR.descidasSemGanho) return false;
r.nivel = Math.max(r.topo, r.nivel - r.descidasSemGanho);
r.travado = true;
return true;
}
function fecharJanela(r: Regulador): void {
const intervalo = p95(r.intervalos);
const trabalho = p95(r.trabalhos);
const duracao = r.somaDaJanela;
r.intervalos.length = 0;
r.trabalhos.length = 0;
r.somaDaJanela = 0;
if (conferirGanho(r, intervalo)) return;
if (intervalo > REGULADOR.limite) descer(r, intervalo);
else if (trabalho < REGULADOR.folgaDeTrabalho) acumularFolga(r, duracao);
else r.folga = 0;
}
export function amostrar(
r: Regulador,
intervaloMs: number,
trabalhoMs: number,
): number {
if (r.travado || intervaloMs > REGULADOR.ignorar) return r.nivel;
r.intervalos.push(intervaloMs);
r.trabalhos.push(trabalhoMs);
r.somaDaJanela += intervaloMs;
r.tempo += intervaloMs;
if (r.somaDaJanela >= REGULADOR.janela) fecharJanela(r);
return r.nivel;
}
+66
View File
@@ -0,0 +1,66 @@
import { semear, sortear, sortearInteiro } from "./rng";
function sequencia(semente: number, n: number): number[] {
const p = { rng: semear(semente) };
return Array.from({ length: n }, () => sortear(p));
}
describe("semear", () => {
it("converte a semente em uint32", () => {
expect(semear(5)).toBe(5);
expect(semear(2 ** 32 + 7)).toBe(7);
expect(semear(-1)).toBe(0xffffffff);
});
it("troca a semente 0 por 0x9e3779b9", () => {
expect(semear(0)).toBe(0x9e3779b9);
});
});
describe("sortear", () => {
it("dá a mesma sequência para a mesma semente", () => {
expect(sequencia(42, 20)).toEqual(sequencia(42, 20));
});
it("dá sequências diferentes para sementes diferentes", () => {
expect(sequencia(1, 20)).not.toEqual(sequencia(2, 20));
});
it("mantém 1000 sorteios em [0, 1)", () => {
for (const v of sequencia(7, 1000)) {
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(1);
}
});
it("funciona com a semente 0", () => {
const valores = sequencia(0, 10);
expect(new Set(valores).size).toBe(10);
});
it("avança o estado de p.rng", () => {
const p = { rng: semear(9) };
const antes = p.rng;
sortear(p);
expect(p.rng).not.toBe(antes);
});
});
describe("sortearInteiro", () => {
it("devolve inteiros de 0 a n-1 e usa todos os valores", () => {
const p = { rng: semear(3) };
const vistos = new Set<number>();
for (let i = 0; i < 500; i++) {
const v = sortearInteiro(p, 6);
expect(Number.isInteger(v)).toBe(true);
expect(v).toBeGreaterThanOrEqual(0);
expect(v).toBeLessThan(6);
vistos.add(v);
}
expect(vistos.size).toBe(6);
});
it("devolve sempre 0 quando n é 1", () => {
expect(sortearInteiro({ rng: semear(1) }, 1)).toBe(0);
});
});
+21
View File
@@ -0,0 +1,21 @@
/** Sorteio determinístico (mulberry32). Único ponto de aleatoriedade da lógica do jogo. */
/** Estado inicial uint32 a partir de uma semente; 0 vira 0x9e3779b9. */
export function semear(semente: number): number {
const estado = semente >>> 0;
return estado === 0 ? 0x9e3779b9 : estado;
}
/** Avança `p.rng` e devolve um número em [0, 1). */
export function sortear(p: { rng: number }): number {
p.rng = (p.rng + 0x6d2b79f5) >>> 0;
let t = p.rng;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
/** Inteiro de 0 a n−1. */
export function sortearInteiro(p: { rng: number }, n: number): number {
return Math.floor(sortear(p) * n);
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Contrato da lógica do jogo. Nada aqui conhece three nem DOM.
*
* Plano XZ em metros, Y para cima. `rumo` em radianos, com direção `(sin(rumo), cos(rumo))` em (x, z);
* `inclinacao` positiva olha para cima. O tempo anda em passos fixos de `PASSO` segundos (`valores.ts`).
* A partida é um objeto só, alterado no lugar por `avancar`.
*/
export type Dificuldade = "facil" | "normal" | "dificil";
export type TipoZumbi = "comum" | "rapido" | "blindado" | "chefao";
export type Fase =
| "inicio"
| "horda"
| "pausa"
| "chefao"
| "vitoria"
| "derrota";
/** 0 pistola, 1 automática, 2 faca. */
export type ArmaId = 0 | 1 | 2;
/** O que o jogador (ou o piloto) pede em um passo. */
export type Entrada = {
/** x: direita (+) e esquerda (−); y: frente (+) e trás (−). Cada um de −1 a 1. */
mover: readonly [number, number];
/** Giro pedido neste passo, em radianos: [rumo, inclinação]. */
olhar: readonly [number, number];
atirar: boolean;
arma: ArmaId;
};
export type Jogador = {
x: number;
z: number;
rumo: number;
inclinacao: number;
vida: number;
arma: ArmaId;
/** Balas da automática. A pistola e a faca não gastam. */
municao: number;
/** Passos que faltam para a arma poder agir de novo. */
recarga: number;
};
export type EstadoZumbi = "andando" | "atacando" | "morrendo";
export type Zumbi = {
id: number;
tipo: TipoZumbi;
/** O problema que este zumbi representa (id de `problemas.ts`). */
problemaId: string;
x: number;
z: number;
rumo: number;
vida: number;
estado: EstadoZumbi;
/** Passos no estado atual. */
tempo: number;
};
/** Zumbi da horda que ainda não entrou no mapa, por causa do teto de zumbis à vista. */
export type NaFila = { tipo: TipoZumbi; problemaId: string };
export type Drone = {
id: number;
/** O problema resolvido que virou este drone; o nome dele é a `solucao` do problema. */
problemaId: string;
x: number;
z: number;
rumo: number;
recarga: number;
eliminacoes: number;
};
/** Caixa de munição caída no chão. */
export type Caixa = { id: number; x: number; z: number };
export type TipoItem = "municao" | "kit" | "cafe" | "bateria";
/** Item largado por um zumbi morto, no chão até ser recolhido ou vencer. */
export type Item = {
id: number;
tipo: TipoItem;
x: number;
z: number;
/** Passo em que caiu. */
nasceu: number;
};
export type LinhaFeed = { passo: number; texto: string };
/** Quem atirou ou matou: o jogador ou o drone com este id. */
export type Autor = "jogador" | number;
/** O que aconteceu neste passo e a tela precisa mostrar. A lista é esvaziada no começo de cada `avancar`. */
export type Evento =
| {
tipo: "tiro";
autor: Autor;
arma: ArmaId | "drone";
/** Distância até onde o tiro parou (zumbi, parede ou alcance). */
distancia: number;
}
| {
tipo: "acerto";
zumbiId: number;
cabeca: boolean;
x: number;
y: number;
z: number;
}
| { tipo: "morte"; zumbiId: number; autor: Autor }
/** A mão do zumbi bateu no jogador: é o único momento em que ele tira vida. */
| { tipo: "golpe"; dano: number; zumbiId: number }
/** O golpe chegou ao impacto com o jogador fora do alcance: não tira vida. */
| { tipo: "golpeNoAr"; zumbiId: number }
| { tipo: "itemCaiu"; item: TipoItem; x: number; z: number }
| { tipo: "itemPego"; item: TipoItem }
| { tipo: "caixa"; balas: number }
| { tipo: "resolvido"; problemaId: string; droneId: number }
| { tipo: "fase"; fase: Fase };
export type Mapa = {
largura: number;
altura: number;
/** 1 onde não dá para andar (parede ou mesa). Índice `z * largura + x`. */
solido: Uint8Array;
/** 1 onde o tiro e a visão param (só parede). */
opaco: Uint8Array;
inicio: readonly [number, number];
nascedouros: readonly (readonly [number, number])[];
};
export type Partida = {
passo: number;
/** Estado do sorteio; só `sortear` (`rng.ts`) mexe nele. */
rng: number;
dificuldade: Dificuldade;
/** O problema escolhido pela pessoa: é o chefão. */
problemaId: string;
/** Problema de cada horda, na ordem; o último é o do chefão. */
hordas: readonly string[];
/** Índice em `hordas` da horda atual. */
horda: number;
fase: Fase;
/** Passos na fase atual. */
tempoFase: number;
/** Quantos zumbis podem estar no mapa ao mesmo tempo. */
tetoAVista: number;
mapa: Mapa;
jogador: Jogador;
zumbis: Zumbi[];
fila: NaFila[];
drones: Drone[];
caixas: Caixa[];
feed: LinhaFeed[];
/** Problemas já resolvidos, na ordem. */
resolvidos: string[];
placar: { jogador: number; drones: number };
eventos: Evento[];
proximoId: number;
};
+205
View File
@@ -0,0 +1,205 @@
/** Números do jogo em um lugar só. São valores iniciais: mudam jogando, nunca espalhados pelo código. */
import GOLPE from "./golpe.json";
import type { Dificuldade, TipoItem, TipoZumbi } from "./tipos";
/** Duração de um passo da lógica, em segundos. */
export const PASSO = 1 / 60;
export const PASSOS_POR_SEGUNDO = 60;
export const JOGADOR = {
vida: 100,
/** m/s */
velocidade: 4.2,
raio: 0.35,
/** Altura dos olhos, de onde sai o tiro. */
olhos: 1.6,
/** Limite da inclinação, para cima e para baixo. */
inclinacaoMax: 1.2,
/** Vida devolvida quando uma horda cai. */
curaPorHorda: 25,
} as const;
export const ZUMBIS: Record<
TipoZumbi,
{
vida: number;
velocidade: number;
dano: number;
raio: number;
altura: number;
}
> = {
comum: { vida: 100, velocidade: 1.2, dano: 10, raio: 0.35, altura: 1.8 },
rapido: { vida: 50, velocidade: 2.4, dano: 6, raio: 0.3, altura: 1.7 },
blindado: { vida: 250, velocidade: 0.9, dano: 15, raio: 0.4, altura: 1.9 },
chefao: { vida: 1500, velocidade: 1.0, dano: 25, raio: 0.6, altura: 2.4 },
};
/** Passos de um golpe inteiro (preparo, impacto e volta): 0,8 s. */
const PASSOS_DO_GOLPE = 48;
export const ATAQUE_DO_ZUMBI = {
/** Distância entre as bordas do zumbi e do jogador para o ataque começar. */
alcance: 1.0,
/** Folga máxima, no passo do impacto, para o golpe acertar. Acima disso o golpe pega o ar. */
alcanceDoGolpe: 1.4,
/** Passos entre um golpe e o próximo. Etapa 2: passa a `PASSOS_DO_GOLPE`. */
intervalo: 60,
/**
* Passo do ciclo em que a mão bate. Vem do clipe `atacar` medido no Blender
* (`golpe.json`, gravado por `3d/jogo.py`): a tela toca o clipe no tempo do ciclo,
* então o impacto do clipe e o da lógica caem no mesmo passo.
*/
passoDoGolpe: Math.round((GOLPE.impacto / GOLPE.duracao) * PASSOS_DO_GOLPE),
/** Passos da animação de morte, até o zumbi sair da lista. */
passosMorrendo: 90,
} as const;
/** Acima desta fração da altura do zumbi o acerto é na cabeça e dobra o dano. */
export const FRACAO_DA_CABECA = 0.86;
export const MULTIPLICADOR_DA_CABECA = 2;
export const ARMAS = [
{ nome: "Pistola", dano: 34, intervalo: 20, alcance: 40, gasta: false },
{ nome: "Automática", dano: 20, intervalo: 6, alcance: 40, gasta: true },
{ nome: "Faca", dano: 100, intervalo: 30, alcance: 1.5, gasta: false },
] as const;
/** Meio ângulo do arco da faca, em radianos. */
export const ARCO_DA_FACA = Math.PI / 4;
export const MUNICAO = {
inicial: 90,
porCaixa: 30,
/** Chance de um zumbi morto largar uma caixa. */
chanceDeCaixa: 0.25,
/** Distância para o jogador recolher a caixa. */
raioDeColeta: 0.9,
} as const;
/** O que os zumbis largam ao morrer. */
export const ITENS = {
/** Chance de um zumbi morto largar um item. O chefão não larga. */
chance: 0.3,
/** Peso de cada tipo no sorteio. */
pesos: { municao: 45, kit: 25, cafe: 15, bateria: 15 } as Record<
TipoItem,
number
>,
/** Abaixo desta vida o peso do kit dobra. */
vidaBaixa: 40,
/** Itens no chão ao mesmo tempo; com o teto cheio, o mais antigo some. */
teto: 10,
/** Passos até o item sumir (20 s). */
duracao: 1200,
/** Distância para o jogador recolher. */
raioDeColeta: 0.9,
/** Balas da automática por item de munição. */
balas: 30,
/** Vida devolvida pelo kit. */
vida: 25,
/** Café: fator da velocidade de andar e passos que dura (8 s). */
cafe: { fator: 1.3, duracao: 480 },
/** Bateria: fator da cadência dos drones e passos que dura (10 s). */
bateria: { fator: 2, duracao: 600 },
} as const;
export const DRONE = {
dano: 25,
intervalo: 30,
alcance: 15,
/** m/s */
velocidade: 5,
/** Distância que o drone mantém do jogador. */
distancia: 2,
} as const;
export const HORDAS = {
/** Zumbis das três hordas, antes do multiplicador da dificuldade. */
tamanhos: [12, 16, 20],
/** Comuns que acompanham o chefão. */
escoltaDoChefao: 6,
/** Passos da pausa entre hordas. */
pausa: 300,
/** Passos da fase "inicio", antes da primeira horda. */
preparo: 120,
/** Passos entre dois nascimentos, por horda (a 4ª é a do chefão). */
nascerACada: [90, 50, 30, 30],
/** Fator da velocidade dos zumbis, por horda. */
velocidade: [0.8, 1, 1.15, 1.15],
} as const;
/** Quantas linhas o feed guarda. */
export const LINHAS_DO_FEED = 5;
export const TETO_A_VISTA = 16;
export const DIFICULDADES: Record<
Dificuldade,
{ velocidade: number; dano: number; quantidade: number }
> = {
facil: { velocidade: 0.6, dano: 0.4, quantidade: 0.7 },
normal: { velocidade: 1, dano: 1, quantidade: 1 },
dificil: { velocidade: 1.3, dano: 1.5, quantidade: 1.3 },
};
/**
* O escritório, em células de 1 m. `#` parede, `M` mesa (não dá para andar, o tiro passa),
* `J` onde o jogador começa, `N` onde os zumbis nascem, `.` chão.
*/
export const ESCRITORIO = [
"############################",
"#N.........#......#.......N#",
"#..MM..MM..#......#..MM....#",
"#..MM..MM.........#..MM....#",
"#..........#...............#",
"#..MM..MM..#......#..MM....#",
"#..MM..MM..###..###........#",
"#..........................#",
"#............J.............#",
"#..........................#",
"#..MM..MM..###..###..MM....#",
"#..MM..MM..#......#..MM....#",
"#..........#......#........#",
"#..MM..MM.........#..MM....#",
"#..MM..MM..#...............#",
"#N.........#......#.......N#",
"############################",
] as const;
/** Degraus de qualidade da tela, do melhor para o mais leve. Desce na ordem: resolução, sombras, zumbis à vista. */
export const NIVEIS = [
{ pixelRatio: 2, sombras: true, teto: 16 },
{ pixelRatio: 1.5, sombras: true, teto: 16 },
{ pixelRatio: 1, sombras: true, teto: 16 },
{ pixelRatio: 1, sombras: false, teto: 16 },
{ pixelRatio: 1, sombras: false, teto: 12 },
{ pixelRatio: 1, sombras: false, teto: 8 },
] as const;
/** Tempos em milissegundos. */
export const REGULADOR = {
/** Tamanho da janela em que o p95 do intervalo entre quadros e o do trabalho são medidos. */
janela: 2000,
/** p95 do intervalo acima disto na janela: desce um degrau. */
limite: 20,
/** Janela sem descida e com p95 do trabalho de JS abaixo disto conta como folga. */
folgaDeTrabalho: 6,
/** Tempo seguido de folga para subir um degrau. */
subida: 10000,
/** Quadro mais longo que isto é pausa da aba, não lentidão: não entra na conta. */
ignorar: 250,
/** Se descer de novo antes disto depois de subir, não sobe mais. */
trava: 20000,
/** Fração mínima em que o p95 do intervalo cai na janela seguinte a uma descida para ela contar como ganho. */
ganho: 0.1,
/** Descidas seguidas sem ganho que fazem o regulador voltar esses degraus e travar. */
descidasSemGanho: 2,
} as const;
/**
* Raio máximo usado para andar e desviar de parede. O chefão é mais largo que meia célula (0,6 m): com o raio inteiro
* ele nasce encostado na parede e não sai do lugar. Para levar tiro, vale o raio do tipo.
*/
export const RAIO_DE_PASSAGEM = 0.45;
+459
View File
@@ -0,0 +1,459 @@
import { campoDeFluxo, lerMapa } from "./mapa";
import { sortear } from "./rng";
import type { Partida, Zumbi } from "./tipos";
import { ATAQUE_DO_ZUMBI, MUNICAO } from "./valores";
import { avancarZumbis, ferir, nascer } from "./zumbis";
const SALA = [
"##############",
"#J...........#",
"#.....N......#",
"##############",
];
const DOIS_NASCEDOUROS_LONGE = [
"################",
"#JN.........N.N#",
"#..............#",
"################",
];
const PAREDE_NO_MEIO = ["#######", "#J#..N#", "#.#...#", "#.....#", "#######"];
const BOLSO_FECHADO = ["#######", "#J#..N#", "#.#...#", "#######"];
/** Mesa entre o zumbi e o jogador: o tiro passa, o zumbi não. */
const MESA_NO_CAMINHO = [
"#########",
"#.......#",
"#J..M..N#",
"#.......#",
"#########",
];
/** Corredor com parede em cima de x 4 a 6; o jogador fica depois da curva, fora da vista. */
const QUINA = [
"#########",
"#...##..#",
"#......N#",
"#.#######",
"#J#######",
"#########",
];
/** Vão de uma célula entre duas mesas (x 4): cabe um zumbi por vez. */
const VAO_ENTRE_MESAS = [
"#########",
"#...M...#",
"#J.....N#",
"#...M...#",
"#########",
];
function partida(linhas: string[], extra: Partial<Partida> = {}): Partida {
const mapa = lerMapa(linhas);
return {
passo: 0,
rng: 1,
dificuldade: "normal",
problemaId: "p",
hordas: ["h"],
horda: 0,
fase: "horda",
tempoFase: 0,
tetoAVista: 16,
mapa,
jogador: {
x: mapa.inicio[0],
z: mapa.inicio[1],
rumo: 0,
inclinacao: 0,
vida: 100,
arma: 0,
municao: 90,
recarga: 0,
},
zumbis: [],
fila: [],
drones: [],
caixas: [],
feed: [],
resolvidos: [],
placar: { jogador: 0, drones: 0 },
eventos: [],
proximoId: 1,
...extra,
};
}
function zumbi(extra: Partial<Zumbi> = {}): Zumbi {
return {
id: 1,
tipo: "comum",
problemaId: "p",
x: 8.5,
z: 1.5,
rumo: 0,
vida: 100,
estado: "andando",
tempo: 0,
...extra,
};
}
function avancar(p: Partida): void {
avancarZumbis(p, campoDeFluxo(p.mapa, p.jogador.x, p.jogador.z));
}
/** Uma semente de `p.rng` cujo próximo sorteio fica abaixo (ou não) da chance de caixa. */
function sementeComSorteio(abaixo: boolean): number {
for (let s = 1; ; s++) {
if (sortear({ rng: s }) < MUNICAO.chanceDeCaixa === abaixo) return s;
}
}
describe("nascer", () => {
it("não age fora do passo múltiplo de 20", () => {
const p = partida(SALA, {
passo: 21,
fila: [{ tipo: "comum", problemaId: "a" }],
});
nascer(p);
expect(p.zumbis).toHaveLength(0);
expect(p.fila).toHaveLength(1);
});
it("não age com a fila vazia", () => {
const p = partida(SALA, { passo: 20 });
nascer(p);
expect(p.zumbis).toHaveLength(0);
expect(p.proximoId).toBe(1);
});
it("respeita o teto de zumbis à vista, sem contar os morrendo", () => {
const p = partida(SALA, {
passo: 20,
tetoAVista: 1,
zumbis: [zumbi({ id: 1 })],
fila: [{ tipo: "comum", problemaId: "a" }],
});
nascer(p);
expect(p.zumbis).toHaveLength(1);
p.zumbis[0].estado = "morrendo";
nascer(p);
expect(p.zumbis).toHaveLength(2);
expect(p.fila).toHaveLength(0);
});
it("cria o primeiro da fila com os valores ajustados, virado para o jogador", () => {
const p = partida(SALA, {
passo: 40,
dificuldade: "dificil",
proximoId: 5,
fila: [
{ tipo: "blindado", problemaId: "a" },
{ tipo: "comum", problemaId: "b" },
],
});
nascer(p);
expect(p.fila).toEqual([{ tipo: "comum", problemaId: "b" }]);
expect(p.proximoId).toBe(6);
const z = p.zumbis[0];
expect(z).toMatchObject({
id: 5,
tipo: "blindado",
problemaId: "a",
vida: 250,
estado: "andando",
tempo: 0,
});
expect([z.x, z.z]).toEqual([6.5, 2.5]);
expect(z.rumo).toBeCloseTo(Math.atan2(1.5 - 6.5, 1.5 - 2.5));
});
it("escolhe só nascedouros a 8 m ou mais, pelo índice id % candidatos", () => {
const fila = [{ tipo: "comum" as const, problemaId: "a" }];
const a = partida(DOIS_NASCEDOUROS_LONGE, {
passo: 0,
proximoId: 4,
fila: [...fila],
});
const b = partida(DOIS_NASCEDOUROS_LONGE, {
passo: 0,
proximoId: 5,
fila: [...fila],
});
nascer(a);
nascer(b);
expect(a.zumbis[0].x).toBe(12.5);
expect(b.zumbis[0].x).toBe(14.5);
});
it("usa todos os nascedouros quando nenhum está longe", () => {
const p = partida(["#####", "#JN.#", "#####"], {
fila: [{ tipo: "comum", problemaId: "a" }],
});
nascer(p);
expect(p.zumbis[0].x).toBe(2.5);
});
});
describe("ferir", () => {
it("ignora zumbi que já está morrendo", () => {
const p = partida(SALA);
const z = zumbi({ estado: "morrendo", vida: 0 });
expect(ferir(p, z, 50, "jogador")).toBe(false);
expect(z.vida).toBe(0);
expect(p.placar.jogador).toBe(0);
});
it("só desconta a vida quando não mata", () => {
const p = partida(SALA);
const z = zumbi();
expect(ferir(p, z, 30, "jogador")).toBe(false);
expect(z.vida).toBe(70);
expect(z.estado).toBe("andando");
expect(p.eventos).toEqual([]);
});
it("mata para o jogador: placar, evento e estado", () => {
const p = partida(SALA, { rng: sementeComSorteio(false), proximoId: 9 });
const z = zumbi({ id: 3, tempo: 12 });
expect(ferir(p, z, 100, "jogador")).toBe(true);
expect(z.estado).toBe("morrendo");
expect(z.tempo).toBe(0);
expect(p.placar).toEqual({ jogador: 1, drones: 0 });
expect(p.eventos).toEqual([
{ tipo: "morte", zumbiId: 3, autor: "jogador" },
]);
expect(p.caixas).toEqual([]);
expect(p.proximoId).toBe(9);
});
it("larga uma caixa quando o sorteio fica abaixo da chance", () => {
const p = partida(SALA, { rng: sementeComSorteio(true), proximoId: 9 });
ferir(p, zumbi({ x: 4.5, z: 2.5 }), 500, "jogador");
expect(p.caixas).toEqual([{ id: 9, x: 4.5, z: 2.5 }]);
expect(p.proximoId).toBe(10);
});
it("morte por drone soma em placar.drones e nas eliminações dele", () => {
const p = partida(SALA, {
drones: [
{
id: 7,
problemaId: "a",
x: 0,
z: 0,
rumo: 0,
recarga: 0,
eliminacoes: 0,
},
],
});
ferir(p, zumbi({ id: 2 }), 100, 7);
expect(p.placar).toEqual({ jogador: 0, drones: 1 });
expect(p.drones[0].eliminacoes).toBe(1);
expect(p.eventos).toEqual([{ tipo: "morte", zumbiId: 2, autor: 7 }]);
});
it("morte por drone que não existe só soma no placar", () => {
const p = partida(SALA);
ferir(p, zumbi(), 100, 99);
expect(p.placar.drones).toBe(1);
});
});
describe("avancarZumbis", () => {
it("remove o morrendo só ao completar a animação", () => {
const passos = ATAQUE_DO_ZUMBI.passosMorrendo;
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, estado: "morrendo", tempo: passos - 2 }),
zumbi({ id: 2, estado: "morrendo", tempo: passos - 1 }),
],
});
avancar(p);
expect(p.zumbis.map((z) => z.id)).toEqual([1]);
expect(p.zumbis[0].tempo).toBe(passos - 1);
});
it("anda em linha reta quando vê o jogador", () => {
const p = partida(SALA, { zumbis: [zumbi({ tempo: 3 })] });
avancar(p);
const z = p.zumbis[0];
expect(z.x).toBeCloseTo(8.5 - 1.2 / 60);
expect(z.z).toBeCloseTo(1.5);
expect(z.rumo).toBeCloseTo(-Math.PI / 2);
expect(z.estado).toBe("andando");
expect(z.tempo).toBe(4);
});
it("usa a velocidade ajustada pela dificuldade", () => {
const p = partida(SALA, {
dificuldade: "dificil",
zumbis: [zumbi()],
});
avancar(p);
expect(p.zumbis[0].x).toBeCloseTo(8.5 - (1.2 * 1.3) / 60);
});
it("zera o tempo ao voltar a andar", () => {
const p = partida(SALA, {
zumbis: [zumbi({ estado: "atacando", tempo: 40 })],
});
avancar(p);
expect(p.zumbis[0].estado).toBe("andando");
expect(p.zumbis[0].tempo).toBe(0);
});
it("sem caminho livre, contorna a parede pelo campo de fluxo", () => {
const p = partida(PAREDE_NO_MEIO, {
zumbis: [zumbi({ x: 3.5, z: 1.5 })],
});
avancar(p);
const z = p.zumbis[0];
expect(z.x).toBeCloseTo(3.5);
expect(z.z).toBeGreaterThan(1.5);
expect(z.rumo).toBeCloseTo(0);
});
it("sem caminho nem visão, fica parado e mantém o rumo", () => {
const p = partida(BOLSO_FECHADO, {
zumbis: [zumbi({ x: 4.5, z: 1.5, rumo: 1.25 })],
});
avancar(p);
const z = p.zumbis[0];
expect([z.x, z.z, z.rumo]).toEqual([4.5, 1.5, 1.25]);
});
it("com mesa entre ele e o jogador, contorna a mesa e chega", () => {
const p = partida(MESA_NO_CAMINHO, {
zumbis: [zumbi({ x: 7.5, z: 2.5 })],
});
for (let i = 0; i < 600; i++) avancar(p);
expect(p.zumbis[0].estado).toBe("atacando");
});
it("encostado numa quina, não fica parado e chega", () => {
const p = partida(QUINA, { zumbis: [zumbi({ x: 6.36, z: 2.0 })] });
avancar(p);
expect(p.zumbis[0].z).toBeGreaterThan(2.0);
for (let i = 0; i < 600; i++) avancar(p);
expect(p.zumbis[0].estado).toBe("atacando");
});
it("ataca ao chegar no alcance, virado para o jogador, sem golpear de cara", () => {
const p = partida(SALA, {
zumbis: [zumbi({ x: 2.5, z: 1.5, tempo: 7 })],
});
avancar(p);
const z = p.zumbis[0];
expect(z.estado).toBe("atacando");
expect(z.tempo).toBe(0);
expect(z.rumo).toBeCloseTo(-Math.PI / 2);
expect([z.x, z.z]).toEqual([2.5, 1.5]);
expect(p.eventos).toEqual([]);
});
it("golpeia só no passo certo do intervalo", () => {
const p = partida(SALA, {
dificuldade: "dificil",
zumbis: [zumbi({ x: 2.5, z: 1.5, estado: "atacando", tempo: 17 })],
});
avancar(p);
avancar(p);
expect(p.eventos).toEqual([]);
avancar(p);
expect(p.zumbis[0].tempo).toBe(20);
expect(p.eventos).toEqual([
{ tipo: "golpe", dano: 15, zumbiId: p.zumbis[0].id },
]);
expect(p.jogador.vida).toBe(85);
for (let i = 0; i < ATAQUE_DO_ZUMBI.intervalo - 1; i++) avancar(p);
expect(p.eventos).toHaveLength(1);
avancar(p);
expect(p.eventos).toHaveLength(2);
});
it("a vida do jogador não fica negativa", () => {
const p = partida(SALA, {
zumbis: [zumbi({ x: 2.5, z: 1.5, estado: "atacando", tempo: 19 })],
});
p.jogador.vida = 4;
avancar(p);
expect(p.jogador.vida).toBe(0);
});
it("dois zumbis sobrepostos terminam afastados", () => {
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, x: 6.5, z: 1.5 }),
zumbi({ id: 2, x: 6.8, z: 1.5 }),
],
});
avancar(p);
const [a, b] = p.zumbis;
expect(Math.hypot(b.x - a.x, b.z - a.z)).toBeGreaterThanOrEqual(0.7 - 1e-9);
});
it("na sobreposição só o mais longe do jogador recua; o da frente segue o passo normal", () => {
for (const perto of [0, 1]) {
const xs = perto === 0 ? [6.5, 6.8] : [6.8, 6.5];
const p = partida(SALA, {
zumbis: xs.map((x, i) => zumbi({ id: i + 1, x, z: 1.5 })),
});
avancar(p);
expect(p.zumbis[perto].x).toBeCloseTo(6.5 - 1.2 / 60);
expect(p.zumbis[1 - perto].x).toBeCloseTo(6.5 - 1.2 / 60 + 0.7);
}
});
it("par exatamente no mesmo ponto é afastado no eixo x", () => {
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, x: 6.5, z: 1.5 }),
zumbi({ id: 2, x: 6.5, z: 1.5 }),
],
});
avancar(p);
const [a, b] = p.zumbis;
expect(b.x - a.x).toBeCloseTo(0.7);
expect(b.z).toBeCloseTo(a.z);
});
it("dois zumbis lado a lado na boca de um vão estreito passam os dois, um por vez", () => {
const p = partida(VAO_ENTRE_MESAS, {
zumbis: [
zumbi({ id: 1, x: 5.4, z: 2.15 }),
zumbi({ id: 2, x: 5.4, z: 2.85 }),
],
});
for (let i = 0; i < 300; i++) avancar(p);
expect(p.zumbis.map((z) => z.x < 4)).toEqual([true, true]);
});
it("não separa quem está morrendo nem quem já está afastado", () => {
const p = partida(SALA, {
zumbis: [
zumbi({ id: 1, x: 6.5, z: 1.5, estado: "morrendo" }),
zumbi({ id: 2, x: 6.5, z: 1.5, estado: "morrendo" }),
zumbi({ id: 3, x: 9.5, z: 1.5 }),
zumbi({ id: 4, x: 11.5, z: 1.5 }),
],
});
avancar(p);
expect([p.zumbis[0].x, p.zumbis[1].x]).toEqual([6.5, 6.5]);
expect(p.zumbis[2].x).toBeCloseTo(9.5 - 1.2 / 60);
expect(p.zumbis[3].x).toBeCloseTo(11.5 - 1.2 / 60);
});
it("chefão no centro de uma célula cercada de parede por três lados anda para o lado livre", () => {
const p = partida(["#######", "#N...J#", "#######"], {
zumbis: [zumbi({ tipo: "chefao", x: 1.5, z: 1.5 })],
});
avancar(p);
expect(p.zumbis[0].x).toBeGreaterThan(1.5);
expect(p.zumbis[0].z).toBeCloseTo(1.5);
});
});
+199
View File
@@ -0,0 +1,199 @@
/** Zumbis: nascer, ferir, andar, atacar e se afastar uns dos outros. Altera a `Partida` no lugar. */
import { ajustar } from "./dificuldade";
import { caminhoLivre, direcaoNoCampo, moverComColisao } from "./mapa";
import { sortear } from "./rng";
import type { Autor, Partida, Zumbi } from "./tipos";
import {
ATAQUE_DO_ZUMBI,
JOGADOR,
MUNICAO,
PASSO,
RAIO_DE_PASSAGEM,
ZUMBIS,
} from "./valores";
const INTERVALO_DE_NASCER = 20;
const DISTANCIA_MINIMA_DO_NASCEDOURO = 8;
/** Passo (dentro do intervalo) em que o golpe acontece. */
const PASSO_DO_GOLPE = 20;
/** Raio usado contra o mapa: corpo grande não pode ficar maior que a passagem. */
function raioNoMapa(zumbi: Zumbi): number {
return Math.min(ZUMBIS[zumbi.tipo].raio, RAIO_DE_PASSAGEM);
}
function distanciaAoJogador(p: Partida, x: number, z: number): number {
return Math.hypot(p.jogador.x - x, p.jogador.z - z);
}
function nascedouroPara(p: Partida, id: number): readonly [number, number] {
const todos = p.mapa.nascedouros;
const longe = todos.filter(
([x, z]) => distanciaAoJogador(p, x, z) >= DISTANCIA_MINIMA_DO_NASCEDOURO,
);
const lista = longe.length > 0 ? longe : todos;
return lista[id % lista.length];
}
export function nascer(p: Partida): void {
const proximo = p.fila[0];
const aVista = p.zumbis.filter((z) => z.estado !== "morrendo").length;
if (
p.passo % INTERVALO_DE_NASCER !== 0 ||
!proximo ||
aVista >= p.tetoAVista
) {
return;
}
p.fila.shift();
const id = p.proximoId++;
const [x, z] = nascedouroPara(p, id);
p.zumbis.push({
id,
tipo: proximo.tipo,
problemaId: proximo.problemaId,
x,
z,
rumo: Math.atan2(p.jogador.x - x, p.jogador.z - z),
vida: ajustar(proximo.tipo, p.dificuldade).vida,
estado: "andando",
tempo: 0,
});
}
function pontuar(p: Partida, autor: Autor): void {
if (autor === "jogador") {
p.placar.jogador++;
return;
}
p.placar.drones++;
const drone = p.drones.find((d) => d.id === autor);
if (drone) drone.eliminacoes++;
}
function registrarMorte(p: Partida, zumbi: Zumbi, autor: Autor): void {
zumbi.estado = "morrendo";
zumbi.tempo = 0;
pontuar(p, autor);
p.eventos.push({ tipo: "morte", zumbiId: zumbi.id, autor });
if (sortear(p) < MUNICAO.chanceDeCaixa) {
p.caixas.push({ id: p.proximoId++, x: zumbi.x, z: zumbi.z });
}
}
/** Devolve true se este golpe matou. */
export function ferir(
p: Partida,
zumbi: Zumbi,
dano: number,
autor: Autor,
): boolean {
if (zumbi.estado === "morrendo") return false;
zumbi.vida -= dano;
if (zumbi.vida > 0) return false;
registrarMorte(p, zumbi, autor);
return true;
}
function noAlcance(p: Partida, zumbi: Zumbi): boolean {
const folga =
distanciaAoJogador(p, zumbi.x, zumbi.z) -
ZUMBIS[zumbi.tipo].raio -
JOGADOR.raio;
return folga <= ATAQUE_DO_ZUMBI.alcance;
}
function entrarEm(zumbi: Zumbi, estado: "andando" | "atacando"): void {
if (zumbi.estado === estado) return;
zumbi.estado = estado;
zumbi.tempo = 0;
}
function atacar(p: Partida, zumbi: Zumbi): void {
entrarEm(zumbi, "atacando");
zumbi.rumo = Math.atan2(p.jogador.x - zumbi.x, p.jogador.z - zumbi.z);
if (zumbi.tempo % ATAQUE_DO_ZUMBI.intervalo !== PASSO_DO_GOLPE) return;
const { dano } = ajustar(zumbi.tipo, p.dificuldade);
p.jogador.vida = Math.max(0, p.jogador.vida - dano);
p.eventos.push({ tipo: "golpe", dano, zumbiId: zumbi.id });
}
/** Em linha reta só se o corpo inteiro passa até o jogador; senão segue o campo. */
function direcaoDoZumbi(
p: Partida,
campo: Int16Array,
zumbi: Zumbi,
): [number, number] {
const { x, z } = zumbi;
const raio = raioNoMapa(zumbi);
if (caminhoLivre(p.mapa, x, z, p.jogador.x, p.jogador.z, raio)) {
const d = distanciaAoJogador(p, x, z);
return [(p.jogador.x - x) / d, (p.jogador.z - z) / d];
}
return direcaoNoCampo(p.mapa, campo, x, z);
}
function andar(p: Partida, campo: Int16Array, zumbi: Zumbi): void {
entrarEm(zumbi, "andando");
const [dx, dz] = direcaoDoZumbi(p, campo, zumbi);
if (dx === 0 && dz === 0) return;
const passo = ajustar(zumbi.tipo, p.dificuldade).velocidade * PASSO;
[zumbi.x, zumbi.z] = moverComColisao(
p.mapa,
zumbi.x,
zumbi.z,
dx * passo,
dz * passo,
raioNoMapa(zumbi),
);
zumbi.rumo = Math.atan2(dx, dz);
}
function avancarUm(p: Partida, campo: Int16Array, zumbi: Zumbi): void {
zumbi.tempo++;
if (zumbi.estado === "morrendo") return;
if (noAlcance(p, zumbi)) atacar(p, zumbi);
else andar(p, campo, zumbi);
}
/**
* Só o mais longe do jogador cede, recuando a sobreposição inteira; no mesmo ponto, no eixo x.
* Meio a meio, dois zumbis na boca de um vão de uma célula se empurravam contra as mesas para sempre.
*/
function separarPar(p: Partida, a: Zumbi, b: Zumbi): void {
const d = Math.hypot(b.x - a.x, b.z - a.z);
const sobra = ZUMBIS[a.tipo].raio + ZUMBIS[b.tipo].raio - d;
if (sobra <= 0) return;
const bCede =
distanciaAoJogador(p, a.x, a.z) <= distanciaAoJogador(p, b.x, b.z);
const [fica, cede] = bCede ? [a, b] : [b, a];
const [ux, uz] =
d === 0 ? [1, 0] : [(cede.x - fica.x) / d, (cede.z - fica.z) / d];
[cede.x, cede.z] = moverComColisao(
p.mapa,
cede.x,
cede.z,
ux * sobra,
uz * sobra,
raioNoMapa(cede),
);
}
function separar(p: Partida): void {
const vivos = p.zumbis.filter((z) => z.estado !== "morrendo");
for (let i = 0; i < vivos.length; i++) {
for (let j = i + 1; j < vivos.length; j++) {
separarPar(p, vivos[i], vivos[j]);
}
}
}
export function avancarZumbis(p: Partida, campo: Int16Array): void {
for (const zumbi of p.zumbis) avancarUm(p, campo, zumbi);
const restantes = p.zumbis.filter(
(z) => z.estado !== "morrendo" || z.tempo < ATAQUE_DO_ZUMBI.passosMorrendo,
);
p.zumbis.splice(0, p.zumbis.length, ...restantes);
separar(p);
}