diff --git a/examples/src/examples/camera/orbit.mjs b/examples/src/examples/camera/orbit.mjs index 8eb5f1e92f2..11f448171ac 100644 --- a/examples/src/examples/camera/orbit.mjs +++ b/examples/src/examples/camera/orbit.mjs @@ -6,6 +6,12 @@ import * as pc from 'playcanvas'; * @returns {Promise} The example application. */ async function example({ canvas, assetPath, scriptsPath }) { + + // We currently need to dynamically import the OrbitCamera script + const OrbitCamera = await import(`${scriptsPath}camera/orbit/orbit-camera.mjs`); + const OrbitCameraInputMouse = await import(`${scriptsPath}camera/orbit/orbit-camera-input-mouse.mjs`); + const OrbitCameraInputTouch = await import(`${scriptsPath}camera/orbit/orbit-camera-input-touch.mjs`); + // Create the app and start the update loop const app = new pc.Application(canvas, { mouse: new pc.Mouse(document.body), @@ -13,8 +19,7 @@ async function example({ canvas, assetPath, scriptsPath }) { }); const assets = { - statue: new pc.Asset('statue', 'container', { url: assetPath + 'models/statue.glb' }), - script: new pc.Asset('script', 'script', { url: scriptsPath + 'camera/orbit-camera.js' }) + statue: new pc.Asset('statue', 'container', { url: assetPath + 'models/statue.glb' }) }; // Set the canvas to fill the window and automatically change resolution to be the same as the canvas size @@ -51,14 +56,12 @@ async function example({ canvas, assetPath, scriptsPath }) { camera.addComponent("camera", { clearColor: new pc.Color(0.4, 0.45, 0.5) }); - camera.addComponent("script"); - camera.script.create("orbitCamera", { - attributes: { - inertiaFactor: 0.2 // Override default of 0 (no inertia) - } + camera.addComponent("esmscript"); + camera.esmscript.add(OrbitCamera.default, { + inertiaFactor: 0.2 // Override default of 0 (no inertia) }); - camera.script.create("orbitCameraInputMouse"); - camera.script.create("orbitCameraInputTouch"); + camera.esmscript.add(OrbitCameraInputMouse.default); + camera.esmscript.add(OrbitCameraInputTouch.default); app.root.addChild(camera); // Create a directional light diff --git a/package-lock.json b/package-lock.json index 33fa2069c6f..06572051f9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8712,4 +8712,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 0f073d6c6d4..b93727e36e6 100644 --- a/package.json +++ b/package.json @@ -158,7 +158,7 @@ "docs": "jsdoc -c conf-api.json", "typedocs": "typedoc", "lint": "eslint --ext .js,.mjs extras scripts src test utils rollup.config.mjs", - "serve": "serve build -l 51000", + "serve": "serve build -l 51000 -C", "test": "mocha --recursive --require test/fixtures.mjs", "test:coverage": "c8 npm test", "test:karma": "karma start tests/karma.conf.cjs -- --single-run --release", diff --git a/scripts/camera/orbit/orbit-camera-input-mouse.mjs b/scripts/camera/orbit/orbit-camera-input-mouse.mjs new file mode 100644 index 00000000000..7c4648cbb99 --- /dev/null +++ b/scripts/camera/orbit/orbit-camera-input-mouse.mjs @@ -0,0 +1,129 @@ +//////////////////////////////////////////////////////////////////////////////// +// Orbit Camera Mouse Input Script // +//////////////////////////////////////////////////////////////////////////////// + +const attributes = { + orbitSensitivity: { + type: 'number', + default: 0.3, + title: 'Orbit Sensitivity', + description: 'How fast the camera moves around the orbit. Higher is faster' + }, + distanceSensitivity: { + type: 'number', + default: 0.15, + title: 'Distance Sensitivity', + description: 'How fast the camera moves in and out. Higher is faster' + } +}; + +export default class OrbitCameraInputMouse { + static attributes = attributes; + + // initialize code called once per entity + initialize() { + this.orbitCamera = this.entity.esmscript.get('OrbitCamera'); + + if (this.orbitCamera) { + + this.app.mouse.on(pc.EVENT_MOUSEDOWN, this.onMouseDown, this); + this.app.mouse.on(pc.EVENT_MOUSEUP, this.onMouseUp, this); + this.app.mouse.on(pc.EVENT_MOUSEMOVE, this.onMouseMove, this); + this.app.mouse.on(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this); + + // Listen to when the mouse travels out of the window + window.addEventListener('mouseout', this.onMouseOut, false); + + } + + // Disabling the context menu stops the browser displaying a menu when + // you right-click the page + this.app.mouse.disableContextMenu(); + + this.lookButtonDown = false; + this.panButtonDown = false; + this.lastPoint = new pc.Vec2(); + } + + // Remove the listeners so if this entity is destroyed + destroy() { + this.app.mouse.off(pc.EVENT_MOUSEDOWN, this.onMouseDown, this); + this.app.mouse.off(pc.EVENT_MOUSEUP, this.onMouseUp, this); + this.app.mouse.off(pc.EVENT_MOUSEMOVE, this.onMouseMove, this); + this.app.mouse.off(pc.EVENT_MOUSEWHEEL, this.onMouseWheel, this); + + window.removeEventListener('mouseout', this.onMouseOut, false); + } + + + static fromWorldPoint = new pc.Vec3(); + + static toWorldPoint = new pc.Vec3(); + + static worldDiff = new pc.Vec3(); + + + pan(screenPoint) { + const fromWorldPoint = OrbitCameraInputMouse.fromWorldPoint; + const toWorldPoint = OrbitCameraInputMouse.toWorldPoint; + const worldDiff = OrbitCameraInputMouse.worldDiff; + + // For panning to work at any zoom level, we use screen point to world projection + // to work out how far we need to pan the pivotEntity in world space + const camera = this.entity.camera; + const distance = this.orbitCamera.distance; + + camera.screenToWorld(screenPoint.x, screenPoint.y, distance, fromWorldPoint); + camera.screenToWorld(this.lastPoint.x, this.lastPoint.y, distance, toWorldPoint); + + worldDiff.sub2(toWorldPoint, fromWorldPoint); + + this.orbitCamera.pivotPoint.add(worldDiff); + } + + onMouseDown(event) { + switch (event.button) { + case pc.MOUSEBUTTON_LEFT: + this.lookButtonDown = true; + break; + case pc.MOUSEBUTTON_MIDDLE: + case pc.MOUSEBUTTON_RIGHT: + this.panButtonDown = true; + break; + } + } + + onMouseUp(event) { + switch (event.button) { + case pc.MOUSEBUTTON_LEFT: + this.lookButtonDown = false; + break; + case pc.MOUSEBUTTON_MIDDLE: + case pc.MOUSEBUTTON_RIGHT: + this.panButtonDown = false; + break; + } + } + + onMouseMove(event) { + if (this.lookButtonDown) { + this.orbitCamera.pitch -= event.dy * this.orbitSensitivity; + this.orbitCamera.yaw -= event.dx * this.orbitSensitivity; + + } else if (this.panButtonDown) { + this.pan(event); + } + + this.lastPoint.set(event.x, event.y); + } + + onMouseWheel(event) { + this.orbitCamera.distance -= event.wheel * this.distanceSensitivity * (this.orbitCamera.distance * 0.1); + event.event.preventDefault(); + } + + onMouseOut(event) { + this.lookButtonDown = false; + this.panButtonDown = false; + } +} diff --git a/scripts/camera/orbit/orbit-camera-input-touch.mjs b/scripts/camera/orbit/orbit-camera-input-touch.mjs new file mode 100644 index 00000000000..98019372fbb --- /dev/null +++ b/scripts/camera/orbit/orbit-camera-input-touch.mjs @@ -0,0 +1,137 @@ +//////////////////////////////////////////////////////////////////////////////// +// Orbit Camera Touch Input Script // +//////////////////////////////////////////////////////////////////////////////// + +const attributes = { + orbitSensitivity: { + type: 'number', + default: 0.3, + title: 'Orbit Sensitivity', + description: 'How fast the camera moves around the orbit. Higher is faster' + }, + distanceSensitivity: { + type: 'number', + default: 0.15, + title: 'Distance Sensitivity', + description: 'How fast the camera moves in and out. Higher is faster' + } +}; + +export default class OrbitCameraInputTouch { + static attributes = attributes; + + // initialize code called once per entity + initialize() { + this.orbitCamera = this.entity.esmscript.get('OrbitCamera'); + + // Store the position of the touch so we can calculate the distance moved + this.lastTouchPoint = new pc.Vec2(); + this.lastPinchMidPoint = new pc.Vec2(); + this.lastPinchDistance = 0; + + if (this.orbitCamera && this.app.touch) { + // Use the same callback for the touchStart, touchEnd and touchCancel events as they + // all do the same thing which is to deal the possible multiple touches to the screen + this.app.touch.on(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this); + this.app.touch.on(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this); + this.app.touch.on(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this); + + this.app.touch.on(pc.EVENT_TOUCHMOVE, this.onTouchMove, this); + + } + } + + destroy() { + this.app.touch.off(pc.EVENT_TOUCHSTART, this.onTouchStartEndCancel, this); + this.app.touch.off(pc.EVENT_TOUCHEND, this.onTouchStartEndCancel, this); + this.app.touch.off(pc.EVENT_TOUCHCANCEL, this.onTouchStartEndCancel, this); + + this.app.touch.off(pc.EVENT_TOUCHMOVE, this.onTouchMove, this); + } + + getPinchDistance(pointA, pointB) { + // Return the distance between the two points + var dx = pointA.x - pointB.x; + var dy = pointA.y - pointB.y; + + return Math.sqrt((dx * dx) + (dy * dy)); + } + + calcMidPoint(pointA, pointB, result) { + result.set(pointB.x - pointA.x, pointB.y - pointA.y); + result.mulScalar(0.5); + result.x += pointA.x; + result.y += pointA.y; + } + + onTouchStartEndCancel(event) { + // We only care about the first touch for camera rotation. As the user touches the screen, + // we stored the current touch position + var touches = event.touches; + if (touches.length === 1) { + this.lastTouchPoint.set(touches[0].x, touches[0].y); + + } else if (touches.length === 2) { + // If there are 2 touches on the screen, then set the pinch distance + this.lastPinchDistance = this.getPinchDistance(touches[0], touches[1]); + this.calcMidPoint(touches[0], touches[1], this.lastPinchMidPoint); + } + } + + static fromWorldPoint = new pc.Vec3(); + + static toWorldPoint = new pc.Vec3(); + + static worldDiff = new pc.Vec3(); + + + pan(midPoint) { + var fromWorldPoint = OrbitCameraInputTouch.fromWorldPoint; + var toWorldPoint = OrbitCameraInputTouch.toWorldPoint; + var worldDiff = OrbitCameraInputTouch.worldDiff; + + // For panning to work at any zoom level, we use screen point to world projection + // to work out how far we need to pan the pivotEntity in world space + const camera = this.entity.camera; + const distance = this.orbitCamera.distance; + + camera.screenToWorld(midPoint.x, midPoint.y, distance, fromWorldPoint); + camera.screenToWorld(this.lastPinchMidPoint.x, this.lastPinchMidPoint.y, distance, toWorldPoint); + + worldDiff.sub2(toWorldPoint, fromWorldPoint); + + this.orbitCamera.pivotPoint.add(worldDiff); + } + + + static pinchMidPoint = new pc.Vec2(); + + onTouchMove(event) { + var pinchMidPoint = OrbitCameraInputTouch.pinchMidPoint; + + // We only care about the first touch for camera rotation. Work out the difference moved since the last event + // and use that to update the camera target position + var touches = event.touches; + if (touches.length === 1) { + var touch = touches[0]; + + this.orbitCamera.pitch -= (touch.y - this.lastTouchPoint.y) * this.orbitSensitivity; + this.orbitCamera.yaw -= (touch.x - this.lastTouchPoint.x) * this.orbitSensitivity; + + this.lastTouchPoint.set(touch.x, touch.y); + + } else if (touches.length === 2) { + // Calculate the difference in pinch distance since the last event + var currentPinchDistance = this.getPinchDistance(touches[0], touches[1]); + var diffInPinchDistance = currentPinchDistance - this.lastPinchDistance; + this.lastPinchDistance = currentPinchDistance; + + this.orbitCamera.distance -= (diffInPinchDistance * this.distanceSensitivity * 0.1) * (this.orbitCamera.distance * 0.1); + + // Calculate pan difference + this.calcMidPoint(touches[0], touches[1], pinchMidPoint); + this.pan(pinchMidPoint); + this.lastPinchMidPoint.copy(pinchMidPoint); + } + } +} diff --git a/scripts/camera/orbit/orbit-camera.mjs b/scripts/camera/orbit/orbit-camera.mjs new file mode 100644 index 00000000000..0f64c5865e8 --- /dev/null +++ b/scripts/camera/orbit/orbit-camera.mjs @@ -0,0 +1,371 @@ +//////////////////////////////////////////////////////////////////////////////// +// Orbit Camera Script // +//////////////////////////////////////////////////////////////////////////////// + +const attributes = { + + distanceMax: { type: 'number', default: 0, title: 'Distance Max', description: 'Setting this at 0 will give an infinite distance limit' }, + distanceMin: { type: 'number', default: 0, title: 'Distance Min' }, + pitchAngleMax: { type: 'number', default: 90, title: 'Pitch Angle Max (degrees)' }, + pitchAngleMin: { type: 'number', default: -90, title: 'Pitch Angle Min (degrees)' }, + + inertiaFactor: { + type: 'number', + default: 0, + title: 'Inertia Factor', + description: 'Higher value means that the camera will continue moving after the user has stopped dragging. 0 is fully responsive.' + }, + focusEntity: { + type: 'entity', + title: 'Focus Entity', + description: 'Entity for the camera to focus on. If blank, then the camera will use the whole scene' + }, + frameOnStart: { + type: 'boolean', + default: true, + title: 'Frame on Start', + description: 'Frames the entity or scene at the start of the application."' + } +}; + +export default class OrbitCamera { + static attributes = attributes; + + /** + * @type {pc.Entity} entity - The entity that has the camera component + */ + entity; + + /** + * @type {pc.AppBase} app - The app that the entity this script is attached to belongs to + */ + app; + + // Reapply the clamps if they are changed in the editor + set distanceMin(value) { + this._distanceMin = value; + this._distance = this._clampDistance(this._distance); + } + + get distanceMin() { + return this._distanceMin; + } + + set distanceMax(value) { + this._distanceMax = value; + this._distance = this._clampDistance(this._distance); + } + + get distanceMax() { + return this._distanceMax; + } + + set pitchAngleMin(value) { + this._pitchAngleMin = value; + this._pitch = this._clampPitchAngle(this._pitch); + } + + get pitchAngleMin() { + return this._pitchAngleMin; + } + + set pitchAngleMax(value) { + this._pitchAngleMax = value; + this._pitch = this._clampPitchAngle(this._pitch); + } + + get pitchAngleMax() { + return this._pitchAngleMax; + } + + set frameOnStart(value) { + this._frameOnStart = value; + if (value && this.app) { + this.focus(this.focusEntity || this.app.root); + } + } + + get frameOnStart() { + return this._frameOnStart; + } + + set focusEntity(value) { + this._focusEntity = value; + if (this.frameOnStart) { + this.focus(value || this.app.root); + } else { + this.resetAndLookAtEntity(this.entity.getPosition(), value || this.app.root); + } + } + + get focusEntity() { + return this._focusEntity; + } + + static distanceBetween = new pc.Vec3(); + + // Property to get and set the distance between the pivot point and camera + // Clamped between this.distanceMin and this.distanceMax + set distance(value) { + this._targetDistance = this._clampDistance(value); + } + + get distance() { + return this._targetDistance; + } + + // Property to get and set the pitch of the camera around the pivot point (degrees) + // Clamped between this.pitchAngleMin and this.pitchAngleMax + // When set at 0, the camera angle is flat, looking along the horizon + set pitch(value) { + this._targetPitch = this._clampPitchAngle(value); + } + + get pitch() { + return this._targetPitch; + } + + // Property to get and set the yaw of the camera around the pivot point (degrees) + set yaw(value) { + this._targetYaw = value; + this._targetYaw = value; + // Ensure that the yaw takes the shortest route by making sure that + // the difference between the targetYaw and the actual is 180 degrees + // in either direction + const diff = this._targetYaw - this._yaw; + const reminder = diff % 360; + if (reminder > 180) { + this._targetYaw = this._yaw - (360 - reminder); + } else if (reminder < -180) { + this._targetYaw = this._yaw + (360 + reminder); + } else { + this._targetYaw = this._yaw + reminder; + } + } + + get yaw() { + return this._targetYaw; + } + + // Property to get and set the world position of the pivot point that the camera orbits around + set pivotPoint(value) { + this._pivotPoint.copy(value); + } + + get pivotPoint() { + return this._pivotPoint; + } + + // Moves the camera to look at an entity and all its children so they are all in the view + focus(focusEntity) { + // Calculate an bounding box that encompasses all the models to frame in the camera view + this._buildAabb(focusEntity); + + var halfExtents = this._modelsAabb.halfExtents; + var radius = Math.max(halfExtents.x, Math.max(halfExtents.y, halfExtents.z)); + + this.distance = (radius * 1.5) / Math.sin(0.5 * this.entity.camera.fov * pc.math.DEG_TO_RAD); + + this._removeInertia(); + + this._pivotPoint.copy(this._modelsAabb.center); + } + + // Set the camera position to a world position and look at a world position + // Useful if you have multiple viewing angles to swap between in a scene + resetAndLookAtPoint(resetPoint, lookAtPoint) { + this.pivotPoint.copy(lookAtPoint); + this.entity.setPosition(resetPoint); + + this.entity.lookAt(lookAtPoint); + + const distance = OrbitCamera.distanceBetween; + distance.sub2(lookAtPoint, resetPoint); + this.distance = distance.length(); + + this.pivotPoint.copy(lookAtPoint); + + const cameraQuat = this.entity.getRotation(); + this.yaw = this._calcYaw(cameraQuat); + this.pitch = this._calcPitch(cameraQuat, this.yaw); + + this._removeInertia(); + this._updatePosition(); + } + + // Set camera position to a world position and look at an entity in the scene + // Useful if you have multiple models to swap between in a scene + resetAndLookAtEntity(resetPoint, entity) { + this._buildAabb(entity); + this.resetAndLookAtPoint(resetPoint, this._modelsAabb.center); + } + + // Set the camera at a specific, yaw, pitch and distance without inertia (instant cut) + reset(yaw, pitch, distance) { + this.pitch = pitch; + this.yaw = yaw; + this.distance = distance; + + this._removeInertia(); + } + + ///////////////////////////////////////////////////////////////////////////////////////////// + // Private methods + ///////////////////////////////////////////////////////////////////////////////////////////// + + initialize() { + + this.checkAspectRatioBound = _ => this._checkAspectRatio(); + + window.addEventListener('resize', this.checkAspectRatioBound, false); + + this._checkAspectRatio(); + + // Find all the models in the scene that are under the focused entity + this._modelsAabb = new pc.BoundingBox(); + this._buildAabb(this.focusEntity || this.app.root); + + this.entity.lookAt(this._modelsAabb.center); + + this._pivotPoint = new pc.Vec3(); + this._pivotPoint.copy(this._modelsAabb.center); + + // Calculate the camera euler angle rotation around x and y axes + // This allows us to place the camera at a particular rotation to begin with in the scene + const cameraQuat = this.entity.getRotation(); + + // Preset the camera + this._yaw = this._calcYaw(cameraQuat); + this._pitch = this._clampPitchAngle(this._calcPitch(cameraQuat, this._yaw)); + this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0); + + this._distance = 0; + + this._targetYaw = this._yaw; + this._targetPitch = this._pitch; + + // If we have ticked focus on start, then attempt to position the camera where it frames + // the focused entity and move the pivot point to entity's position otherwise, set the distance + // to be between the camera position in the scene and the pivot point + if (this.frameOnStart) { + this.focus(this.focusEntity || this.app.root); + } else { + const distanceBetween = new pc.Vec3(); + distanceBetween.sub2(this.entity.getPosition(), this._pivotPoint); + this._distance = this._clampDistance(distanceBetween.length()); + } + + this._targetDistance = this._distance; + + } + + destroy() { + window.removeEventListener('resize', this.checkAspectRatioBound, false); + } + + update(dt) { + // Add inertia, if any + var t = this.inertiaFactor === 0 ? 1 : Math.min(dt / this.inertiaFactor, 1); + this._distance = pc.math.lerp(this._distance, this._targetDistance, t); + this._yaw = pc.math.lerp(this._yaw, this._targetYaw, t); + this._pitch = pc.math.lerp(this._pitch, this._targetPitch, t); + + this._updatePosition(); + } + + + _updatePosition() { + // Work out the camera position based on the pivot point, pitch, yaw and distance + this.entity.setLocalPosition(0, 0, 0); + this.entity.setLocalEulerAngles(this._pitch, this._yaw, 0); + + const position = this.entity.getPosition(); + position.copy(this.entity.forward); + position.mulScalar(-this._distance); + position.add(this.pivotPoint); + this.entity.setPosition(position); + } + + _removeInertia() { + this._yaw = this._targetYaw; + this._pitch = this._targetPitch; + this._distance = this._targetDistance; + } + + _checkAspectRatio() { + const height = this.app.graphicsDevice.height; + const width = this.app.graphicsDevice.width; + + // Match the axis of FOV to match the aspect ratio of the canvas so + // the focused entities is always in frame + this.entity.camera.horizontalFov = height > width; + } + + _buildAabb(entity) { + var i, m, meshInstances = []; + + const renders = entity.findComponents("render"); + for (i = 0; i < renders.length; i++) { + const render = renders[i]; + for (m = 0; m < render.meshInstances.length; m++) { + meshInstances.push(render.meshInstances[m]); + } + } + + const models = entity.findComponents("model"); + for (i = 0; i < models.length; i++) { + const model = models[i]; + for (m = 0; m < model.meshInstances.length; m++) { + meshInstances.push(model.meshInstances[m]); + } + } + + for (i = 0; i < meshInstances.length; i++) { + if (i === 0) { + this._modelsAabb.copy(meshInstances[i].aabb); + } else { + this._modelsAabb.add(meshInstances[i].aabb); + } + } + } + + _calcYaw(quat) { + const transformedForward = new pc.Vec3(); + quat.transformVector(pc.Vec3.FORWARD, transformedForward); + + return Math.atan2(-transformedForward.x, -transformedForward.z) * pc.math.RAD_TO_DEG; + } + + _clampDistance(distance) { + if (this.distanceMax > 0) { + return pc.math.clamp(distance, this.distanceMin, this.distanceMax); + } + return Math.max(distance, this.distanceMin); + + } + + + _clampPitchAngle(pitch) { + // Negative due as the pitch is inversed since the camera is orbiting the entity + return pc.math.clamp(pitch, -this.pitchAngleMax, -this.pitchAngleMin); + } + + + static quatWithoutYaw = new pc.Quat(); + + static yawOffset = new pc.Quat(); + + _calcPitch(quat, yaw) { + const quatWithoutYaw = OrbitCamera.quatWithoutYaw; + const yawOffset = OrbitCamera.yawOffset; + + yawOffset.setFromEulerAngles(0, -yaw, 0); + quatWithoutYaw.mul2(yawOffset, quat); + + const transformedForward = new pc.Vec3(); + + quatWithoutYaw.transformVector(pc.Vec3.FORWARD, transformedForward); + + return Math.atan2(transformedForward.y, -transformedForward.z) * pc.math.RAD_TO_DEG; + } +} diff --git a/src/core/class-utils.js b/src/core/class-utils.js new file mode 100644 index 00000000000..c2c879ac0b8 --- /dev/null +++ b/src/core/class-utils.js @@ -0,0 +1,13 @@ +/** + * Checks if a class contains a method either itself or in it's inheritance chain + * + * @param {Function} testClass - The class to check + * @param {string} method - The name of the method to check + * @returns {boolean} if a valid class and contains the method in it's inheritance chain + * @ignore + */ +export const classHasMethod = (testClass, method) => { + return typeof testClass === 'function' && + typeof testClass.prototype === 'object' && + method in testClass.prototype; +}; diff --git a/src/framework/app-base.js b/src/framework/app-base.js index 3701615777a..0e2db915cd4 100644 --- a/src/framework/app-base.js +++ b/src/framework/app-base.js @@ -2002,6 +2002,9 @@ class AppBase extends EventHandler { const scriptHandler = this.loader.getHandler('script'); scriptHandler?.clearCache(); + const esmscriptHandler = this.loader.getHandler('esmscript'); + esmscriptHandler?.clearCache(); + this.loader.destroy(); this.loader = null; diff --git a/src/framework/application.js b/src/framework/application.js index 470483e882a..b3337fe1606 100644 --- a/src/framework/application.js +++ b/src/framework/application.js @@ -34,7 +34,7 @@ import { ZoneComponentSystem } from './components/zone/system.js'; import { CameraComponentSystem } from './components/camera/system.js'; import { LightComponentSystem } from './components/light/system.js'; import { ScriptComponentSystem } from './components/script/system.js'; - +import { EsmScriptComponentSystem } from './components/esmscript/system.js'; import { RenderHandler } from './handlers/render.js'; import { AnimationHandler } from './handlers/animation.js'; import { AnimClipHandler } from './handlers/anim-clip.js'; @@ -44,6 +44,7 @@ import { BinaryHandler } from './handlers/binary.js'; import { ContainerHandler } from './handlers/container.js'; import { CssHandler } from './handlers/css.js'; import { CubemapHandler } from './handlers/cubemap.js'; +import { EsmScriptHandler } from './handlers/esmscript.js'; import { FolderHandler } from './handlers/folder.js'; import { FontHandler } from './handlers/font.js'; import { HierarchyHandler } from './handlers/hierarchy.js'; @@ -168,6 +169,7 @@ class Application extends AppBase { CameraComponentSystem, LightComponentSystem, script.legacy ? ScriptLegacyComponentSystem : ScriptComponentSystem, + EsmScriptComponentSystem, AudioSourceComponentSystem, SoundComponentSystem, AudioListenerComponentSystem, @@ -196,6 +198,7 @@ class Application extends AppBase { TextHandler, JsonHandler, AudioHandler, + EsmScriptHandler, ScriptHandler, SceneHandler, CubemapHandler, diff --git a/src/framework/components/esmscript/attribute-utils.js b/src/framework/components/esmscript/attribute-utils.js new file mode 100644 index 00000000000..33b1b69df13 --- /dev/null +++ b/src/framework/components/esmscript/attribute-utils.js @@ -0,0 +1,364 @@ +import { Color } from "../../../core/math/color.js"; +import { Curve } from "../../../core/math/curve.js"; +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 { Asset } from "../../../framework/asset/asset.js"; +import { GraphNode } from "../../../scene/graph-node.js"; +import { Debug } from "../../../core/debug.js"; + +/** + * @callback UpdateFunction + * @param {number} dt - The time since the last update. + * @ignore + */ + +/** + * @callback SwapFunction + * @param {Object} newState - The new state to swap to. + * @ignore + */ + +/** + * This type represents a generic class constructor. + * @typedef {Function} ModuleClass + */ + +/** + * @typedef {Object} AttributeDefinition + * @property {'asset'|'boolean'|'curve'|'entity'|'json'|'number'|'rgb'|'rgba'|'string'|'vec2'|'vec3'|'vec4'} type - The attribute type + */ + +/** + * The expected output of an ESM Script file. It contains the class definition and the attributes it requires. + * @typedef {Object} ModuleExport + * @property {ModuleClass} default - The default export of a esm script that defines a class + * @property {Object} attributes - An object containing the names of attributes and their definitions; + */ + +/** + * A list of valid attribute types + * @ignore + */ +const VALID_ATTR_TYPES = new Set([ + "asset", + "boolean", + "curve", + "entity", + "json", + "number", + "rgb", + "rgba", + "string", + "vec2", + "vec3", + "vec4" +]); + +/** + * For any given attribute definition returns whether it conforms to the required + * shape of an attribute definition. + * + * @param {AttributeDefinition|object} attributeDefinition - The attribute to check + * @returns {boolean} True if the object can be treated as a attribute definition + * @example + * isValidAttributeDefinition({ type: 'entity' }); // true + * isValidAttributeDefinition({ type: 'invalidType' }); // false + * isValidAttributeDefinition({ x: 'y' }); // false + */ +const isValidAttributeDefinition = (attributeDefinition) => { + return attributeDefinition && attributeDefinition.hasOwnProperty('type'); +}; + +/** + * This function returns the value at a path from an object. + * @param {Object} object - The object to get the value from + * @param {string[]} path - The path to the value as an array of strings + * @returns {*} The value at the path or undefined if it doesn't exist + */ +export const getValueAtPath = (object, path) => { + return path.reduce((prev, curr) => prev?.[curr], object); +}; + +/** + * This function sets the value at a path on an object. + * @param {Object} object - The object to set the value on + * @param {string[]} path - The path to the value as an array of strings + * @param {*} value - The value to set + * @returns {*} The value that was set + */ +export const setValueAtPath = (object, path, value) => { + const last = path[path.length - 1]; + const parent = path.slice(0, -1).reduce((prev, curr) => { + if (!prev[curr]) prev[curr] = {}; + return prev[curr]; + }, object); + parent[last] = value; + return value; +}; + +/** + * This callback is executed for each attribute definition. + * + * @callback forEachAttributeDefinitionCallback + * @param {AttributeDefinition} attributeDefinition - The current attribute definition. + * @param {string[]} path - The path to the current attribute definition. + */ + +/** + * This function iterates over an attribute definition dictionary and calls a callback for each valid definition. + * @param {AttributeDefinitionDict} attributeDefDict - The attribute definition dictionary to iterate over. + * @param {forEachAttributeDefinitionCallback} callback - The callback to call for each valid attribute definition. + * @param {string[]} [path] - The path to begin iterating from attribute definition. If empty, it starts from the root. + */ +export const forEachAttributeDefinition = (attributeDefDict, callback, path = []) => { + + const attributeDefEntries = Object.entries(attributeDefDict); + + attributeDefEntries.forEach(([attributeName, def]) => { + + const localPath = [...path, attributeName]; + + if (isValidAttributeDefinition(def)) { + + callback(def, localPath); + + } else if (typeof def === 'object') { + forEachAttributeDefinition(def, callback, localPath); + } + }); +}; + +/** + * A Dictionary object where each key is a string and each value is an AttributeDefinition. + * @typedef {Object.} AttributeDefinitionDict + */ + +/** + * This function recursively populates an object with attributes based on an attribute definition. + * It's used to populate the attributes of an ESM Script Component from an object literal, only + * copying those attributes that are defined in the attributes definition and have the correct type. + * If no attribute is specified it uses the default value from the attribute definition if available. + * + * @param {import('../../app-base.js').AppBase} app - The app base to search for asset references + * @param {AttributeDefinitionDict} attributeDefDict - The definition + * @param {Object} attributes - The attributes to apply + * @param {Object} [object] - The object to populate with attributes + * @returns {Object} the object with properties set + * + * @example + * const attributes = { someNum: 1, nested: { notStr: 2, ignoredValue: 20 }} + * const definitions = { + * someNum: { type: 'number' }, + * nested: { + * notStr: { type: 'string' }, + * otherValue: { type: 'number', default: 3 } + * } + * } + * + * // only the attributes that are defined in the definition are copied + * populateWithAttributes(app, object, attributeDefDict, attributes) + * // outputs { someNum: 1, nested: { notStr: 2, otherValue: 3 }} + */ +export function populateWithAttributes(app, attributeDefDict, attributes, object = {}) { + + // Iterate over each attribute definition + forEachAttributeDefinition(attributeDefDict, (def, path) => { + + const isSimpleType = typeof def.type === 'string'; + const valueFromAttributes = getValueAtPath(attributes, path); + const valueFromObject = getValueAtPath(object, path); + + // If the attribute is an array, then we need to recurse into each element + if (def.array) { + + // In order of preference, take the value from the attributes, from the object, the default, or an empty array + const arr = valueFromAttributes ?? + valueFromObject ?? + def.default ?? + []; + + + // If the array is not an array, then warn and set it to an empty array + if (!Array.isArray(arr)) { + + Debug.warn(`The attribute '${path.join('.')}' is an array but the value provided is not an array.`); + setValueAtPath(object, path, []); + + } else { + // If the array is a simple type, just copy it + let value = [...arr]; + + // If the array is a complex type, then recurse through each element + if (!isSimpleType) value = arr.map(v => populateWithAttributes(app, def.type, v, {})); + + // Set the resulting array on the path + setValueAtPath(object, path, value); + } + + } else { + + const value = valueFromAttributes ?? def.default; + const mappedValue = rawToValue(app, def, value); + + // If we have a complex object (ie {type: CustomType}) then recurse into it + if (typeof def.type === 'object') { + + const localValue = setValueAtPath(object, [...path], {}); + populateWithAttributes(app, def.type, value, localValue); + + // We have a valid value so set it on the object + } else if (mappedValue != null) { + + setValueAtPath(object, [...path], mappedValue); + + // We have an invalid value so warn + } else if (value != null && mappedValue === null) { + + Debug.warn(`'${path.join('.')}' is a '${typeof value}' but a '${def.type}' was expected. Please see the attribute definition.`); + + // Assert the type is valid + } else { + + const isValidAttributeType = VALID_ATTR_TYPES.has(def.type); + Debug.assert(isValidAttributeType, `The attribute definition for '${path.join('.')}' is malformed with a type of '${def.type}'.`); + + } + } + }); + + // Perform a shallow comparison to warn of any attributes that are not defined in the definition + for (const key in attributes) { + if (attributeDefDict[key] === undefined) { + Debug.warn(`'${key}' is not defined. Please see the attribute definition.`); + } + } + + return object; + +} + + +const components = ['x', 'y', 'z', 'w']; +const vecLookup = [undefined, undefined, Vec2, Vec3, Vec4]; + +/** + * Converts raw attribute data to actual values. + * + * @param {import('../../app-base.js').AppBase} app - The app to use for asset lookup + * @param {AttributeDefinition} attributeDefinition - The attribute definition + * @param {*} value - The raw value to convert + * @returns {*} The converted value + */ +export function rawToValue(app, attributeDefinition, value) { + + const { type } = attributeDefinition; + + // If the type is an object, assume it's a complex type and simply return it + if (typeof type === 'object') return {}; + + switch (type) { + case 'boolean': + return !!value; + case 'app': + return value; + case 'number': + if (typeof value === 'number') { + return value; + } else if (typeof value === 'string') { + const v = parseInt(value, 10); + if (isNaN(v)) return null; + return v; + } else if (typeof value === 'boolean') { + return 0 + value; + } + return null; + case 'asset': + if (value instanceof Asset) { + return value; + } else if (typeof value === 'number') { + return app.assets.get(value) || null; + } else if (typeof value === 'string') { + return app.assets.get(parseInt(value, 10)) || null; + } + return null; + case 'entity': + if (value instanceof GraphNode) { + return value; + } else if (typeof value === 'string') { + return app.getEntityFromIndex(value); + } + return null; + case 'rgb': + case 'rgba': + if (value instanceof Color) { + // if (old instanceof Color) { + // old.copy(value); + // return old; + // } + return value.clone(); + } else if (value instanceof Array && value.length >= 3 && value.length <= 4) { + for (let i = 0; i < value.length; i++) { + if (typeof value[i] !== 'number') + return null; + } + // if (!old) + const color = new Color(); + + color.r = value[0]; + color.g = value[1]; + color.b = value[2]; + color.a = (value.length === 3) ? 1 : value[3]; + + return color; + } else if (typeof value === 'string' && /#([0-9abcdef]{2}){3,4}/i.test(value)) { + // if (!old) + const color = new Color(); + + color.fromString(value); + return color; + } + return null; + case 'vec2': + case 'vec3': + case 'vec4': { + const len = parseInt(type.slice(3), 10); + const vecType = vecLookup[len]; + + if (value instanceof vecType) { + // if (old instanceof vecType) { + // old.copy(value); + // return old; + // } + return value.clone(); + } else if (value instanceof Array && value.length === len) { + for (let i = 0; i < value.length; i++) { + if (typeof value[i] !== 'number') + return null; + } + // if (!old) + const vec = new vecType(); + + for (let i = 0; i < len; i++) + vec[components[i]] = value[i]; + + return vec; + } + return null; + } + case 'curve': + if (value) { + let curve; + if (value instanceof Curve || value instanceof CurveSet) { + curve = value.clone(); + } else { + const CurveType = value.keys[0] instanceof Array ? CurveSet : Curve; + curve = new CurveType(value.keys); + curve.type = value.type; + } + return curve; + } + break; + } +} diff --git a/src/framework/components/esmscript/component.js b/src/framework/components/esmscript/component.js new file mode 100644 index 00000000000..b078050f0c9 --- /dev/null +++ b/src/framework/components/esmscript/component.js @@ -0,0 +1,372 @@ +import { Debug } from '../../../core/debug.js'; +import { Component } from '../component.js'; +import { classHasMethod } from '../../../core/class-utils.js'; +import { forEachAttributeDefinition, getValueAtPath, populateWithAttributes, setValueAtPath } from './attribute-utils.js'; + +/** + * @callback UpdateFunction + * @param {number} dt - The time since the last update. + * @ignore + */ + +/** + * @typedef {Object} ModuleInstance + * @property {boolean} enabled - Whether the module is enabled or not + * @property {Function} [initialize] - A function called once when the module becomes initialized + * @property {Function} [postInitialize] - A function called once after all modules become initialized + * @property {Function} [active] - A function called when the module becomes active + * @property {Function} [inactive] - A function called when the module becomes inactive + * @property {UpdateFunction} [update] - A function called on game tick if the module is enabled + * @property {Function} [destroy] - A function called when the module should be destroyed + */ + +/** + * This type represents a generic class constructor. + * @typedef {Function} ModuleClass - The class constructor + * @property {string} name - The name of the class + * @property {AttributeDefinition} attributes - The attribute definitions for the class + */ + +/** + * @typedef {Object|Map} AttributeDefinition + * @property {'asset'|'boolean'|'curve'|'entity'|'json'|'number'|'rgb'|'rgba'|'string'|'vec2'|'vec3'|'vec4'} type - The attribute type + */ + + +const defaultScriptDefinition = { + app: { type: 'app' }, + entity: { type: 'entity' }, + enabled: { type: 'boolean', default: true } +}; + +/** + * The EsmScriptComponent extends the functionality of an Entity by + * allowing you to attach your own ESM modules to it. + * + * **The api is likely to change, use at your own discretion** + * @ignore + * @augments Component + */ +class EsmScriptComponent extends Component { + /** + * Create a new EsmScriptComponent instance. + * + * @param {import('./system.js').EsmScriptComponentSystem} system - The ComponentSystem that + * created this Component. + * @param {import('./../../../framework/entity.js').Entity} entity - The Entity that this Component is attached to. + */ + constructor(system, entity) { + super(system, entity); + + this.initialized = false; + + /** + * Object shorthand passed to scripts update and postUpdate. + */ + this.appEntity = { app: system.app, entity: entity }; + + /** + * Holds all ESM instances of this component. + * @type {Set} + */ + this.modules = new Set(); + + /** + * Holds a map of modules class names to instances to enable shorthand lookup. + * @type {Map} + */ + this.moduleNameInstanceMap = new Map(); + + // Holds all modules with an `update` method. + this.modulesWithUpdate = new Set(); + + // Holds all modules with a `postUpdate` method. + this.modulesWithPostUpdate = new Set(); + + // Contains all the uninitialized modules. + this.uninitializedModules = new Set(); + + } + + /** + * Fired when Component becomes enabled. Note: this event does not take in account entity or + * any of its parent enabled state. + * + * @event EsmScriptComponent#enable + * @example + * entity.esmscript.on('enable', function () { + * // component is enabled + * }); + */ + + /** + * Fired when Component becomes disabled. Note: this event does not take in account entity or + * any of its parent enabled state. + * + * @event EsmScriptComponent#disable + * @example + * entity.esmscript.on('disable', function () { + * // component is disabled + * }); + */ + + /** + * Fired when Component changes state to enabled or disabled. Note: this event does not take in + * account entity or any of its parent enabled state. + * + * @event EsmScriptComponent#state + * @param {boolean} enabled - True if now enabled, False if disabled. + * @example + * entity.esmscript.on('state', function (enabled) { + * // component changed state + * }); + */ + + /** + * Fired when Component is removed from entity. + * + * @event EsmScriptComponent#remove + * @example + * entity.esmscript.on('remove', function () { + * // entity has no more script component + * }); + */ + + /** + * Fired when an esm script instance is created and attached to component. + * + * @event EsmScriptComponent#create + * @param {ModuleInstance} moduleInstance - The module instance that was created. + * @example + * entity.esmscript.on('create', function (name, moduleInstance) { + * // new script instance added to component + * }); + */ + + /** + * Fired when a script instance is destroyed and removed from component. + * + * @event EsmScriptComponent#destroyed:[name] + * @param {ModuleInstance} moduleInstance - The module instance + * that has been destroyed. + * @example + * entity.esmscript.on('destroyed:playerController', function (moduleInstance) { + * // modules instance 'playerController' has been destroyed and removed from component + * }); + */ + + _onBeforeRemove() { + for (const module of this.modules) { + + const ModuleClass = module.constructor; + + // disables the module + module.enabled = false; + + // Call modules destroy if present + module.destroy?.(); + + // Remove from local data + this.modules.delete(module); + this.moduleNameInstanceMap.delete(ModuleClass.name); + } + } + + set enabled(value) { + this._enabled = value; + if (this.isActive) this.flushUninitializedModules(); + } + + get enabled() { + return !!this._enabled; + } + + get isActive() { + return this.enabled && this.entity.enabled; + } + + flushUninitializedModules() { + for (const module of this.uninitializedModules) { + if (!this.isActive) break; + if (!module.enabled/* && !this.awaitingToBeEnabledModules.has(module)*/) continue; + module.initialize(); + this.uninitializedModules.delete(module); + } + } + + onEnable() { + if (this.isActive) this.flushUninitializedModules(); + } + + _onInitialize() { + if (!this.initialized) this.flushUninitializedModules(); + this.initialized = true; + } + + _onPostInitialize() { + for (const module of this.modules) { + if (!this.isActive) break; + if (!module.enabled) module.postInitialize?.(); + } + } + + _onUpdate(dt) { + for (const module of this.modulesWithUpdate) { + if (!this.isActive) break; + if (module.enabled) module.update(dt); + } + } + + _onPostUpdate(dt) { + for (const module of this.modulesWithPostUpdate) { + if (!this.isActive) break; + if (module.enabled) module.postUpdate(dt); + } + } + + /** + * @internal + * @todo + * When an entity is cloned and it has entity script attributes that point to other entities in + * the same subtree that are also cloned, then we want the new script attributes to point at the + * cloned entities. This method remaps the script attributes for this entity and it assumes + * that this entity is the result of the clone operation. + * + * @param {EsmScriptComponent} oldScriptComponent - The source script component that belongs to + * the entity that was being cloned. + * @param {object} duplicatedIdsMap - A dictionary with guid-entity values that contains the + * entities that were cloned. + */ + resolveDuplicatedEntityReferenceProperties(oldScriptComponent, duplicatedIdsMap) { + + // for each module in the old component + for (const esmscript of oldScriptComponent.modules) { + + // Get the attribute definition for the specified esm script + const newModule = this.moduleNameInstanceMap.get(esmscript.constructor.name); + const attributeDefinitions = newModule.constructor.attributes || {}; + + // for each attribute definition + forEachAttributeDefinition(attributeDefinitions, (def, path) => { + + // If the attribute is an entity + if (def.type === 'entity') { + + // Get the value of the attribute + const entity = getValueAtPath(esmscript, path); + if (!entity) return; + + // Get the guid of the entity + const guid = entity.getGuid(); + + // If the guid is in the duplicatedIdsMap, then we need to update the value + if (guid && duplicatedIdsMap[guid]) { + setValueAtPath(newModule, path, duplicatedIdsMap[guid]); + } + } + }); + } + } + + /** + * Checks if the component contains an esm script. + * + * @param {string} moduleName - A case sensitive esm script name. + * @returns {boolean} If script is attached to an entity. + * @example + * if (entity.module.has('Rotator')) { + * // entity has script + * } + */ + has(moduleName) { + return this.moduleNameInstanceMap.has(moduleName); + } + + /** + * Returns a module instance from it's name + * + * @param {string} moduleName - A case sensitive esm script name. + * @returns {ModuleInstance|undefined} the module if attached to this component + * @example + * const rotator = entity.esmscript.get('Rotator') + */ + get(moduleName) { + return this.moduleNameInstanceMap.get(moduleName); + } + + /** + * Removes a module instance from the component. + * @param {ModuleInstance} module - The instance of the esm script to remove + */ + remove(module) { + + if (!this.modules.has(module)) { + Debug.warn(`The ESM Script '${module.constructor?.name}' has not been added to this component`); + return; + } + + // this.disableModule(module); + this.modules.delete(module); + this.moduleNameInstanceMap.delete(module.constructor.name); + } + + /** + * Adds an ESM Script class to the component system and assigns its attributes based on the `attributeDefinition` + * If the module is enabled, it will receive lifecycle updates. + * + * @param {ModuleClass} ModuleClass - The ESM Script class to add to the component + * @param {Object.} [attributeValues] - A set of attributes to be assigned to the Script Module instance + * @returns {ModuleInstance|null} An instance of the module + */ + add(ModuleClass, attributeValues = {}) { + + if (!ModuleClass || typeof ModuleClass !== 'function') + throw new Error(`The ESM Script is undefined`); + + if (!ModuleClass.name || ModuleClass.name === '') + throw new Error('Anonymous classes are not supported. Use `class MyClass{}` instead of `const MyClass = class{}`'); + + if (this.moduleNameInstanceMap.has(ModuleClass.name)) + throw new Error(`An ESM Script called '${ModuleClass.name}' has already been added to this component.`); + + // @ts-ignore + const attributeDefinition = ModuleClass.attributes || {}; + + // @ts-ignore + // Create the esm script instance + const module = new ModuleClass(); + + // Create an attribute definition and values with { app, entity } + const attributeDefinitionWithAppEntity = { ...attributeDefinition, ...defaultScriptDefinition }; + const attributeValueWithAppEntity = { ...attributeValues, ...this.appEntity }; + + // Assign any provided attributes + populateWithAttributes( + this.system.app, + attributeDefinitionWithAppEntity, + attributeValueWithAppEntity, + module); + + this.modules.add(module); + this.moduleNameInstanceMap.set(ModuleClass.name, module); + + // If the class has an initialize method ... + if (classHasMethod(ModuleClass, 'initialize')) { + + // If the component and hierarchy are currently active, initialize now. + if (this.isActive && module.enabled) module.initialize(); + + // otherwise mark to initialize later + else this.uninitializedModules.add(module); + } + + if (classHasMethod(ModuleClass, 'update')) this.modulesWithUpdate.add(module); + if (classHasMethod(ModuleClass, 'postUpdate')) this.modulesWithPostUpdate.add(module); + + this.fire('create', module); + + return module; + } +} + +export { EsmScriptComponent }; diff --git a/src/framework/components/esmscript/data.js b/src/framework/components/esmscript/data.js new file mode 100644 index 00000000000..a6be96860fe --- /dev/null +++ b/src/framework/components/esmscript/data.js @@ -0,0 +1,12 @@ +/** + * **The api is likely to change, use at your own discretion.** + * + * @ignore + */ +class EsmScriptComponentData { + constructor() { + this.enabled = true; + } +} + +export { EsmScriptComponentData }; diff --git a/src/framework/components/esmscript/system.js b/src/framework/components/esmscript/system.js new file mode 100644 index 00000000000..d05735e8629 --- /dev/null +++ b/src/framework/components/esmscript/system.js @@ -0,0 +1,134 @@ +import { Debug } from '../../../core/debug.js'; +import { ComponentSystem } from '../system.js'; +import { EsmScriptComponent } from './component.js'; +import { EsmScriptComponentData } from './data.js'; + +/** + * Allows scripts to be attached to an Entity and executed. + * + * **The api is likely to change, use at your own discretion** + * + * @ignore + * @augments ComponentSystem + */ +class EsmScriptComponentSystem extends ComponentSystem { + /** + * Create a new EsmScriptComponentSystem. + * + * @param {import('../../app-base.js').AppBase} app - The application. + * @hideconstructor + */ + + _components = new Set(); + + _componentDataMap = new Map(); + + constructor(app) { + super(app); + + Debug.warnOnce('The EsmScriptComponentSystem is experimental and the api is likely to change, use at your own discretion'); + + this.id = 'esmscript'; + + this.ComponentType = EsmScriptComponent; + this.DataType = EsmScriptComponentData; + + this.on('beforeremove', this._onBeforeRemove, this); + this.app.systems.on('initialize', this._onInitialize, this); + this.app.systems.on('update', this._onUpdate, this); + this.app.systems.on('postUpdate', this._onPostUpdate, this); + } + + initializeComponentData(component, data) { + + this._components.add(component); + this._componentDataMap.set(component, data); + const modules = data.modules || []; + + component.enabled = data.hasOwnProperty('enabled') ? !!data.enabled : true; + + // Initiate the imports concurrently + const esmScriptHandler = this.app.loader?.getHandler('esmscript'); + const scripts = modules.map(({ moduleSpecifier }) => esmScriptHandler.cache.get(moduleSpecifier)); + + // add the modules to the components + for (const i in modules) { + const { attributes, enabled } = modules[i]; + const script = scripts[i]; + if (script) + component.add(scripts[i], { ...attributes, enabled }); + } + } + + cloneComponent(entity, clone) { + + const component = entity.esmscript; + + Debug.assert(component, `The entity '${entity.name}' does not have an 'EsmScriptComponent' to clone`); + + const cloneComponent = this.addComponent(clone, { enabled: component.enabled }); + + component.modules.forEach((module) => { + const enabled = module.enabled; + + // Use the previous module's attributes as the default values for the new module + cloneComponent.add(module.constructor, module, enabled); + }); + + return cloneComponent; + + } + + _onInitialize() { + for (const component of this._components) { + if (component.enabled) + component._onInitialize(); + } + } + + _onPostInitialize() { + for (const component of this._components) { + if (component.enabled) + component._onPostInitialize(); + } + } + + _onUpdate(dt) { + for (const component of this._components) { + if (component.enabled) + component.flushUninitializedModules(); + } + + for (const component of this._components) { + if (component.enabled) + component._onUpdate(dt); + } + } + + _onPostUpdate(dt) { + for (const component of this._components) { + if (component.enabled) + component._onPostUpdate(dt); + } + } + + _onBeforeRemove(entity, component) { + if (this._components.has(component)) { + component._onBeforeRemove(); + } + + // remove from components array + this._components.delete(component); + this._componentDataMap.delete(component); + } + + destroy() { + super.destroy(); + + this.app.systems.off('initialize', this._onInitialize, this); + this.app.systems.off('update', this._onUpdate, this); + this.app.systems.off('postUpdate', this._onPostUpdate, this); + } +} + +export { EsmScriptComponentSystem }; diff --git a/src/framework/components/registry.js b/src/framework/components/registry.js index 50b0d5be007..178d6839374 100644 --- a/src/framework/components/registry.js +++ b/src/framework/components/registry.js @@ -69,6 +69,14 @@ class ComponentSystemRegistry extends EventHandler { */ element; + /** + * Gets the {@link EsmScriptComponentSystem} from the registry. + * + * @type {import('./esmscript/system.js').EsmScriptComponentSystem|undefined} + * @readonly + */ + esmscript; + /** * Gets the {@link JointComponentSystem} from the registry. * diff --git a/src/framework/entity.js b/src/framework/entity.js index e2948d60d5d..c518ffa99f9 100644 --- a/src/framework/entity.js +++ b/src/framework/entity.js @@ -650,6 +650,11 @@ function resolveDuplicatedEntityReferenceProperties(oldSubtreeRoot, oldEntity, n newEntity.script.resolveDuplicatedEntityReferenceProperties(components.script, duplicatedIdsMap); } + // Handle entity ESM Script attributes + if (components.esmscript) { + newEntity.esmscript.resolveDuplicatedEntityReferenceProperties(components.esmscript, duplicatedIdsMap); + } + // Handle entity render attributes if (components.render) { newEntity.render.resolveDuplicatedEntityReferenceProperties(components.render, duplicatedIdsMap); diff --git a/src/framework/handlers/esmscript.js b/src/framework/handlers/esmscript.js new file mode 100644 index 00000000000..41f0811cda6 --- /dev/null +++ b/src/framework/handlers/esmscript.js @@ -0,0 +1,104 @@ +import { AssetRegistry } from '../asset/asset-registry.js'; + +class ScriptCache { + _scripts = new Map(); + + register(path, script) { + const url = URL.canParse(path) ? new URL(path) : new URL(path, 'file://'); + this._scripts.set(url.pathname, script); + } + + get(path) { + const url = URL.canParse(path) ? new URL(path) : new URL(path, 'file://'); + return this._scripts.get(url.pathname); + } + + clear() { + this._scripts.clear(); + } +} + +/** + * This custom import statement for ES Modules conditionally + * checks and resolves modules in bundled or non bundled modules + * + * **The api is likely to change, use at your own discretion** + * + * @param {import('../app-base').AppBase} app - The application scope to load from. + * @param {string} moduleSpecifier - A unique path or specifier used to import the module + * @returns {Promise} Returns a promise which fulfills to a module namespace object: an object containing all exports from moduleName. + * + * @todo add support for bundle contexts + * @ignore + */ +export const DynamicImport = (app, moduleSpecifier) => { + + // If the `AssetRegistry.assetBaseUrl` is defined, use it as the base URL for the import. + const baseUrl = AssetRegistry?.assetBaseUrl ?? import.meta.url; + const isFileProtocol = import.meta.resolve(moduleSpecifier).startsWith('file:'); + + // If we are loading a local file, we don't need to append the baseUrl. + const path = isFileProtocol ? + moduleSpecifier : + `${baseUrl}/${moduleSpecifier}`; + + return import(`${path}`); +}; + +/** @typedef {import('./handler.js').ResourceHandler} ResourceHandler */ + +/** + * Resource handler for dynamically importing script es modules. + * @implements {ResourceHandler} + * @ignore + */ +class EsmScriptHandler { + /** + * Type of the resource the handler handles. + * + * @type {string} + */ + handlerType = "esmscript"; + + /** + * Create a new ScriptHandler instance. + * + * @param {import('../app-base.js').AppBase} app - The running {@link AppBase}. + * @hideconstructor + */ + constructor(app) { + this._app = app; + this._scripts = { }; + this.cache = new ScriptCache(); + } + + clearCache() { + this.cache.clear(); + } + + load(url, callback) { + + if (typeof url === 'string') { + url = { + load: url, + original: url + }; + } + + DynamicImport(this._app, url.load).then(({ default: module }) => { + this.cache.register(url.load, module); + callback(null, module, url); + }).catch((err) => { + callback(err); + }); + + } + + open(url, data) { + return data; + } + + patch(asset, assets) { } +} + +export { EsmScriptHandler }; diff --git a/src/framework/script/esm-script-type.js b/src/framework/script/esm-script-type.js new file mode 100644 index 00000000000..66afb8bd21c --- /dev/null +++ b/src/framework/script/esm-script-type.js @@ -0,0 +1,121 @@ +import { EventHandler } from "../../core/event-handler.js"; + +/** + * A base class for esm scripts that provide a similar set features and functionality + * to the traditional scripts interface. Events and 'enabled' prop. + * + * Note that implementing this base class is not required by the component system, it's + * simply to provide a convenient and familiar api to migrate users to the new ESM script system + */ +export class EsmScriptType extends EventHandler { + /** + * The {@link AppBase} that the instance of this type belongs to. + * + * @type {import('../entity.js').Entity} + */ + entity; + + /** + * The {@link AppBase} that the instance of this type belongs to. + * + * @type {import('../app-base.js').AppBase} + */ + app; + + set enabled(isEnabled) { + + if (isEnabled) { + this.entity.esmscript.enableModule(this); + } else { + this.entity.esmscript.disableModule(this); + } + + this.fire(isEnabled ? 'enable' : 'disable'); + this.fire('state', isEnabled); + } + + get enabled() { + return this.entity.esmscript.isModuleEnabled(this); + } +} + +/** + * The EsmScriptInterface below illustrates the set of features available for an esm script. + * + * ** Note you do not have to implement or extend this class directly, it's purely for illustrative purposes** + * + * All of the life-cycle methods below are optional, you only use what you need, but the'll be + * invoked where available. So if you just want to rotate an entity you'd likely only need the `update()` hook. + * @example update(){ this.entity.rotateLocal(0, 1, 0); } + * + * By design, this is a very minimal api with no dependencies on internal state or events, + * however we recognize this might not provide all the features you need which is why we've + * provided the {@link EsmScriptType} base class which you can extend and provides a familiar + * set of features to the older `ScriptType` class. The aim of this class isn't to provide + * an identical feature set, but to give a familiar set of functionality to begin using ESM Scripts. + * Of course, you can also use your own base class, on a per Script basis which gives you the freedom + * to build much more powerful Script types. + * + * The game loop for an ESM Script can be seen as... + * + * +--------------+ +--------------+ +-------------+ +--------------+ +--------------+ +--------------+ + * | initialize() | -> | active() | -> | update() | -> | postUpdate() | -> | inactive() | -> | destroy() | + * +--------------+ +--------------+ +-------------+ +--------------+ +--------------+ +--------------+ + * | Game Loop | + * +-------------------------------------------------------------------------+ + */ +export class EsmScriptInterface { + // Do not assume when the constructor will be called at a particular point in the application lifecycle + // eslint-disable-next-line no-useless-constructor + constructor() {} + + /** + * This lifecycle method is invoked immediately when added to an esm component + * with `esmscript.add(YourScript)`. It's only ever invoked once and should be + * used to initialize any internal state required by the script + */ + initialize() {} + + /** + * This lifecycle method is called as part of the the game loop before any + * other method. If a script, its component, entity or any parent entity + * in it's hierarchy becomes enabled in the previous in the scene hierarchy + */ + active() {} + + /** + * This method is called once per frame. It's a general purpose hook for updating + * and animating things every game tick. + * + * @param {number} dt - The delta time (time since the last frame) measured in milliseconds. + * @example update(dt){ this.entity.rotateLocal(0, 10 * dt, 0);} + */ + update(dt) {} + + /** + * This is a kind of late update method called immediately after every ESM Scripts has finished their + * `update()` step. It can be used as a way to guarantee the order of behaviors. For example, + * if you're positioning an object in one script and want a camera to follow it, you might use + * the `update` to position it in one script, and the `postUpdate` on the camera to follow it + * in a separate script. It allows you to keep your logic separate but guarantee things happen + * in a certain order. + * + * @param {number} dt - The delta time (time since the last frame) measured in milliseconds. + */ + postUpdate(dt) {} + + /** + * During the game loop entities, scripts and components often become disabled. If any of those + * happen which would result in this script becoming inactive, meaning that it won't participate + * in the next game loop, then this `inactive()` method will be called at the end of the current frame. + * This allows you to react to this as late as possible + */ + inactive() {} + + /** + * This method ia called immediately when an ESM Script, it's component or entity is either + * destroyed or removed from the scene hierarchy. It doesn't occur as part of the game loop, + * it's called immediately as a response to the Script being removed. + */ + destroy() {} +} diff --git a/src/index.js b/src/index.js index 29d614bdf57..83169a99853 100644 --- a/src/index.js +++ b/src/index.js @@ -196,6 +196,8 @@ export { ElementComponentSystem } from './framework/components/element/system.js export { ElementDragHelper } from './framework/components/element/element-drag-helper.js'; export { Entity } from './framework/entity.js'; export { EntityReference } from './framework/utils/entity-reference.js'; +export { EsmScriptComponent } from './framework/components/esmscript/component.js'; +export { EsmScriptComponentSystem } from './framework/components/esmscript/system.js'; export { ImageElement } from './framework/components/element/image-element.js'; export * from './framework/components/joint/constants.js'; export { JointComponent } from './framework/components/joint/component.js'; @@ -293,6 +295,7 @@ export { BundleHandler } from './framework/handlers/bundle.js'; export { ContainerHandler, ContainerResource } from './framework/handlers/container.js'; export { CssHandler } from './framework/handlers/css.js'; export { CubemapHandler } from './framework/handlers/cubemap.js'; +export { EsmScriptHandler } from './framework/handlers/esmscript.js'; export { FolderHandler } from './framework/handlers/folder.js'; export { FontHandler } from './framework/handlers/font.js'; export { HierarchyHandler } from './framework/handlers/hierarchy.js'; @@ -321,6 +324,7 @@ export { JsonStandardMaterialParser } from './framework/parsers/material/json-st // FRAMEWORK /SCRIPTS export { createScript, registerScript, getReservedScriptNames } from './framework/script/script.js'; +export { EsmScriptType } from './framework/script/esm-script-type.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'; diff --git a/src/platform/graphics/graphics-device-create.js b/src/platform/graphics/graphics-device-create.js index 3767e6157c4..b7c49f1ea04 100644 --- a/src/platform/graphics/graphics-device-create.js +++ b/src/platform/graphics/graphics-device-create.js @@ -107,5 +107,4 @@ function createGraphicsDevice(canvas, options = {}) { next(); }); } - export { createGraphicsDevice }; diff --git a/test/framework/components/esmscript/basic-app-options.mjs b/test/framework/components/esmscript/basic-app-options.mjs new file mode 100644 index 00000000000..1a1c470fd16 --- /dev/null +++ b/test/framework/components/esmscript/basic-app-options.mjs @@ -0,0 +1,38 @@ +import { AppOptions } from '../../../../src/framework/app-options.js'; +import { CameraComponentSystem } from '../../../../src/framework/components/camera/system.js'; +import { EsmScriptComponentSystem } from '../../../../src/framework/components/esmscript/system.js'; +import { LightComponentSystem } from '../../../../src/framework/components/light/system.js'; +import { RenderComponentSystem } from '../../../../src/framework/components/render/system.js'; +import { ScriptComponentSystem } from '../../../../src/framework/components/script/system.js'; +import { ContainerHandler } from '../../../../src/framework/handlers/container.js'; +import { TextureHandler } from '../../../../src/framework/handlers/texture.js'; +// import { DEVICETYPE_WEBGL1 } from '../../../../src/platform/graphics/constants.js'; + +// const gfxOptions = { +// deviceTypes: [DEVICETYPE_WEBGL1], +// // glslangUrl: '/editor/scene/js/launch/webgpu/glslang.js', +// // twgslUrl: '/editor/scene/js/launch/webgpu/twgsl.js', +// // powerPreference: powerPreference, +// // antialias: config.project.settings.antiAlias !== false, +// // alpha: config.project.settings.transparentCanvas !== false, +// // preserveDrawingBuffer: !!config.project.settings.preserveDrawingBuffer +// }; + +const createOptions = new AppOptions(); + +createOptions.componentSystems = [ + ScriptComponentSystem, + EsmScriptComponentSystem, + RenderComponentSystem, + CameraComponentSystem, + LightComponentSystem + +]; +createOptions.resourceHandlers = [ + // @ts-ignore + TextureHandler, + // @ts-ignore + ContainerHandler +]; + +export default createOptions; diff --git a/test/framework/components/esmscript/component.test.mjs b/test/framework/components/esmscript/component.test.mjs new file mode 100644 index 00000000000..c0f9d969941 --- /dev/null +++ b/test/framework/components/esmscript/component.test.mjs @@ -0,0 +1,1709 @@ +import { readFileSync } from 'fs'; +import { Application } from '../../../../src/framework/application.js'; +import { Debug } from '../../../../src/core/debug.js'; +import { Entity } from '../../../../src/framework/entity.js'; +import { Color } from '../../../../src/core/math/color.js'; + +import { HTMLCanvasElement } from '@playcanvas/canvas-mock'; +import { expect } from 'chai'; +import { reset, calls, expectCall, INITIALIZE, waitForNextFrame, UPDATE, POST_UPDATE, DESTROY } from './method-util.mjs'; + +// Can use import assertion, but eslint doesn't like it. +const sceneData = JSON.parse(readFileSync(new URL('../../../test-assets/esm-scripts/scene1.json', import.meta.url))); + +describe('EsmScriptComponent', function () { + + let app; + + beforeEach(function (done) { + + // Override the debug to remove console noise + Debug.warn = () => null; + Debug.error = () => null; + console.error = () => null; + + reset(); + const canvas = new HTMLCanvasElement(500, 500); + app = new Application(canvas); + + const handler = app.loader.getHandler('scene'); + + app._parseAssets({ + 1: { + tags: [], + name: 'scriptA.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'scriptA.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs' + }, + region: 'eu-west-1', + id: 1 + }, + 2: { + tags: [], + name: 'scriptB.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'scriptB.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs' + }, + region: 'eu-west-1', + id: 2 + }, + 3: { + tags: [], + name: 'cloner.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-cloner.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'cloner.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-cloner.mjs' + }, + region: 'eu-west-1', + id: 3 + }, + 4: { + tags: [], + name: 'enabler.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-enabler.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'enabler.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-enabler.mjs' + }, + region: 'eu-west-1', + id: 4 + }, + 5: { + tags: [], + name: 'disabler.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-disabler.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'disabler.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-disabler.mjs' + }, + region: 'eu-west-1', + id: 5 + }, + 6: { + tags: [], + name: 'scriptWithAttributes.mjs', + revision: 1, + preload: true, + meta: null, + data: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + attributes: { + attribute1: { type: 'entity' }, + attribute2: { type: 'number', default: 2 } + } + }], + type: 'esmscript', + file: { + filename: 'scriptWithAttributes.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs' + }, + region: 'eu-west-1', + id: 6 + }, + 7: { + tags: [], + name: 'loadedLater.mjs', + revision: 1, + preload: false, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-loadedLater.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'loadedLater.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-loadedLater.mjs' + }, + region: 'eu-west-1', + id: 7 + }, + 8: { + tags: [], + name: 'destroyer.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-destroyer.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'destroyer.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-destroyer.mjs' + }, + region: 'eu-west-1', + id: 8 + }, + 9: { + tags: [], + name: 'postCloner.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-postCloner.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'postCloner.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-postCloner.mjs' + }, + region: 'eu-west-1', + id: 9 + }, + 10: { + tags: [], + name: 'postInitializeReporter.mjs', + revision: 1, + preload: true, + meta: null, + data: { + enabled: true, + modules: [ + { moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-postInitializeReporter.mjs' } + ] + }, + type: 'esmscript', + file: { + filename: 'postInitializeReporter.mjs', + size: 1, + url: '../../../test/test-assets/esm-scripts/esm-postInitializeReporter.mjs' + }, + region: 'eu-west-1', + id: 10 + } + }); + + app.preload(function (err) { + if (err) throw err; + // app.scenes.loadScene('../../../test/test-assets/esm-scripts/scene1.json', function (err) { + // if (err) throw err; + const scene = handler.open(null, sceneData); + app.root.addChild(scene.root); + app.start(); + done(); + // }); + }); + + }); + + afterEach(function () { + app.destroy(); + }); + + describe('#initialize', function () { + + it('expects `entity` and `app` to be set on a new ESM Script', function () { + + const e = new Entity(); + const component = e.addComponent('esmscript', { + enabled: true, + modules: [{ + enabled: true, + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs' + }] + }); + + app.root.addChild(e); + + const script = component.get('ScriptA'); + + expect(script).to.exist; + expect(script.entity).to.equal(e); + expect(script.app).to.equal(component.system.app); + + }); + + it('expects `initialize` to be called on a new ESM Script', function () { + + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + enabled: true, + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs' + }] + }); + + app.root.addChild(e); + expect(e.esmscript).to.exist; + + const script = e.esmscript.get('ScriptA'); + expect(script).to.exist; + + expect(calls.length).to.equal(1); + expectCall(0, INITIALIZE(script)); + + }); + + it('expects `initialize` to be called on an entity that is enabled later', function () { + + const e = new Entity(); + e.enabled = false; + e.addComponent('esmscript', { + enabled: true, + modules: [{ + enabled: true, + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs' + }] + }); + + app.root.addChild(e); + expect(e.esmscript).to.exist; + + const script = e.esmscript.get('ScriptA'); + expect(script).to.exist; + + e.enabled = true; + + expectCall(0, INITIALIZE(script)); + + }); + + it('expects `initialize()` to be called on a component that is enabled later', function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: false, + modules: [{ + enabled: true, + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs' + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptA'); + expect(script).to.exist; + + expect(calls).to.have.a.lengthOf(0); + + e.esmscript.enabled = true; + + expectCall(0, INITIALIZE(script)); + + }); + + it('expects `initialize` to be called on an ESM Script that is enabled later', function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + enabled: false, + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs' + }] + }); + + app.root.addChild(e); + + // module is not active + expect(calls).to.have.a.lengthOf(0); + + const script = e.esmscript.get('ScriptA'); + + script.enabled = true; + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + expect(script.enabled).to.be.true; + expectCall(0, INITIALIZE(script)); + expectCall(1, UPDATE(script)); + expectCall(2, POST_UPDATE(script)); + }); + it('expects `initialize`, `active`, `update` and `postUpdate` to be called on a script instance that is created later', async function () { + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const ScriptA = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + e.esmscript.add(ScriptA.default); + + const script = e.esmscript.get('ScriptA'); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + expectCall(0, INITIALIZE(script)); + expectCall(1, UPDATE(script)); + expectCall(2, POST_UPDATE(script)); + }); + + it('expects `initialize`, `active`, `update` and `postUpdate` to be called on cloned enabled entity', function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: true, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: true, + attributes: {} + }] + }); + + app.root.addChild(e); + + const ScriptA = e.esmscript.get('ScriptA'); + const ScriptB = e.esmscript.get('ScriptB'); + + let n = 0; + expectCall(n++, INITIALIZE(ScriptA)); + expectCall(n++, INITIALIZE(ScriptB)); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + expectCall(n++, UPDATE(ScriptA)); + expectCall(n++, UPDATE(ScriptB)); + + expectCall(n++, POST_UPDATE(ScriptA)); + expectCall(n++, POST_UPDATE(ScriptB)); + + // reset calls + reset(); + + const clone = e.clone(); + app.root.addChild(clone); + + // clone is initialized + const cloneScriptA = clone.esmscript.get('ScriptA'); + const cloneScriptB = clone.esmscript.get('ScriptB'); + + n = 0; + expectCall(n++, INITIALIZE(cloneScriptA)); + expectCall(n++, INITIALIZE(cloneScriptB)); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + // existing scripts then clones updated + expectCall(n++, UPDATE(ScriptA)); + expectCall(n++, UPDATE(ScriptB)); + expectCall(n++, UPDATE(cloneScriptA)); + expectCall(n++, UPDATE(cloneScriptB)); + + // existing scripts then clones post-updated + expectCall(n++, POST_UPDATE(ScriptA)); + expectCall(n++, POST_UPDATE(ScriptB)); + expectCall(n++, POST_UPDATE(cloneScriptA)); + expectCall(n++, POST_UPDATE(cloneScriptB)); + + }); + + it('expects `update` not to be called when a script disables itself', async function () { + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const Disabler = await import('../../../test-assets/esm-scripts/esm-disabler.mjs'); + e.esmscript.add(Disabler.default); + + const DisablerScript = e.esmscript.get('Disabler'); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + expectCall(0, INITIALIZE(DisablerScript)); + }); + + it('expects that all `initialize` calls are before `update` calls when enabling entity from inside a separate `initialize` call', function () { + + const e = new Entity('entity to enable'); + e.enabled = false; + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: true, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: true, + attributes: {} + }] + }); + + app.root.addChild(e); + + expect(calls).to.have.lengthOf(0); + + const enabler = new Entity('enabler'); + enabler.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-enabler.mjs', + enabled: true, + attributes: { + entityToEnable: e + } + }] + }); + + app.root.addChild(enabler); + + const enablerScript = enabler.esmscript.get('Enabler'); + const scriptA = e.esmscript.get('ScriptA'); + const scriptB = e.esmscript.get('ScriptB'); + + // scripts should exist + expect(enablerScript).to.exist; + expect(scriptA).to.exist; + expect(scriptB).to.exist; + + expect(calls).to.have.lengthOf(3); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + expect(calls).to.have.lengthOf(7); + + let n = 0; + expectCall(n++, INITIALIZE(enablerScript)); // 'initialize enabler'); + expectCall(n++, INITIALIZE(scriptA)); // 'initialize scriptA'); + expectCall(n++, INITIALIZE(scriptB)); // 'initialize scriptB'); + expectCall(n++, UPDATE(scriptA)); // 'update scriptA'); + expectCall(n++, UPDATE(scriptB)); // 'update scriptB'); + expectCall(n++, POST_UPDATE(scriptA)); // 'post-update scriptA'); + expectCall(n++, POST_UPDATE(scriptB)); // 'post-update scriptB'); + + }); + + it('expects all `active` calls are called before `update` for an entity whose script component is enabled inside a separate `initialize` call', function () { + + // Create a disabled entity, awaiting to be enabled + const e = new Entity('entity to enable'); + e.addComponent('esmscript', { + enabled: false, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: true, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: true, + attributes: {} + }] + }); + + app.root.addChild(e); + + expect(calls).to.have.lengthOf(0); + + // Create an entity/script that enables the previous entity + const enabler = new Entity('enabler'); + enabler.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-enabler.mjs', + enabled: true, + attributes: { + entityToEnable: e + } + }] + }); + + app.root.addChild(enabler); + + expect(calls).to.have.lengthOf(3); + + const enablerScript = enabler.esmscript.get('Enabler'); + const scriptA = e.esmscript.get('ScriptA'); + const scriptB = e.esmscript.get('ScriptB'); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + expect(calls).to.have.lengthOf(7); + + let n = 0; + expectCall(n++, INITIALIZE(enablerScript)); // 'initialize enabler'); + expectCall(n++, INITIALIZE(scriptA)); // 'initialize scriptA'); + expectCall(n++, INITIALIZE(scriptB)); // 'initialize scriptB'); + expectCall(n++, UPDATE(scriptA)); // 'update scriptA'); + expectCall(n++, UPDATE(scriptB)); // 'update scriptB'); + expectCall(n++, POST_UPDATE(scriptA)); // 'post-update scriptA'); + expectCall(n++, POST_UPDATE(scriptB)); // 'post-update scriptB'); + + }); + + it('expects `initialize` is called together for script instance that when during the initialize stage', function () { + // Create a disabled entity, awaiting to be enabled + const e = new Entity('entity to enable'); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: false, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: false, + attributes: {} + }] + }); + + app.root.addChild(e); + + expect(calls).to.have.lengthOf(0); + + // Create an entity/script that enables the previous entity + const enabler = new Entity('enabler'); + enabler.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-enabler.mjs', + enabled: true, + attributes: { + entityToEnable: e + } + }] + }); + + app.root.addChild(enabler); + + const enablerScript = enabler.esmscript.get('Enabler'); + const scriptA = e.esmscript.get('ScriptA'); + const scriptB = e.esmscript.get('ScriptB'); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + let n = 0; + expectCall(n++, INITIALIZE(enablerScript)); // 'initialize enabler'); + expectCall(n++, INITIALIZE(scriptA)); // 'initialize scriptA'); + expectCall(n++, INITIALIZE(scriptB)); // 'initialize scriptB'); + expectCall(n++, UPDATE(scriptA)); // 'update scriptA'); + expectCall(n++, UPDATE(scriptB)); // 'update scriptB'); + expectCall(n++, POST_UPDATE(scriptA)); // 'post-update scriptA'); + expectCall(n++, POST_UPDATE(scriptB)); // 'post-update scriptB'); + + }); + + it('expects `initialize` is called for entity and all children before `active` and `update`', function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: true, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: true, + attributes: {} + }] + }); + + const eScriptA = e.esmscript.get('ScriptA'); + const eScriptB = e.esmscript.get('ScriptB'); + + const c1 = new Entity('c1'); + c1.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: true, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: true, + attributes: {} + }] + }); + + const c1ScriptA = c1.esmscript.get('ScriptA'); + const c1ScriptB = c1.esmscript.get('ScriptB'); + + e.addChild(c1); + + const c2 = new Entity('c2'); + c2.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: true, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: true, + attributes: {} + }] + }); + + const c2ScriptA = c2.esmscript.get('ScriptA'); + const c2ScriptB = c2.esmscript.get('ScriptB'); + + e.addChild(c2); + + const c3 = new Entity('c3'); + c3.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptA.mjs', + enabled: true, + attributes: {} + }, { + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptB.mjs', + enabled: true, + attributes: {} + }] + }); + + const c3ScriptA = c3.esmscript.get('ScriptA'); + const c3ScriptB = c3.esmscript.get('ScriptB'); + + c1.addChild(c3); + app.root.addChild(e); + + expect(calls).to.have.lengthOf(8); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + let idx = -1; + expectCall(++idx, INITIALIZE(eScriptA)); + expectCall(++idx, INITIALIZE(eScriptB)); + expectCall(++idx, INITIALIZE(c1ScriptA)); + expectCall(++idx, INITIALIZE(c1ScriptB)); + expectCall(++idx, INITIALIZE(c3ScriptA)); + expectCall(++idx, INITIALIZE(c3ScriptB)); + expectCall(++idx, INITIALIZE(c2ScriptA)); + expectCall(++idx, INITIALIZE(c2ScriptB)); + + expectCall(++idx, UPDATE(eScriptA)); + expectCall(++idx, UPDATE(eScriptB)); + expectCall(++idx, UPDATE(c1ScriptA)); + expectCall(++idx, UPDATE(c1ScriptB)); + expectCall(++idx, UPDATE(c2ScriptA)); + expectCall(++idx, UPDATE(c2ScriptB)); + expectCall(++idx, UPDATE(c3ScriptA)); + expectCall(++idx, UPDATE(c3ScriptB)); + + expectCall(++idx, POST_UPDATE(eScriptA)); + expectCall(++idx, POST_UPDATE(eScriptB)); + expectCall(++idx, POST_UPDATE(c1ScriptA)); + expectCall(++idx, POST_UPDATE(c1ScriptB)); + expectCall(++idx, POST_UPDATE(c2ScriptA)); + expectCall(++idx, POST_UPDATE(c2ScriptB)); + expectCall(++idx, POST_UPDATE(c3ScriptA)); + expectCall(++idx, POST_UPDATE(c3ScriptB)); + + }); + + it('should initialize script attributes for an enabled entity', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes on a disabled entity', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.enabled = false; + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + }); + + it('should initialize script with attributes on a disabled script component', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.addComponent('esmscript', { + enabled: false, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes on a disabled script instance', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: false, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes when cloning an enabled entity', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + + const clone = e.clone(); + app.root.addChild(clone); + + const clonedModule = clone.esmscript.get('ScriptWithAttributes'); + + expect(clonedModule.attribute1).to.equal(e2); + expect(clonedModule.attribute2).to.equal(2); + + }); + + it('should initialize a script with attributes when cloning a disabled entity', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.enabled = false; + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + + const clone = e.clone(); + app.root.addChild(clone); + + const clonedModule = clone.esmscript.get('ScriptWithAttributes'); + + expect(clonedModule.attribute1).to.equal(e2); + expect(clonedModule.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes when cloning a disabled script component', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.addComponent('esmscript', { + enabled: false, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + + const clone = e.clone(); + app.root.addChild(clone); + + const clonedModule = clone.esmscript.get('ScriptWithAttributes'); + + expect(clonedModule.attribute1).to.equal(e2); + expect(clonedModule.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes when cloning a disabled script instance', function () { + const e2 = new Entity(); + app.root.addChild(e2); + + expect(e2).to.exist; + + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: false, + attributes: { + attribute1: e2 + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(e2); + expect(script.attribute2).to.equal(2); + + const clone = e.clone(); + app.root.addChild(clone); + + const clonedModule = clone.esmscript.get('ScriptWithAttributes'); + + expect(clonedModule.attribute1).to.equal(e2); + expect(clonedModule.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes when loading a scene with an enabled entity', async function () { + const a = app.root.findByName('EnabledEntity'); + const b = app.root.findByName('ReferencedEntity'); + + expect(a).to.exist; + expect(b).to.exist; + + await waitForNextFrame(); + + const scriptWithAttributes = a.esmscript.get('ScriptWithAttributes'); + + expect(scriptWithAttributes).to.exist; + expect(scriptWithAttributes.attribute1).to.equal(b); + expect(scriptWithAttributes.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes when loading a scene with a disabled entity', async function () { + const a = app.root.findByName('DisabledEntity'); + const b = app.root.findByName('ReferencedEntity'); + + expect(a).to.exist; + expect(b).to.exist; + + await waitForNextFrame(); + + const scriptWithAttributes = a.esmscript.get('ScriptWithAttributes'); + + expect(scriptWithAttributes).to.exist; + expect(scriptWithAttributes.attribute1).to.equal(b); + expect(scriptWithAttributes.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes when loading a scene for a disabled script component', async function () { + const a = app.root.findByName('DisabledScriptComponent'); + const b = app.root.findByName('ReferencedEntity'); + + expect(a).to.exist; + expect(b).to.exist; + + await waitForNextFrame(); + + const scriptWithAttributes = a.esmscript.get('ScriptWithAttributes'); + + expect(scriptWithAttributes).to.exist; + expect(scriptWithAttributes.attribute1).to.equal(b); + expect(scriptWithAttributes.attribute2).to.equal(2); + }); + + it('should initialize a script with attributes when loading scene for disabled script instance', async function () { + const a = app.root.findByName('DisabledScriptInstance'); + const b = app.root.findByName('ReferencedEntity'); + + expect(a).to.exist; + expect(b).to.exist; + + await waitForNextFrame(); + + const scriptWithAttributes = a.esmscript.get('ScriptWithAttributes'); + + expect(scriptWithAttributes).to.exist; + expect(scriptWithAttributes.attribute1).to.equal(b); + expect(scriptWithAttributes.attribute2).to.equal(2); + }); + }); + + // it('should initialize a script with attributes when reloading a scene', function (done) { + // // destroy current scene + // app.root.children[0].destroy(); + + // expect(app.root.findByName('ReferencedEntity')).to.not.exist; + + // // verify entities are not there anymore + // var names = ['EnabledEntity', 'DisabledEntity', 'DisabledScriptComponent', 'DisabledScriptInstance']; + // names.forEach(function (name) { + // expect(app.root.findByName(name)).to.not.exist; + // }) + + // app.loadSceneHierarchy('../../../test/test-assets/esm-scripts/esm-scene1.json', function () { + + // // verify entities are loaded + // names.forEach(function (name) { + // expect(app.root.findByName(name)).to.exist; + // }) + + // var referenced = app.root.findByName('ReferencedEntity'); + + // // verify script attributes are initialized + // names.forEach(async function (name) { + // var e = app.root.findByName(name); + // await waitForNextFrame(); + + // expect(e.esmscript).to.exist; + + // const scriptWithAttributes = e.esmscript.get('ScriptWithAttributes'); + + // expect(scriptWithAttributes).to.exist; + // expect(scriptWithAttributes.attribute1).to.equal(referenced); + // expect(scriptWithAttributes.attribute2).to.equal(2); + // done(); + // }); + + // }); + // }); + + describe('script attributes', function () { + + it('expects invalid or malformed script attributes to initialize correctly', async function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + invalidAttribute: 'Should not instantiate', + invalidAttributeType: 'Should not instantiate', + invalidAttributeTypeWithDefault: 'Should not instantiate', + invalidAttributeTypeArray: ['Should not instantiate'] + } + }] + }); + + app.root.addChild(e); + + await waitForNextFrame(); + + const script = e.esmscript.get('ScriptWithAttributes'); + + // invalid attribute + expect(script.invalidAttribute).to.equal(undefined); + + // invalid attribute type + expect(script.invalidAttributeType).to.equal(undefined); + + // invalid attribute type with default + expect(script.invalidAttributeTypeWithDefault).to.equal(undefined); + + // invalid attribute type with array + expect(script.invalidAttributeTypeArray).to.be.an.instanceOf(Array); + + }); + + it('should initialize simple script attributes with their correct defaults', function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + // simple attribute - No default + expect(script.simpleAttributeNoDefault).to.equal(undefined); + + // simple attribute - No default + expect(script.simpleAttributeWithFalsyDefault).to.equal(false); + + // simple attribute - w/ default + expect(script.simpleAttribute).to.equal(10); + + // simple color attribute - w/ default + expect(script.simpleColorHex).to.be.an.instanceof(Color); + + // simple attribute - w/ default + expect(script.simpleColorAsArray).to.be.an.instanceof(Color); + + // simple attribute array - w/ default + expect(script.simpleAttributeArray).to.be.an.instanceof(Array); + expect(script.simpleAttributeArray).to.have.lengthOf(1); + expect(script.simpleAttributeArray[0]).to.equal(10); + + // Simple attribute array with an invalid default + expect(script.simpleAttributeArrayInvalidDefault).to.be.an.instanceOf(Array); + expect(script.simpleAttributeArrayInvalidDefault).to.have.lengthOf(0); + + // simple attribute array - no default + expect(script.simpleAttributeArrayNoDefault).to.be.an.instanceof(Array); + expect(script.simpleAttributeArrayNoDefault).to.have.lengthOf(0); + + // simple attribute array - w/ default array + expect(script.simpleAttributeArrayWithDefaultArray).to.be.an.instanceof(Array); + expect(script.simpleAttributeArrayWithDefaultArray).to.have.lengthOf(3); + expect(script.simpleAttributeArrayWithDefaultArray).to.deep.equal([10, 20, 30]); + + }); + + it('should override simple script attributes with their correct values', async function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + simpleAttributeNoDefault: 54321, + simpleAttributeWithFalsyDefault: true, + simpleAttribute: 1234, + simpleColorHex: [0.5, 0.5, 0.5, 0.5], + simpleColorAsArray: [0.9, 0.8, 0.7, 0.6], + simpleAttributeArray: [1, 2, 4, 5], + simpleAttributeArrayInvalidDefault: [123], + simpleAttributeArrayNoDefault: [1, 2, 3, 4], + simpleAttributeArrayWithDefaultArray: [9, 8, 7] + } + }] + }); + + app.root.addChild(e); + + await waitForNextFrame(); + + const script = e.esmscript.get('ScriptWithAttributes'); + + // simple attribute - No default + expect(script.simpleAttributeNoDefault).to.equal(54321); + + // // simple attribute - No default + expect(script.simpleAttributeWithFalsyDefault).to.equal(true); + + // // simple attribute - w/ default + expect(script.simpleAttribute).to.equal(1234); + + // simple color attribute - w/ default + expect(script.simpleColorHex).to.be.an.instanceof(Color); + expect({ ...script.simpleColorHex }).to.deep.equal({ r: 0.5, g: 0.5, b: 0.5, a: 0.5 }); + + // simple attribute - w/ default + expect(script.simpleColorAsArray).to.be.an.instanceof(Color); + expect({ ...script.simpleColorAsArray }).to.deep.equal({ r: 0.9, g: 0.8, b: 0.7, a: 0.6 }); + + // simple attribute array - w/ default + expect(script.simpleAttributeArray).to.be.an.instanceof(Array); + expect(script.simpleAttributeArray).to.have.lengthOf(4); + expect(script.simpleAttributeArray).to.deep.equal([1, 2, 4, 5]); + + // Simple attribute array with an invalid default + expect(script.simpleAttributeArrayInvalidDefault).to.be.an.instanceOf(Array); + expect(script.simpleAttributeArrayInvalidDefault).to.have.lengthOf(1); + expect(script.simpleAttributeArrayInvalidDefault).to.deep.equal([123]); + + // simple attribute array - no default + expect(script.simpleAttributeArrayNoDefault).to.be.an.instanceof(Array); + expect(script.simpleAttributeArrayNoDefault).to.have.lengthOf(4); + expect(script.simpleAttributeArrayNoDefault).to.deep.equal([1, 2, 3, 4]); + + // simple attribute array - w/ default array + expect(script.simpleAttributeArrayWithDefaultArray).to.be.an.instanceof(Array); + expect(script.simpleAttributeArrayWithDefaultArray).to.have.lengthOf(3); + expect(script.simpleAttributeArrayWithDefaultArray).to.deep.equal([9, 8, 7]); + + }); + + it('should initialize complex script attributes with their correct values', async function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true + }] + }); + + app.root.addChild(e); + + await waitForNextFrame(); + + const script = e.esmscript.get('ScriptWithAttributes'); + + // complex attribute - no default + expect(script.complexAttributeNoDefault).to.be.a('object'); + expect(script.complexAttributeNoDefault.internalNumberNoDefault).to.equal(undefined); + expect(script.complexAttributeNoDefault.internalNumber).to.equal(1); + expect(script.complexAttributeNoDefault.internalArrayNoDefault).to.deep.equal([]); + expect(script.complexAttributeNoDefault.internalArray).to.deep.equal([1, 2, 3, 4]); + expect(script.complexAttributeNoDefault.internalColor).to.be.an.instanceOf(Color); + + // complex attribute - w/ default + expect(script.complexAttribute).to.be.a('object'); + expect(script.complexAttribute.internalNumberNoDefault).to.equal(10); + expect(script.complexAttribute.internalNumber).to.equal(1); + expect(script.complexAttribute.internalArrayNoDefault).to.deep.equal([]); + expect(script.complexAttribute.internalArray).to.deep.equal([6, 7, 8, 9]); + expect(script.complexAttribute.internalColor).to.be.an.instanceOf(Color); + expect(script.complexAttribute.nonExistent).to.equal(undefined); + + }); + + it('should override complex script attributes with their correct defaults', async function () { + const e = new Entity(); + e.addComponent('esmscript', { + enabled: true, + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + complexAttributeNoDefault: { + internalNumberNoDefault: 20, + internalNumber: 10, + internalArrayNoDefault: [1, 2, 3], + internalArray: [9, 8, 7], + internalColor: '#ffffff' + }, + complexAttribute: { + internalNumberNoDefault: 1, + internalNumber: 2, + internalArrayNoDefault: [1, 2, 3], + internalArray: [9, 8, 7], + internalColor: '#ffffff', + nonExistent: 'SHOULD NOT EXIST' + } + } + }] + }); + + app.root.addChild(e); + + await waitForNextFrame(); + + const script = e.esmscript.get('ScriptWithAttributes'); + + // complex attribute - no default + expect(script.complexAttributeNoDefault).to.be.a('object'); + expect(script.complexAttributeNoDefault.internalNumberNoDefault).to.equal(20); + expect(script.complexAttributeNoDefault.internalNumber).to.equal(10); + expect(script.complexAttributeNoDefault.internalArrayNoDefault).to.deep.equal([1, 2, 3]); + expect(script.complexAttributeNoDefault.internalArray).to.deep.equal([9, 8, 7]); + expect(script.complexAttributeNoDefault.internalColor).to.be.an.instanceOf(Color); + expect({ ...script.complexAttributeNoDefault.internalColor }).to.deep.equal({ r: 1, g: 1, b: 1, a: 1 }); + + // complex attribute - w/ default + expect(script.complexAttribute).to.be.a('object'); + expect(script.complexAttribute.internalNumberNoDefault).to.equal(1); + expect(script.complexAttribute.internalNumber).to.equal(2); + expect(script.complexAttribute.internalArrayNoDefault).to.deep.equal([1, 2, 3]); + expect(script.complexAttribute.internalArray).to.deep.equal([9, 8, 7]); + expect(script.complexAttribute.internalColor).to.be.an.instanceOf(Color); + expect({ ...script.complexAttribute.internalColor }).to.deep.equal({ r: 1, g: 1, b: 1, a: 1 }); + expect(script.complexAttribute.nonExistent).to.equal(undefined); + + }); + + it('should initialize scripts with attributes with default values', function () { + const e = new Entity(); + e.addComponent('esmscript', { + modules: [{ + moduleSpecifier: '../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs', + enabled: true, + attributes: { + attribute2: 3, + attribute3: { + internalNumber: 10, + internalArrayNoDefault: [4] + } + } + }] + }); + + app.root.addChild(e); + + const script = e.esmscript.get('ScriptWithAttributes'); + + expect(script).to.exist; + expect(script.attribute3.internalNumber).to.equal(10); + + expect(script.attribute3.internalArrayNoDefault).to.deep.equal([4]); + expect(script.attribute3.internalArray).to.deep.equal([1, 2, 3, 4]); + }); + }); + + describe('Warnings and notifications', function () { + + + it('should warn when attempting to add an undefined script', function () { + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + + const EsmScript = null; + + expect(_ => e.esmscript.add(EsmScript)).to.throw; + }); + + it('should warn when attempting to add a script that has already been added', async function () { + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + const EsmScript2 = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + + e.esmscript.add(EsmScript.default); + + expect(_ => e.esmscript.add(EsmScript2.default)).to.throw; + }); + + it('should warn when attempting to add an anonymous ESM Script', function () { + + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = class {}; + + expect(_ => e.esmscript.add(EsmScript)).to.throw; + + }); + + it('should warn when attempting to assign values that aren\'t present in the attribute definition', async function () { + + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptWithSimpleAttributes.mjs'); + + // collect warnings + const warnings = []; + Debug.warn = warning => warnings.push(warning); + + e.esmscript.add(EsmScript.default, { + attribute3: 'This should warn that it hasn\'t been defined', + attribute4: 'this too' + }); + + expect(warnings).to.have.a.lengthOf(2); + expect(warnings[0]).to.equal('\'attribute3\' is not defined. Please see the attribute definition.'); + expect(warnings[1]).to.equal('\'attribute4\' is not defined. Please see the attribute definition.'); + + }); + + it('should warn when assigning an invalid value type to a attribute', async function () { + + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptWithSimpleAttributes.mjs'); + + // collect warnings + const warnings = []; + Debug.warn = warning => warnings.push(warning); + + e.esmscript.add(EsmScript.default, { + simpleAttribute: 'This should warn', + simpleAttributeNoDefault: 'Yep, another warning' + }); + + expect(warnings).to.have.a.lengthOf(2); + expect(warnings[0]).to.equal('\'simpleAttributeNoDefault\' is a \'string\' but a \'number\' was expected. Please see the attribute definition.'); + expect(warnings[1]).to.equal('\'simpleAttribute\' is a \'string\' but a \'number\' was expected. Please see the attribute definition.'); + + }); + + it('should raise an error when attributes definitions are malformed', async function () { + + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptWithAttributes.mjs'); + + // collect errors + const errors = []; + console.error = (error, args) => errors.push(args); + + e.esmscript.add(EsmScript.default); + + expect(errors[0]).to.equal('The attribute definition for \'invalidAttributeType\' is malformed with a type of \'An invalid attribute type\'.'); + + }); + + }); + + describe('cloning', function () { + + it('should clone an ESM Script component and retain the correct attributes as copies', async function () { + + const colorAttribute = new Color(1, 0, 0, 1); + + const e = new Entity(); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptWithSimpleAttributes.mjs'); + const component = e.addComponent('esmscript'); + component.add(EsmScript.default, { colorAttribute }); + app.root.addChild(e); + + const clone = e.clone(); + app.root.addChild(clone); + + await waitForNextFrame(); + + const script = e.esmscript.get('ScriptWithSimpleAttributes'); + const clonedScript = clone.esmscript.get('ScriptWithSimpleAttributes'); + + expect(e.esmscript).to.exist; + console.log(script); + expect(script).to.exist; + expect(script.colorAttribute).to.not.equal(colorAttribute); + expect(script.colorAttribute).to.deep.equal(colorAttribute); + expect(clonedScript.colorAttribute).to.not.equal(script.colorAttribute); + expect(clonedScript.colorAttribute).to.deep.equal(script.colorAttribute); + + }); + + it('should clone an ESM Script component that contains an entity attribute and retain the correct attributes', async function () { + + const a = app.root.findByName('EnabledEntity'); + const b = app.root.findByName('ReferencedEntity'); + + expect(a).to.exist; + expect(b).to.exist; + + await waitForNextFrame(); + + const scriptWithAttributes = a.esmscript.get('ScriptWithAttributes'); + + // verify script attributes are initialized + expect(scriptWithAttributes).to.exist; + expect(scriptWithAttributes.attribute1).to.equal(b); + + const clone = a.clone(); + app.root.addChild(clone); + + // verify cloned script attributes are initialized + const clonedScriptWithAttributes = clone.esmscript.get('ScriptWithAttributes'); + expect(clonedScriptWithAttributes).to.exist; + expect(clonedScriptWithAttributes.attribute1).to.equal(b); + + }); + + it('should clone a script component that contains entity attributes', async function () { + + const parent = new Entity('parent'); + const child = new Entity('child'); + parent.addChild(child); + app.root.addChild(parent); + + parent.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptWithAttributes.mjs'); + + parent.esmscript.add(EsmScript.default, { + attribute1: child, + folder: { + entityAttribute: child + } + }); + + const script = parent.esmscript.get('ScriptWithAttributes'); + + expect(script.attribute1).to.equal(child); + expect(script.folder.entityAttribute).to.equal(child); + + const clone = parent.clone(); + const clonedScript = clone.esmscript.get('ScriptWithAttributes'); + + expect(clonedScript.attribute1 !== child).to.be.true; + expect(clonedScript.folder.entityAttribute === child).to.be.false; + + }); + + }); + + describe('enabling', function () { + + it('should not call any lifecycle methods if disabled', async function () { + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + e.esmscript.add(EsmScript.default, { enabled: false }); + + const script = e.esmscript.get('ScriptA'); + + // The script should exist + expect(script).to.exist; + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + // Destroy should have been called + expect(calls).to.have.a.lengthOf(0); + }); + + it('should call all lifecycle methods after being enabled', async function () { + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + e.esmscript.add(EsmScript.default); + + const script = e.esmscript.get('ScriptA'); + + // The script should exist + expect(script).to.exist; + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + script.enabled = true; + + app.update(16.6); + + // Destroy should have been called + expectCall(0, INITIALIZE(script)); + expectCall(1, UPDATE(script)); + expectCall(2, POST_UPDATE(script)); + }); + + it('should not call any lifecycle methods after being disabled', async function () { + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + e.esmscript.add(EsmScript.default); + + const script = e.esmscript.get('ScriptA'); + + // The script should exist + expect(script).to.exist; + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + + script.enabled = true; + + app.update(16.6); + + // Destroy should have been called + expectCall(0, INITIALIZE(script)); + expectCall(1, UPDATE(script)); + expectCall(2, POST_UPDATE(script)); + + app.update(16.6); + reset(); + script.enabled = false; + + app.update(16.6); + + // only one call this frame + expect(calls).to.have.lengthOf(0); + + }); + + }); + + describe('destroy', function () { + + it('should destroy an ESM Script component', async function () { + + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + e.esmscript.add(EsmScript.default); + + const script = e.esmscript.get('ScriptA'); + + // The script should exist + expect(script).to.exist; + + // Reset the calls + reset(); + + // destroy the entity + e.destroy(); + + // Destroy should have been called + expectCall(0, DESTROY(script)); + + }); + + it('should not call any more methods after a ESM Script is destroyed', async function () { + + const e = new Entity(); + app.root.addChild(e); + e.addComponent('esmscript'); + const EsmScript = await import('../../../test-assets/esm-scripts/esm-scriptA.mjs'); + e.esmscript.add(EsmScript.default); + + const script = e.esmscript.get('ScriptA'); + + // The script should exist + expect(script).to.exist; + + // Reset the calls + reset(); + + // destroy the entity + e.destroy(); + + // Destroy should have been called + expectCall(0, DESTROY(script)); + + // Reset the calls + reset(); + + // Node doesn't have `requestAnimationFrame` so manually trigger a tick + app.update(16.6); + app.update(16.6); + + expect(calls).to.have.lengthOf(0); + + }); + + }); + +}); diff --git a/test/framework/components/esmscript/method-util.mjs b/test/framework/components/esmscript/method-util.mjs new file mode 100644 index 00000000000..6df6573db2f --- /dev/null +++ b/test/framework/components/esmscript/method-util.mjs @@ -0,0 +1,29 @@ +import { expect } from 'chai'; + +/** + * Utility functions for testing methods across modules + */ + +export const calls = []; +export const call = args => calls.push(args); +export const reset = () => calls.splice(0, calls.length); +export const expectCall = (index, text) => expect(calls[index]).to.equal(text); + +/** + * String generators for comparative checking + */ + +export const INITIALIZE = script => `initialize ${script.constructor.name} ${script.entity.getGuid()}`; +export const POST_INITIALIZE = script => `postInitialize ${script.constructor.name} ${script.entity.getGuid()}`; +export const UPDATE = script => `update ${script.constructor.name} ${script.entity.getGuid()}`; +export const POST_UPDATE = script => `postUpdate ${script.constructor.name} ${script.entity.getGuid()}`; +export const DESTROY = script => `destroy ${script.constructor.name} ${script.entity.getGuid()}`; + +// For node envs +function requestAnimationFrame(f) { + setImmediate(() => f(Date.now())); +} + +export const waitForNextFrame = () => new Promise((resolve) => { + requestAnimationFrame(() => resolve()); +}); diff --git a/test/test-assets/esm-scripts/esm-cloner.mjs b/test/test-assets/esm-scripts/esm-cloner.mjs new file mode 100644 index 00000000000..dce03781798 --- /dev/null +++ b/test/test-assets/esm-scripts/esm-cloner.mjs @@ -0,0 +1,16 @@ +export default class Cloner { + initialize() { + window.initializeCalls.push(this.entity.getGuid() + ' initialize cloner'); + const clone = this.entityToClone.clone(); + clone.name += ' - clone'; + this.app.root.addChild(clone); + } + + postInitialize() { + window.initializeCalls.push(this.entity.getGuid() + ' postInitialize cloner'); + } +} + +Cloner.attributes = { + entityToClone: { type: 'entity' } +}; diff --git a/test/test-assets/esm-scripts/esm-destroyer.mjs b/test/test-assets/esm-scripts/esm-destroyer.mjs new file mode 100644 index 00000000000..bbc406ce8fa --- /dev/null +++ b/test/test-assets/esm-scripts/esm-destroyer.mjs @@ -0,0 +1,71 @@ +export default class Destroyer { + // Default values for attributes + static attributes = { + methodName: { type: 'string' }, + destroyEntity: { type: 'boolean' }, + destroyScriptComponent: { type: 'boolean' }, + destroyScriptInstance: { type: 'boolean' } + }; + + initialize() { + window.initializeCalls.push(this.entity.getGuid() + ' initialize destroyer'); + + this.on('state', function (state) { + window.initializeCalls.push(this.entity.getGuid() + ' state ' + state + ' destroyer'); + }); + this.on('disable', function () { + window.initializeCalls.push(this.entity.getGuid() + ' disable destroyer'); + }); + this.on('enable', function () { + window.initializeCalls.push(this.entity.getGuid() + ' enable destroyer'); + }); + + if (this.methodName === 'initialize') { + this.destroySomething(); + } + } + + postInitialize() { + window.initializeCalls.push(this.entity.getGuid() + ' postInitialize destroyer'); + + if (this.methodName === 'postInitialize') { + this.destroySomething(); + } + } + + update() { + window.initializeCalls.push(this.entity.getGuid() + ' update destroyer'); + + if (!this.methodName || this.methodName === 'update') { + this.destroySomething(); + } + } + + postUpdate() { + window.initializeCalls.push(this.entity.getGuid() + ' postUpdate destroyer'); + + if (this.methodName === 'postUpdate') { + this.destroySomething(); + } + } + + destroy() { + window.initializeCalls.push(this.entity.getGuid() + ' destroy destroyer'); + } + + destroySomething() { + if (this.destroyEntity) { + return this.entity.destroy(); + } + + if (this.destroyScriptComponent) { + return this.entity.removeComponent('script'); + } + + if (this.destroyScriptInstance) { + if (this.entity.script.scriptA) { + return this.entity.script.destroy('scriptA'); + } + } + } +} diff --git a/test/test-assets/esm-scripts/esm-disabler.mjs b/test/test-assets/esm-scripts/esm-disabler.mjs new file mode 100644 index 00000000000..91925aa1a11 --- /dev/null +++ b/test/test-assets/esm-scripts/esm-disabler.mjs @@ -0,0 +1,37 @@ +import { INITIALIZE, POST_INITIALIZE, call } from '../../framework/components/esmscript/method-util.mjs'; + +const attributes = { + disableEntity: { type: 'boolean' }, + disableScriptComponent: { type: 'boolean' }, + disableScriptInstance: { type: 'boolean' } +}; + +export default class Disabler { + static attributes = attributes; + + initialize() { + call(INITIALIZE(this)); + + if (this.disableEntity) { + this.entity.enabled = false; + } + + if (this.disableScriptComponent) { + this.entity.script.enabled = false; + } + + if (this.disableScriptInstance) { + if (this.entity.script.scriptA) { + this.entity.script.scriptA.enabled = false; + } + + if (this.entity.script.scriptB) { + this.entity.script.scriptB.enabled = false; + } + } + } + + postInitialize() { + call(POST_INITIALIZE(this)); + } +} diff --git a/test/test-assets/esm-scripts/esm-enabler.mjs b/test/test-assets/esm-scripts/esm-enabler.mjs new file mode 100644 index 00000000000..1a7a42a7b23 --- /dev/null +++ b/test/test-assets/esm-scripts/esm-enabler.mjs @@ -0,0 +1,27 @@ +import { INITIALIZE, call } from '../../framework/components/esmscript/method-util.mjs'; + +const attributes = { + entityToEnable: { type: 'entity' } +}; + +export default class Enabler { + static attributes = attributes; + + initialize() { + call(INITIALIZE(this)); + this.entityToEnable.enabled = true; + this.entityToEnable.esmscript.enabled = true; + + const scriptA = this.entityToEnable.esmscript.get('ScriptA'); + const scriptB = this.entityToEnable.esmscript.get('ScriptB'); + + if (scriptA) { + // this.entityToEnable.esmscript.enableModule(scriptA); + scriptA.enabled = true; + } + if (scriptB) { + // this.entityToEnable.esmscript.enableModule(scriptB); + scriptB.enabled = true; + } + } +} diff --git a/test/test-assets/esm-scripts/esm-loadedLater.mjs b/test/test-assets/esm-scripts/esm-loadedLater.mjs new file mode 100644 index 00000000000..acd155f2a8f --- /dev/null +++ b/test/test-assets/esm-scripts/esm-loadedLater.mjs @@ -0,0 +1,29 @@ +const attributes = { + disableEntity: { type: 'boolean' }, + disableScriptComponent: { type: 'boolean' }, + disableScriptInstance: { type: 'boolean' } +}; + +export default class LoadedLater { + static attributes = attributes; + + initialize() { + window.initializeCalls.push(this.entity.getGuid() + ' initialize loadedLater'); + + if (this.disableEntity) { + this.entity.enabled = false; + } + + if (this.disableScriptComponent) { + this.entity.script.enabled = false; + } + + if (this.disableScriptInstance) { + this.entity.script.loadedLater.enabled = false; + } + } + + postInitialize() { + window.initializeCalls.push(this.entity.getGuid() + ' postInitialize loadedLater'); + } +} diff --git a/test/test-assets/esm-scripts/esm-postCloner.mjs b/test/test-assets/esm-scripts/esm-postCloner.mjs new file mode 100644 index 00000000000..e51e094be46 --- /dev/null +++ b/test/test-assets/esm-scripts/esm-postCloner.mjs @@ -0,0 +1,13 @@ +const attributes = { + entityToClone: { type: 'entity' } +}; + +export default class PostCloner { + static attributes = attributes; + + postInitialize() { + const clone = this.entityToClone.clone(); + this.app.root.addChild(clone); + clone.enabled = true; + } +} diff --git a/test/test-assets/esm-scripts/esm-postInitializeReporter.mjs b/test/test-assets/esm-scripts/esm-postInitializeReporter.mjs new file mode 100644 index 00000000000..459f234457d --- /dev/null +++ b/test/test-assets/esm-scripts/esm-postInitializeReporter.mjs @@ -0,0 +1,11 @@ +// import { EsmScriptType } from "../../../../src/framework/script/esm-script-type"; + +export default class PostInitializeReporter { + initialize() { + console.log(this.entity.getGuid() + ' initialize postInitializeReporter'); + } + + postInitialize() { + window.initializeCalls.push(this.entity.getGuid() + ' postInitialize postInitializeReporter'); + } +} diff --git a/test/test-assets/esm-scripts/esm-scriptA.mjs b/test/test-assets/esm-scripts/esm-scriptA.mjs new file mode 100644 index 00000000000..a9a3af960bf --- /dev/null +++ b/test/test-assets/esm-scripts/esm-scriptA.mjs @@ -0,0 +1,23 @@ +import { DESTROY, INITIALIZE, POST_INITIALIZE, POST_UPDATE, UPDATE, call } from '../../framework/components/esmscript/method-util.mjs'; + +export default class ScriptA { + initialize() { + call(INITIALIZE(this)); + } + + postInitialize() { + call(POST_INITIALIZE(this)); + } + + update(dt) { + call(UPDATE(this)); + } + + postUpdate(dt) { + call(POST_UPDATE(this)); + } + + destroy() { + call(DESTROY(this)); + } +} diff --git a/test/test-assets/esm-scripts/esm-scriptB.mjs b/test/test-assets/esm-scripts/esm-scriptB.mjs new file mode 100644 index 00000000000..8d95a6f7291 --- /dev/null +++ b/test/test-assets/esm-scripts/esm-scriptB.mjs @@ -0,0 +1,23 @@ +import { DESTROY, INITIALIZE, POST_INITIALIZE, POST_UPDATE, UPDATE, call } from '../../framework/components/esmscript/method-util.mjs'; + +export default class ScriptB { + initialize() { + call(INITIALIZE(this)); + } + + postInitialize() { + call(POST_INITIALIZE(this)); + } + + update(dt) { + call(UPDATE(this)); + } + + postUpdate(dt) { + call(POST_UPDATE(this)); + } + + destroy() { + call(DESTROY(this)); + } +} diff --git a/test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs b/test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs new file mode 100644 index 00000000000..d87cdc1b9a8 --- /dev/null +++ b/test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs @@ -0,0 +1,93 @@ +const CustomType = { + internalNumberNoDefault: { type: 'number' }, + internalNumber: { type: 'number', default: 1 }, + + internalArrayNoDefault: { type: 'number', array: true }, + internalArray: { type: 'number', array: true, default: [1, 2, 3, 4] }, + + internalColorNoDefault: { type: 'rgba' }, + internalColor: { type: 'rgba', default: [1, 1, 1, 1] }, + + invalidAttribute: 'This is an invalid attribute', + invalidAttributeType: { type: 'This is an invalid type' } +}; + +const attributes = { + + invalidAttribute: 'Should Raise A Warning/Error', + invalidAttributeType: { type: 'An invalid attribute type' }, + invalidAttributeTypeWithDefault: { type: 'An invalid attribute type', default: 'wrong' }, + invalidAttributeTypeArray: { type: 'An invalid attribute type', array: true }, + + simpleAttributeNoDefault: { + type: 'number' + }, + + simpleAttributeWithFalsyDefault: { + type: 'boolean', + default: false + }, + + simpleAttribute: { + type: 'number', + default: 10 + }, + + simpleColorHex: { + type: 'rgb', + default: '#ff0000' + }, + + simpleColorAsArray: { + type: 'rgb', + default: [1, 2, 3, 4] + }, + + simpleAttributeArray: { + type: 'number', + default: [10], + array: true + }, + + simpleAttributeArrayInvalidDefault: { + type: 'number', + default: 10, + array: true + }, + + simpleAttributeArrayNoDefault: { + type: 'number', + array: true + }, + + simpleAttributeArrayWithDefaultArray: { + type: 'number', + default: [10, 20, 30], + array: true + }, + + complexAttributeNoDefault: { + type: CustomType + }, + + complexAttribute: { + type: CustomType, + default: { + internalNumberNoDefault: 10, + internalArray: [6, 7, 8, 9], + nonExistent: 22 + } + }, + + attribute1: { type: 'entity' }, + attribute2: { type: 'number', default: 2 }, + attribute3: CustomType, + + folder: { + entityAttribute: { type: 'entity' } + } +}; + +export default class ScriptWithAttributes { + static attributes = attributes; +} diff --git a/test/test-assets/esm-scripts/esm-scriptWithSimpleAttributes.mjs b/test/test-assets/esm-scripts/esm-scriptWithSimpleAttributes.mjs new file mode 100644 index 00000000000..e6f2d678c6d --- /dev/null +++ b/test/test-assets/esm-scripts/esm-scriptWithSimpleAttributes.mjs @@ -0,0 +1,27 @@ +import { Color } from '../../../src/core/math/color.js'; + +const attributes = { + + simpleAttributeNoDefault: { + type: 'number' + }, + + simpleAttributeWithFalsyDefault: { + type: 'boolean', + default: false + }, + + simpleAttribute: { + type: 'number', + default: 10 + }, + + colorAttribute: { + type: 'rgba', + default: new Color(1, 1, 1, 1) + } +}; + +export default class ScriptWithSimpleAttributes { + static attributes = attributes; +} diff --git a/test/test-assets/esm-scripts/scene1.json b/test/test-assets/esm-scripts/scene1.json new file mode 100644 index 00000000000..1dae183e22e --- /dev/null +++ b/test/test-assets/esm-scripts/scene1.json @@ -0,0 +1,240 @@ +{ + "name": "scene1", + "created": "2018-04-17T17:52:32.194Z", + "settings": { + "physics": { + "gravity": [ + 0, + -9.8, + 0 + ] + }, + "render": { + "fog_end": 1000, + "tonemapping": 0, + "skybox": null, + "fog_density": 0.01, + "gamma_correction": 1, + "exposure": 1, + "fog_start": 1, + "global_ambient": [ + 0.2, + 0.2, + 0.2 + ], + "skyboxIntensity": 1, + "fog_color": [ + 0, + 0, + 0 + ], + "lightmapMode": 1, + "fog": "none", + "lightmapMaxResolution": 2048, + "skyboxMip": 0, + "lightmapSizeMultiplier": 16 + } + }, + "entities": { + "1a69e96b-4268-11e8-a6fb-784f436c1506": { + "position": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ], + "name": "Root", + "parent": null, + "resource_id": "1a69e96b-4268-11e8-a6fb-784f436c1506", + "components": {}, + "rotation": [ + 0, + 0, + 0 + ], + "tags": [], + "enabled": true, + "children": [ + "f7b6aba2-fee9-4335-b422-af6e3994d9d3", + "f740db25-2f26-4b03-98b8-7527c6a96310", + "7b7b17d4-e27b-4ff2-baba-d8200531ac9e", + "716a4573-5119-4b18-9b46-5215ad244ad5", + "db06fa49-fd37-4d86-b310-cde03a977053" + ] + }, + "f7b6aba2-fee9-4335-b422-af6e3994d9d3": { + "name": "EnabledEntity", + "tags": [], + "enabled": true, + "resource_id": "f7b6aba2-fee9-4335-b422-af6e3994d9d3", + "parent": "1a69e96b-4268-11e8-a6fb-784f436c1506", + "children": [], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ], + "components": { + "esmscript": { + "enabled": true, + "modules": [{ + "moduleSpecifier": "../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs", + "enabled": true, + "attributes": { + "attribute1": "f740db25-2f26-4b03-98b8-7527c6a96310", + "attribute2": 2 + } + }] + } + } + }, + "f740db25-2f26-4b03-98b8-7527c6a96310": { + "name": "ReferencedEntity", + "tags": [], + "enabled": true, + "resource_id": "f740db25-2f26-4b03-98b8-7527c6a96310", + "parent": "1a69e96b-4268-11e8-a6fb-784f436c1506", + "children": [], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ], + "components": {} + }, + "7b7b17d4-e27b-4ff2-baba-d8200531ac9e": { + "name": "DisabledEntity", + "tags": [], + "enabled": false, + "resource_id": "7b7b17d4-e27b-4ff2-baba-d8200531ac9e", + "parent": "1a69e96b-4268-11e8-a6fb-784f436c1506", + "children": [], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ], + "components": { + "esmscript": { + "enabled": true, + "modules": [{ + "moduleSpecifier": "../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs", + "enabled": true, + "attributes": { + "attribute1": "f740db25-2f26-4b03-98b8-7527c6a96310", + "attribute2": 2 + } + }] + } + } + }, + "716a4573-5119-4b18-9b46-5215ad244ad5": { + "name": "DisabledScriptComponent", + "tags": [], + "enabled": true, + "resource_id": "716a4573-5119-4b18-9b46-5215ad244ad5", + "parent": "1a69e96b-4268-11e8-a6fb-784f436c1506", + "children": [], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ], + "components": { + "esmscript": { + "enabled": true, + "modules": [{ + "moduleSpecifier": "../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs", + "enabled": true, + "attributes": { + "attribute1": "f740db25-2f26-4b03-98b8-7527c6a96310", + "attribute2": 2 + } + }] + } + } + }, + "db06fa49-fd37-4d86-b310-cde03a977053": { + "name": "DisabledScriptInstance", + "tags": [], + "enabled": true, + "resource_id": "db06fa49-fd37-4d86-b310-cde03a977053", + "parent": "1a69e96b-4268-11e8-a6fb-784f436c1506", + "children": [], + "position": [ + 0, + 0, + 0 + ], + "rotation": [ + 0, + 0, + 0 + ], + "scale": [ + 1, + 1, + 1 + ], + "components": { + "esmscript": { + "enabled": true, + "modules": [{ + "moduleSpecifier": "../../../test/test-assets/esm-scripts/esm-scriptWithAttributes.mjs", + "enabled": true, + "attributes": { + "attribute1": "f740db25-2f26-4b03-98b8-7527c6a96310", + "attribute2": 2 + } + }] + } + } + } + }, + "_o": "5ad6368a83618a3d24f55511", + "id": 514 +}