skinview3d-blockbench
Version:
SkinView3d animation provider for blockbench bedrock minecraft animations
302 lines (301 loc) • 11.2 kB
JavaScript
import { PlayerAnimation } from 'skinview3d';
import { Clock, Euler, Group, MathUtils } from 'three';
import { catmullRom } from './lerp';
import { defaultBonesOverrides, defaultPositions } from './defaults';
/** Provider for bedrock .animation.json files */
export class SkinViewBlockbench extends PlayerAnimation {
animations;
/** Function called when looped animation loop ends */
onLoopEnd;
/** Function call when single-iteration animation ends */
onFinish;
/**
* Force loop animation, ignoring its settings
* (undefined for using loop setting from animation)
*/
forceLoop;
/** Connect cape to body if its not animated */
connectCape;
/** Currently playing animation name */
animationName = '';
/** Currently playing animation iteration */
animationIteration = 0;
/** Player object */
player;
/** Animation progress in milliseconds */
_progress = 0;
clock;
torsoWrapper;
/** Normalize keyframes names by adding explicit lerp mode */
convertKeyframe(input) {
if (!input)
return undefined;
const result = {};
for (const [k, v] of Object.entries(input)) {
if (Array.isArray(v)) {
result[k] = { post: v, lerp_mode: 'linear' };
}
else {
result[k] = v;
}
}
return result;
}
constructor(params) {
super();
this.animations = {};
this.onFinish = params.onFinish;
this.onLoopEnd = params.onLoopEnd;
this.forceLoop = params.forceLoop;
this.connectCape = params.connectCape ?? false;
this.clock = new Clock();
// Initialize all animations
for (const animation_name of Object.keys(params.animation.animations)) {
this.processAnimation(params.animation.animations, animation_name, params.bonesOverrides);
}
// Reset animation
this.reset(params.animationName);
}
/** Reset animation */
reset(animation_name) {
const _animation_name = animation_name ?? Object.keys(this.animations).at(0);
if (!_animation_name || !(_animation_name in this.animations))
throw new Error('Animation name not specified or no animation found');
this.clock.stop();
this.clock.autoStart = true;
this._progress = 0;
this.animationIteration = 0;
this.animationName = _animation_name;
this.paused = false;
}
/** Resets player' joints */
resetPose() {
this.player.resetJoints();
}
/** Get list of available animations */
get animationNames() {
return Object.keys(this.animations);
}
/** Prepare single animation */
processAnimation(animations, animation_name, bones_overrides) {
const animation = animations[animation_name];
const bones = {};
const keyframes_list = {};
// Iterate by each bone animation
for (const [bone, value] of Object.entries(animation.bones)) {
// Normalizing bones names
let normalizedBoneName = undefined;
if (bone in defaultBonesOverrides) {
normalizedBoneName = defaultBonesOverrides[bone];
}
// Apply overrides
if (bones_overrides && Object.values(bones_overrides).includes(bone)) {
normalizedBoneName = Object.entries(bones_overrides)
.find(([, v]) => v === bone)
?.at(0);
}
if (!normalizedBoneName)
throw Error(`Found unknown bone: ${bone}`);
// Normalize keyframes objects
bones[normalizedBoneName] = {
position: this.convertKeyframe(value.position),
rotation: this.convertKeyframe(value.rotation)
};
// Prepare keyframes for proper use
const rotation_keys = Object.keys(value.rotation ?? {});
const position_keys = Object.keys(value.position ?? {});
keyframes_list[normalizedBoneName] = {
rotation: {
str: rotation_keys,
num: rotation_keys.map(parseFloat)
},
position: {
str: position_keys,
num: position_keys.map(parseFloat)
}
};
}
this.animations[animation_name] = {
bones,
keyframes_list,
animation_length: animation.animation_length,
animation_name,
animation_loop: animation.loop == true
};
}
/** Sets the current animation by name from already imported animation set */
setAnimation(animation_name, options) {
this.reset(animation_name);
if (!options)
return;
if (options.connectCape)
this.connectCape = options.connectCape ?? false;
if (options.forceLoop)
this.forceLoop = options.forceLoop;
}
clamp(val, min, max) {
return Math.max(min, Math.min(val, max));
}
resolveFrame(frame) {
if (frame.pre)
return frame.pre;
if (frame.post)
return frame.post;
return [0, 0, 0];
}
getCurrentKeyframe(value, keyframes_list, insertion, looped_time, loop) {
const times = keyframes_list.num;
const labels = keyframes_list.str;
const n = times.length;
let i0, i1, i2, i3;
const stepBackwards = (i, step, n) => {
const _i = i - step;
if (_i <= 0)
return n - (1 + step);
return _i;
};
if (loop) {
i0 = stepBackwards(insertion, 1, n);
i1 = insertion % n;
i2 = (insertion + 1) % n;
i3 = (insertion + 2) % n;
}
else {
i0 = Math.max(insertion - 1, 0);
i1 = insertion;
i2 = Math.min(insertion + 1, n - 1);
i3 = Math.min(insertion + 2, n - 1);
}
const t1 = times[i1];
let t2 = times[i2];
const k0 = value[labels[i0]];
const k1 = value[labels[i1]];
const k2 = value[labels[i2]];
const k3 = value[labels[i3]];
let time = looped_time;
if (loop && t2 <= t1) {
const duration = times[n - 1] - times[0];
t2 += duration;
if (time < t1)
time += duration;
}
let t;
if (t2 === t1) {
t = 0;
}
else {
t = (time - t1) / (t2 - t1);
}
const start = this.resolveFrame(k1);
const end = this.resolveFrame(k2);
if (k2.lerp_mode === 'catmullrom') {
const p0 = this.resolveFrame(k0);
const p1 = start;
const p2 = end;
const p3 = this.resolveFrame(k3);
return [0, 1, 2].map(i => catmullRom(p0[i], p1[i], p2[i], p3[i], t));
}
else {
return [0, 1, 2].map(i => start[i] + (end[i] - start[i]) * t);
}
}
/** Group body parts to single torso */
initTorso() {
if (this.torsoWrapper || !this.animations[this.animationName].bones.torso)
return;
this.torsoWrapper = new Group();
this.player.add(this.torsoWrapper);
const torso = new Group();
torso.attach(this.player.skin.head);
torso.attach(this.player.skin.leftArm);
torso.attach(this.player.skin.rightArm);
torso.attach(this.player.skin.body);
torso.position.y = 8;
this.torsoWrapper.add(torso);
if (this.connectCape) {
const cape = new Group();
cape.attach(this.player.cape);
cape.position.y = -1;
this.player.skin.body.add(cape);
}
}
animate(player) {
const delta = this.clock.getDelta() * this.speed;
this.player = player; // Save player object for future
this.initTorso();
const current_animation = this.animations[this.animationName];
const looped = this.forceLoop !== undefined
? this.forceLoop
: current_animation.animation_loop;
const looped_time = looped
? this._progress % current_animation.animation_length
: this.clamp(this._progress, 0, current_animation.animation_length);
for (const [bone, value] of Object.entries(current_animation.bones)) {
for (const type of ['rotation', 'position']) {
if (!value[type])
continue;
const keyframes_list = current_animation.keyframes_list[bone][type];
let insert_index = this.findInsertPosition(keyframes_list.num, looped_time);
if (insert_index === null)
insert_index = keyframes_list.num.length - 1;
const curr = this.getCurrentKeyframe(value[type], keyframes_list, insert_index, looped_time, current_animation.animation_loop);
let skin_bone;
switch (bone) {
case 'all':
skin_bone = this.player;
break;
case 'torso':
skin_bone = this.torsoWrapper;
break;
case 'cape':
skin_bone = this.player.cape;
break;
default:
skin_bone = this.player.skin[bone];
}
if (type === 'rotation') {
const [x, y, z] = curr.map(MathUtils.degToRad);
skin_bone.setRotationFromEuler(new Euler(x, -y, -z, 'ZYX'));
}
else {
const defaults = defaultPositions[bone];
skin_bone.position.set(defaults[0] + curr[0], defaults[1] + curr[1], defaults[2] + -curr[2]);
}
}
}
const old_progress = this._progress;
this._progress += delta;
const animation_iteration = Math.floor(old_progress / current_animation.animation_length);
if (animation_iteration > this.animationIteration && looped) {
this.animationIteration = animation_iteration;
this.onLoopEnd?.(this);
}
if (old_progress >= current_animation.animation_length && !looped) {
this.paused = true;
this.onFinish?.(this);
}
}
findInsertPosition(arr, target) {
let left = 0;
let right = arr.length - 1;
if (target < arr[0] || target > arr[arr.length - 1]) {
return null;
}
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) {
return mid;
}
if (arr[mid] < target && target < arr[mid + 1]) {
return mid;
}
if (target < arr[mid]) {
right = mid - 1;
}
else {
left = mid + 1;
}
}
return null;
}
}