Files
portifolio-evandrodevbr/app/jogo/atores.ts
T
evandro 67b32fcab3
CI / audit (push) Successful in 10m48s
CI / verify (push) Successful in 19m2s
CI / deploy (push) Successful in 2m14s
CI / e2e (push) Failing after 36m15s
feat(jogo): expediente dos mortos, jogo de zumbis em primeira pessoa
Etapa 1 inteira (logica com 100% de cobertura, cena 3D, toque, sons, bancada de desempenho, e2e) e a base da etapa 2 (mapa novo no Tiled, texturas, imagens dos itens, sons conferidos, golpe recortado e medido). Pagina /jogo fora do menu e do sitemap ate a medicao no celular.
2026-11-10 19:37:10 -03:00

578 lines
19 KiB
TypeScript

/**
* Zumbis, drones e caixas de munição desenhados a partir da `Partida`. Os zumbis vêm de uma reserva de clones que só
* cresce: quando um sai da partida, o clone se esconde e volta para outro. Cada zumbi leva o nome do problema que é,
* e cada drone o nome da solução.
*/
import {
BoxGeometry,
type BufferGeometry,
type CanvasTexture,
Color,
InstancedMesh,
type Material,
Matrix4,
type Mesh,
MeshStandardMaterial,
Object3D,
type Scene,
Sprite,
SpriteMaterial,
type Texture,
} from "three";
import { problemaPorId } from "@/lib/jogo/problemas";
import type { Drone, Partida, TipoZumbi, Zumbi } from "@/lib/jogo/tipos";
import { DIFICULDADES, HORDAS } from "@/lib/jogo/valores";
import { texturaDeCanvas } from "./ambiente";
import {
criarZumbi,
type ModeloDoZumbi,
type Zumbi as ZumbiNaTela,
} from "./zumbis3d";
/** O que os atores precisam já baixado. `roupas` são as variações B e C da roupa; a que faltar fica de fora. */
export type ModelosDosAtores = {
zumbi: ModeloDoZumbi;
bloco: BufferGeometry;
roupas: readonly Texture[];
};
export type Atores = {
/** Depois de cada passo da lógica: guarda posição e rumo de antes e de agora, para interpolar. */
guardarPasso(p: Partida): void;
/** `alfa` de 0 a 1 entre o passo anterior e o atual; `dt` em segundos do jogo, para as animações. */
sincronizar(p: Partida, alfa: number, dt: number): void;
descartar(): void;
};
const ESCALA: Record<TipoZumbi, number> = {
comum: 1,
rapido: 0.95,
blindado: 1.08,
chefao: 1.5,
};
/** Altura do modelo em escala 1, em metros. */
const ALTURA_DO_MODELO = 1.8;
const ALCANCE_DO_ROTULO = 10;
/** Só os zumbis mais perto (e o chefão) levam rótulo, para não empilhar nomes na tela. */
const ROTULOS_DE_ZUMBI = 3;
/** O rótulo do chefão não passa disto, para não entrar no forro (3 m). */
const TETO_DO_ROTULO = 2.8;
/** Metade do campo de visão vertical da câmera (75° em `cena.ts`), para medir o rótulo em pixels. */
const TANGENTE_DO_CAMPO = Math.tan((37.5 * Math.PI) / 180);
/** Rótulo do drone em relação ao do zumbi. */
const ESCALA_DO_ROTULO_DO_DRONE = 0.8;
/** Na tela, o drone fica acima da cabeça do jogador (olhos a 1,6 m); a lógica continua no chão. */
const ALTURA_DO_DRONE = 2.3;
const BLOCOS_POR_DRONE = 8;
const VERDE = "#c5f04a";
/** shortcut: no máximo 32 caixas desenhadas ao mesmo tempo; se o chão juntar mais, as velhas somem até alguém pegar. */
const MAX_CAIXAS = 32;
type Rotulo = {
material: SpriteMaterial;
textura: CanvasTexture;
aspecto: number;
texto: string;
};
type Trio = [number, number, number];
/** Posição e rumo no passo anterior e no atual. */
type Rastro = { antes: Trio; agora: Trio };
type Ator = Rastro & {
zumbi: ZumbiNaTela;
roupa: Mesh | null;
olhos: Mesh | null;
rotulo: Sprite;
aspecto: number;
id: number;
/** Último quadro em que o zumbi estava na partida. */
visto: number;
clipe: string;
};
function misturarAngulo(a: number, b: number, t: number): number {
const d = Math.atan2(Math.sin(b - a), Math.cos(b - a));
return a + d * t;
}
/** Põe antes e agora no mesmo lugar, sem alocar. */
function fixar(r: Rastro, x: number, z: number, rumo: number) {
r.antes[0] = r.agora[0] = x;
r.antes[1] = r.agora[1] = z;
r.antes[2] = r.agora[2] = rumo;
}
/** Um passo adiante sem alocar: o vetor de antes vira o de agora. Partida parada (passo igual): antes = agora. */
function seguir(
r: Rastro,
x: number,
z: number,
rumo: number,
parado: boolean,
) {
if (parado) {
fixar(r, r.agora[0], r.agora[1], r.agora[2]);
return;
}
const velho = r.antes;
r.antes = r.agora;
velho[0] = x;
velho[1] = z;
velho[2] = rumo;
r.agora = velho;
}
function malhaDoMaterial(raiz: Object3D, nome: string): Mesh | null {
let achada: Mesh | null = null;
raiz.traverse((objeto) => {
const malha = objeto as Mesh;
if (!achada && malha.isMesh && (malha.material as Material).name === nome)
achada = malha;
});
return achada;
}
function fonteMono(): string {
const raiz = getComputedStyle(document.documentElement);
return raiz.getPropertyValue("--font-mono").trim() || "monospace";
}
/** Etiqueta reta (cantos retos, como o site), texto em mono. */
function texturaDoRotulo(texto: string, fundo: string, tinta: string) {
const fonte = `500 46px ${fonteMono()}`;
const medida = document.createElement("canvas").getContext("2d");
if (medida) medida.font = fonte;
const largura = Math.ceil(
(medida?.measureText(texto).width ?? texto.length * 28) + 32,
);
const textura = texturaDeCanvas(largura, 64, (ctx) => {
ctx.fillStyle = fundo;
ctx.fillRect(0, 0, largura, 64);
ctx.font = fonte;
ctx.fillStyle = tinta;
ctx.textBaseline = "middle";
ctx.fillText(texto, 16, 34);
});
return { textura, aspecto: largura / 64 };
}
/** Altura do rótulo em unidades de `Sprite` sem atenuação: ~18 px no computador, ~14 px no celular. */
function alturaDoRotulo(): number {
const altura = window.innerHeight || 900;
const pixels = Math.min(18, Math.max(14, altura * 0.02));
return (pixels * 2 * TANGENTE_DO_CAMPO) / altura;
}
function clipeDo(z: Zumbi): string {
if (z.estado === "atacando") return "atacar";
if (z.estado === "morrendo") return "morrer";
return z.tipo === "rapido" ? "correr" : "andar";
}
export function criarAtores(cena: Scene, modelo: ModelosDosAtores): Atores {
const reserva: Ator[] = [];
const porId = new Map<number, Ator>();
const rotulos = new Map<string, Rotulo>();
const materiais = new Map<string, Material>();
let passoVisto = -1;
let relogio = 0;
let quadro = 0;
// os zumbis mais perto deste quadro, em reserva fixa
const maisPerto: (Ator | null)[] = Array(ROTULOS_DE_ZUMBI).fill(null);
const distanciasMaisPerto = new Float64Array(ROTULOS_DE_ZUMBI);
const roupaOriginal = malhaDoMaterial(modelo.zumbi.cena, "roupa")?.material as
| MeshStandardMaterial
| undefined;
const olhosOriginais = malhaDoMaterial(modelo.zumbi.cena, "olhos")
?.material as MeshStandardMaterial | undefined;
/** Material derivado, criado uma vez e guardado: variação de roupa, blindado escurecido, olhos do chefão. */
function derivado(
chave: string,
base: MeshStandardMaterial,
ajustar: (m: MeshStandardMaterial) => void,
): Material {
let material = materiais.get(chave);
if (!material) {
const novo = base.clone();
ajustar(novo);
materiais.set(chave, novo);
material = novo;
}
return material;
}
function roupaPara(z: Zumbi): Material | null {
if (!roupaOriginal) return null;
const variacao = z.id % 3;
const mapa = variacao > 0 ? modelo.roupas[variacao - 1] : undefined;
const base = mapa
? (derivado(`roupa-${variacao}`, roupaOriginal, (m) => {
m.map = mapa;
}) as MeshStandardMaterial)
: roupaOriginal;
if (z.tipo !== "blindado") return base;
return derivado(`blindado-${variacao}`, base, (m) => {
m.color.multiplyScalar(0.35);
m.metalness = 0.75;
m.roughness = 0.35;
});
}
function olhosPara(z: Zumbi): Material | null {
if (!olhosOriginais) return null;
if (z.tipo !== "chefao") return olhosOriginais;
return derivado("olhos-chefao", olhosOriginais, (m) => {
m.color.set("#ff2a1a");
m.emissive = new Color("#ff1a0a");
m.emissiveIntensity = 4;
});
}
function rotuloDo(
chave: string,
texto: string,
fundo: string,
tinta: string,
discreto = false,
): Rotulo {
let rotulo = rotulos.get(chave);
if (!rotulo) {
const { textura, aspecto } = texturaDoRotulo(texto, fundo, tinta);
// sem atenuação: o rótulo tem o mesmo tamanho na tela a 1 m ou a 10 m. O do zumbi testa profundidade para
// não atravessar parede; o do drone, discreto, não, senão o próprio drone come o texto.
const material = new SpriteMaterial({
map: textura,
fog: false,
sizeAttenuation: false,
opacity: discreto ? 0.85 : 1,
depthTest: !discreto,
});
rotulo = { material, textura, aspecto, texto };
rotulos.set(chave, rotulo);
}
return rotulo;
}
function aplicarRotulo(sprite: Sprite, rotulo: Rotulo) {
sprite.material = rotulo.material;
sprite.name = rotulo.texto;
}
function novoAtor(): Ator {
const zumbi = criarZumbi(modelo.zumbi);
zumbi.raiz.traverse((objeto) => {
objeto.castShadow = true;
});
// os olhos são pequenos demais para sombra; poupa uma chamada por zumbi
const olhos = malhaDoMaterial(zumbi.raiz, "olhos");
if (olhos) olhos.castShadow = false;
const rotulo = new Sprite();
rotulo.renderOrder = 10;
// ancorado embaixo: o rótulo sobe a partir do ponto acima da cabeça
rotulo.center.set(0.5, 0);
cena.add(zumbi.raiz, rotulo);
const ator: Ator = {
zumbi,
roupa: malhaDoMaterial(zumbi.raiz, "roupa"),
olhos,
rotulo,
aspecto: 1,
id: -1,
visto: 0,
clipe: "",
antes: [0, 0, 0],
agora: [0, 0, 0],
};
reserva.push(ator);
return ator;
}
/** Prepara um clone livre (ou novo) para o zumbi `z`. */
function vestir(z: Zumbi): Ator {
const ator = reserva.find((a) => a.id < 0) ?? novoAtor();
ator.id = z.id;
fixar(ator, z.x, z.z, z.rumo);
ator.zumbi.raiz.scale.setScalar(ESCALA[z.tipo]);
ator.zumbi.raiz.visible = true;
const roupa = roupaPara(z);
if (ator.roupa && roupa) ator.roupa.material = roupa;
const olhos = olhosPara(z);
if (ator.olhos && olhos) ator.olhos.material = olhos;
const problema = problemaPorId(z.problemaId);
const chefao = z.tipo === "chefao";
const rotulo = rotuloDo(
`${chefao ? "chefao" : "zumbi"}:${z.problemaId}`,
problema?.nome ?? z.problemaId,
"rgba(10,10,10,0.78)",
chefao ? "#ff5a4a" : "#f4f2ed",
);
aplicarRotulo(ator.rotulo, rotulo);
ator.aspecto = rotulo.aspecto;
// degraus por id, na tela: rótulos de zumbis juntos não se sobrepõem por inteiro
ator.rotulo.center.y = -(z.id % 3) * 1.15;
ator.clipe = clipeDo(z);
ator.zumbi.tocar(ator.clipe, { umaVez: ator.clipe === "morrer" });
// fases diferentes, para a horda não andar em marcha
ator.zumbi.avancar((z.id * 0.37) % 1.2);
porId.set(z.id, ator);
return ator;
}
function soltar(ator: Ator) {
porId.delete(ator.id);
ator.id = -1;
ator.zumbi.raiz.visible = false;
ator.rotulo.visible = false;
}
// Drones: os 8 blocos de cada um numa malha só.
const maxDrones = HORDAS.tamanhos.length;
const verde = new MeshStandardMaterial({
color: VERDE,
emissive: VERDE,
emissiveIntensity: 0.45,
roughness: 0.5,
});
const blocos = new InstancedMesh(
modelo.bloco,
verde,
maxDrones * BLOCOS_POR_DRONE,
);
blocos.count = 0;
blocos.frustumCulled = false;
const rotulosDosDrones = Array.from({ length: maxDrones }, () => {
const sprite = new Sprite();
sprite.renderOrder = 10;
sprite.center.set(0.5, 0);
sprite.visible = false;
return sprite;
});
const problemasDosRotulos: string[] = Array(maxDrones).fill("");
const aspectosDosRotulos = new Float64Array(maxDrones);
const posicoesDosDrones = new Map<number, Rastro>();
const materialDaCaixa = new MeshStandardMaterial({
color: "#d9a400",
emissive: "#d9a400",
emissiveIntensity: 0.6,
roughness: 0.6,
});
const caixas = new InstancedMesh(
new BoxGeometry(0.32, 0.2, 0.24),
materialDaCaixa,
MAX_CAIXAS,
);
caixas.count = 0;
caixas.frustumCulled = false;
cena.add(blocos, caixas, ...rotulosDosDrones);
const peca = new Object3D();
const corpo = new Object3D();
const m = new Matrix4();
function guardarPasso(p: Partida) {
// terminada, a partida não anda mais: sem isto antes ≠ agora e a interpolação treme
const parado = p.passo === passoVisto;
passoVisto = p.passo;
for (const z of p.zumbis) {
const ator = porId.get(z.id);
if (ator) seguir(ator, z.x, z.z, z.rumo, parado);
}
for (const d of p.drones) {
const pos = posicoesDosDrones.get(d.id);
if (pos) seguir(pos, d.x, d.z, d.rumo, parado);
}
}
/** O rastro do drone; o que acabou de nascer começa parado onde está. */
function rastroDo(d: Drone): Rastro {
let pos = posicoesDosDrones.get(d.id);
if (!pos) {
pos = { antes: [d.x, d.z, d.rumo], agora: [d.x, d.z, d.rumo] };
posicoesDosDrones.set(d.id, pos);
}
return pos;
}
/** Guarda `ator` entre os mais perto deste quadro, em ordem de distância. */
function guardarMaisPerto(ator: Ator, distancia: number) {
let i = ROTULOS_DE_ZUMBI - 1;
if (distancia >= distanciasMaisPerto[i]) return;
while (i > 0 && distanciasMaisPerto[i - 1] > distancia) {
distanciasMaisPerto[i] = distanciasMaisPerto[i - 1];
maisPerto[i] = maisPerto[i - 1];
i--;
}
distanciasMaisPerto[i] = distancia;
maisPerto[i] = ator;
}
function sincronizarZumbis(
p: Partida,
alfa: number,
dt: number,
alturaDoTexto: number,
) {
quadro++;
const ritmo = DIFICULDADES[p.dificuldade].velocidade;
const { x: jx, z: jz } = p.jogador;
distanciasMaisPerto.fill(Number.POSITIVE_INFINITY);
maisPerto.fill(null);
for (const z of p.zumbis) {
const ator = porId.get(z.id) ?? vestir(z);
ator.visto = quadro;
const clipe = clipeDo(z);
if (clipe !== ator.clipe) {
ator.clipe = clipe;
ator.zumbi.tocar(clipe, { umaVez: clipe === "morrer" });
}
const [x0, z0, r0] = ator.antes;
const [x1, z1, r1] = ator.agora;
const x = x0 + (x1 - x0) * alfa;
const zz = z0 + (z1 - z0) * alfa;
const raiz = ator.zumbi.raiz;
raiz.position.set(x, 0, zz);
raiz.rotation.y = misturarAngulo(r0, r1, alfa);
// andar e correr no ritmo da dificuldade, para o pé não deslizar no chão
ator.zumbi.avancar(z.estado === "andando" ? dt * ritmo : dt);
const altura = ALTURA_DO_MODELO * ESCALA[z.tipo] + 0.25;
const rotulo = ator.rotulo;
rotulo.position.set(x, Math.min(altura, TETO_DO_ROTULO), zz);
rotulo.scale.set(alturaDoTexto * ator.aspecto, alturaDoTexto, 1);
rotulo.visible = z.tipo === "chefao" && z.estado !== "morrendo";
if (z.tipo === "chefao" || z.estado === "morrendo") continue;
const distancia = Math.hypot(x - jx, zz - jz);
if (distancia <= ALCANCE_DO_ROTULO) guardarMaisPerto(ator, distancia);
}
for (let i = 0; i < ROTULOS_DE_ZUMBI; i++) {
const ator = maisPerto[i];
if (ator) ator.rotulo.visible = true;
}
for (const ator of reserva)
if (ator.id >= 0 && ator.visto !== quadro) soltar(ator);
}
function sincronizarDrones(p: Partida, alfa: number, alturaDoTexto: number) {
const n = Math.min(p.drones.length, maxDrones);
blocos.count = n * BLOCOS_POR_DRONE;
for (let i = 0; i < n; i++) {
const d = p.drones[i];
const pos = rastroDo(d);
const [x0, z0, r0] = pos.antes;
const [x1, z1, r1] = pos.agora;
const x = x0 + (x1 - x0) * alfa;
const z = z0 + (z1 - z0) * alfa;
const y = ALTURA_DO_DRONE + Math.sin(relogio * 2 + d.id) * 0.06;
corpo.position.set(x, y, z);
corpo.rotation.set(
Math.sin(relogio * 1.3 + d.id) * 0.08,
misturarAngulo(r0, r1, alfa),
0,
);
corpo.updateMatrix();
for (let b = 0; b < BLOCOS_POR_DRONE; b++) {
peca.position.set(
(b & 1 ? 1 : -1) * 0.07,
(b & 2 ? 1 : -1) * 0.07,
(b & 4 ? 1 : -1) * 0.07,
);
peca.scale.setScalar(0.12);
peca.updateMatrix();
m.multiplyMatrices(corpo.matrix, peca.matrix);
blocos.setMatrixAt(i * BLOCOS_POR_DRONE + b, m);
}
const sprite = rotulosDosDrones[i];
// troca a etiqueta só quando muda o drone da vaga: nada de texto novo por quadro
if (problemasDosRotulos[i] !== d.problemaId) {
problemasDosRotulos[i] = d.problemaId;
const problema = problemaPorId(d.problemaId);
const rotulo = rotuloDo(
`drone:${d.problemaId}`,
problema?.solucao ?? d.problemaId,
"rgba(10,10,10,0.55)",
"#ccff00",
true,
);
aplicarRotulo(sprite, rotulo);
aspectosDosRotulos[i] = rotulo.aspecto;
}
const alturaDoRotuloDoDrone = alturaDoTexto * ESCALA_DO_ROTULO_DO_DRONE;
sprite.scale.set(
alturaDoRotuloDoDrone * aspectosDosRotulos[i],
alturaDoRotuloDoDrone,
1,
);
// acima do topo do drone visto de baixo, para o texto não cair sobre os blocos
sprite.position.set(x, y + 0.34, z);
sprite.visible = true;
}
for (let i = n; i < maxDrones; i++) rotulosDosDrones[i].visible = false;
blocos.instanceMatrix.needsUpdate = true;
}
function sincronizarCaixas(p: Partida) {
const n = Math.min(p.caixas.length, MAX_CAIXAS);
caixas.count = n;
for (let i = 0; i < n; i++) {
const c = p.caixas[i];
peca.position.set(c.x, 0.22 + Math.sin(relogio * 3 + c.id) * 0.05, c.z);
peca.rotation.set(0, relogio * 1.5 + c.id, 0);
peca.scale.setScalar(1);
peca.updateMatrix();
caixas.setMatrixAt(i, peca.matrix);
}
peca.rotation.set(0, 0, 0);
caixas.instanceMatrix.needsUpdate = true;
}
return {
guardarPasso,
sincronizar(p, alfa, dt) {
relogio += dt;
const alturaDoTexto = alturaDoRotulo();
sincronizarZumbis(p, alfa, dt, alturaDoTexto);
sincronizarDrones(p, alfa, alturaDoTexto);
sincronizarCaixas(p);
},
descartar() {
for (const ator of reserva) {
ator.zumbi.descartar();
ator.rotulo.removeFromParent();
}
reserva.length = 0;
porId.clear();
cena.remove(blocos, caixas, ...rotulosDosDrones);
blocos.dispose();
caixas.dispose();
verde.dispose();
materialDaCaixa.dispose();
caixas.geometry.dispose();
modelo.bloco.dispose();
// a geometria dos sprites é uma só, do módulo do three: soltar libera o renderer desta partida
rotulosDosDrones[0].geometry.dispose();
for (const { material, textura } of rotulos.values()) {
material.dispose();
textura.dispose();
}
rotulos.clear();
for (const material of materiais.values()) material.dispose();
materiais.clear();
for (const textura of modelo.roupas) textura.dispose();
// o modelo fica em cache para a próxima partida; dispose só solta a GPU (e os ouvintes) deste renderer
modelo.zumbi.cena.traverse((objeto) => {
const malha = objeto as Mesh;
if (!malha.isMesh) return;
malha.geometry.dispose();
const material = malha.material as MeshStandardMaterial;
for (const valor of Object.values(material))
if ((valor as Texture | null)?.isTexture)
(valor as Texture).dispose();
material.dispose();
});
},
};
}