/** * 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 = { 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(); const rotulos = new Map(); const materiais = new Map(); 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(); 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(); }); }, }; }