animation.js

import { vectors } from "./vector.js"
import { images } from "./image.js"

/** Animation types. Most types replace the value (absolute)
 * @member
 * @alias gameify.Animation.propertyTypes
 * @enum
 */
let animationPropertyTypes = {
    // Base types
    /** Apply the property by overwriting the old value each frame */
    simple: { apply: (property, value, object) => object[property] = value },
    /** Apply the property using Object.apply() each frame */
    object: { apply: (property, value, object) => Object.apply(object[property], value) },

    // Other simple types
    /** A number */
    number: { apply: (...args) => animationPropertyTypes.simple.apply(...args) },
    /** A string */
    string: { apply: (...args) => animationPropertyTypes.simple.apply(...args) },
    /** A boolean */
    boolean: { apply: (...args) => animationPropertyTypes.simple.apply(...args) },
    /** A gameify.Image (on a sprite, or other object with a setImage method) */
    'Image': {
        apply: (property, value, object) => object.setImage(value),
        toJSON: (obj, ref) => {
            const reference = ref(obj);
            if (reference) return reference;
            else if (obj.toJSON) return obj.toJSON('AnimationTsImage', ref);
            else return undefined;
        },
        fromJSON: (dat, find) => {
            if (dat === undefined) return undefined;
            if (typeof dat === 'string') return find(dat);
            return images.Image.fromJSON(dat, find); // it should be an object
        }
    },
    /** A gameify.Vector2d */
    'Vector2d': {
        apply: (property, value, object) => { object[property].x = value.x; object[property].y = value.y; },
        toJSON: (obj, ref) => {
            return new vectors.Vector2d(obj).toJSON()
        },
        fromJSON: (dat, find) => new vectors.Vector2d(dat)
    }
}

/** Animations for use in gameify. Usually you'll access this through the gameify object.
 * @example // Use via gameify
 * // This is the most common way
 * import { gameify } from "./gameify/gameify.js"
 * let myAnimation = new gameify.Animation(frames, options);
 * @example // Import just sprites
 * import { animations } from "./gameify/animation.js"
 * let myAnimation = new animations.Animation(frames, options);
 * @global
 */
export let animation = {
    /** Manages animations for sprites, text, etc. Usually used via the Sprite or Text object
     * @constructor
     * @alias gameify.Animator
     * @example
     * 
     * mySprite = new gameify.Sprite(0, 0, new gameify.Image("player.png"));
     *
     * let myAnimation = new gameify.Animation(frames, { duration: 200, loop: true });
     * 
     * mySprite.animator.set('idle', myAnimation);
     * mySprite.animator.play('idle');
     * 
     * @arg {Object} parent - The object to animate
     */
    Animator: class {
        constructor (parent) {
            this.parent = parent;
        }

        /** The animations currently assigned to the animator.
         * Use Animator.set() and Animator.play() to add and play animations
         * @readonly
        */
        animations = {};

        /** The object that this animator is attached to
         * @type {Object}
         */
        parent;
        
        /** The animation that is currently playing (or undefined if not playing). Use Animator.play() to change the current animation being played.
         * @readonly
        */
        currentAnimation = undefined;
        /** The name of the current animation (or undefined if not). Use Animator.play() to change the current animation being played.
         * @readonly
        */
        currentAnimationName = undefined;

        /** If an animation is currently playing */
        playing = false;

        /** The time the animation started playing */
        animationProgress = 0;

        /** Creates a object from JSON data
         * @method
         * @arg {Object|Array} data - Serialized object data (from object.toJSON)
         * @arg {Function} ref - A function that returns a name for other objects, so they can be restored later
         * @returns {gameify.Animation}
        */
        static fromJSON = (data, find) => {
            return new animation.Animation(data.frames, data.options);
        }
        
        /** Convert the object to JSON
         * @method
         * @arg {string} [key] - Key object is stored under (unused, here for consistency with e.g. Date.toJSON, etc.)
         * @arg {function} ref - A function that returns a name for other objects, so they can be restored later
         * @returns {Object}
         */
        toJSON = (key, ref) => {
            const out = {
                currentAnimation: Object.keys(this.animations).find(key => this.animations[key] === value),
                animationProgress: this.animationProgress,
                playing: this.playing,
                parent: ref(this.parent),
                animations: {}
            };
            for (const key in this.animations) {
                const anim = this.animations[key];
                out.animations[key] = ref(anim);
            }
            return out;
        }

        /** Add an animation to the animations list
         * @method
         * @param {String} name - The name of the animation
         * @param {gameify.Animation} animation - The animation
         */
        set = (name, animation) => {
            this.animations[name] = animation;
        }

        /** Play an animation, or resume it if it's paused.
         * If the animation is paused or already playing, this function does not reset the animation
         * (use Animator.stop() to reset the animation).
         * @method
         * @param {String} name - The name of the animation
         */
        play = (name) => {
            if (!this.animations[name]) {
                throw new Error(`Animation '${name}' not found or was not added to this animator.`);
            }

            if (this.currentAnimation !== this.animations[name] || this.playing === false) {
                this.currentAnimation = this.animations[name];
                this.currentAnimationName = name;
                if(this.currentAnimation !== this.animations[name]) {
                    // New animation, reset the timer
                    this.animationProgress = 0;
                }
                this.playing = true;
            }
        }

        /** Stop & reset the animation
         * @method
         */
        stop = () => {
            this.playing = false;
            this.currentAnimation = undefined;
            this.currentAnimationName = undefined;
            this.animationProgress = 0;
        }

        /** Pause the animation
         * @method
         */
        pause = () => {
            this.playing = false;
        }
        
        /** Resume the animation
         * @method
         */
        resume = () => {
            if (!this.currentAnimation) {
                throw new Error('Cannot resume, no animation is playing.');
            }
            this.playing = true;
        }

        /** Update the animation
         * @method
         * @param {Number} delta - The time, in miliseconds, since the last frame
         */
        update = (delta) => {
            if (!this.playing) return;

            this.animationProgress += delta;
            if (this.currentAnimation.isAfterCompletion(this.animationProgress)) {
                this.stop();
                return;
            }

            // ignore looping; we would've stopped anyway
            // because of isAfterCompletion.
            if (this.animationProgress < 0) {
                this.animationProgress += this.currentAnimation.options.duration;
            }
            if (this.animationProgress > this.currentAnimation.options.duration) {
                this.animationProgress -= this.currentAnimation.options.duration;
            }

            this.currentAnimation.applyTo(this.parent, this.animationProgress);
        }
    },

    /** Animation options
     * @typedef {Object} AnimationOptions
     * @property {Number} [duration=1000] - The duration of the animation, in milliseconds (calculated based on frameDuration if frameDuration is set)
     * @property {Number} [frameDuration] - The duration of each frame (by default, calculated based on duration)
     * @property {Boolean} [loop=false] - If the animation should loop
     */

    /** An animation frame
     * @typedef {Object} AnimationFrame
     * @property {animationPropertyTypes|string} [type=gameify.animation.types.simple] - An animation type, or the name of an animation type
     * @property {any} value - The value of the property at the frame
     * @example
     * const frames = [{
     *     image: { type: 'Image', value: new gameify.Image("player_idle1.png") },
     *     position: { type: 'Vector2d', value: new gameify.Vector2d(0, 2) },
     * },{
     *     image: { type: 'Image', value: new gameify.Image("player_idle2.png") },
     *     position: { type: 'Vector2d', value: new gameify.Vector2d(0, 2) },
     * },{
     *     image: { type: 'Image', value: new gameify.Image("player_idle3.png") },
     *     position: { type: 'Vector2d', value: new gameify.Vector2d(0, -2) },
     * },{
     *     image: { type: 'Image', value: new gameify.Image("player_idle4.png") },
     *     position: { type: 'Vector2d', value: new gameify.Vector2d(0, -2) },
     * }];
     * let myAnimation = new gameify.Animation(frames, { duration: 200, loop: true });
     */

    /** Creates an animation
     * @constructor
     * @alias gameify.Animation
     * @example 
     * // ...
     * 
     * // Create a sprite with the image "player.png" in the top left corner
     * const frames = [{
     *     image: { type: 'Image', value: new gameify.Image("player_idle1.png") },
     *     position: { type: 'Vector2d', value: new gameify.Vector2d(0, 2) },
     * }, // ... more frames
     * ];
     * let myAnimation = new gameify.Animation(frames, { duration: 200, loop: true });
     * 
     * mySprite.animator.set('idle', myAnimation);
     * 
     * // ...
     * 
     * myScene.onUpdate(() => {
     *     if (mySprite.velocity.getMagnitude() < .1) {
     *         mySprite.animator.play('idle');
     *     } else {
     *         mySprite.animator.play('walking');
     *     }
     * });
     * 
     * @arg {Array<AnimationFrame>} frames - The frames of the animation
     * @arg {AnimationOptions} options - animation options
     */
    Animation: class {
        constructor (frames, options) {
            this.#frames = frames;
            this.frames = new Proxy(this.#frames, {
                set: (target, key, value) => {
                    target[key] = value;
                    this.#updateOptions(); // Options depend on frames length
                    return true;
                }
            });

            this.#options = Object.assign({
                duration: 1000,
                frameDuration: undefined,
                loop: false
            }, options);
            this.options = new Proxy(this.#options, {
                set: (target, key, value) => {
                    target[key] = value;
                    this.#updateOptions();
                    return true;
                }
            });

            this.#updateOptions();
        }

        /** The frames of the animation
         * @type {Array<Object>}
         */
        frames;

        /** The animation's options
         * @type {AnimationOptions}
         */
        options;

        #frames;

        #options;

        #updateOptions = () => {
            if (this.#options.frameDuration === undefined) {
                this.#options.frameDuration = this.#options.duration / this.frames.length;
                if (this.frames.length === 0) this.#options.frameDuration = 0;
            } else {
                this.#options.duration = this.#options.frameDuration * this.frames.length;
            }
        }

        // Documented above at animationPropertyTypes
        static propertyTypes = animationPropertyTypes;

        /** Creates a object from JSON data
         * @method
         * @arg {Object|Array} data - Serialized object data (from object.toJSON)
         * @arg {Function} ref - A function that returns a name for other objects, so they can be restored later
         * @returns {gameify.Animation}
        */
        static fromJSON = (data, find) => {
            const frames = animation.Animation.framesFromJSON(data.frames, find);
            return new animation.Animation(frames, data.options);
        }
        
        /** Convert the object to JSON
         * @method
         * @arg {string} [key] - Key object is stored under (unused, here for consistency with e.g. Date.toJSON, etc.)
         * @arg {function} ref - A function that returns a name for other objects, so they can be restored later
         * @returns {Object}
         */
        toJSON = (key, ref) => {
            return {
                frames: this.framesToJSON(key, ref),
                options: this.#options
            };
        }

        /** Creates frames from their JSON representation
         * @method
         * @arg {Array} data - Serialized frame data (from object.framesToJSON)
         * @arg {Function} ref - A function that returns a name for other objects, so they can be restored
         * @returns {Array<AnimationFrame>}
        */
        static framesFromJSON = (data, find) => {
            const newFrames = [];
            for (const index in data) {
                const frame = data[index];
                newFrames[index] = {};
                for (const propName in frame) {
                    const prop = frame[propName];
                    // Copy property
                    newFrames[index][propName] = Object.assign({}, prop);
                    // If applicable, convert from JSON
                    if (animationPropertyTypes[prop.type].fromJSON) {
                        newFrames[index][propName].value = animationPropertyTypes[prop.type].fromJSON(prop.value, find);
                    }
                }
            }
            return newFrames;
        }

        /** Converts this animation's frames to JSON
         * @method
         * @arg {string} [key] - Key object is stored under (unused, here for consistency with e.g. Date.toJSON, etc.)
         * @arg {function} ref - A function that returns a name for other objects, so they can be restored later
         * @returns {Object}
        */
        framesToJSON = (key, ref) => {
            const newFrames = [];
            for (const index in this.#frames) {
                const frame = this.#frames[index];
                newFrames[index] = {};
                for (const propName in frame) {
                    const prop = frame[propName];
                    // Copy property
                    newFrames[index][propName] = Object.assign({}, prop);
                    // If applicable, convert to JSON
                    if (animationPropertyTypes[prop.type].toJSON) {
                        newFrames[index][propName].value = animationPropertyTypes[prop.type].toJSON(prop.value, ref);
                    }
                }
            }
            return newFrames;
        }

        /** Update the animation's frames
         * @method
         * @arg {Array<AnimationFrame>} frames - The new frames
         */
        setFrames = (frames) => {
            this.frames = frames;
            this.options.duration = this.options.frameDuration * this.frames.length;
        }

        /** Get the frame number at the given time
         * @method
         * @param {Number} time - The time (in milliseconds) to get the frame at
         * @returns {Number}
         */
        getFrameNumberAt = (time) => {
            const framesElapsed = Math.floor(time / this.options.frameDuration);
            if (this.options.loop) {
                return framesElapsed % this.frames.length;
            }
            return Math.min(framesElapsed, this.frames.length - 1);
        }

        /** Get the frame at the given time
         * @method
         * @param {Number} time - The time (in milliseconds) to get the frame at
         * @returns {Object}
        */
        getFrameAt = (time) => {
            return this.frames[this.getFrameNumberAt(time)];
        }

        /** Apply an animation frame to an object
         * @method
         * @param {Object} object - The object to apply the frame to
         * @param {Number} time - The time (in milliseconds) to of the frame
         */
        applyTo = (object, time) => {
            const frame = this.getFrameAt(time);
            this.applyFrameTo(object, frame);
        }

        /** Apply an animation frame to an object
         * @method
         * @param {Object} object - The object to apply the frame to
         * @param {Object} frame - The frame to apply
         */
        applyFrameTo = (object, frame) => {
            for (const property in frame) {
                // Type can be a type, or a string, or blank (in which case, use simple)
                let type = frame[property].type;
                if (!type.apply) type = animationPropertyTypes[type];
                if (!type.apply) type = animationPropertyTypes.simple;

                type.apply(property, frame[property].value, object);
            }
        }

        /** Check if a the animation is completed after a specific time
         * If options.loop is true, always returns false
         * @method
         * @param {Number} time - The time (in milliseconds) to check if the animation is completed at
         */
        isAfterCompletion = (time) => {
            if (this.#options.loop) return false;
            const framesElapsed = Math.floor(time / this.#options.frameDuration);
            // Compare to length (not length - 1), b/c the animation isn't
            // done until it's the whole way through the last frame
            if (framesElapsed >= this.frames.length) return true;
            return false;
        }
    }
}