modules_sprite_sprite.js

import BaseEvent from "@pencil.js/base-event";
import Image from "@pencil.js/image";
import { modulo } from "@pencil.js/math";
import minimatch from "minimatch";

/**
 * @module Sprite
 */

/**
 * Sprite class
 * <br><img src="./media/examples/sprite.gif" alt="sprite demo"/>
 * @class
 * @extends {module:Image}
 */
export default class Sprite extends Image {
    /**
     * @typedef {Object} Frame
     * @prop {Number} x - Horizontal position
     * @prop {Number} y - Vertical position
     * @prop {Number} w - Width
     * @prop {Number} h - Height
     */
    /**
     * @typedef {Object} FrameData
     * @prop {Frame} frame - Data about this frame in the sprite-sheet
     * @prop {Frame} spriteSourceSize - Data about the original file
     */
    /**
     * Sprite constructor
     * @param {PositionDefinition} positionDefinition -
     * @param {String} url -
     * @param {Array<FrameData>} frames -
     * @param {SpriteOptions} [options] - Drawing options
     */
    constructor (positionDefinition, url, frames, options) {
        super(positionDefinition, url, options);
        this.frames = frames;
        this.frame = 0;
        this.isPaused = false;
    }

    /**
     * @inheritDoc
     * @return {Sprite} Itself
     */
    makePath (ctx) {
        const { sourceSize } = this.frames[Math.floor(this.frame)];
        this.width = sourceSize.w;
        this.height = sourceSize.h;

        super.makePath(ctx);

        if (this.isLoaded) {
            const frameNumber = Math.floor(this.frame);
            if (!this.isPaused && (this.options.loop || this.frame < this.frames.length - 1)) {
                this.setFrame(this.frame + this.options.speed);
            }

            const nextFrame = Math.floor(this.frame);
            if (nextFrame !== frameNumber) {
                this.fire(new BaseEvent(Sprite.events.frame, this));

                if (!this.options.loop && nextFrame === this.frames.length - 1) {
                    this.fire(new BaseEvent(Sprite.events.end, this));
                }
            }
        }

        return this;
    }

    /**
     * @inheritDoc
     * @return {Sprite} Itself
     */
    draw (ctx) {
        const { frame, spriteSourceSize } = this.frames[Math.floor(this.frame)];

        ctx.drawImage(
            this.file,
            frame.x, frame.y, frame.w, frame.h,
            spriteSourceSize.x, spriteSourceSize.y, spriteSourceSize.w, spriteSourceSize.h,
        );

        return this;
    }

    /**
     * Play the sprite animation
     * @param {Number} [speed] - Choose a play speed
     * @return {Sprite} Itself
     */
    play (speed) {
        this.isPaused = false;
        if (speed !== undefined) {
            this.options.speed = speed;
        }
        return this;
    }

    /**
     * Put the sprite on pause
     * @return {Sprite} Itself
     */
    pause () {
        this.isPaused = true;
        return this;
    }

    /**
     *
     * @param {Number} frame - Number of the frame to set
     * @return {Sprite} Itself
     */
    setFrame (frame) {
        this.frame = modulo(frame, this.frames.length);
        return this;
    }

    /**
     * @inheritDoc
     */
    toJSON () {
        const { frames, frame, isPaused } = this;
        return {
            ...super.toJSON(),
            frames,
            frame,
            isPaused,
        };
    }

    /**
     *
     * @param {Object} definition -
     * @return {Sprite}
     */
    static from (definition) {
        const { position, url, frames, frame, isPaused, options } = definition;
        const sprite = new Sprite(position, url, frames, options);
        sprite.setFrame(frame);
        if (isPaused) {
            sprite.pause();
        }
        return sprite;
    }

    /**
     * Load and return a spritesheet json file
     * @param {String} url - Url to the file
     * @return {Spritesheet}
     */
    static async sheet (url) {
        const response = await window.fetch(url);
        const json = await response.json();

        json.meta.file = await this.load(json.meta.image);

        // eslint-disable-next-line no-use-before-define
        return new Spritesheet(json);
    }

    /**
     * @typedef {Object} SpriteOptions
     * @extends ComponentOptions
     * @prop {Number} [speed=1] -
     * @prop {Boolean} [loop=true] -
     */
    /**
     * @type {SpriteOptions}
     */
    static get defaultOptions () {
        return {
            ...super.defaultOptions,
            speed: 1,
            loop: true,
        };
    }

    /**
     * @typedef {Object} SpriteEvents
     * @extends ContainerEvent
     * @prop {String} start -
     * @prop {String} frame -
     * @prop {String} end -
     */
    /**
     * @type {SpriteEvents}
     */
    static get events () {
        return {
            ...super.events,
            start: "sprite-start",
            frame: "sprite-frame",
            end: "sprite-end",
        };
    }
}

/**
 * Spritesheet class
 * @class
 */
class Spritesheet {
    /**
     * Spritesheet constructor
     * @param {Object} json -
     */
    constructor (json) {
        this.json = json;
    }

    /**
     * Getter for the image file
     * @return {Image}
     */
    get file () {
        return this.json.meta.file;
    }

    /**
     * Return all the frames corresponding to a selector
     * @param {String|Function|RegExp} [selector="*"] - Match against the spritesheet images name using a glob pattern, a validation function or a regular expression
     * @return {Array}
     */
    get (selector = "*") {
        const filter = ((matcher) => {
            if (typeof matcher === "function") {
                return matcher;
            }
            if (typeof matcher === "string") {
                const glob = new minimatch.Minimatch(matcher, {
                    dot: true,
                    matchBase: true,
                });
                return string => matcher === string || glob.match(string);
            }
            if (matcher instanceof RegExp) {
                return string => matcher.test(string);
            }
            return () => false;
        })(selector);

        const { frames } = this.json;
        return Object.keys(frames)
            .filter(filter)
            .map(key => frames[key]);
    }

    /**
     * Group images from the spritesheet into a single sprite
     * @param {PositionDefinition} position - Position of the sprite
     * @param {String|Function|RegExp} [selector="*"] - Match against the spritesheet images name using a glob pattern, a validation function or a regular expression
     * @param {ImageOptions} [options] - Options of the sprite
     * @return {Sprite}
     */
    extract (position, selector, options) {
        return new Sprite(position, this.file, this.get(selector), options);
    }
}