freeblox/packages/engine/src/utils/character.ts

220 lines
6.2 KiB
TypeScript

import {
AnimationClip,
Bone,
Object3D,
Quaternion,
SkinnedMesh,
Vector3,
} from 'three';
import { assetManager } from '../assets';
import { Group } from '../gameobjects/group.object';
import { MeshPart } from '../gameobjects/mesh.object';
import { Humanoid } from '../gameobjects/humanoid.object';
import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
import {
CharacterBodyPart,
CharacterSpecification,
CharacterTextureType,
} from '../types/character';
const CHARACTER_MAX_VERSION = 1;
const CHARACTER_MIN_VERSION = 1;
const cachedMeta: {
faceTexture: string;
root: Object3D | null;
clips: AnimationClip[];
specification: CharacterSpecification | null;
} = {
faceTexture: '',
root: null,
clips: [],
specification: null,
};
function deepen(obj: Record<string, any>) {
const result = {};
// For each object path (property key) in the object
for (const objectPath in obj) {
// Split path into component parts
const parts = objectPath.split('.');
// Create sub-objects along path as needed
let target: any = result;
while (parts.length > 1) {
const part = parts.shift();
if (!part) continue;
target = target[part] = target[part] || {};
}
// Set value at end of path
target[parts[0]] = obj[objectPath];
}
return result as Record<string, any>;
}
function splitKeys(obj: Record<string, string>, keys?: string[]) {
const tmp: Record<string, string[]> = {};
Object.keys(obj).forEach((key) => {
if (!keys || keys.includes(key)) {
tmp[key] = obj[key].split(',');
}
});
return { ...obj, ...tmp };
}
function convertKey(key: string) {
return key.replace(/\./g, '');
}
export const parseCharacterSpecificaton = (armature: Object3D) => {
const temporaryObject = deepen(armature.userData);
const bodyParts: CharacterBodyPart[] = [];
// Check for required properties and version number
if (
!temporaryObject.FBLXC?.Version ||
!temporaryObject.FBLXC?.Parts ||
!temporaryObject.FBLXC?.Bones ||
temporaryObject.FBLXC.Version > CHARACTER_MAX_VERSION ||
temporaryObject.FBLXC.Version < CHARACTER_MIN_VERSION
) {
throw new Error('Character model parsing failed: Unsupported');
}
// Comma-separated lists to string arrays
const fblxc = temporaryObject.FBLXC;
Object.assign(
fblxc,
splitKeys(fblxc, ['Parts', 'ColorParts', 'Bones', 'Accessories'])
);
// Comma-separated lists to string arrays
if (fblxc.Texture) {
Object.assign(fblxc.Texture, splitKeys(fblxc.Texture));
}
// Parse accessory arrays
if (fblxc.Accessory) {
Object.keys(fblxc.Accessory).forEach((accessory) => {
for (const [key, value] of Object.entries(fblxc.Accessory[accessory])) {
if (key === 'Origin') {
fblxc.Accessory[accessory][key] = new Vector3().fromArray(
value as number[]
);
}
if (key === 'Quat') {
const wComponent = (value as number[]).shift() as number;
fblxc.Accessory[accessory][key] = new Quaternion().fromArray([
...(value as number[]),
wComponent,
]);
}
}
});
}
// Collect body part information
for (const [index, part] of (fblxc.Parts as string[]).entries()) {
const bodyPart = <CharacterBodyPart>{
meshName: convertKey(part),
boneName: convertKey(fblxc.Bones?.[index] || part),
colored: fblxc.ColorParts?.includes(part) || false,
accessories: Object.keys(fblxc.Accessory)
.filter((accessory) => fblxc.Accessory[accessory].Parent === part)
.map((accessory) => ({
type: accessory,
origin: fblxc.Accessory[accessory].Origin || new Vector3(),
quaternion: fblxc.Accessory[accessory].Quat || new Quaternion(),
})),
textures: Object.keys(fblxc.Texture)
.filter((texture) => fblxc.Texture[texture].includes(part))
.map((texture) => texture),
};
bodyParts.push(bodyPart);
}
return <CharacterSpecification>{
info: fblxc,
bodyParts,
};
};
export const loadBaseCharacter = async () => {
if (cachedMeta.root) return cachedMeta;
// TODO: temporary loading method
const loadMesh = await assetManager.loadMeshData(
'https://lunasqu.ee/freeblox/character-base.glb'
);
const loadFace = await assetManager.createAsset(
'https://lunasqu.ee/freeblox/face-1.png',
'CharacterFace1',
'Texture'
);
const armature = loadMesh.scene.getObjectByName('Armature');
if (!armature) throw new Error('Invalid character asset');
const specification = parseCharacterSpecificaton(armature);
loadFace.texture!.flipY = false;
loadFace.texture!.needsUpdate = true;
cachedMeta.faceTexture = loadFace.path!;
cachedMeta.root = loadMesh.scene;
cachedMeta.clips = loadMesh.animations;
cachedMeta.specification = specification;
return cachedMeta;
};
export const instanceCharacterObject = async (
name: string,
pos = new Vector3(0, 1, 0)
) => {
const base = await loadBaseCharacter();
const cloned = SkeletonUtils.clone(base.root!);
const baseObject = new Group();
const armature = cloned.getObjectByName('Armature');
const bone = armature!.getObjectByName('BRoot');
for (const part of base.specification!.bodyParts) {
const object = armature!.getObjectByName(part.meshName) as Object3D;
const converted = MeshPart.fromLoaded(object as SkinnedMesh);
object.name = object.name + 'Mesh';
converted.name = part.meshName;
converted.archivable = false;
if (part.textures.includes(CharacterTextureType.Face)) {
converted.texture = base.faceTexture;
}
baseObject.add(converted);
}
baseObject.animations = base.clips;
baseObject.add(bone as Bone);
baseObject.archivable = false;
baseObject.name = name;
const controller = new Humanoid();
controller.position.set(0, 4.75, 0);
controller.archivable = false;
controller.bodyPartNames = base.specification!.bodyParts.map((entry) =>
convertKey(entry.meshName)
);
controller.bodyBoneNames = base.specification!.bodyParts.map((entry) =>
convertKey(entry.boneName)
);
baseObject.add(controller);
baseObject.position.copy(pos);
return baseObject;
};
export const getCharacterController = (object: Object3D) => {
return object.getObjectByName('Humanoid') as Humanoid;
};