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.
This commit is contained in:
1 parent
c7d09d63d4
commit
67b32fcab3
118 files changed
+16730
No files matched your search
@@ -0,0 +1,90 @@
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user