import { type AnimationAction, AnimationClip, AnimationMixer, LoopOnce, LoopRepeat, type Object3D, type Skeleton, type SkinnedMesh, } from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { clone } from "three/examples/jsm/utils/SkeletonUtils.js"; /** O zumbi baixado: a cena original (nunca vai para a tela, só é clonada) e os clipes de animação. */ export type ModeloDoZumbi = { cena: Object3D; clipes: AnimationClip[] }; export type Zumbi = { raiz: Object3D; tocar(clipe: string, opcoes?: { umaVez?: boolean }): void; avancar(dt: number): void; descartar(): void; }; /** Duração da transição entre um clipe e o seguinte, em segundos. */ const TRANSICAO = 0.2; let carga: Promise | null = null; /** Baixa `public/jogo/zumbi.glb` uma vez só; se a carga falhar, a próxima chamada tenta de novo. */ export function carregarZumbi(): Promise { carga ??= new GLTFLoader() .loadAsync("/jogo/zumbi.glb") .then((gltf) => ({ cena: gltf.scene, clipes: gltf.animations })) .catch((erro) => { carga = null; throw erro; }); return carga; } /** * Um zumbi novo: esqueleto próprio e um `AnimationMixer` só dele; geometria, materiais e texturas são os do modelo, * compartilhados entre todos. `SkeletonUtils.clone` dá um esqueleto a cada malha; as malhas do clone passam a usar o * da primeira, para os ossos serem calculados e enviados uma vez por quadro. `descartar` solta só o que é do clone * (mixer e textura dos ossos); quem descarta a geometria e os materiais é a cena. */ export function criarZumbi(modelo: ModeloDoZumbi): Zumbi { const raiz = clone(modelo.cena); let esqueleto: Skeleton | null = null; raiz.traverse((objeto) => { const malha = objeto as SkinnedMesh; if (!malha.isSkinnedMesh) return; // os esqueletos descartados ainda não têm textura: ela só nasce no primeiro desenho if (esqueleto) malha.bind(esqueleto, malha.bindMatrix); else esqueleto = malha.skeleton; }); const mixer = new AnimationMixer(raiz); let atual: AnimationAction | null = null; return { raiz, tocar(nome, opcoes) { const clipe = AnimationClip.findByName(modelo.clipes, nome); if (!clipe) throw new Error(`O zumbi não tem o clipe "${nome}"`); const acao = mixer.clipAction(clipe); const umaVez = opcoes?.umaVez ?? false; if (acao === atual && !umaVez) return; // "morrer" é o último clipe de um zumbi: o próximo é de um clone que voltou da reserva, e começa do zero if (atual?.loop === LoopOnce) { mixer.stopAllAction(); mixer.setTime(0); atual = null; } acao.reset(); acao.setLoop(umaVez ? LoopOnce : LoopRepeat, Infinity); acao.clampWhenFinished = umaVez; acao.play(); if (atual && atual !== acao) atual.crossFadeTo(acao, TRANSICAO, false); atual = acao; }, avancar(dt) { mixer.update(dt); }, descartar() { mixer.stopAllAction(); mixer.uncacheRoot(raiz); esqueleto?.dispose(); raiz.removeFromParent(); }, }; }