sprite.js

import { vectors } from "./vector.js"
import { animation } from "./animation.js"

/** Sprite class for use in gameify. Usually you'll access this through the gameify object.
 * @example // Use sprites via gameify
 * // This is the most common way
 * import { gameify } from "./gameify/gameify.js"
 * let playerSprite = new gameify.Sprite(0, 0, "player.png");
 * @example // Import just sprites
 * import { sprites } from "./gameify/sprite.js"
 * let playerSprite = new sprites.Sprite(0, 0);
 * @global
 */
export let sprites = {
    /** Creates a scene in the game. (Eg. a menu or level)
     * @constructor
     * @alias gameify.Sprite
     * @example 
     * // ...
     * 
     * // Create a sprite with the image "player.png" in the top left corner
     * let mySprite = new gameify.Sprite(0, 0, "player.png");
     * // Add the sprite to the screen
     * myScreen.add(Sprite);
     * 
     * myScene.onUpdate(() => {
     *     // update the sprite
     *     mySprite.update();
     * });
     * myScene.onDraw(() => {
     *     myScene.clear(); // clear the screen
     *     mySprite.draw(); // draw the sprite
     * });
     * @arg {Number} x - The x (horizontal) position of the sprite, left-to-right.
     * @arg {Number} y - The y (vertical) position of the sprite, top-to-bottom.
     * @arg {gameify.Image} image - The image the sprite should have.
     */
    Sprite: class {
        constructor(x, y, image) {
            this.position = new vectors.Vector2d(x, y);
            this.image = image;
        }

        /** The animator for this sprite.
         * @type {gameify.Animator}
         */
        animator = new animation.Animator(this);
        /** The position of the Sprite on the screen
         * @type {gameify.Vector2d}
         */
        position;
        /** The velocity of the Sprite
         * @type {gameify.Vector2d}
         */
        velocity = new vectors.Vector2d(0, 0);
        /** The Sprite's image / texture
         */
        image;
        /** The sprite's rotation, in degrees */
        rotation = 0;
        /** The sprite's shape, for collision, etc.
         * @type {shapes.Shape}
         */
        shape = undefined;
        /** The sprite's shape offset (to align it properly)
         * @type {gameify.Vector2d}
        */
        shapeOffset = new vectors.Vector2d(0, 0);
        /** The scale of the sprite's texture */
        scale = 1;
        /** The parent screen (not used directly) */
        parent = null;

        #deltaWarned = false;
        /** The Canvas context to draw to
         * @private
         */
        #context = null;
        /** The user-set draw function
         * @private
         */
        #drawFunction = null;

        /** 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.Sprite}
        */
        static fromJSON = (data, find) => {
            // Serialization format is the same (Sprites always stored as JSON, yay!)
            const obj = new sprites.Sprite(data.position?.x || 0, data.position?.y || 0, undefined);
            if (data.rotation) obj.rotation = data.rotation;    // Set rotation
            if (data.scale) obj.scale = data.scale;             // Set scale
            if (data.image?.parent) {
                // Set image from tileset
                const set = find(data.image.parent);
                if (!set) {
                    // Timemap not found
                    console.warn('Could load sprite image (tileset not found)');
                } else {
                    // Found tilemap
                    obj.setImage(set.getTile(
                        data.image.position.x, data.image.position.y,
                        data.image.size?.x || 1, data.image.size?.y || 1
                    ));
                }
            } else if (data.image) {
                obj.setImage(find(data.image.name));        // Set image
            }
            if (data.shape) obj.setShape(find(data.shape)); // Set shape
            if (data.parent) find(data.parent).add(obj);    // Add to screen
            return obj;
        }
        
        /** 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 {
                position: this.position.toJSON(),
                scale: this.scale,
                rotation: this.rotation,
                image: {
                    name: ref(this.image),
                    parent: ref(this.image?.tileData?.tileset),
                    position: this.image?.tileData?.position,
                    size: this.image?.tileData?.size
                },
                shape: ref(this.shape),
                parent: ref(this.parent)
            };
        }

        /** Set a shape for collisions. If no offset is provided, the shape's position is used.
         * @method
         * @param {gameify.shapes.Shape} shape - The shape (This shape is NOT copied, and it's position will be modified)
         * @param {gameify.Vector2d} [offset] - The shape's offset (to align it properly)
         *//** Set a shape for collisions. If no offset is provided, the shape's position is used.
         * @method
         * @param {gameify.shapes.Shape} shape - The shape (This shape is NOT copied, and it's position will be modified)
         * @param {Number} [offsetx] - The shape's offset x (to align it properly)
         * @param {Number} [offsety] - The shape's offset y
         */
        setShape = (shape, x, y) => {
            this.shape = shape;
            if (x !== undefined && y === undefined) {
                this.shapeOffset = new vectors.Vector2d(x);
            } else if (x !== undefined && y !== undefined) {
                this.shapeOffset = new vectors.Vector2d(x, y);
            } else {
                this.shapeOffset = shape.position.copy();
            }
        }

        /** Change the Sprite's image / texture
         * @method
         * @param {gameify.Image} newImage - The image to change the sprite to
         */
        setImage = (newImage) => {
            this.image = newImage;
        }

        /** Run a function when this sprite updates
         * @method
         * @param {function} callback - The function to be run when the sprite updates. An optional argument can be included for a delta since the last update, and another for a reference to the sprite
         */
        onUpdate = (callback) => {
            this.updateFunction = callback;
        }

        /** Have the sprite move towards a point.
         * @method
         * @param {gameify.Vector2d} pos - The point to move towards
         * @param {Number} [speed] - How quickly the sprite should move towards the point. If speed isn't specified, it keeps its current speed.
         */
        goTowards = (pos, speed) => {
            const magnitude = this.velocity.getMagnitude();
            this.velocity = pos.subtract(this.position).getNormalized();
            // keep the same magnitude
            if (speed === undefined) {
                this.velocity = this.velocity.multiply(magnitude);
            } else {
                this.velocity = this.velocity.multiply(speed);
            }
        }

        /** Have the sprite rotate to face towards a point
         * @method
         * @param {gameify.Vector2d} point -  The point to face towards
         * @param {Number} [offset] -  Rotational offset, in degrees
        */
        faceTowards = (point, offset) => {
            const rise = this.position.y - point.y;
            const run = this.position.x - point.x;
                                            // convert to degrees  // add the offset
            this.rotation = (Math.atan(rise / run) * 180/Math.PI) + (offset || 0);
            if (run < 0) {
                this.rotation -= 180;
            }
        }

        /** Update the Sprite
         * @method
         * @param {Number} [delta] - The time, in miliseconds, since the last frame
         * @param {Boolean} [updateAnimator] - Whether or not to update the animator
        */
        update = (delta, updateAnimator = true) => {
            if (delta === undefined) {
                delta = 1000;
                if (!this.#deltaWarned) {
                    console.warn(`You should include a delta argument with your update call, eg sprite.update(delta)
This way speeds and physics are the same regardless of FPS or how good your computer is.`);
                    this.#deltaWarned = true;
                }
            }
            
            if (updateAnimator) {
                this.animator.update(delta);
            }

            // make the velocity dependant on the update speed
            this.position = this.position.add(this.velocity.multiply(delta/1000));

            if (this.shape) {
                this.shape.position = this.position.add(this.shapeOffset);
            }

            if (this.updateFunction) {
                this.updateFunction(delta, this);
            }
        }

        /** Set the draw function for this sprite
         * @method
         * @param {function} callback - The function to be called right before the sprite is drawn
         */
        onDraw = (callback) => {
            this.#drawFunction = callback;
        }

        /** Draw the Sprite
         * @method
         */
        draw = () => {
            if (this.#drawFunction) {
                this.#drawFunction();
            }
            if (!this.#context) {
                throw new Error("You need to add this sprite to a screen before you can draw it.");
            }

            const crop = this.image.getCrop();

            this.image.draw( this.#context,
                             this.position.x, this.position.y,
                             crop.width * this.scale,
                             crop.height * this.scale,
                             this.rotation, /* ignoreOpacity= */ true  );
        }

        /** Get the size of the sprite
         * @returns {gameify.Vector2d}
         */
        getSize = () => {
            const crop = this.image.getCrop();
            return new vectors.Vector2d(
                crop.width * this.scale,
                crop.height * this.scale,
            );
        }

        /** Get the screen this sprite draws to
         * @method
         * @returns {gameify.Screen}
         */
        getParent = () => {
            return this.parent;
        }

        /** Set the Canvas context to draw to. This should be called whenever a sprite is added to a Screen
         * @method
         * @package
         */
        setContext = (context, parent) => {
            this.#context = context;
            this.parent = parent;
        }
    }
};