diff --git a/src/framework/components/script/component.js b/src/framework/components/script/component.js index f36c02d3a1f..b99fd3efc8a 100644 --- a/src/framework/components/script/component.js +++ b/src/framework/components/script/component.js @@ -1,7 +1,7 @@ import { Debug } from '../../../core/debug.js'; import { SortedLoopArray } from '../../../core/sorted-loop-array.js'; -import { ScriptAttributes } from '../../script/script-attributes.js'; +import { ScriptAttributes, assignAttributesToScript } from '../../script/script-attributes.js'; import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE, SCRIPT_UPDATE, SCRIPT_POST_UPDATE, SCRIPT_SWAP @@ -9,6 +9,10 @@ import { import { Component } from '../component.js'; import { Entity } from '../../entity.js'; +import { ScriptType } from '../../script/script-type.js'; +import { getScriptName } from '../../script/script.js'; + +const toLowerCamelCase = str => str[0].toLowerCase() + str.substring(1); /** * The ScriptComponent allows you to extend the functionality of an Entity by attaching your own @@ -18,6 +22,14 @@ import { Entity } from '../../entity.js'; * @category Script */ class ScriptComponent extends Component { + /** + * A map of script name to initial component data. + * + * @type {Map} + * @private + */ + _attributeDataMap = new Map(); + /** * Fired when a {@link ScriptType} instance is created and attached to the script component. * This event is available in two forms. They are as follows: @@ -191,7 +203,7 @@ class ScriptComponent extends Component { * Sets the 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; @@ -205,8 +217,14 @@ class ScriptComponent extends Component { // existing script // enabled - if (typeof value[key].enabled === 'boolean') + if (typeof value[key].enabled === 'boolean') { + + // Before a script is initialized, initialize any attributes + script.once('preInitialize', () => { + this.initializeAttributes(script); + }); script.enabled = !!value[key].enabled; + } // attributes if (typeof value[key].attributes === 'object') { @@ -330,6 +348,9 @@ class ScriptComponent extends Component { for (let i = 0, len = this.scripts.length; i < len; i++) { const script = this.scripts[i]; + script.once('preInitialize', () => { + this.initializeAttributes(script); + }); script.enabled = script._enabled; } @@ -368,8 +389,40 @@ class ScriptComponent extends Component { } _onInitializeAttributes() { - for (let i = 0, len = this.scripts.length; i < len; i++) - this.scripts[i].__initializeAttributes(); + for (let i = 0, len = this.scripts.length; i < len; i++) { + const script = this.scripts[i]; + this.initializeAttributes(script); + } + } + + initializeAttributes(script) { + + // if script has __initializeAttributes method assume it has a runtime schema + if (script instanceof ScriptType) { + + script.__initializeAttributes(); + + } else { + + // otherwise we need to manually initialize attributes from the schema + const name = script.__scriptType.__name; + const data = this._attributeDataMap.get(name); + + // If not data exists return early + if (!data) { + return; + } + + // Fetch schema and warn if it doesn't exist + const schema = this.system.app.scripts?.getSchema(name); + if (!schema) { + Debug.warnOnce(`No schema exists for the script '${name}'. A schema must exist for data to be instantiated on the script.`); + } + + // Assign the attributes to the script instance based on the attribute schema + assignAttributesToScript(this.system.app, schema.attributes, data, script); + + } } _scriptMethod(script, method, arg) { @@ -599,8 +652,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. @@ -631,7 +684,7 @@ class ScriptComponent extends Component { if (typeof scriptType === 'string') { scriptType = this.system.app.scripts.get(scriptType); } else if (scriptType) { - scriptName = scriptType.__name; + scriptName = scriptType.__name ?? toLowerCamelCase(getScriptName(scriptType)); } if (scriptType) { @@ -644,6 +697,15 @@ class ScriptComponent extends Component { attributes: args.attributes }); + + // If the script is not a ScriptType then we must store attribute data on the component + if (!(scriptInstance instanceof ScriptType)) { + + // Store the Attribute data + this._attributeDataMap.set(scriptName, args.attributes); + + } + const len = this._scripts.length; let ind = -1; if (typeof args.ind === 'number' && args.ind !== -1 && len > args.ind) @@ -661,7 +723,7 @@ class ScriptComponent extends Component { this[scriptName] = scriptInstance; if (!args.preloading) - scriptInstance.__initializeAttributes(); + this.initializeAttributes(scriptInstance); this.fire('create', scriptName, scriptInstance); this.fire('create:' + scriptName, scriptInstance); @@ -725,6 +787,8 @@ class ScriptComponent extends Component { delete this._scriptsIndex[scriptName]; if (!scriptData) return false; + this._attributeDataMap.delete(scriptName); + const scriptInstance = scriptData.instance; if (scriptInstance && !scriptInstance._destroyed) { scriptInstance.enabled = false; @@ -793,7 +857,7 @@ class ScriptComponent extends Component { if (!scriptInstance.swap) return false; - scriptInstance.__initializeAttributes(); + this.initializeAttributes(scriptInstance); // add to component this._scripts[ind] = scriptInstance; diff --git a/src/framework/handlers/script.js b/src/framework/handlers/script.js index bca774bf995..5940350b9bd 100644 --- a/src/framework/handlers/script.js +++ b/src/framework/handlers/script.js @@ -1,12 +1,11 @@ import { platform } from '../../core/platform.js'; import { script } from '../script.js'; -import { ScriptType } from '../script/script-type.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'; -import { ScriptAttributes } from '../script/script-attributes.js'; +import { Script } from '../script/script.js'; const toLowerCamelCase = str => str[0].toLowerCase() + str.substring(1); @@ -134,21 +133,22 @@ class ScriptHandler extends ResourceHandler { // @ts-ignore import(importUrl.toString()).then((module) => { + const filename = importUrl.pathname.split('/').pop(); + const scriptSchema = this._app.assets.find(filename, 'script').data.scripts; + for (const key in module) { const scriptClass = module[key]; - const extendsScriptType = scriptClass.prototype instanceof ScriptType; + const extendsScriptType = scriptClass.prototype instanceof Script; if (extendsScriptType) { - // Check if attributes is defined directly on the class and not inherited - if (scriptClass.hasOwnProperty('attributes')) { - const attributes = new ScriptAttributes(scriptClass); - for (const key in scriptClass.attributes) { - attributes.add(key, scriptClass.attributes[key]); - } - scriptClass.attributes = attributes; - } - registerScript(scriptClass, toLowerCamelCase(scriptClass.name)); + const scriptName = toLowerCamelCase(scriptClass.name); + + // Register the script name + registerScript(scriptClass, scriptName); + + // Store any schema associated with the script + this._app.scripts.addSchema(scriptName, scriptSchema[scriptName]); } } diff --git a/src/framework/script/script-attributes.js b/src/framework/script/script-attributes.js index 0d0dc1d9011..154868a9468 100644 --- a/src/framework/script/script-attributes.js +++ b/src/framework/script/script-attributes.js @@ -5,9 +5,7 @@ import { CurveSet } from '../../core/math/curve-set.js'; import { Vec2 } from '../../core/math/vec2.js'; import { Vec3 } from '../../core/math/vec3.js'; import { Vec4 } from '../../core/math/vec4.js'; - import { GraphNode } from '../../scene/graph-node.js'; - import { Asset } from '../asset/asset.js'; const components = ['x', 'y', 'z', 'w']; @@ -148,6 +146,50 @@ function rawToValue(app, args, value, old) { return value; } +/** + * @typedef {Object} AttributeSchema + * @property {"boolean"|"number"|"string"|"json"|"asset"|"entity"|"rgb"|"rgba"|"vec2"|"vec3"|"vec4"|"curve"} type - The Attribute type + * @property {boolean} [array] - True if this attribute is an array of `type` + */ + +/** + * Takes an attribute schema, a value and current value, and return a new value. + * + * @param {import('../../framework/application.js').Application} app - The working application + * @param {AttributeSchema} schema - The attribute schema used to resolve properties + * @param {*} value - The raw value to create + * @param {*} current - The existing value + * @returns {*} The return value + */ +function attributeToValue(app, schema, value, current) { + if (schema.array) { + return value.map((item, index) => rawToValue(app, schema, item, current ? current[index] : null)); + } + + return rawToValue(app, schema, value, current); +} + +/** + * Assigns values to a script instance based on a map of attributes schemas + * and a corresponding map of data. + * + * @param {import('../../framework/application.js').Application} app - The application instance + * @param {Object} attributeSchemaMap - A map of names to Schemas + * @param {Object} data - A Map of data to assign to the Script instance + * @param {import('../../framework/script/script.js').Script} script - A Script instance to assign values on + */ +export function assignAttributesToScript(app, attributeSchemaMap, data, script) { + + // Iterate over the schema and assign corresponding data + for (const attributeName in attributeSchemaMap) { + const attributeSchema = attributeSchemaMap[attributeName]; + const dataToAssign = data[attributeName]; + + // Assign the value to the script based on the attribute schema + script[attributeName] = attributeToValue(app, attributeSchema, dataToAssign, script); + } +} + /** * 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 @@ -156,6 +198,8 @@ function rawToValue(app, args, value, old) { * @category Script */ class ScriptAttributes { + static assignAttributesToScript = assignAttributesToScript; + /** * Create a new ScriptAttributes instance. * diff --git a/src/framework/script/script-create.js b/src/framework/script/script-create.js new file mode 100644 index 00000000000..2a1c04fbf4b --- /dev/null +++ b/src/framework/script/script-create.js @@ -0,0 +1,143 @@ +import { EventHandler } from '../../core/event-handler.js'; +import { AppBase } from '../app-base.js'; +import { ScriptAttributes } from './script-attributes.js'; +import { ScriptType } from './script-type.js'; +import { ScriptTypes } from './script-types.js'; +import { Script } from './script.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 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. + * + * @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'); + * + * // 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 (reservedScriptNames.has(name)) + throw new Error(`Script name '${name}' is reserved, please rename the script`); + + const scriptType = function (args) { + EventHandler.prototype.initEventHandler.call(this); + ScriptType.prototype.initScriptType.call(this, args); + }; + + scriptType.prototype = Object.create(ScriptType.prototype); + scriptType.prototype.constructor = scriptType; + + scriptType.extend = ScriptType.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 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 (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: '${ScriptType.__getScriptName(script)}' does not extend pc.Script.`); + + name = name || script.__name || ScriptType.__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); +} +/* 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..752151f2fd0 100644 --- a/src/framework/script/script-registry.js +++ b/src/framework/script/script-registry.js @@ -20,6 +20,14 @@ class ScriptRegistry extends EventHandler { */ _list = []; + /** + * A Map of script names to attribute schemas. + * + * @type {Map} + * @private + */ + _scriptSchemas = new Map(); + /** * Create a new ScriptRegistry instance. * @@ -36,6 +44,27 @@ class ScriptRegistry extends EventHandler { this.off(); } + /** + * Registers a schema against a script instance. + * + * @param {string} id - The key to use to store the schema + * @param {import('./script-attributes.js').AttributeSchema} schema - An schema definition for the script + */ + addSchema(id, schema) { + if (!schema) return; + this._scriptSchemas.set(id, schema); + } + + /** + * Returns a schema for a given script name. + * + * @param {string} id - The key to store the schema under + * @returns {import('./script-attributes.js').AttributeSchema | undefined} - The schema stored under the key + */ + getSchema(id) { + return this._scriptSchemas.get(id); + } + /** * 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 @@ -110,13 +139,14 @@ class ScriptRegistry extends EventHandler { if (scriptInstance) scriptInstances.push(scriptInstance); + + // initialize attributes + for (const script of component.scripts) { + component.initializeAttributes(script); + } } } - // initialize attributes - for (let i = 0; i < scriptInstances.length; i++) - scriptInstances[i].__initializeAttributes(); - // call initialize() for (let i = 0; i < scriptInstances.length; i++) { if (scriptInstances[i].enabled) { diff --git a/src/framework/script/script-type.js b/src/framework/script/script-type.js index 97c49855b31..19b93f924b5 100644 --- a/src/framework/script/script-type.js +++ b/src/framework/script/script-type.js @@ -1,296 +1,20 @@ -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*'); +import { Script } from './script.js'; /** - * 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. + * This is the legacy format for creating PlayCanvas script returned when calling `pc.createScript()`. + * You should not use this inherit from this class directly. * + * @deprecated Use {@link Script} instead. * @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; - +class ScriptType extends Script { /** @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); - } - - /** - * Sets the enabled state for this ScriptType. 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); - } - } - - /** - * Gets the enabled state for this ScriptType. - * - * @type {boolean} - */ - 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}. * @@ -310,9 +34,28 @@ class ScriptType extends EventHandler { return this.__attributes; } + /** + * @param {*} args - initialization arguments + * @protected + */ + initScript(args) { + // super does not exist due to the way the class is instantiated + Script.prototype.initScript.call(this, args); + this.__attributes = { }; + this.__attributesRaw = args.attributes || { }; // need at least an empty object to make sure default attributes are initialized + } + + /** + * Expose initScript as initScriptType for backwards compatibility + * @param {*} args - Initialization arguments + * @protected + */ + initScriptType(args) { + this.initScript(args); + } + /** * @param {boolean} [force] - Set to true to force initialization of the attributes. - * @private */ __initializeAttributes(force) { if (!force && !this.__attributesRaw) @@ -358,41 +101,6 @@ class ScriptType extends EventHandler { 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 d6bede29603..3bc0f58a42a 100644 --- a/src/framework/script/script.js +++ b/src/framework/script/script.js @@ -1,144 +1,344 @@ +import { Debug } from '../..//core/debug.js'; import { EventHandler } from '../../core/event-handler.js'; - -import { AppBase } from '../app-base.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; -} +import { SCRIPT_INITIALIZE, SCRIPT_POST_INITIALIZE } from './constants.js'; /** - * 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 the fundamental base class for all scripts within PlayCanvas. It provides + * the minimal interface required for a script to be compatible with both the Engine and the + * Editor. + * + * At its core, a script is simply a collection of methods that are called at various points in the Engine's lifecycle. These methods are: + * + * {@link Script#initialize} - Called once when the script is initialized + * {@link Script#postInitialize} - Called once after all scripts have been initialized + * {@link Script#update} - Called every frame, if the script is enabled + * {@link Script#postUpdate} - Called every frame, after all scripts have been updated + * {@link Script#swap} - Called when a script is redefined * - * @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. + * These methods are entirely optional, but provide a useful way to manage the lifecycle of a script and perform any necessary setup and cleanup. + * + * Below is a simple example of a script that rotates an entity every frame. * @example - * var Turning = pc.createScript('turn'); + * ```javascript + * class EntityRotator extends Script { + * update() { + * this.entity.rotateLocal(0, 1, 0); + * } + * } + * ``` + * + * When this script is attached to an entity, the update will be called every frame, slowly rotating the entity around the Y-axis. * - * // define 'speed' attribute that is available in Editor UI - * Turning.attributes.add('speed', { - * type: 'number', - * default: 180, - * placeholder: 'deg/s' - * }); + * For more information on how to create scripts, see the [Scripting Overview](https://developer.playcanvas.com/user-manual/scripting/). * - * // runs every tick - * Turning.prototype.update = function (dt) { - * this.entity.rotate(0, this.speed * dt, 0); - * }; * @category Script */ -function createScript(name, app) { - if (reservedScriptNames.has(name)) - throw new Error(`Script name '${name}' is reserved, please rename the script`); +export class Script extends EventHandler { + /** + * Fired when a script instance becomes enabled. + * + * @event + * @example + * export class PlayerController extends Script { + * initialize() { + * this.on('enable', () => { + * // Script Instance is now enabled + * }); + * } + * }; + */ + static EVENT_ENABLE = 'enable'; - const scriptType = function (args) { - EventHandler.prototype.initEventHandler.call(this); - ScriptType.prototype.initScriptType.call(this, args); - }; + /** + * Fired when a script instance becomes disabled. + * + * @event + * @example + * export class PlayerController extends Script { + * initialize() { + * this.on('disable', () => { + * // Script Instance is now disabled + * }); + * } + * }; + */ + static EVENT_DISABLE = 'disable'; - scriptType.prototype = Object.create(ScriptType.prototype); - scriptType.prototype.constructor = scriptType; + /** + * 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 + * export class PlayerController extends Script { + * initialize() { + * this.on('state', (enabled) => { + * console.log(`Script Instance is now ${enabled ? 'enabled' : 'disabled'}`); + * }); + * } + * }; + */ + static EVENT_STATE = 'state'; - scriptType.extend = ScriptType.extend; - scriptType.attributes = new ScriptAttributes(scriptType); + /** + * Fired when a script instance is destroyed and removed from component. + * + * @event + * @example + * export class PlayerController extends Script { + * initialize() { + * 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'; - registerScript(scriptType, name, app); - return scriptType; -} + /** + * 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 + * export class PlayerController extends Script { + * initialize() { + * this.on('attr', (name, newValue, oldValue) => { + * console.log(`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`); + * }); + * } + * }; + * @example + * export class PlayerController extends Script { + * initialize() { + * this.on('attr:speed', (newValue, oldValue) => { + * console.log(`Attribute 'speed' changed from '${oldValue}' to '${newValue}'`); + * }); + * } + * }; + */ + static EVENT_ATTR = 'attr'; -// Editor uses this - migrate to ScriptAttributes.reservedNames and delete this -const reservedAttributes = {}; -ScriptAttributes.reservedNames.forEach((value, value2, set) => { - reservedAttributes[value] = 1; -}); -createScript.reservedAttributes = reservedAttributes; + /** + * Fired when a script instance had an exception. The script instance will be automatically + * disabled. The handler is passed an Error object containing the details of the + * exception and the name of the method that threw the exception. + * + * @event + * @example + * export class PlayerController extends Script { + * initialize() { + * this.on('error', (err, method) => { + * // caught an exception + * console.log(err.stack); + * }); + * } + * }; + */ + static EVENT_ERROR = 'error'; -/* 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 (typeof script !== 'function') - throw new Error(`script class: '${script}' must be a constructor function (i.e. class).`); + /** + * 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; - if (!(script.prototype instanceof ScriptType)) - throw new Error(`script class: '${ScriptType.__getScriptName(script)}' does not extend pc.ScriptType.`); + /** @private */ + _initialized; - name = name || script.__name || ScriptType.__getScriptName(script); + /** @private */ + _postInitialized; - if (reservedScriptNames.has(name)) - throw new Error(`script name: '${name}' is reserved, please change script name`); + /** @private */ + __destroyed; - script.__name = name; + /** @private */ + __scriptType; - // add to scripts registry - const registry = app ? app.scripts : AppBase.getApplication().scripts; - registry.add(script); + /** + * 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; - ScriptTypes.push(script); + /** + * 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.initScript(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.fire('preInitialize'); + + 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; + } + + /** + * @typedef {object} ScriptInitializationArgs + * @property {boolean} [enabled] - True if the script instance is in running state. + * @property {import('../app-base.js').AppBase} app - The {@link AppBase} that is running the script. + * @property {import('../entity.js').Entity} entity - The {@link Entity} that the script is attached to. + */ + + /** + * @param {ScriptInitializationArgs} args - The input arguments object. + * @protected + */ + initScript(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.__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 = getScriptName; + + /** + * Name of a Script Type. + * + * @type {string|null} + */ + static get scriptName() { + return this.__name; + } + + /** + * @function + * @name Script#[initialize] + * @description Called when script is about to run for the first time. + */ + + /** + * @function + * @name Script#[postInitialize] + * @description Called after all initialize methods are executed in the same tick or enabling chain of actions. + */ + + /** + * @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. + */ + + /** + * @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. + */ + + /** + * @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, then it will be executed to perform hot-reload at runtime. + * @param {Script} old - Old instance of the scriptType to copy data to the new instance. + */ } -/* eslint-enable jsdoc/check-examples */ -export { createScript, registerScript, getReservedScriptNames }; +const funcNameRegex = new RegExp('^\\s*function(?:\\s|\\s*\\/\\*.*\\*\\/\\s*)+([^\\(\\s\\/]*)\\s*'); + +/** + * @param {Function} constructorFn - The constructor function of the script type. + * @returns {string|undefined} The script name. + */ +export function 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; +} diff --git a/src/index.js b/src/index.js index 1932181b009..4a7052d5eea 100644 --- a/src/index.js +++ b/src/index.js @@ -356,10 +356,11 @@ 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 51dd33c1ea3..3ecb0dbf67c 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';