diff --git a/src/deprecated/deprecated.js b/src/deprecated/deprecated.js index 1ad88648eeb..e2af6dcd4d4 100644 --- a/src/deprecated/deprecated.js +++ b/src/deprecated/deprecated.js @@ -118,6 +118,9 @@ import { RigidBodyComponentSystem } from '../framework/components/rigid-body/sys import { basisInitialize } from '../framework/handlers/basis.js'; import { LitShader } from '../scene/shader-lib/programs/lit-shader.js'; +// ScriptType alias +export { Script as ScriptType } from '../framework/script/script.js'; + // CORE export const LINEBATCH_WORLD = 0; export const LINEBATCH_OVERLAY = 1; diff --git a/src/framework/components/script/component.js b/src/framework/components/script/component.js index c182759bf74..cc918e72130 100644 --- a/src/framework/components/script/component.js +++ b/src/framework/components/script/component.js @@ -19,7 +19,7 @@ import { Entity } from '../../entity.js'; */ class ScriptComponent extends Component { /** - * Fired when a {@link ScriptType} instance is created and attached to the script component. + * Fired when a {@link Script} instance is created and attached to the script component. * This event is available in two forms. They are as follows: * * 1. `create` - Fired when a script instance is created. The name of the script type and the @@ -40,7 +40,7 @@ class ScriptComponent extends Component { static EVENT_CREATE = 'create'; /** - * Fired when a {@link ScriptType} instance is destroyed and removed from the script component. + * Fired when a {@link Script} instance is destroyed and removed from the script component. * This event is available in two forms. They are as follows: * * 1. `destroy` - Fired when a script instance is destroyed. The name of the script type and @@ -109,7 +109,7 @@ class ScriptComponent extends Component { static EVENT_STATE = 'state'; /** - * Fired when the index of a {@link ScriptType} instance is changed in the script component. + * Fired when the index of a {@link Script} instance is changed in the script component. * This event is available in two forms. They are as follows: * * 1. `move` - Fired when a script instance is moved. The name of the script type, the script @@ -130,7 +130,7 @@ class ScriptComponent extends Component { static EVENT_MOVE = 'move'; /** - * Fired when a {@link ScriptType} instance had an exception. The handler is passed the script + * Fired when a {@link Script} instance had an exception. The handler is passed the script * instance, the exception and the method name that the exception originated from. * * @event @@ -154,7 +154,7 @@ class ScriptComponent extends Component { /** * Holds all script instances for this component. * - * @type {import('../../script/script-type.js').ScriptType[]} + * @type {import('../../script/script.js').Script[]} * @private */ this._scripts = []; @@ -191,7 +191,7 @@ class ScriptComponent extends Component { * An array of all script instances attached to an entity. This array is read-only and should * not be modified by developer. * - * @type {import('../../script/script-type.js').ScriptType[]} + * @type {import('../../script/script.js').Script[]} */ set scripts(value) { this._scriptsData = value; @@ -546,8 +546,8 @@ class ScriptComponent extends Component { /** * Detect if script is attached to an entity. * - * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The - * name or type of {@link ScriptType}. + * @param {string|typeof import('../../script/script.js').Script} nameOrType - The + * name or type of {@link Script}. * @returns {boolean} If script is attached to an entity. * @example * if (entity.script.has('playerController')) { @@ -570,9 +570,9 @@ class ScriptComponent extends Component { /** * Get a script instance (if attached). * - * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The - * name or type of {@link ScriptType}. - * @returns {import('../../script/script-type.js').ScriptType|null} If script is attached, the + * @param {string|typeof import('../../script/script.js').Script} nameOrType - The + * name or type of {@link Script}. + * @returns {import('../../script/script.js').Script|null} If script is attached, the * instance is returned. Otherwise null is returned. * @example * const controller = entity.script.get('playerController'); @@ -594,8 +594,8 @@ class ScriptComponent extends Component { /** * Create a script instance and attach to an entity script component. * - * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The - * name or type of {@link ScriptType}. + * @param {string|typeof import('../../script/script.js').Script} nameOrType - The + * name or type of {@link Script}. * @param {object} [args] - Object with arguments for a script. * @param {boolean} [args.enabled] - If script instance is enabled after creation. Defaults to * true. @@ -605,9 +605,9 @@ class ScriptComponent extends Component { * script and attributes must be initialized manually. Defaults to false. * @param {number} [args.ind] - The index where to insert the script instance at. Defaults to * -1, which means append it at the end. - * @returns {import('../../script/script-type.js').ScriptType|null} Returns an instance of a - * {@link ScriptType} if successfully attached to an entity, or null if it failed because a - * script with a same name has already been added or if the {@link ScriptType} cannot be found + * @returns {import('../../script/script.js').Script|null} Returns an instance of a + * {@link Script} if successfully attached to an entity, or null if it failed because a + * script with a same name has already been added or if the {@link Script} cannot be found * by name in the {@link ScriptRegistry}. * @example * entity.script.create('playerController', { @@ -699,8 +699,8 @@ class ScriptComponent extends Component { /** * Destroy the script instance that is attached to an entity. * - * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The - * name or type of {@link ScriptType}. + * @param {string|typeof import('../../script/script.js').Script} nameOrType - The + * name or type of {@link Script}. * @returns {boolean} If it was successfully destroyed. * @example * entity.script.destroy('playerController'); @@ -756,8 +756,8 @@ class ScriptComponent extends Component { /** * Swap the script instance. * - * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The - * name or type of {@link ScriptType}. + * @param {string|typeof import('../../script/script.js').Script} nameOrType - The + * name or type of {@link Script}. * @returns {boolean} If it was successfully swapped. * @private */ @@ -927,8 +927,8 @@ class ScriptComponent extends Component { /** * Move script instance to different position to alter update order of scripts within entity. * - * @param {string|typeof import('../../script/script-type.js').ScriptType} nameOrType - The - * name or type of {@link ScriptType}. + * @param {string|typeof import('../../script/script.js').Script} nameOrType - The + * name or type of {@link Script}. * @param {number} ind - New position index. * @returns {boolean} If it was successfully moved. * @example diff --git a/src/framework/entity.js b/src/framework/entity.js index 08b80812789..e4ca2cb4b2c 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -400,8 +400,8 @@ class Entity extends GraphNode { /** * Search the entity and all of its descendants for the first script instance of specified type. * - * @param {string|typeof import('./script/script-type.js').ScriptType} nameOrType - The name or type of {@link ScriptType}. - * @returns {import('./script/script-type.js').ScriptType|undefined} A script instance of specified type, if the entity or any of its descendants + * @param {string|typeof import('./script/script.js').Script} nameOrType - The name or type of {@link Script}. + * @returns {import('./script/script.js').Script|undefined} A script instance of specified type, if the entity or any of its descendants * has one. Returns undefined otherwise. * @example * // Get the first found "playerController" instance in the hierarchy tree that starts with this entity @@ -415,8 +415,8 @@ class Entity extends GraphNode { /** * Search the entity and all of its descendants for all script instances of specified type. * - * @param {string|typeof import('./script/script-type.js').ScriptType} nameOrType - The name or type of {@link ScriptType}. - * @returns {import('./script/script-type.js').ScriptType[]} All script instances of specified type in the entity or any of its + * @param {string|typeof import('./script/script.js').Script} nameOrType - The name or type of {@link Script}. + * @returns {import('./script/script.js').Script[]} All script instances of specified type in the entity or any of its * descendants. Returns empty array if none found. * @example * // Get all "playerController" instances in the hierarchy tree that starts with this entity diff --git a/src/framework/handlers/script.js b/src/framework/handlers/script.js index d77f0f71fcb..c63caab4e0c 100644 --- a/src/framework/handlers/script.js +++ b/src/framework/handlers/script.js @@ -1,8 +1,8 @@ import { platform } from '../../core/platform.js'; import { script } from '../script.js'; -import { ScriptType } from '../script/script-type.js'; +import { Script } from '../script/script.js'; import { ScriptTypes } from '../script/script-types.js'; -import { registerScript } from '../script/script.js'; +import { registerScript } from '../script/script-create.js'; import { ResourceLoader } from './loader.js'; import { ResourceHandler } from './handler.js'; @@ -154,7 +154,7 @@ class ScriptHandler extends ResourceHandler { for (const key in module) { const scriptClass = module[key]; - const extendsScriptType = scriptClass.prototype instanceof ScriptType; + const extendsScriptType = scriptClass.prototype instanceof Script; if (extendsScriptType) { diff --git a/src/framework/script/script-attributes.js b/src/framework/script/script-attributes.js index 0d0dc1d9011..7a718b4011a 100644 --- a/src/framework/script/script-attributes.js +++ b/src/framework/script/script-attributes.js @@ -150,8 +150,8 @@ function rawToValue(app, args, value, old) { /** * Container of Script Attribute definitions. Implements an interface to add/remove attributes and - * store their definition for a {@link ScriptType}. Note: An instance of ScriptAttributes is - * created automatically by each {@link ScriptType}. + * store their definition for a {@link Script}. Note: An instance of ScriptAttributes is + * created automatically by each {@link Script}. * * @category Script */ @@ -159,7 +159,7 @@ class ScriptAttributes { /** * Create a new ScriptAttributes instance. * - * @param {typeof import('./script-type.js').ScriptType} scriptType - Script Type that attributes relate to. + * @param {typeof import('./script.js').Script} scriptType - Script Type that attributes relate to. */ constructor(scriptType) { this.scriptType = scriptType; diff --git a/src/framework/script/script-create.js b/src/framework/script/script-create.js new file mode 100644 index 00000000000..cd31cf49dd5 --- /dev/null +++ b/src/framework/script/script-create.js @@ -0,0 +1,156 @@ +import { Debug } from '../../core/debug.js'; +import { EventHandler } from '../../core/event-handler.js'; + +import { script } from '../script.js'; +import { AppBase } from '../app-base.js'; + +import { ScriptAttributes } from './script-attributes.js'; +import { Script } from './script.js'; +import { ScriptTypes } from './script-types.js'; + +const reservedScriptNames = new Set([ + 'system', 'entity', 'create', 'destroy', 'swap', 'move', 'data', + 'scripts', '_scripts', '_scriptsIndex', '_scriptsData', + 'enabled', '_oldState', 'onEnable', 'onDisable', 'onPostStateChange', + '_onSetEnabled', '_checkState', '_onBeforeRemove', + '_onInitializeAttributes', '_onInitialize', '_onPostInitialize', + '_onUpdate', '_onPostUpdate', + '_callbacks', '_callbackActive', 'has', 'get', 'on', 'off', 'fire', 'once', 'hasEvent' +]); + +function getReservedScriptNames() { + return reservedScriptNames; +} + +/** + * Create and register a new {@link Script}. It returns new class type (constructor function), + * which is auto-registered to {@link ScriptRegistry} using its name. This is the main interface to + * create Script Types, to define custom logic using JavaScript, that is used to create interaction + * for entities. + * + * @param {string} name - Unique Name of a Script Type. If a Script Type with the same name has + * already been registered and the new one has a `swap` method defined in its prototype, then it + * will perform hot swapping of existing Script Instances on entities using this new Script Type. + * Note: There is a reserved list of names that cannot be used, such as list below as well as some + * starting from `_` (underscore): system, entity, create, destroy, swap, move, scripts, onEnable, + * onDisable, onPostStateChange, has, on, off, fire, once, hasEvent. + * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry} + * to add a script to. By default it will use `Application.getApplication()` to get current + * {@link AppBase}. + * @returns {typeof Script|null} A class type (constructor function) that inherits {@link Script}, + * which the developer is meant to further extend by adding attributes and prototype methods. + * Returns null if there was an error. + * @example + * var Turning = pc.createScript('turn'); + * + * // define 'speed' attribute that is available in Editor UI + * Turning.attributes.add('speed', { + * type: 'number', + * default: 180, + * placeholder: 'deg/s' + * }); + * + * // runs every tick + * Turning.prototype.update = function (dt) { + * this.entity.rotate(0, this.speed * dt, 0); + * }; + * @category Script + */ +function createScript(name, app) { + if (script.legacy) { + Debug.error('This project is using the legacy script system. You cannot call pc.createScript().'); + return null; + } + + if (reservedScriptNames.has(name)) + throw new Error(`Script name '${name}' is reserved, please rename the script`); + + const scriptType = function (args) { + EventHandler.prototype.initEventHandler.call(this); + Script.prototype.initScriptType.call(this, args); + }; + + scriptType.prototype = Object.create(Script.prototype); + scriptType.prototype.constructor = scriptType; + + scriptType.extend = Script.extend; + scriptType.attributes = new ScriptAttributes(scriptType); + + registerScript(scriptType, name, app); + return scriptType; +} + +// Editor uses this - migrate to ScriptAttributes.reservedNames and delete this +const reservedAttributes = {}; +ScriptAttributes.reservedNames.forEach((value, value2, set) => { + reservedAttributes[value] = 1; +}); +createScript.reservedAttributes = reservedAttributes; + +/* eslint-disable jsdoc/check-examples */ +/** + * Register a existing class type as a Script Type to {@link ScriptRegistry}. Useful when defining + * a ES6 script class that extends {@link Script} (see example). + * + * @param {typeof Script} script - The existing class type (constructor function) to be + * registered as a Script Type. Class must extend {@link Script} (see example). Please note: A + * class created using {@link createScript} is auto-registered, and should therefore not be pass + * into {@link registerScript} (which would result in swapping out all related script instances). + * @param {string} [name] - Optional unique name of the Script Type. By default it will use the + * same name as the existing class. If a Script Type with the same name has already been registered + * and the new one has a `swap` method defined in its prototype, then it will perform hot swapping + * of existing Script Instances on entities using this new Script Type. Note: There is a reserved + * list of names that cannot be used, such as list below as well as some starting from `_` + * (underscore): system, entity, create, destroy, swap, move, scripts, onEnable, onDisable, + * onPostStateChange, has, on, off, fire, once, hasEvent. + * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry} + * to register the script type to. By default it will use `Application.getApplication()` to get + * current {@link AppBase}. + * @example + * // define a ES6 script class + * class PlayerController extends pc.Script { + * + * initialize() { + * // called once on initialize + * } + * + * update(dt) { + * // called each tick + * } + * } + * + * // register the class as a script + * pc.registerScript(PlayerController); + * + * // declare script attributes (Must be after pc.registerScript()) + * PlayerController.attributes.add('attribute1', {type: 'number'}); + * @category Script + */ +function registerScript(script, name, app) { + if (script.legacy) { + Debug.error('This project is using the legacy script system. You cannot call pc.registerScript().'); + return; + } + + if (typeof script !== 'function') + throw new Error(`script class: '${script}' must be a constructor function (i.e. class).`); + + if (!(script.prototype instanceof Script)) + throw new Error(`script class: '${Script.__getScriptName(script)}' does not extend pc.Script.`); + + name = name || script.__name || Script.__getScriptName(script); + + if (reservedScriptNames.has(name)) + throw new Error(`script name: '${name}' is reserved, please change script name`); + + script.__name = name; + + // add to scripts registry + const registry = app ? app.scripts : AppBase.getApplication().scripts; + registry.add(script); + + ScriptTypes.push(script, script.legacy); +} +/* eslint-enable jsdoc/check-examples */ + +export { createScript, registerScript, getReservedScriptNames }; diff --git a/src/framework/script/script-registry.js b/src/framework/script/script-registry.js index 12f08201cba..78db4b531c5 100644 --- a/src/framework/script/script-registry.js +++ b/src/framework/script/script-registry.js @@ -1,7 +1,7 @@ import { EventHandler } from '../../core/event-handler.js'; /** - * Container for all {@link ScriptType}s that are available to this application. Note that + * Container for all {@link Script}s that are available to this application. Note that * PlayCanvas scripts can access the Script Registry from inside the application with * {@link AppBase#scripts}. * @@ -9,13 +9,13 @@ import { EventHandler } from '../../core/event-handler.js'; */ class ScriptRegistry extends EventHandler { /** - * @type {Object} + * @type {Object} * @private */ _scripts = {}; /** - * @type {typeof import('./script-type.js').ScriptType[]} + * @type {typeof import('./script.js').Script[]} * @private */ _list = []; @@ -37,12 +37,12 @@ class ScriptRegistry extends EventHandler { } /** - * Add {@link ScriptType} to registry. Note: when {@link createScript} is called, it will add - * the {@link ScriptType} to the registry automatically. If a script already exists in + * Add {@link Script} to registry. Note: when {@link createScript} is called, it will add + * the {@link Script} to the registry automatically. If a script already exists in * registry, and the new script has a `swap` method defined, it will perform code hot swapping * automatically in async manner. * - * @param {typeof import('./script-type.js').ScriptType} script - Script Type that is created + * @param {typeof import('./script.js').Script} script - Script Type that is created * using {@link createScript}. * @returns {boolean} True if added for the first time or false if script already exists. * @example @@ -146,10 +146,10 @@ class ScriptRegistry extends EventHandler { } /** - * Remove {@link ScriptType}. + * Remove {@link Script}. * - * @param {string|typeof import('./script-type.js').ScriptType} nameOrType - The name or type - * of {@link ScriptType}. + * @param {string|typeof import('./script.js').Script} nameOrType - The name or type + * of {@link Script}. * @returns {boolean} True if removed or False if already not in registry. * @example * app.scripts.remove('playerController'); @@ -178,10 +178,10 @@ class ScriptRegistry extends EventHandler { } /** - * Get {@link ScriptType} by name. + * Get {@link Script} by name. * - * @param {string} name - Name of a {@link ScriptType}. - * @returns {typeof import('./script-type.js').ScriptType} The Script Type if it exists in the + * @param {string} name - Name of a {@link Script}. + * @returns {typeof import('./script.js').Script} The Script Type if it exists in the * registry or null otherwise. * @example * var PlayerController = app.scripts.get('playerController'); @@ -191,11 +191,11 @@ class ScriptRegistry extends EventHandler { } /** - * Check if a {@link ScriptType} with the specified name is in the registry. + * Check if a {@link Script} with the specified name is in the registry. * - * @param {string|typeof import('./script-type.js').ScriptType} nameOrType - The name or type - * of {@link ScriptType}. - * @returns {boolean} True if {@link ScriptType} is in registry. + * @param {string|typeof import('./script.js').Script} nameOrType - The name or type + * of {@link Script}. + * @returns {boolean} True if {@link Script} is in registry. * @example * if (app.scripts.has('playerController')) { * // playerController is in pc.ScriptRegistry @@ -212,9 +212,9 @@ class ScriptRegistry extends EventHandler { } /** - * Get list of all {@link ScriptType}s from registry. + * Get list of all {@link Script}s from registry. * - * @returns {Array} list of all {@link ScriptType}s + * @returns {Array} list of all {@link Script}s * in registry. * @example * // logs array of all Script Type names available in registry diff --git a/src/framework/script/script-type.js b/src/framework/script/script-type.js deleted file mode 100644 index 3a1ff14a3d6..00000000000 --- a/src/framework/script/script-type.js +++ /dev/null @@ -1,393 +0,0 @@ -import { Debug } from '../../core/debug.js'; -import { EventHandler } from '../../core/event-handler.js'; - -import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE } from './constants.js'; -import { ScriptAttributes } from './script-attributes.js'; - -const funcNameRegex = new RegExp('^\\s*function(?:\\s|\\s*\\/\\*.*\\*\\/\\s*)+([^\\(\\s\\/]*)\\s*'); - -/** - * Represents the type of a script. It is returned by {@link createScript}. Also referred to as - * Script Type. - * - * The type is to be extended using its JavaScript prototype. There is a list of methods that will - * be executed by the engine on instances of this type, such as: - * - * - `initialize` - * - `postInitialize` - * - `update` - * - `postUpdate` - * - `swap` - * - * `initialize` and `postInitialize` - are called (if defined) when a script is about to run for - * the first time - `postInitialize` will run after all `initialize` methods are executed in the - * same tick or enabling chain of actions. - * - * `update` and `postUpdate` - are called (if defined) for enabled (running state) scripts on each - * tick. - * - * `swap` - is called when a ScriptType that already exists in the registry gets redefined. If the - * new ScriptType has a `swap` method in its prototype, then it will be executed to perform hot- - * reload at runtime. - * - * @category Script - */ -class ScriptType extends EventHandler { - /** - * Fired when a script instance becomes enabled. - * - * @event - * @example - * PlayerController.prototype.initialize = function () { - * this.on('enable', () => { - * // Script Instance is now enabled - * }); - * }; - */ - static EVENT_ENABLE = 'enable'; - - /** - * Fired when a script instance becomes disabled. - * - * @event - * @example - * PlayerController.prototype.initialize = function () { - * this.on('disable', () => { - * // Script Instance is now disabled - * }); - * }; - */ - static EVENT_DISABLE = 'disable'; - - /** - * Fired when a script instance changes state to enabled or disabled. The handler is passed a - * boolean parameter that states whether the script instance is now enabled or disabled. - * - * @event - * @example - * PlayerController.prototype.initialize = function () { - * this.on('state', (enabled) => { - * console.log(`Script Instance is now ${enabled ? 'enabled' : 'disabled'}`); - * }); - * }; - */ - static EVENT_STATE = 'state'; - - /** - * Fired when a script instance is destroyed and removed from component. - * - * @event - * @example - * PlayerController.prototype.initialize = function () { - * this.on('destroy', () => { - * // no longer part of the entity - * // this is a good place to clean up allocated resources used by the script - * }); - * }; - */ - static EVENT_DESTROY = 'destroy'; - - /** - * Fired when script attributes have changed. This event is available in two forms. They are as follows: - * - * 1. `attr` - Fired for any attribute change. The handler is passed the name of the attribute - * that changed, the value of the attribute before the change and the value of the attribute - * after the change. - * 2. `attr:[name]` - Fired for a specific attribute change. The handler is passed the value of - * the attribute before the change and the value of the attribute after the change. - * - * @event - * @example - * PlayerController.prototype.initialize = function () { - * this.on('attr', (name, newValue, oldValue) => { - * console.log(`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`); - * }); - * }; - * @example - * PlayerController.prototype.initialize = function () { - * this.on('attr:speed', (newValue, oldValue) => { - * console.log(`Attribute 'speed' changed from '${oldValue}' to '${newValue}'`); - * }); - * }; - */ - static EVENT_ATTR = 'attr'; - - /** - * Fired when a script instance had an exception. The script instance will be automatically - * disabled. The handler is passed an {@link Error} object containing the details of the - * exception and the name of the method that threw the exception. - * - * @event - * @example - * PlayerController.prototype.initialize = function () { - * this.on('error', (err, method) => { - * // caught an exception - * console.log(err.stack); - * }); - * }; - */ - static EVENT_ERROR = 'error'; - - /** - * The {@link AppBase} that the instance of this type belongs to. - * - * @type {import('../app-base.js').AppBase} - */ - app; - - /** - * The {@link Entity} that the instance of this type belongs to. - * - * @type {import('../entity.js').Entity} - */ - entity; - - /** @private */ - _enabled; - - /** @private */ - _enabledOld; - - /** @private */ - _initialized; - - /** @private */ - _postInitialized; - - /** @private */ - __destroyed; - - /** @private */ - __attributes; - - /** @private */ - __attributesRaw; - - /** @private */ - __scriptType; - - /** - * The order in the script component that the methods of this script instance will run - * relative to other script instances in the component. - * - * @type {number} - * @private - */ - __executionOrder; - - /** - * Create a new ScriptType instance. - * - * @param {object} args - The input arguments object. - * @param {import('../app-base.js').AppBase} args.app - The {@link AppBase} that is running the - * script. - * @param {import('../entity.js').Entity} args.entity - The {@link Entity} that the script is - * attached to. - */ - constructor(args) { - super(); - this.initScriptType(args); - } - - /** - * True if the instance of this type is in running state. False when script is not running, - * because the Entity or any of its parents are disabled or the {@link ScriptComponent} is - * disabled or the Script Instance is disabled. When disabled no update methods will be called - * on each tick. initialize and postInitialize methods will run once when the script instance - * is in `enabled` state during app tick. - * - * @type {boolean} - */ - set enabled(value) { - this._enabled = !!value; - - if (this.enabled === this._enabledOld) return; - - this._enabledOld = this.enabled; - this.fire(this.enabled ? 'enable' : 'disable'); - this.fire('state', this.enabled); - - // initialize script if not initialized yet and script is enabled - if (!this._initialized && this.enabled) { - this._initialized = true; - - this.__initializeAttributes(true); - - if (this.initialize) - this.entity.script._scriptMethod(this, SCRIPT_INITIALIZE); - } - - // post initialize script if not post initialized yet and still enabled - // (initialize might have disabled the script so check this.enabled again) - // Warning: Do not do this if the script component is currently being enabled - // because in this case post initialize must be called after all the scripts - // in the script component have been initialized first - if (this._initialized && !this._postInitialized && this.enabled && !this.entity.script._beingEnabled) { - this._postInitialized = true; - - if (this.postInitialize) - this.entity.script._scriptMethod(this, SCRIPT_POST_INITIALIZE); - } - } - - get enabled() { - return this._enabled && !this._destroyed && this.entity.script.enabled && this.entity.enabled; - } - - /** - * @param {{entity: import('../entity.js').Entity, app: import('../app-base.js').AppBase}} args - - * The entity and app. - * @private - */ - initScriptType(args) { - const script = this.constructor; // get script type, i.e. function (class) - Debug.assert(args && args.app && args.entity, `script [${script.__name}] has missing arguments in constructor`); - - this.app = args.app; - this.entity = args.entity; - - this._enabled = typeof args.enabled === 'boolean' ? args.enabled : true; - this._enabledOld = this.enabled; - - this.__destroyed = false; - this.__attributes = { }; - this.__attributesRaw = args.attributes || { }; // need at least an empty object to make sure default attributes are initialized - this.__scriptType = script; - this.__executionOrder = -1; - } - - /** - * Name of a Script Type. - * - * @type {string} - * @private - */ - static __name = null; // Will be assigned when calling createScript or registerScript. - - /** - * @param {*} constructorFn - The constructor function of the script type. - * @returns {string} The script name. - * @private - */ - static __getScriptName(constructorFn) { - if (typeof constructorFn !== 'function') return undefined; - if ('name' in Function.prototype) return constructorFn.name; - if (constructorFn === Function || constructorFn === Function.prototype.constructor) return 'Function'; - const match = ('' + constructorFn).match(funcNameRegex); - return match ? match[1] : undefined; - } - - /** - * Name of a Script Type. - * - * @type {string|null} - */ - static get scriptName() { - return this.__name; - } - - /** - * The interface to define attributes for Script Types. Refer to {@link ScriptAttributes}. - * - * @type {ScriptAttributes} - * @example - * var PlayerController = pc.createScript('playerController'); - * - * PlayerController.attributes.add('speed', { - * type: 'number', - * title: 'Speed', - * placeholder: 'km/h', - * default: 22.2 - * }); - */ - static get attributes() { - if (!this.hasOwnProperty('__attributes')) this.__attributes = new ScriptAttributes(this); - return this.__attributes; - } - - /** - * @param {boolean} [force] - Set to true to force initialization of the attributes. - * @private - */ - __initializeAttributes(force) { - if (!force && !this.__attributesRaw) - return; - - // set attributes values - for (const key in this.__scriptType.attributes.index) { - if (this.__attributesRaw && this.__attributesRaw.hasOwnProperty(key)) { - this[key] = this.__attributesRaw[key]; - } else if (!this.__attributes.hasOwnProperty(key)) { - if (this.__scriptType.attributes.index[key].hasOwnProperty('default')) { - this[key] = this.__scriptType.attributes.index[key].default; - } else { - this[key] = null; - } - } - } - - this.__attributesRaw = null; - } - - /** - * Shorthand function to extend Script Type prototype with list of methods. - * - * @param {object} methods - Object with methods, where key - is name of method, and value - is function. - * @example - * var PlayerController = pc.createScript('playerController'); - * - * PlayerController.extend({ - * initialize: function () { - * // called once on initialize - * }, - * update: function (dt) { - * // called each tick - * } - * }); - */ - static extend(methods) { - for (const key in methods) { - if (!methods.hasOwnProperty(key)) - continue; - - this.prototype[key] = methods[key]; - } - } - - /** - * @function - * @name ScriptType#[initialize] - * @description Called when script is about to run for the first time. - */ - - /** - * @function - * @name ScriptType#[postInitialize] - * @description Called after all initialize methods are executed in the same tick or enabling chain of actions. - */ - - /** - * @function - * @name ScriptType#[update] - * @description Called for enabled (running state) scripts on each tick. - * @param {number} dt - The delta time in seconds since the last frame. - */ - - /** - * @function - * @name ScriptType#[postUpdate] - * @description Called for enabled (running state) scripts on each tick, after update. - * @param {number} dt - The delta time in seconds since the last frame. - */ - - /** - * @function - * @name ScriptType#[swap] - * @description Called when a ScriptType that already exists in the registry - * gets redefined. If the new ScriptType has a `swap` method in its prototype, - * then it will be executed to perform hot-reload at runtime. - * @param {ScriptType} old - Old instance of the scriptType to copy data to the new instance. - */ -} - -export { ScriptType }; diff --git a/src/framework/script/script.js b/src/framework/script/script.js index b25939d386c..a136619f170 100644 --- a/src/framework/script/script.js +++ b/src/framework/script/script.js @@ -1,156 +1,404 @@ import { Debug } from '../../core/debug.js'; import { EventHandler } from '../../core/event-handler.js'; -import { script } from '../script.js'; -import { AppBase } from '../app-base.js'; - +import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE } from './constants.js'; import { ScriptAttributes } from './script-attributes.js'; -import { ScriptType } from './script-type.js'; -import { ScriptTypes } from './script-types.js'; - -const reservedScriptNames = new Set([ - 'system', 'entity', 'create', 'destroy', 'swap', 'move', 'data', - 'scripts', '_scripts', '_scriptsIndex', '_scriptsData', - 'enabled', '_oldState', 'onEnable', 'onDisable', 'onPostStateChange', - '_onSetEnabled', '_checkState', '_onBeforeRemove', - '_onInitializeAttributes', '_onInitialize', '_onPostInitialize', - '_onUpdate', '_onPostUpdate', - '_callbacks', '_callbackActive', 'has', 'get', 'on', 'off', 'fire', 'once', 'hasEvent' -]); - -function getReservedScriptNames() { - return reservedScriptNames; -} + +const funcNameRegex = new RegExp('^\\s*function(?:\\s|\\s*\\/\\*.*\\*\\/\\s*)+([^\\(\\s\\/]*)\\s*'); /** - * Create and register a new {@link ScriptType}. It returns new class type (constructor function), - * which is auto-registered to {@link ScriptRegistry} using its name. This is the main interface to - * create Script Types, to define custom logic using JavaScript, that is used to create interaction - * for entities. + * The Script class is a base class that you must extend to receive + * {@link https://developer.playcanvas.com/user-manual/scripting/anatomy/ various lifecycle updates} + * from the engine. + * + * You can create a Script using either {@link createScript} or by extending the class directly. + * + * ```javascript + * import { Script } from 'playcanvas'; + * class Rotator extends Script { + * update() { + * this.entity.rotate(0, 0.1, 0); + * } + * } + * ``` + * The following methods are called if they exist on the Script instance: + * + * - `initialize` + * - `postInitialize` + * - `update` + * - `postUpdate` + * - `swap` * - * @param {string} name - Unique Name of a Script Type. If a Script Type with the same name has - * already been registered and the new one has a `swap` method defined in its prototype, then it - * will perform hot swapping of existing Script Instances on entities using this new Script Type. - * Note: There is a reserved list of names that cannot be used, such as list below as well as some - * starting from `_` (underscore): system, entity, create, destroy, swap, move, scripts, onEnable, - * onDisable, onPostStateChange, has, on, off, fire, once, hasEvent. - * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry} - * to add a script to. By default it will use `Application.getApplication()` to get current - * {@link AppBase}. - * @returns {typeof ScriptType|null} A class type (constructor function) that inherits {@link ScriptType}, - * which the developer is meant to further extend by adding attributes and prototype methods. - * Returns null if there was an error. - * @example - * var Turning = pc.createScript('turn'); + * `initialize` and `postInitialize` - are called (if defined) when a script is about to run for + * the first time - `postInitialize` will run after all `initialize` methods are executed in the + * same tick or enabling chain of actions. * - * // define 'speed' attribute that is available in Editor UI - * Turning.attributes.add('speed', { - * type: 'number', - * default: 180, - * placeholder: 'deg/s' - * }); + * `update` and `postUpdate` - are called (if defined) for enabled (running state) scripts on each + * tick. * - * // runs every tick - * Turning.prototype.update = function (dt) { - * this.entity.rotate(0, this.speed * dt, 0); - * }; + * `swap` - is called when a Script that already exists in the registry gets redefined. If the + * new Script has a `swap` method in its prototype, then it will be executed to perform hot- + * reload at runtime. + * + * @see {@link https://developer.playcanvas.com/user-manual/scripting/anatomy/} for more information. * @category Script */ -function createScript(name, app) { - if (script.legacy) { - Debug.error('This project is using the legacy script system. You cannot call pc.createScript().'); - return null; +class Script extends EventHandler { + /** + * Fired when a script instance becomes enabled. + * + * @event + * @example + * PlayerController.prototype.initialize = function () { + * this.on('enable', () => { + * // Script Instance is now enabled + * }); + * }; + */ + static EVENT_ENABLE = 'enable'; + + /** + * Fired when a script instance becomes disabled. + * + * @event + * @example + * PlayerController.prototype.initialize = function () { + * this.on('disable', () => { + * // Script Instance is now disabled + * }); + * }; + */ + static EVENT_DISABLE = 'disable'; + + /** + * Fired when a script instance changes state to enabled or disabled. The handler is passed a + * boolean parameter that states whether the script instance is now enabled or disabled. + * + * @event + * @example + * PlayerController.prototype.initialize = function () { + * this.on('state', (enabled) => { + * console.log(`Script Instance is now ${enabled ? 'enabled' : 'disabled'}`); + * }); + * }; + */ + static EVENT_STATE = 'state'; + + /** + * Fired when a script instance is destroyed and removed from component. + * + * @event + * @example + * PlayerController.prototype.initialize = function () { + * this.on('destroy', () => { + * // no longer part of the entity + * // this is a good place to clean up allocated resources used by the script + * }); + * }; + */ + static EVENT_DESTROY = 'destroy'; + + /** + * Fired when script attributes have changed. This event is available in two forms. They are as follows: + * + * 1. `attr` - Fired for any attribute change. The handler is passed the name of the attribute + * that changed, the value of the attribute before the change and the value of the attribute + * after the change. + * 2. `attr:[name]` - Fired for a specific attribute change. The handler is passed the value of + * the attribute before the change and the value of the attribute after the change. + * + * @event + * @example + * PlayerController.prototype.initialize = function () { + * this.on('attr', (name, newValue, oldValue) => { + * console.log(`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`); + * }); + * }; + * @example + * PlayerController.prototype.initialize = function () { + * this.on('attr:speed', (newValue, oldValue) => { + * console.log(`Attribute 'speed' changed from '${oldValue}' to '${newValue}'`); + * }); + * }; + */ + static EVENT_ATTR = 'attr'; + + /** + * Fired when a script instance had an exception. The script instance will be automatically + * disabled. The handler is passed an {@link Error} object containing the details of the + * exception and the name of the method that threw the exception. + * + * @event + * @example + * PlayerController.prototype.initialize = function () { + * this.on('error', (err, method) => { + * // caught an exception + * console.log(err.stack); + * }); + * }; + */ + static EVENT_ERROR = 'error'; + + /** + * The {@link AppBase} that the instance of this type belongs to. + * + * @type {import('../app-base.js').AppBase} + */ + app; + + /** + * The {@link Entity} that the instance of this type belongs to. + * + * @type {import('../entity.js').Entity} + */ + entity; + + /** @private */ + _enabled; + + /** @private */ + _enabledOld; + + /** @private */ + _initialized; + + /** @private */ + _postInitialized; + + /** @private */ + __destroyed; + + /** @private */ + __attributes; + + /** @private */ + __attributesRaw; + + /** @private */ + __scriptType; + + /** + * The order in the script component that the methods of this script instance will run + * relative to other script instances in the component. + * + * @type {number} + * @private + */ + __executionOrder; + + /** + * Create a new Script instance. + * + * @param {object} args - The input arguments object. + * @param {import('../app-base.js').AppBase} args.app - The {@link AppBase} that is running the + * script. + * @param {import('../entity.js').Entity} args.entity - The {@link Entity} that the script is + * attached to. + */ + constructor(args) { + super(); + this.initScriptType(args); } - if (reservedScriptNames.has(name)) - throw new Error(`Script name '${name}' is reserved, please rename the script`); + /** + * True if the instance of this type is in running state. False when script is not running, + * because the Entity or any of its parents are disabled or the {@link ScriptComponent} is + * disabled or the Script Instance is disabled. When disabled no update methods will be called + * on each tick. initialize and postInitialize methods will run once when the script instance + * is in `enabled` state during app tick. + * + * @type {boolean} + */ + set enabled(value) { + this._enabled = !!value; - const scriptType = function (args) { - EventHandler.prototype.initEventHandler.call(this); - ScriptType.prototype.initScriptType.call(this, args); - }; + if (this.enabled === this._enabledOld) return; - scriptType.prototype = Object.create(ScriptType.prototype); - scriptType.prototype.constructor = scriptType; + this._enabledOld = this.enabled; + this.fire(this.enabled ? 'enable' : 'disable'); + this.fire('state', this.enabled); - scriptType.extend = ScriptType.extend; - scriptType.attributes = new ScriptAttributes(scriptType); + // initialize script if not initialized yet and script is enabled + if (!this._initialized && this.enabled) { + this._initialized = true; - registerScript(scriptType, name, app); - return scriptType; -} + this.__initializeAttributes(true); -// Editor uses this - migrate to ScriptAttributes.reservedNames and delete this -const reservedAttributes = {}; -ScriptAttributes.reservedNames.forEach((value, value2, set) => { - reservedAttributes[value] = 1; -}); -createScript.reservedAttributes = reservedAttributes; + if (this.initialize) + this.entity.script._scriptMethod(this, SCRIPT_INITIALIZE); + } -/* eslint-disable jsdoc/check-examples */ -/** - * Register a existing class type as a Script Type to {@link ScriptRegistry}. Useful when defining - * a ES6 script class that extends {@link ScriptType} (see example). - * - * @param {typeof ScriptType} script - The existing class type (constructor function) to be - * registered as a Script Type. Class must extend {@link ScriptType} (see example). Please note: A - * class created using {@link createScript} is auto-registered, and should therefore not be pass - * into {@link registerScript} (which would result in swapping out all related script instances). - * @param {string} [name] - Optional unique name of the Script Type. By default it will use the - * same name as the existing class. If a Script Type with the same name has already been registered - * and the new one has a `swap` method defined in its prototype, then it will perform hot swapping - * of existing Script Instances on entities using this new Script Type. Note: There is a reserved - * list of names that cannot be used, such as list below as well as some starting from `_` - * (underscore): system, entity, create, destroy, swap, move, scripts, onEnable, onDisable, - * onPostStateChange, has, on, off, fire, once, hasEvent. - * @param {AppBase} [app] - Optional application handler, to choose which {@link ScriptRegistry} - * to register the script type to. By default it will use `Application.getApplication()` to get - * current {@link AppBase}. - * @example - * // define a ES6 script class - * class PlayerController extends pc.ScriptType { - * - * initialize() { - * // called once on initialize - * } - * - * update(dt) { - * // called each tick - * } - * } - * - * // register the class as a script - * pc.registerScript(PlayerController); - * - * // declare script attributes (Must be after pc.registerScript()) - * PlayerController.attributes.add('attribute1', {type: 'number'}); - * @category Script - */ -function registerScript(script, name, app) { - if (script.legacy) { - Debug.error('This project is using the legacy script system. You cannot call pc.registerScript().'); - return; + // post initialize script if not post initialized yet and still enabled + // (initialize might have disabled the script so check this.enabled again) + // Warning: Do not do this if the script component is currently being enabled + // because in this case post initialize must be called after all the scripts + // in the script component have been initialized first + if (this._initialized && !this._postInitialized && this.enabled && !this.entity.script._beingEnabled) { + this._postInitialized = true; + + if (this.postInitialize) + this.entity.script._scriptMethod(this, SCRIPT_POST_INITIALIZE); + } + } + + get enabled() { + return this._enabled && !this._destroyed && this.entity.script.enabled && this.entity.enabled; } - if (typeof script !== 'function') - throw new Error(`script class: '${script}' must be a constructor function (i.e. class).`); + /** + * @param {{entity: import('../entity.js').Entity, app: import('../app-base.js').AppBase}} args - + * The entity and app. + * @private + */ + initScriptType(args) { + const script = this.constructor; // get script type, i.e. function (class) + Debug.assert(args && args.app && args.entity, `script [${script.__name}] has missing arguments in constructor`); - if (!(script.prototype instanceof ScriptType)) - throw new Error(`script class: '${ScriptType.__getScriptName(script)}' does not extend pc.ScriptType.`); + this.app = args.app; + this.entity = args.entity; + + this._enabled = typeof args.enabled === 'boolean' ? args.enabled : true; + this._enabledOld = this.enabled; + + this.__destroyed = false; + this.__attributes = { }; + this.__attributesRaw = args.attributes || { }; // need at least an empty object to make sure default attributes are initialized + this.__scriptType = script; + this.__executionOrder = -1; + } + + /** + * Name of a Script. + * + * @type {string} + * @private + */ + static __name = null; // Will be assigned when calling createScript or registerScript. + + /** + * @param {*} constructorFn - The constructor function of the script type. + * @returns {string} The script name. + * @private + */ + static __getScriptName(constructorFn) { + if (typeof constructorFn !== 'function') return undefined; + if ('name' in Function.prototype) return constructorFn.name; + if (constructorFn === Function || constructorFn === Function.prototype.constructor) return 'Function'; + const match = ('' + constructorFn).match(funcNameRegex); + return match ? match[1] : undefined; + } + + /** + * Name of a Script Type. + * + * @type {string|null} + */ + static get scriptName() { + return this.__name; + } + + /** + * The interface to define attributes for Script Types. Refer to {@link ScriptAttributes}. + * + * @type {ScriptAttributes} + * @example + * var PlayerController = pc.createScript('playerController'); + * + * PlayerController.attributes.add('speed', { + * type: 'number', + * title: 'Speed', + * placeholder: 'km/h', + * default: 22.2 + * }); + */ + static get attributes() { + if (!this.hasOwnProperty('__attributes')) this.__attributes = new ScriptAttributes(this); + return this.__attributes; + } + + /** + * @param {boolean} [force] - Set to true to force initialization of the attributes. + * @private + */ + __initializeAttributes(force) { + if (!force && !this.__attributesRaw) + return; + + // set attributes values + for (const key in this.__scriptType.attributes.index) { + if (this.__attributesRaw && this.__attributesRaw.hasOwnProperty(key)) { + this[key] = this.__attributesRaw[key]; + } else if (!this.__attributes.hasOwnProperty(key)) { + if (this.__scriptType.attributes.index[key].hasOwnProperty('default')) { + this[key] = this.__scriptType.attributes.index[key].default; + } else { + this[key] = null; + } + } + } + + this.__attributesRaw = null; + } + + /** + * Shorthand function to extend Script Type prototype with list of methods. + * + * @param {object} methods - Object with methods, where key - is name of method, and value - is function. + * @example + * var PlayerController = pc.createScript('playerController'); + * + * PlayerController.extend({ + * initialize: function () { + * // called once on initialize + * }, + * update: function (dt) { + * // called each tick + * } + * }); + */ + static extend(methods) { + for (const key in methods) { + if (!methods.hasOwnProperty(key)) + continue; + + this.prototype[key] = methods[key]; + } + } - name = name || script.__name || ScriptType.__getScriptName(script); + /** + * @function + * @name Script#[initialize] + * @description Called when script is about to run for the first time. + */ - if (reservedScriptNames.has(name)) - throw new Error(`script name: '${name}' is reserved, please change script name`); + /** + * @function + * @name Script#[postInitialize] + * @description Called after all initialize methods are executed in the same tick or enabling chain of actions. + */ - script.__name = name; + /** + * @function + * @name Script#[update] + * @description Called for enabled (running state) scripts on each tick. + * @param {number} dt - The delta time in seconds since the last frame. + */ - // add to scripts registry - const registry = app ? app.scripts : AppBase.getApplication().scripts; - registry.add(script); + /** + * @function + * @name Script#[postUpdate] + * @description Called for enabled (running state) scripts on each tick, after update. + * @param {number} dt - The delta time in seconds since the last frame. + */ - ScriptTypes.push(script, script.legacy); + /** + * @function + * @name Script#[swap] + * @description Called when a Script that already exists in the registry + * gets redefined. If the new Script has a `swap` method in its prototype, + * then it will be executed to perform hot-reload at runtime. + * @param {Script} old - Old instance of the script to copy data to the new instance. + */ } -/* eslint-enable jsdoc/check-examples */ -export { createScript, registerScript, getReservedScriptNames }; +export { Script }; diff --git a/src/index.js b/src/index.js index f4d29cf8fab..29579aec0c7 100644 --- a/src/index.js +++ b/src/index.js @@ -349,10 +349,10 @@ export { ElementInput, ElementInputEvent, ElementMouseEvent, ElementSelectEvent, export { JsonStandardMaterialParser } from './framework/parsers/material/json-standard-material.js'; // FRAMEWORK /SCRIPTS -export { createScript, registerScript, getReservedScriptNames } from './framework/script/script.js'; +export { createScript, registerScript, getReservedScriptNames } from './framework/script/script-create.js'; export { ScriptAttributes } from './framework/script/script-attributes.js'; export { ScriptRegistry } from './framework/script/script-registry.js'; -export { ScriptType } from './framework/script/script-type.js'; +export { Script } from './framework/script/script.js'; // FRAMEWORK / LOCALIZATION export { I18n } from './framework/i18n/i18n.js'; diff --git a/test/framework/entity.test.mjs b/test/framework/entity.test.mjs index 4bbd61bc05e..38f5d80c8d4 100644 --- a/test/framework/entity.test.mjs +++ b/test/framework/entity.test.mjs @@ -1,4 +1,4 @@ -import { createScript } from '../../src/framework/script/script.js'; +import { createScript } from '../../src/framework/script/script-create.js'; import { Color } from '../../src/core/math/color.js'; import { AnimComponent } from '../../src/framework/components/anim/component.js'; diff --git a/utils/plugins/rollup-types-fixup.mjs b/utils/plugins/rollup-types-fixup.mjs index d33e8155162..8f2a445ab2a 100644 --- a/utils/plugins/rollup-types-fixup.mjs +++ b/utils/plugins/rollup-types-fixup.mjs @@ -209,7 +209,7 @@ import { Texture } from '../../platform/graphics/texture.js'; ` } }, { - path: `${TYPES_PATH}/framework/script/script-type.d.ts`, + path: `${TYPES_PATH}/framework/script/script.d.ts`, replacement: { from: 'get enabled(): boolean;', to: `get enabled(): boolean; @@ -232,12 +232,12 @@ import { Texture } from '../../platform/graphics/texture.js'; */ postUpdate?(dt: number): void; /** - * Called when a ScriptType that already exists in the registry gets redefined. If the new - * ScriptType has a \`swap\` method in its prototype, then it will be executed to perform + * Called when a Script that already exists in the registry gets redefined. If the new + * Script has a \`swap\` method in its prototype, then it will be executed to perform * hot-reload at runtime. - * @param old - Old instance of the scriptType to copy data to the new instance. + * @param old - Old instance of the script to copy data to the new instance. */ - swap?(old: ScriptType): void; + swap?(old: Script): void; ` } }];