UNPKG

shaku

Version:

A simple and effective JavaScript game development framework that knows its place!

248 lines (214 loc) 12.2 kB
/** * Render a 2d skeleton-based animation using a skeleton (joints with transformations) and skin (sprites to attach to skeleton). * Author: Ronen Ness. * Since: 2023. */ class SkeletonRenderer { /** * Create the skeleton renderer. * @param {*} skeleton Tree of bones representing the skeleton bones at idle position. Made of a dictionary of bones. * Every bone dictionary has: * { * name: bone identifier (to attach skin parts to). don't use forward slashes ( / ) in name, as we use them internally to seperate paths. * origin [default=1]: where this bone starts from parent bone, values range from 0 (at parent origin) to 1 (at parent edge). 0.5 for example will erect this bone from the center of its parent bone. * rotation [default=0]: rotation from parent bone. * scale [default=1]: scale the bone length and sprite. * constLength [default=undefined]: if provided, will set the bone to this length regardless of the sprite attached to it. If not provided, will calculate length based on the sprite attached to the bone. * children [default=[]]: list with child bones. * } * @param {Array<*>} animation Animation to play. Made of an array of animation steps, where every step contains the following values: * { * duration: animation step duration in seconds. * transitionToNext: how we want to transition between this animation step and the next. Valid values are: * 'linear' (default), 'smoothStep', 'quadLerp', 'squaredLerp'. * transformations: dictionary of transformations to apply on bones during this step. * Key should be bone path seperated with forward slashes (for example 'root/chest/head') and values should be dictionaries with: * { * rotation: rotation to add to bone rotation in skeleton during this animation step. * scale: scale to apply on bone during this animation step. * boneScale: increase just the length of this particular bone without scaling children. * } * } * @param {*} skin Dictionary with sprites to attach to skeleton. Key is bone identifier, value is whatever data you need to render the sprite + a required 'boneLength' numeric value to stretch the bones to fit the sprite. * Ror example it can be something like this: * bone_name: [ * { * boneLength [MANDATORY]: length to apply on the parent bone. unless you provide 'constLength' to bones, their size will be calculated by the 'boneLength' of the sprites attached to them. * sourceRect [suggestion]: source rectangle in texture. * texture [suggestion]: texture identifier or instance. * rotation [suggestion]: sprite rotation relative to bone rotation. * origin [suggestion]: sprite origin. * } * ] * Note: only 'boneLength' is mandatory, the other params (texture, rotation, origin) are just recommendation and you can attach and use them however you like. * @param {Number=} scale Scale the entire skeleton uniformly. */ constructor(skeleton, animation, skin, scale) { this.skeleton = skeleton; this.animation = animation; this.skin = skin; this.scale = scale || 1; this.rotation = 0; this.position = Shaku.utils.Vector2.zero(); this.resetAnimation(); } /** * Reset animation. */ resetAnimation() { this.__animationStepProgress = 0; this.__currAnimationStepData = this.animation[0]; this.__currAnimationNextStepData = this.animation[1] || this.__currAnimationStepData; this.__currAnimationStepIndex = 0; } /** * Update skeleton animation. * @param {Number} deltaTime Delta time in seconds to advance animation. */ update(deltaTime) { // no animation to play if (!this.__currAnimationStepData) { return; } // update current step progress and check if finished step this.__animationStepProgress += deltaTime; while (this.__animationStepProgress >= this.__currAnimationStepData.duration) { // proceed to next step and if wrap around go back to first step this.__animationStepProgress -= this.__currAnimationStepData.duration; this.__currAnimationStepIndex++; if (this.__currAnimationStepIndex >= this.animation.length) { this.__currAnimationStepIndex = 0; } // get current step data this.__currAnimationStepData = this.animation[this.__currAnimationStepIndex]; // get next step data this.__currAnimationNextStepData = this.animation[this.__currAnimationStepIndex + 1] || this.animation[0]; } } /** * Walk over the skeleton in current animation step. * @param {Function} handler Method to invoke on sprites in skeleton for every sprite. * Method receive the following params: * bone: bone instance with all its data as provided in the skeleton. * parent bone: parent bone instance. * sprite: current sprite data as provided in the skin. * transformations: current absolute transformations: {position = render position, rotation = sprite rotation, scale = scale factor}. */ walk(handler) { // walk bones recursively and fill the return array // every bone contains: origin, rotation, name, scale, constLength, spriteLengthComponent, children. // transform = {position, rotation} // parent = parent bone. // parentLen = parent bone length. // bone full path, components separated with forward slashes. const walkBones = (currBone, parentTransform, parent, parentLen, bonePath) => { // get current bone length let boneLen = currBone.constLength; // get sprites for current bone (and if got a single value, convert to array) var sprites = this.skin[currBone.name] || _emptyList; if (!Array.isArray(sprites)) { sprites = [sprites]; } // no const len? calculate from sprites if (boneLen === undefined) { // get sprites and find max bone len boneLen = 0; for (let sprite of sprites) { boneLen = Math.max(boneLen, sprite.boneLength || 0); } } // calculate current transformations const transformations = {position: parentTransform.position.clone(), scale: parentTransform.scale * (currBone.scale || 1), rotation: parentTransform.rotation + (currBone.rotation || 0)}; // apply animation transformations if (this.__currAnimationStepData) { // get current and next transformations + calculate lerp values const currAnimationTransform = (this.__currAnimationStepData.transformations || _emptyDict)[bonePath] || _emptyDict; const nextAnimationTransform = (this.__currAnimationNextStepData.transformations || _emptyDict)[bonePath] || _emptyDict; if (currAnimationTransform != nextAnimationTransform) { const transitionType = (this.__currAnimationNextStepData.transitionToNext || 'linear'); var lerpValue = (this.__animationStepProgress / this.__currAnimationStepData.duration); // get curr and next rotation const currRotation = currAnimationTransform.rotation || 0; const nextRotation = nextAnimationTransform.rotation || 0; // get curr and next scale const currScale = currAnimationTransform.scale || 1; const nextScale = nextAnimationTransform.scale || 1; // get curr and next bone scale const currBoneScale = currAnimationTransform.boneScale || 1; const nextBoneScale = nextAnimationTransform.boneScale || 1; // apply transition type if (transitionType === 'linear') { } else if (transitionType == 'smoothStep') { lerpValue = ss(lerpValue); } else if (transitionType == 'quadLerp') { lerpValue = quadlerp(lerpValue); } else if (transitionType == 'squaredLerp') { lerpValue = squaredlerp(lerpValue); } else { throw new Error(`Unknown transition type '${transitionType}'!`); } // apply transition transformations.rotation += (currRotation !== nextRotation) ? Shaku.utils.MathHelper.lerpDegrees(currRotation, nextRotation, lerpValue) : nextRotation; transformations.scale *= (currScale !== nextScale) ? Shaku.utils.MathHelper.lerp(currScale, nextScale, lerpValue) : nextScale; boneLen *= (currBoneScale !== nextBoneScale) ? Shaku.utils.MathHelper.lerp(currBoneScale, nextBoneScale, lerpValue) : nextBoneScale; } } // wrap rotation while (transformations.rotation < 0) { transformations.rotation += 360; } while (transformations.rotation > 360) { transformations.rotation -= 360; } // set current bone position if (parentLen) { const origin = (currBone.origin === undefined) ? 1 : currBone.origin; transformations.position.addSelf(Shaku.utils.Vector2.fromDegrees(parentTransform.rotation || 0).mulSelf(parentLen * origin)); } // apply scale on bone length boneLen *= (transformations.scale || 1); // invoke handler for (let sprite of sprites) { handler(currBone, parent, sprite, transformations); } // iterate children if (currBone.children && currBone.children.length) { for (let child of currBone.children) { walkBones(child, transformations, currBone, boneLen, bonePath ? (bonePath + '/' + child.name) : child.name); } } } // create phantom root bone for walking the skeleton if (!this._root) { this._root = { origin: 0, rotation: 0, name: '__root__', scale: 1, constLength: 0, children: [this.skeleton] } } // start walking skeleton walkBones(this._root, {position: this.position, rotation: this.rotation, scale: this.scale}, null, 1, null); } } // light optimization const _emptyDict = {}; const _emptyList = []; function lerp(a, b, t) { return a + (b-a) * t; } function llerp(t) { return t; } function squaredlerp(t) { return t*t; } function quadlerp(t) { return 1.0 - (1.0 - t) * (1.0 - t); } function ss(t) { return lerp(quadlerp(t),squaredlerp(t),t); } // export the skeleton renderer window.SkeletonRenderer = SkeletonRenderer;