base/Tickable.js

/*
 * Copyright 2020 WICKLETS LLC
 *
 * This file is part of Wick Engine.
 *
 * Wick Engine is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Wick Engine is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with Wick Engine.  If not, see <https://www.gnu.org/licenses/>.
 */

/**
 * A class that is extended by any wick object that ticks.
 */
Wick.Tickable = class extends Wick.Base {
    /**
     * Debugging feature. Logs errors as they happen
     */
    static get LOG_ERRORS () {
        return false;
    }

    /**
     * Returns a list of all possible events for this object.
     * @return {string[]} Array of all possible scripts.
     */
    static get possibleScripts () {
        return [
            'default',
            'mouseenter',
            'mousedown',
            'mousepressed',
            'mousereleased',
            'mouseleave',
            'mousehover',
            'mousedrag',
            'mouseclick',
            'keypressed',
            'keyreleased',
            'keydown',
            'load',
            'update',
            'unload',
        ];
    }

    /**
     * Create a new tickable object.
     */
    constructor (args) {
        if(!args) args = {};
        super(args);

        this._onscreen = false;
        this._onscreenLastTick = false;

        this._mouseState = 'out';
        this._lastMouseState = 'out';
        this._isClickTarget = false;

        this._scripts = [];

        this.cursor = 'default';

        this.addScript('default', '');

        this._onEventFns = {};
        this._cachedScripts = {};
    }

    _deserialize (data) {
        super._deserialize(data);

        this._onscreen = false;
        this._onscreenLastTick = false;

        this._mouseState = 'out';
        this._lastMouseState = 'out';

        this._scripts = JSON.parse(JSON.stringify(data.scripts));
        this.cursor = data.cursor;

        this._onEventFns = {};
        this._cachedScripts = {};

    }

    _serialize (args) {
        var data = super._serialize(args);

        data.scripts = JSON.parse(JSON.stringify(this._scripts));
        data.cursor = this.cursor;

        return data;
    }

    get classname () {
        return 'Tickable';
    }

    /**
     * The scripts on this object.
     * @type {object[]}
     */
    get scripts () {
        return this._scripts;
    }

    /**
     * Checks if this object has a non-empty script.
     * @type {boolean}
     */
    get hasContentfulScripts () {
        var hasContentfulScripts = false;

        for (var script of this.scripts) {
            if (this.scriptIsContentful(script.name)) {
                hasContentfulScripts = true;
            }
        }

        return hasContentfulScripts;
    }

    /**
     * Check if this object is currently visible in the project, based on its parent.
     * @type {boolean}
     */
    get onScreen () {
        if(!this.parent) return false;
        return this.parent.onScreen;
    }

    /**
     * Add a function to be called when an event happens.
     * @param {string} name - The name of the event to attach the function to.
     * @param {function} fn - The function to call when the given event happens.
     */
    onEvent (name, fn) {
        if(Wick.Tickable.possibleScripts.indexOf(name) === -1) {
            console.warn("onEvent: " + name + " is not a valid event name.");
            return;
        }

        this.addEventFn(name, fn);
    }

    /**
     * Attach a function to a given event.
     * @param {string} name - the name of the event to attach a function to.
     * @param {function} fn - the function to attach
     */
    addEventFn (name, fn) {
        this.getEventFns(name).push(fn);
    }

    /**
     * Gets all functions attached to an event with a given name.
     * @param {string} - The name of the event
     */
    getEventFns (name) {
        if(!this._onEventFns[name]) {
            this._onEventFns[name] = [];
        }

        return this._onEventFns[name];
    }

    /**
     * Check if an object can have scripts attached to it. Helpful when iterating through a lot of different wick objects that may or may not be tickables. Always returns true.
     * @type {boolean}
     */
    get isScriptable () {
        return true;
    }

    /**
     * Add a new script to an object.
     * @param {string} name - The name of the event that will trigger the script. See Wick.Tickable.possibleScripts
     * @param {string} src - The source code of the new script.
     */
    addScript (name, src) {
        if(Wick.Tickable.possibleScripts.indexOf(name) === -1) console.error(name + ' is not a valid script!');
        if(this.hasScript(name)) {
            this.updateScript(name, src);
            return;
        }

        this._scripts.push({
            name: name,
            src: '',
        });

        // Sort scripts by where they appear in the possibleScripts list
        var possibleScripts = Wick.Tickable.possibleScripts;
        this._scripts.sort((a,b) => {
            return possibleScripts.indexOf(a.name) - possibleScripts.indexOf(b.name);
        });

        if(src) {
            this.updateScript(name, src);
        }
    }

    /**
     * Get the script of this object that is triggered when the given event name happens.
     * @param {string} name - The name of the event. See Wick.Tickable.possibleScripts
     * @returns {object} the script with the given name. Can be null if the object doesn't have that script.
     */
    getScript (name) {
        if (Wick.Tickable.possibleScripts.indexOf(name) === -1) {
            console.error(name + ' is not a valid script!');
            return null;
        } else {
            // Get expected script.
            let script = this._scripts.find(script => {
                return script.name === name;
            });

            if (!script) {
                // Create the script if it doesn't exist.
                script = {
                    name: name,
                    src: "",
                }

                return script;
            } 
            
            // If the script is missing, add an empty.
            if (!script.src) {
                script.src = "";
            }

            return script;
        }   
    } 

    /**
     * Returns a list of script names which are not currently in use for this object.
     * @return {string[]} Available script names.
     */
    getAvailableScripts () {
      return Wick.Tickable.possibleScripts.filter(script => !this.hasScript(script));
    }

    /**
     * Check if the object has a script with the given event name.
     * @param {string} name - The name of the event. See Wick.Tickable.possibleScripts
     * @returns {boolean} True if the script with the given name exists
     */
    hasScript (name) {
        let script = this.scripts.find(script => (script.name === name));

        if (script) {
            return true;
        } 

        return false;
    }

    /**
     * Check if the object has a non-empty script with a given name.
     * @param {string} name - The name of the event. See Wick.Tickable.possibleScripts
     * @returns {boolean} True if the script with the given name has code
     */
    scriptIsContentful (name) {
        if(!this.hasScript(name)) {
            return false;
        }

        var script = this.getScript(name);
        
        if (script && script.src.trim() !== '') {
            return true;
        } 

        return false;
    }

    /**
     * Changes the source of the script with the given event name.
     * @param {string} name - The name of the event that will trigger the script. See Wick.Tickable.possibleScripts
     * @param {string} src - The source code of the script.
     */
    updateScript (name, src) {
        if (!src) src = ""; // Reset script if it is not defined.
        this.getScript(name).src = src;
        delete this._cachedScripts[name];
    }

    /**
     * Remove the script that corresponds to a given event name.
     * @param {string} name - The name of the event. See Wick.Tickable.possibleScripts
     */
    removeScript (name) {
        this._scripts = this._scripts.filter(script => {
            return script.name !== name;
        });
    }

    /**
     * Schedule a script to run at the end of the tick.
     * @param {string} name - The name of the script to run. See Tickable.possibleScripts
     * @param {Object} parameters - An object consisting of key,value pairs which correspond to parameters to pass to the script.
     */
    scheduleScript (name, parameters) {
        if(!this.project) return;

        this.project.scheduleScript(this.uuid, name, parameters);
    }

    /**
     * Run the script with the corresponding event name. Will not run the script if the object is marked as removed.
     * @param {string} name - The name of the event. See Wick.Tickable.possibleScripts
     * @param {Object} parameters - An object containing key,value pairs of parameters to send to the script.
     * @returns {object} object containing error info if an error happened. Returns null if there was no error (script ran successfully)
     */
    runScript (name, parameters) {
        if (this.removed || !this.onScreen) {
            return;
        }

        if(!Wick.Tickable.possibleScripts.indexOf(name) === -1) {
            console.error(name + ' is not a valid script!');
        }
        // Don't run scripts if this object is the focus
        // (this makes it so preview play will always play, even if the parent Clip of the timeline has a stop script)
        if(this.project && this.project.focus === this) {
            return null;
        }

        // Run functions attached using onEvent
        var eventFnError = null;
        this.getEventFns(name).forEach(eventFn => {
            if(eventFnError) return;
            eventFnError = this._runFunction(eventFn, name, parameters);
        });

        if (eventFnError) {
            this.project.error = eventFnError;
            return;
        }


        // Run function inside tab
        if(this.scriptIsContentful(name)) {
            var script = this.getScript(name);
            var fn = this._cachedScripts[name] || this._evalScript(name, script.src);
            if(!(fn instanceof Function)) {
                return fn; // error
            }

            this._cachedScripts[name] = fn;
            var error = this._runFunction(fn, name, parameters);

            if (error && this.project) {
                this.project.error = error;
                return;
            }
        }
    }

    /**
     * The tick routine to be called when the object ticks.
     * @returns {object} - An object with information about the result from ticking. Null if no errors occured, and the script ran successfully.
     */
    tick () {
        // Update named child references
        this._attachChildClipReferences();

        // Update onScreen flags.
        this._onscreenLastTick = this._onscreen;
        this._onscreen = this.onScreen;

        // Update mouse states.
        this._lastMouseState = this._mouseState;
        if(this.project && this.project.objectIsMouseTarget(this)) {
            if(this.project.isMouseDown) {
                this._mouseState = 'down';
            } else {
                this._mouseState = 'over';
            }
        } else {
            this._mouseState = 'out';
        }

        // Call tick event function that corresponds to state.
        if(!this._onscreen && !this._onscreenLastTick) {
            this._onInactive();
        } else if (this._onscreen && !this._onscreenLastTick) {
            this._onActivated();
        } else if (this._onscreen && this._onscreenLastTick) {
            this._onActive();
        } else if (!this._onscreen && this._onscreenLastTick) {
            this._onDeactivated();
        }
    }

    _onInactive () {
        // Do nothing.
    }

    _onActivated () {
        this.runScript('default'); // Run the script immediately.
        this.scheduleScript('load');
    }

    _onActive () {
        this.scheduleScript('update');

        var current = this._mouseState;
        var last = this._lastMouseState;

        // Mouse enter
        if(last === 'out' && current !== 'out') {
            this.scheduleScript('mouseenter');
        }

        // Mouse down
        if(current === 'down') {
            this.scheduleScript('mousedown');
        }

        // Mouse pressed
        if(last === 'over' && current === 'down') {
            this._isClickTarget = true;
            this.scheduleScript('mousepressed');
        }

        // Mouse click
        if(last === 'down' && current === 'over' && this._isClickTarget) {
            this.scheduleScript('mouseclick');
        }

        // Mouse released
        if(last === 'down' && current === 'over') {
            this._isClickTarget = false;
            this.scheduleScript('mousereleased');
        }

        // Mouse leave
        if(last !== 'out' && current === 'out') {
            this.scheduleScript('mouseleave');
        }

        // Mouse hover
        if(current === 'over') {
            this.scheduleScript('mousehover');
        }

        // Mouse drag
        if(last === 'down' && current === 'down') {
            this.scheduleScript('mousedrag');
        }

        // Key down
        this.project.keysDown.forEach(key => {
            this.project.currentKey = key;
            this.scheduleScript('keydown', {key: key});
        });

        // Key press
        this.project.keysJustPressed.forEach(key => {
            this.project.currentKey = key;
            this.scheduleScript('keypressed', {key: key});
        });

        // Key released
        this.project.keysJustReleased.forEach(key => {
            this.project.currentKey = key;
            this.scheduleScript('keyreleased', {key: key});
        });
    }

    _onDeactivated () {
        this._isClickTarget = false;
        this.scheduleScript('unload');
    }

    _evalScript (name, src) {
        var fn = null;

        // Check for syntax/parsing errors
        try {
            esprima.parseScript(src);
        } catch (e) {
            this.project.error = this._generateEsprimaErrorInfo(e, name);
            return;
        }

        // Attempt to create valid function...
        try {
            fn = new Function([], src);
        } catch (e) {
            // This should almost never be thrown unless there is an attempt to use syntax
            // that the syntax checker (esprima) does not understand.
            this.project.error = this._generateErrorInfo(e, name);
            return;
        }

        return fn;
    }

    /**
     * _runFunction runs an event function while passing in necessary global and local parameters.
     * @param {string} fn - Function to run.
     * @param {string} name - Name of the event function being run (i.e. keyDown)
     * @param {Object} parameters - An object of key,value pairs to be passed as parameters to the function.
     */
    _runFunction (fn, name, parameters) {
          var error = null;

          // Attach API methods
          var globalAPI = new GlobalAPI(this);
          var otherObjects = this.parentClip ? this.parentClip.activeNamedChildren : [];
          var apiMembers = globalAPI.apiMembers.concat(otherObjects.map(otherObject => {
              return {
                  name: otherObject.identifier,
                  fn: otherObject,
              }
          }));

          // Add in parameters, if necessary.
          if (parameters) {
            Object.keys(parameters).forEach(parameter => {
                apiMembers.push({
                    name: parameter,
                    fn: parameters[parameter],
                })
            });
          }

          apiMembers.forEach(apiMember => {
              window[apiMember.name] = apiMember.fn;
          });

          // These are currently hacked in here for performance reasons...
          var project = this.project;
          var root = project && project.root;
          window.project = root;
          if(project) {
              window.project.resolution = {x: project.width, y: project.height};
              window.project.framerate = project.framerate;
              window.project.backgroundColor = project.backgroundColor;
              //window.project.hitTestOptions = project.hitTestOptions;
          }
          window.root = root;
          window.parent = this.parentClip;
          window.parentObject = this.parentObject;

          // Run the function
          var thisScope = this instanceof Wick.Frame ? this.parentClip : this;
          try {
              fn.bind(thisScope)();
          } catch (e) {
              // Catch runtime errors
              console.error(e);
              error = this._generateErrorInfo(e, name);
          }

          // These are currently hacked in here for performance reasons...
          delete window.project;
          delete window.root;
          delete window.parent;
          delete window.parentObject;

          // Detatch API methods
          apiMembers.forEach(apiMember => {
              delete window[apiMember.name];
          });

          return error;
    }

    _generateErrorInfo (error, name) {
        if(Wick.Tickable.LOG_ERRORS) console.log(error);

        return {
            name: name !== undefined ? name : '',
            lineNumber: this._generateLineNumberFromStackTrace(error.stack),
            message: error.message,
            uuid: this.isClone ? this.sourceClipUUID : this.uuid,
        }
    }

    _generateEsprimaErrorInfo (error, name) {
        if(Wick.Tickable.LOG_ERRORS) console.log(error);

        return {
            name: name !== undefined ? name : '',
            lineNumber: error.lineNumber,
            message: error.description,
            uuid: this.uuid,
        }
    }

    _generateLineNumberFromStackTrace (trace) {
        var lineNumber = null;

        trace.split('\n').forEach(line => {
            if(lineNumber !== null) return;

            var split = line.split(':');
            var lineString = split[split.length-2];
            var lineInt = parseInt(lineString);
            if(!isNaN(lineInt)) {
                lineNumber = lineInt - 2;
                lineNumber = lineInt;
                lineNumber = lineNumber - 2;
            }
        });

        return lineNumber;
    }

    _attachChildClipReferences () {
        // Implemented by Wick.Clip and Wick.Frame.
    }
}