Repository: playcanvas/engine
Pull request #6950 (2024-09-12 08:23:27)

ESM Script class V1
Cherry picks #6367 and #6908 from V2  to allow ESM Script class support in 1.X version

Also adds `@alpha` tag to `Script` class in jsdocs


I confirm I have read the [contributing guidelines](https://github.com/playcanvas/engine/blob/master/.github/CONTRIBUTING.md) and signed the [Contributor License Agreement](https://docs.google.com/a/playcanvas.com/forms/d/1Ih69zQfJG-QDLIEpHr6CsaAs6fPORNOVnMv5nuo0cjk/viewform).



Patch:
diff --git a/src/framework/components/script/component.js b/src/framework/components/script/component.js
index 4d6ca1fb7..51ef355e6 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<string, object>}
+     * @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;
@@ -207,6 +219,11 @@ class ScriptComponent extends Component {
 
                 // enabled
                 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;
                 }
 
@@ -336,6 +353,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;
         }
 
@@ -375,7 +395,38 @@ class ScriptComponent extends Component {
 
     _onInitializeAttributes() {
         for (let i = 0, len = this.scripts.length; i < len; i++) {
-            this.scripts[i].__initializeAttributes();
+            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);
+
         }
     }
 
@@ -607,8 +658,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.
@@ -639,7 +690,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) {
@@ -652,6 +703,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) {
@@ -670,7 +730,7 @@ class ScriptComponent extends Component {
                 this[scriptName] = scriptInstance;
 
                 if (!args.preloading) {
-                    scriptInstance.__initializeAttributes();
+                    this.initializeAttributes(scriptInstance);
                 }
 
                 this.fire('create', scriptName, scriptInstance);
@@ -737,6 +797,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;
@@ -807,7 +869,7 @@ class ScriptComponent extends Component {
             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 e60824f1b..5f813c99c 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);
 
@@ -154,21 +153,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 85e9f835f..1874f6e12 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'];
@@ -152,6 +150,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<string, AttributeSchema>} attributeSchemaMap - A map of names to Schemas
+ * @param {Object<string, *>} 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
@@ -160,6 +202,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 000000000..afc44ca35
--- /dev/null
+++ b/src/framework/script/script-create.js
@@ -0,0 +1,147 @@
+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 83ca57bef..4332cb436 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<string, import('./script-attributes.js').AttributeSchema>}
+     * @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
@@ -113,12 +142,12 @@ class ScriptRegistry extends EventHandler {
                     if (scriptInstance) {
                         scriptInstances.push(scriptInstance);
                     }
-                }
-            }
 
-            // initialize attributes
-            for (let i = 0; i < scriptInstances.length; i++) {
-                scriptInstances[i].__initializeAttributes();
+                    // initialize attributes
+                    for (const script of component.scripts) {
+                        component.initializeAttributes(script);
+                    }
+                }
             }
 
             // call initialize()
diff --git a/src/framework/script/script-type.js b/src/framework/script/script-type.js
index d130987c1..75b43f0a4 100644
--- a/src/framework/script/script-type.js
+++ b/src/framework/script/script-type.js
@@ -1,181 +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';
-
-// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/no-useless-escape
-const funcNameRegex = /^\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.
      *
@@ -186,114 +25,10 @@ class ScriptType extends EventHandler {
      * attached to.
      */
     constructor(args) {
-        super();
+        super(args);
         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}.
      *
@@ -313,9 +48,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) {
@@ -363,41 +117,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 441d8b050..e93a9ea0e 100644
--- a/src/framework/script/script.js
+++ b/src/framework/script/script.js
@@ -1,160 +1,347 @@
-import { Debug } from '../../core/debug.js';
+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 { 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);
+ *     }
+ * }
+ * ```
  *
- * // define 'speed' attribute that is available in Editor UI
- * Turning.attributes.add('speed', {
- *     type: 'number',
- *     default: 180,
- *     placeholder: 'deg/s'
- * });
+ * When this script is attached to an entity, the update will be called every frame, slowly rotating the entity around the Y-axis.
+ *
+ * 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 (script.legacy) {
-        Debug.error('This project is using the legacy script system. You cannot call pc.createScript().');
-        return null;
-    }
+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';
+
+    /**
+     * 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';
+
+    /**
+     * 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';
+
+    /**
+     * 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';
+
+    /**
+     * 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';
 
-    if (reservedScriptNames.has(name)) {
-        throw new Error(`Script name '${name}' is reserved, please rename the script`);
+    /**
+     * 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';
+
+    /**
+     * 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 */
+    __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.initScript(args);
     }
 
-    const scriptType = function (args) {
-        EventHandler.prototype.initEventHandler.call(this);
-        ScriptType.prototype.initScriptType.call(this, 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;
 
-    scriptType.prototype = Object.create(ScriptType.prototype);
-    scriptType.prototype.constructor = scriptType;
+        if (this.enabled === this._enabledOld) return;
 
-    scriptType.extend = ScriptType.extend;
-    scriptType.attributes = new ScriptAttributes(scriptType);
+        this._enabledOld = this.enabled;
+        this.fire(this.enabled ? 'enable' : 'disable');
+        this.fire('state', this.enabled);
 
-    registerScript(scriptType, name, app);
-    return scriptType;
-}
+        // initialize script if not initialized yet and script is enabled
+        if (!this._initialized && this.enabled) {
+            this._initialized = 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;
+            this.fire('preInitialize');
 
-/* 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;
+            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);
+            }
+        }
     }
 
-    if (typeof script !== 'function') {
-        throw new Error(`script class: '${script}' must be a constructor function (i.e. class).`);
+    get enabled() {
+        return this._enabled && !this._destroyed && this.entity.script.enabled && this.entity.enabled;
     }
 
-    if (!(script.prototype instanceof ScriptType)) {
-        throw new Error(`script class: '${ScriptType.__getScriptName(script)}' does not extend pc.ScriptType.`);
+    /**
+     * @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 = name || script.__name || ScriptType.__getScriptName(script);
+    /**
+     * 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;
 
-    if (reservedScriptNames.has(name)) {
-        throw new Error(`script name: '${name}' is reserved, please change script name`);
+    /**
+     * Name of a Script Type.
+     *
+     * @type {string|null}
+     */
+    static get scriptName() {
+        return this.__name;
     }
 
-    script.__name = name;
+    /**
+     * @function
+     * @name Script#[initialize]
+     * @description Called when script is about to run for the first time.
+     */
 
-    // add to scripts registry
-    const registry = app ? app.scripts : AppBase.getApplication().scripts;
-    registry.add(script);
+    /**
+     * @function
+     * @name Script#[postInitialize]
+     * @description Called after all initialize methods are executed in the same tick or enabling chain of actions.
+     */
 
-    ScriptTypes.push(script, script.legacy);
+    /**
+     * @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 };
+// eslint-disable-next-line regexp/no-super-linear-backtracking, regexp/no-useless-escape
+const funcNameRegex = /^\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 ee4274508..d5f92b80b 100644
--- a/src/index.js
+++ b/src/index.js
@@ -360,10 +360,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';


Test patch:
diff --git a/test/framework/entity.test.mjs b/test/framework/entity.test.mjs
index 8026dfbac..2a7516661 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';