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