game_actor_actor.js

import Vec2 from "../vector/vec2.js";
import ComponentManager from "./component/component_manager.js";
import DrawableComponent from "./component/drawable_component.js";
import Bounds from "../vector/bounds.js";
import CollisionComponent from "./component/collision_component.js";

/**
 * @module Actor
 * @fileoverview Contains Actor class.
 */

/**
 * @class
 * Base class for objects in space.
 */
class Actor {
    /**
     * Create a new actor.
     * @param {Vec2} pos Initial position in space
     * @param {number} rotation Rotation around center
     * @param {number} scale Uniform scaling for rendering
     * @param {"auto"|null} collision Collision initializer
     * @param {Sprite|string|null} sprite Sprite or sprite identifier for rendering
     * @param {boolean} persistent Whether to always update regardless of loaded chunks
     * @param {Object.<string, function|function[]>} events Events to register
     * @param {Object.<string, ActorComponent>} components Components to add
     * @param {string} id Global (in space) identifier of actor
     * @constructor
     */
    constructor({
        pos = new Vec2(),
        rotation = 0,
        scale = 4,
        collision = null,
        sprite = null,
        persistent = false,
        events = {},
        components = {},
        id = ""
    } = {}) {
        this._intialized = false;

        this._parent = null;
        this._children = new Set();

        this.events = new Map();

        for (const event of Object.keys(events)) {
            const handler = events[event];
            if (handler instanceof Array)
                for (const h of handler)
                    this.on(event, h);
            else
                this.on(event, handler);
        }

        this._components = new ComponentManager(this);

        for (const compID of Object.keys(components))
            this.components.add(compID, components[compID]);

        this._pos = pos.copy();
        this.scale = Vec2.from(scale);

        this.persistent = persistent;

        this.bounds = {
            l: 0,
            t: 0,
            r: 0,
            b: 0
        };

        this.bounds = null;

        this.overlappingChunks = new Set();

        if (sprite !== null) {
            this.components.add("drawable", new DrawableComponent({
                sprite: sprite
            }));
        }

        if (collision !== null) {
            if (collision === "auto") {
                this.components.add("collision", new CollisionComponent({
                    bounds: this.drawableBounds
                }));
            } else if (collision instanceof CollisionComponent) {
                this.components.add("collision", collision);
            }
        }

        this.shadow = true;

        this.id = null;
        this._globalID = id;

        this.space = null;

        this.alwaysOnTop = false;

        this.rotation = rotation;

        this.debugPrefix = "ID: ";

        this.markedForDeletion = false;

        this._animations = new Set();
    }

    /**
     * Executed when an actor is added to a space.
     */
    init() {
        for (const comp of this.components.getAll())
            comp.init();

        this.findOverlappingChunks();

        this._intialized = true;
        this.doEvent("init", this);

        this.forEachChild(child => {
            if (child.space === null)
                this.space.addActor(child);
        });

        this.space.addGlobalID(this);
    }

    /**
     * Mark the actor for deletion.
     */
    delete() {
        if (this.markedForDeletion)
            return;

        this.doEvent("delete", this);

        this.clearOverlappingChunks();

        this.components.destructor();

        this.markedForDeletion = true;

        for (const child of this._children.values())
            child.delete();

        if (this._parent !== null)
            this._parent.removeChild(this);
    }


    //
    // Getters
    //

    /**
     * Get the global identifier.
     * @returns {string} Global ID
     */
    get globalID() {
        return this._globalID;
    }

    /**
     * Get the local position.
     * @returns {Vec2} Local (relative) position
     */
    get pos() {
        return this._pos.copy();
    }

    /**
     * Get the global position.
     * @returns {Vec2} Global (absolute) position
     */
    get globalPos() {
        if (this.hasParent())
            return this.pos.transform(this._parent.getTransformMatrix());

        return this.pos;
    }

    /**
     * Get the global transformation matrix.
     * @param {boolean} trans Whether to include translation
     * @param {boolean} rot Whether to include rotation
     * @param {boolean} scale Whether to include scaling
     * @returns Global transformation matrix
     */
    getTransformMatrix(trans = true, rot = true, scale = true) {
        let parent = mat3.create();
        if (this._parent !== null)
            parent = this._parent.getTransformMatrix(trans, rot, scale);

        const matrix = mat3.create();
        if (scale)
            mat3.multiply(matrix, this.scale.toScaleMatrix(), matrix);
        if (rot) {
            const rotMat = mat3.create();
            mat3.fromRotation(rotMat, this.rotation);
            mat3.multiply(matrix, rotMat, matrix);
        }
        if (trans)
            mat3.multiply(matrix, this.pos.toTranslationMatrix(), matrix);

        mat3.multiply(matrix, parent, matrix);

        return matrix;
    }

    /**
     * Get the component manager.
     * @returns {ComponentManager} Component manager
     */
    get components() {
        return this._components;
    }

    /**
     * Get the total bounding box of all drawable components.
     * @returns {Bounds} Total drawable bounding box
     */
    get drawableBounds() {
        let bounds = new Bounds();

        for (const comp of this.components.getDrawables())
            bounds = bounds.merge(comp.bounds);

        return bounds;
    }

    /**
     * Get the total bounding box of all collision components.
     * @returns {Bounds} Total collision bounding box
     */
    get collisionBounds() {
        let bounds = new Bounds();

        for (const comp of this.components.getCollisions())
            bounds = bounds.merge(comp.bounds);

        return bounds;
    }

    /**
     * Check if the actor has been initialized.
     * @returns {boolean} Whether actor has been initialized
     */
    get initialized() {
        return this._intialized;
    }


    //
    // Setters
    //

    /**
     * Set the global (space) identifier.
     * @param {string} id New global identifier
     */
    set globalID(id) {
        this._globalID = id;

        if (this.space !== null)
            this.space.addGlobalID(this);
    }

    /**
     * Set the local (relative) position.
     * @param {Vec2} pos New local position
     */
    set pos(pos) {
        this._pos = pos.copy();

        this.findOverlappingChunks();
    }

    /**
     * Set the global (absolute) position.
     * @param {Vec2} pos New global position
     */
    set globalPos(pos) {
        let matrix = mat3.create();
        if (this.hasParent())
            matrix = this._parent.getTransformMatrix();

        this._pos = pos.inverseTransform(matrix);
    }


    //
    // Update & Draw
    //

    /**
     * Update the actor
     * @param {number} elapsed Time since last update
     */
    update(elapsed) {
        this._components.updateAll(elapsed);

        this.doEvent("update", elapsed, this);

        for (const animation of this._animations.values()) {
            animation.update(elapsed);

            if (animation.done)
                this._animations.delete(animation);
        }
    }

    /**
     * Draw debug information for the actor (if enabled).
     * @param {CanvasRenderingContext2D} ctx Canvas context to draw on
     * @param {boolean} showID Whether to show chunk actor ID
     * @param {boolean} showDot Whether to show the dot in the center
     */
    drawDebug(ctx, showID = true, showDot = true) {
        const pos = this.globalPos;

        ctx.save();
        ctx.translate(pos.x, pos.y);

        ctx.fillStyle = $$.colors.debug;
        if (showID) {
            ctx.textAlign = "center";
            ctx.font = "bold 16px " + $$.fonts.debug;
            const id = this.debugPrefix + this.id;
            const y = this.drawableBounds.top * this.scale.y;
            ctx.fillText(id, 0, y - 12);
        }

        this.components.drawDebugAll(ctx);

        if (showDot)
            ctx.fillRect(-2, -2, 4, 4);

        ctx.restore();
    }

    /**
     * Draw the actor
     * @param {CanvasRenderingContext2D} ctx The canvas context to draw on
     */
    draw(ctx) {
        const pos = this.pos;

        ctx.save();
        ctx.translate(pos.x, pos.y);
        ctx.rotate(this.rotation);
        ctx.scale(this.scale.x, this.scale.y);

        this._components.drawAll(ctx);

        this.forEachChild(child => child.draw(ctx));

        ctx.restore();
    }

    /**
     * Set up the shadow for drawing.
     * @param {CanvasRenderingContext2D} ctx Canvas context to draw on
     */
    setShadow(ctx) {
        ctx.shadowOffsetX = 2;
        ctx.shadowOffsetY = 2;
        ctx.shadowColor = "rgba(0,0,0,0.32)";
    }

    /**
     * Reset/remove the shadow for drawing.
     * @param {CanvasRenderingContext2D} ctx Canvas context to draw on
     */
    resetShadow(ctx) {
        ctx.shadowOffsetX = 0;
        ctx.shadowOffsetY = 0;
    }

    /**
     * Check collision with another actor.
     * @param {CollisionComponent} other Other component to check with
     * @param {Vec2} pos The position to attempt to move to
     * @returns {Object} Collision result
     */
    checkCollision(other, pos) {
        for (const comp of this.components.getCollisions()) {
            const result = comp.check(other, pos);
            if (result.result)
                return result;
        }

        return {
            result: false
        };
    }

    /**
     * Find, save, and register in all chunks that collision components are overlapping with.
     */
    findOverlappingChunks() {
        if (this.space === null)
            return;

        if (this.components.collisionCount === 0) {
            this.clearOverlappingChunks();
            return;
        }

        const old = this.overlappingChunks;

        this.overlappingChunks = new Set(this.space.chunks.getInBounds(this.collisionBounds.scale(this.scale).add(this._pos)));
        for (const chunk of this.overlappingChunks.values()) {
            chunk.addOverlapping(this);
            old.delete(chunk);
        }

        for (const chunk of old.values())
            chunk.removeOverlapping(this);
    }

    /**
     * Clear and remove actor from all overlapping chunks.
     */
    clearOverlappingChunks() {
        for (const chunk of this.overlappingChunks.values())
            chunk.removeOverlapping(this);

        this.overlappingChunks.clear();
    }

    /**
     * Add a child actor.
     * @typedef {T}
     * @param {Actor|T} actor Actor to add as child
     * @returns {Actor|T} Actor that was added
     */
    addChild(actor) {
        if (actor === null)
            return actor;

        this._children.add(actor);
        actor.setParent(this);

        if (this.space !== null && !actor.initialized)
            this.space.addActor(actor);

        return actor;
    }

    /**
     * Remove a child actor. This will delete the actor.
     * @param {Actor} actor Child to remove
     */
    removeChild(actor) {
        if (!this._children.has(actor))
            return;

        this._children.delete(actor);
        actor.delete();
    }

    /**
     * Remove and delete all child actors.
     */
    clearChildren() {
        for (const child of this._children)
            child.delete();

        this._children.clear();
    }

    /**
     * Run a function for each child actor.
     * @param {function} func Function to run for each child
     */
    forEachChild(func = child => {}) {
        this._children.forEach(func);
    }

    /**
     * Set the parent actor (do not call this manually).
     * @param {Actor} actor New parent actor.
     */
    setParent(actor) {
        this._parent = actor;
    }

    /**
     * Check if the actor has a parent actor.
     * @returns {boolean} Whether actor has a parent
     */
    hasParent() {
        return this._parent !== null;
    }


    //
    // Events
    //

    /**
     * @typedef {"init" | "update" | "delete" | "collide"} ActorEvent
     */

    /**
     * Add an event function.
     * @param {ActorEvent|string} event Event to add function to
     * @param {function} func Function to run when called
     */
    on(event, func) {
        if (!this.events.has(event))
            this.events.set(event, []);

        this.events.get(event).push(func);
    }

    /**
     * Run all functions attached to a specified event.
     * @param {ActorEvent|string} event Event to trigger
     * @param {...any} args Arguments to pass into functions
     */
    doEvent(event, ...args) {
        if (event !== ".event")
            this.doEvent(".event", event, ...args);

        if (this.events.has(event))
            for (const handler of this.events.get(event))
                handler(...args);
    }

    //
    // Animations
    //

    /**
     * Add an animation.
     * @param {ActorAnimation} animation Animation to add
     * @returns {ActorAnimation} Animation that was added
     */
    addAnimation(animation) {
        animation.actor = this;

        this._animations.add(animation);

        return animation;
    }

    /**
     * Stop all animations.
     */
    stopAnimations() {
        this._animations.clear();
    }

    /**
     * Check if there are any active animations.
     * @returns {boolean} Whether any animations are active
     */
    hasAnimations() {
        return this._animations.size > 0;
    }
}

export default Actor;