Files
portifolio-evandrodevbr/app/jogo/zumbis3d.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

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();
},
};
}