diff --git a/Extensions/3D/Model3DRuntimeObject.ts b/Extensions/3D/Model3DRuntimeObject.ts index 1da6d637a8a9..5549284d6530 100644 --- a/Extensions/3D/Model3DRuntimeObject.ts +++ b/Extensions/3D/Model3DRuntimeObject.ts @@ -113,6 +113,10 @@ namespace gdjs { ); } + if (this.isNeedingLifecycleFunctions()) { + this.getLifecycleSleepState().wakeUp(); + } + // *ALWAYS* call `this.onCreated()` at the very end of your object constructor. this.onCreated(); } @@ -194,6 +198,10 @@ namespace gdjs { } } + isNeedingLifecycleFunctions(): boolean { + return super.isNeedingLifecycleFunctions() || this._animations.length > 0; + } + update(instanceContainer: gdjs.RuntimeInstanceContainer): void { const elapsedTime = this.getElapsedTime() / 1000; this._renderer.updateAnimation(elapsedTime * this._animationSpeedScale); diff --git a/Extensions/Lighting/lightobstacleruntimebehavior.ts b/Extensions/Lighting/lightobstacleruntimebehavior.ts index 1568f4b2a5cf..32220145ddf3 100644 --- a/Extensions/Lighting/lightobstacleruntimebehavior.ts +++ b/Extensions/Lighting/lightobstacleruntimebehavior.ts @@ -1,11 +1,9 @@ namespace gdjs { - declare var rbush: any; - export class LightObstaclesManager { - _obstacleRBush: any; + _obstacleRBush: RBush; constructor(instanceContainer: gdjs.RuntimeInstanceContainer) { - this._obstacleRBush = new rbush(); + this._obstacleRBush = new RBush(); } /** @@ -41,6 +39,9 @@ namespace gdjs { * added before. */ removeObstacle(obstacle: gdjs.LightObstacleRuntimeBehavior) { + if (!obstacle.currentRBushAABB) { + return; + } this._obstacleRBush.remove(obstacle.currentRBushAABB); } @@ -59,9 +60,9 @@ namespace gdjs { // is not necessarily in the middle of the object (for sprites for example). const x = object.getX(); const y = object.getY(); - const searchArea = gdjs.staticObject( + const searchArea: SearchArea = gdjs.staticObject( LightObstaclesManager.prototype.getAllObstaclesAround - ); + ) as SearchArea; // @ts-ignore searchArea.minX = x - radius; // @ts-ignore @@ -70,13 +71,8 @@ namespace gdjs { searchArea.maxX = x + radius; // @ts-ignore searchArea.maxY = y + radius; - const nearbyObstacles: gdjs.BehaviorRBushAABB< - gdjs.LightObstacleRuntimeBehavior - >[] = this._obstacleRBush.search(searchArea); result.length = 0; - nearbyObstacles.forEach((nearbyObstacle) => - result.push(nearbyObstacle.behavior) - ); + this._obstacleRBush.search(searchArea, result); } } diff --git a/Extensions/PathfindingBehavior/pathfindingobstacleruntimebehavior.ts b/Extensions/PathfindingBehavior/pathfindingobstacleruntimebehavior.ts index 612996434aef..a2fb68b7654b 100644 --- a/Extensions/PathfindingBehavior/pathfindingobstacleruntimebehavior.ts +++ b/Extensions/PathfindingBehavior/pathfindingobstacleruntimebehavior.ts @@ -7,7 +7,6 @@ namespace gdjs { export interface RuntimeInstanceContainer { pathfindingObstaclesManager: gdjs.PathfindingObstaclesManager; } - declare var rbush: any; /** * PathfindingObstaclesManager manages the common objects shared by objects @@ -18,10 +17,10 @@ namespace gdjs { * `gdjs.PathfindingRuntimeBehavior.obstaclesManagers`). */ export class PathfindingObstaclesManager { - _obstaclesRBush: any; + _obstaclesRBush: RBush; constructor(instanceContainer: gdjs.RuntimeInstanceContainer) { - this._obstaclesRBush = new rbush(); + this._obstaclesRBush = new RBush(); } /** @@ -60,6 +59,9 @@ namespace gdjs { removeObstacle( pathfindingObstacleBehavior: PathfindingObstacleRuntimeBehavior ) { + if (!pathfindingObstacleBehavior.currentRBushAABB) { + return; + } this._obstaclesRBush.remove(pathfindingObstacleBehavior.currentRBushAABB); } @@ -74,9 +76,9 @@ namespace gdjs { radius: float, result: gdjs.PathfindingObstacleRuntimeBehavior[] ): void { - const searchArea = gdjs.staticObject( + const searchArea: SearchArea = gdjs.staticObject( PathfindingObstaclesManager.prototype.getAllObstaclesAround - ); + ) as SearchArea; // @ts-ignore searchArea.minX = x - radius; // @ts-ignore @@ -85,13 +87,8 @@ namespace gdjs { searchArea.maxX = x + radius; // @ts-ignore searchArea.maxY = y + radius; - const nearbyObstacles: gdjs.BehaviorRBushAABB< - gdjs.PathfindingObstacleRuntimeBehavior - >[] = this._obstaclesRBush.search(searchArea); result.length = 0; - nearbyObstacles.forEach((nearbyObstacle) => - result.push(nearbyObstacle.behavior) - ); + this._obstaclesRBush.search(searchArea, result); } } diff --git a/Extensions/PlatformBehavior/platformerobjectruntimebehavior.ts b/Extensions/PlatformBehavior/platformerobjectruntimebehavior.ts index 854a7f1555e7..36f2b79ddf37 100644 --- a/Extensions/PlatformBehavior/platformerobjectruntimebehavior.ts +++ b/Extensions/PlatformBehavior/platformerobjectruntimebehavior.ts @@ -211,6 +211,9 @@ namespace gdjs { } doStepPreEvents(instanceContainer: gdjs.RuntimeInstanceContainer) { + // Update platforms locations. + this._manager.doStepPreEvents(); + const LEFTKEY = 37; const UPKEY = 38; const RIGHTKEY = 39; diff --git a/Extensions/PlatformBehavior/platformruntimebehavior.ts b/Extensions/PlatformBehavior/platformruntimebehavior.ts index 820cd96f6b7d..f1a2f09f43c6 100644 --- a/Extensions/PlatformBehavior/platformruntimebehavior.ts +++ b/Extensions/PlatformBehavior/platformruntimebehavior.ts @@ -3,7 +3,6 @@ GDevelop - Platform Behavior Extension Copyright (c) 2013-2016 Florian Rival (Florian.Rival@gmail.com) */ namespace gdjs { - declare var rbush: any; type SearchArea = { minX: float; minY: float; maxX: float; maxY: float }; /** @@ -13,10 +12,12 @@ namespace gdjs { * of their associated container (see PlatformRuntimeBehavior.getManager). */ export class PlatformObjectsManager { - private _platformRBush: any; + private _platformRBush: RBush; + private movedPlatforms: Array; constructor(instanceContainer: gdjs.RuntimeInstanceContainer) { - this._platformRBush = new rbush(); + this._platformRBush = new RBush(); + this.movedPlatforms = []; } /** @@ -38,7 +39,7 @@ namespace gdjs { /** * Add a platform to the list of existing platforms. */ - addPlatform(platformBehavior: gdjs.PlatformRuntimeBehavior) { + private addPlatform(platformBehavior: gdjs.PlatformRuntimeBehavior) { if (platformBehavior.currentRBushAABB) platformBehavior.currentRBushAABB.updateAABBFromOwner(); else @@ -52,10 +53,39 @@ namespace gdjs { * Remove a platform from the list of existing platforms. Be sure that the platform was * added before. */ - removePlatform(platformBehavior: gdjs.PlatformRuntimeBehavior) { + private removePlatform(platformBehavior: gdjs.PlatformRuntimeBehavior) { + if (!platformBehavior.currentRBushAABB) { + return; + } this._platformRBush.remove(platformBehavior.currentRBushAABB); } + invalidatePlatformHitbox(platformBehavior: gdjs.PlatformRuntimeBehavior) { + this.movedPlatforms.push(platformBehavior); + } + + onDestroy(platformBehavior: gdjs.PlatformRuntimeBehavior): void { + if (!platformBehavior.activated()) { + return; + } + if (platformBehavior.isAABBInvalidated()) { + const index = this.movedPlatforms.indexOf(platformBehavior); + this.movedPlatforms.splice(index, 1); + } + this.removePlatform(platformBehavior); + } + + doStepPreEvents() { + for (const platformBehavior of this.movedPlatforms) { + this.removePlatform(platformBehavior); + if (platformBehavior.activated()) { + this.addPlatform(platformBehavior); + } + platformBehavior.onHitboxUpdatedInTree(); + } + this.movedPlatforms.length = 0; + } + /** * Returns all the platforms around the specified object. * @param maxMovementLength The maximum distance, in pixels, the object is going to do. @@ -75,21 +105,19 @@ namespace gdjs { const searchArea: SearchArea = gdjs.staticObject( PlatformObjectsManager.prototype.getAllPlatformsAround ) as SearchArea; + result.length = 0; searchArea.minX = x - ow / 2 - maxMovementLength; searchArea.minY = y - oh / 2 - maxMovementLength; searchArea.maxX = x + ow / 2 + maxMovementLength; searchArea.maxY = y + oh / 2 + maxMovementLength; - const nearbyPlatforms: gdjs.BehaviorRBushAABB< - PlatformRuntimeBehavior - >[] = this._platformRBush.search(searchArea); - - result.length = 0; + this._platformRBush.search(searchArea, result); // Extra check on the platform owner AABB // TODO: PR https://github.com/4ian/GDevelop/pull/2602 should remove the need // for this extra check once merged. - for (let i = 0; i < nearbyPlatforms.length; i++) { - const platform = nearbyPlatforms[i].behavior; + let writtenIndex = 0; + for (let readIndex = 0; readIndex < result.length; readIndex++) { + const platform = result[readIndex]; const platformAABB = platform.owner.getAABB(); const platformIsStillAround = platformAABB.min[0] <= searchArea.maxX && @@ -100,9 +128,11 @@ namespace gdjs { // This can happen because platforms are not updated in the RBush before that // characters movement are being processed. if (platformIsStillAround) { - result.push(platform); + result[writtenIndex] = platform; + writtenIndex++; } } + result.length = writtenIndex; } } @@ -127,6 +157,7 @@ namespace gdjs { > | null = null; _manager: gdjs.PlatformObjectsManager; _registeredInManager: boolean = false; + _isAABBInvalidated = false; constructor( instanceContainer: gdjs.RuntimeInstanceContainer, @@ -145,6 +176,10 @@ namespace gdjs { this._canBeGrabbed = behaviorData.canBeGrabbed || false; this._yGrabOffset = behaviorData.yGrabOffset || 0; this._manager = PlatformObjectsManager.getManager(instanceContainer); + this.owner.registerHitboxChangedCallback((object) => + this.onHitboxChanged() + ); + this.onHitboxChanged(); } updateFromBehaviorData(oldBehaviorData, newBehaviorData): boolean { @@ -160,10 +195,12 @@ namespace gdjs { return true; } - onDestroy() { - if (this._manager && this._registeredInManager) { - this._manager.removePlatform(this); - } + onDestroy(): void { + this._manager.onDestroy(this); + } + + usesLifecycleFunction(): boolean { + return false; } doStepPreEvents(instanceContainer: gdjs.RuntimeInstanceContainer) { @@ -176,54 +213,32 @@ namespace gdjs { sceneManager = parentScene ? &ScenePlatformObjectsManager::managers[&scene] : NULL; registeredInManager = false; }*/ - - //Make sure the platform is or is not in the platforms manager. - if (!this.activated() && this._registeredInManager) { - this._manager.removePlatform(this); - this._registeredInManager = false; - } else { - if (this.activated() && !this._registeredInManager) { - this._manager.addPlatform(this); - this._registeredInManager = true; - } - } - - //Track changes in size or position - if ( - this._oldX !== this.owner.getX() || - this._oldY !== this.owner.getY() || - this._oldWidth !== this.owner.getWidth() || - this._oldHeight !== this.owner.getHeight() || - this._oldAngle !== this.owner.getAngle() - ) { - if (this._registeredInManager) { - this._manager.removePlatform(this); - this._manager.addPlatform(this); - } - this._oldX = this.owner.getX(); - this._oldY = this.owner.getY(); - this._oldWidth = this.owner.getWidth(); - this._oldHeight = this.owner.getHeight(); - this._oldAngle = this.owner.getAngle(); - } } doStepPostEvents(instanceContainer: gdjs.RuntimeInstanceContainer) {} onActivate() { - if (this._registeredInManager) { - return; - } - this._manager.addPlatform(this); - this._registeredInManager = true; + this.onHitboxChanged(); } onDeActivate() { - if (!this._registeredInManager) { + this.onHitboxChanged(); + } + + onHitboxChanged() { + if (this._isAABBInvalidated || !this.owner.isAlive()) { return; } - this._manager.removePlatform(this); - this._registeredInManager = false; + this._isAABBInvalidated = true; + this._manager.invalidatePlatformHitbox(this); + } + + onHitboxUpdatedInTree() { + this._isAABBInvalidated = false; + } + + isAABBInvalidated() { + return !this._isAABBInvalidated; } changePlatformType(platformType: string) { diff --git a/Extensions/PlatformBehavior/tests/JumpAndFallingPlatformer.spec.js b/Extensions/PlatformBehavior/tests/JumpAndFallingPlatformer.spec.js index 4c130f24470d..cff5bb1a50ce 100644 --- a/Extensions/PlatformBehavior/tests/JumpAndFallingPlatformer.spec.js +++ b/Extensions/PlatformBehavior/tests/JumpAndFallingPlatformer.spec.js @@ -1,8 +1,11 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () { const epsilon = 1 / (2 << 16); describe('(falling)', function () { + /** @type {gdjs.RuntimeScene} */ let runtimeScene; + /** @type {gdjs.RuntimeObject} */ let object; + /** @type {gdjs.RuntimeObject} */ let platform; beforeEach(function () { @@ -114,7 +117,7 @@ describe('gdjs.PlatformerObjectRuntimeBehavior', function () { expect(object.getBehavior('auto1').isMoving()).to.be(false); // Remove the platform - runtimeScene.markObjectForDeletion(platform); + platform.deleteFromScene(runtimeScene); runtimeScene.renderAndStep(1000 / 60); expect(object.getBehavior('auto1').isFalling()).to.be(true); expect(object.getBehavior('auto1').isFallingWithoutJumping()).to.be(true); diff --git a/Extensions/TileMap/tilemapruntimeobject.ts b/Extensions/TileMap/tilemapruntimeobject.ts index d5f0d725d708..3857468e6c87 100644 --- a/Extensions/TileMap/tilemapruntimeobject.ts +++ b/Extensions/TileMap/tilemapruntimeobject.ts @@ -39,6 +39,10 @@ namespace gdjs { ); this._updateTileMap(); + if (this.isNeedingLifecycleFunctions()) { + this.getLifecycleSleepState().wakeUp(); + } + // *ALWAYS* call `this.onCreated()` at the very end of your object constructor. this.onCreated(); } @@ -47,6 +51,11 @@ namespace gdjs { return this._renderer.getRendererObject(); } + isNeedingLifecycleFunctions(): boolean { + // TODO Tile maps without animated tiles should return false. + return true; + } + update(instanceContainer: gdjs.RuntimeInstanceContainer): void { if (this._animationSpeedScale <= 0 || this._animationFps === 0) { return; diff --git a/GDJS/GDJS/IDE/ExporterHelper.cpp b/GDJS/GDJS/IDE/ExporterHelper.cpp index bcf86307b965..990190c2b098 100644 --- a/GDJS/GDJS/IDE/ExporterHelper.cpp +++ b/GDJS/GDJS/IDE/ExporterHelper.cpp @@ -670,6 +670,7 @@ void ExporterHelper::AddLibsInclude(bool pixiRenderers, InsertUnique(includesFiles, "ResourceCache.js"); InsertUnique(includesFiles, "timemanager.js"); InsertUnique(includesFiles, "polygon.js"); + InsertUnique(includesFiles, "ObjectSleepState.js"); InsertUnique(includesFiles, "runtimeobject.js"); InsertUnique(includesFiles, "profiler.js"); InsertUnique(includesFiles, "RuntimeInstanceContainer.js"); diff --git a/GDJS/Runtime/CustomRuntimeObject.ts b/GDJS/Runtime/CustomRuntimeObject.ts index b99cbdd51c3e..2d88ef3ba4c0 100644 --- a/GDJS/Runtime/CustomRuntimeObject.ts +++ b/GDJS/Runtime/CustomRuntimeObject.ts @@ -60,6 +60,10 @@ namespace gdjs { this._instanceContainer.loadFrom(objectData); this.getRenderer().reinitialize(this, parent); + if (this.isNeedingLifecycleFunctions()) { + this.getLifecycleSleepState().wakeUp(); + } + // The generated code calls onCreated at the constructor end // and onCreated calls its super implementation at its end. } @@ -120,6 +124,10 @@ namespace gdjs { */ onHotReloading(parent: gdjs.RuntimeInstanceContainer) {} + isNeedingLifecycleFunctions(): boolean { + return true; + } + // This is only to handle trigger once. doStepPreEvents(parent: gdjs.RuntimeInstanceContainer) {} diff --git a/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts b/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts index 3ff00c0e9575..1ecd24a38cbe 100644 --- a/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts +++ b/GDJS/Runtime/CustomRuntimeObjectInstanceContainer.ts @@ -235,32 +235,6 @@ namespace gdjs { return; } - /** - * Update the objects before launching the events. - */ - _updateObjectsPreEvents() { - const allInstancesList = this.getAdhocListOfAllInstances(); - for (let i = 0, len = allInstancesList.length; i < len; ++i) { - const obj = allInstancesList[i]; - const elapsedTime = obj.getElapsedTime(); - if (!obj.hasNoForces()) { - const averageForce = obj.getAverageForce(); - const elapsedTimeInSeconds = elapsedTime / 1000; - obj.setX(obj.getX() + averageForce.getX() * elapsedTimeInSeconds); - obj.setY(obj.getY() + averageForce.getY() * elapsedTimeInSeconds); - obj.update(this); - obj.updateForces(elapsedTimeInSeconds); - } else { - obj.update(this); - } - obj.updateTimers(elapsedTime); - obj.stepBehaviorsPreEvents(this); - } - - // Some behaviors may have request objects to be deleted. - this._cacheOrClearRemovedInstances(); - } - /** * Get the renderer associated to the RuntimeScene. */ diff --git a/GDJS/Runtime/ObjectSleepState.ts b/GDJS/Runtime/ObjectSleepState.ts new file mode 100644 index 000000000000..aa8df84a8fcf --- /dev/null +++ b/GDJS/Runtime/ObjectSleepState.ts @@ -0,0 +1,119 @@ +/* + * GDevelop JS Platform + * Copyright 2023-2023 Florian Rival (Florian.Rival@gmail.com). All rights reserved. + * This project is released under the MIT License. + */ +namespace gdjs { + export class ObjectSleepState { + private static readonly framesBeforeSleep = 60; + private _object: RuntimeObject; + private _isNeedingToBeAwake: () => boolean; + private _state: ObjectSleepState.State; + private _lastActivityFrameIndex: integer; + _onWakingUpCallbacks: Array<(object: RuntimeObject) => void> = []; + + constructor( + object: RuntimeObject, + isNeedingToBeAwake: () => boolean, + initialSleepState: ObjectSleepState.State + ) { + this._object = object; + this._isNeedingToBeAwake = isNeedingToBeAwake; + this._state = initialSleepState; + this._lastActivityFrameIndex = this._object + .getRuntimeScene() + .getFrameIndex(); + } + + _reinitialize( + initialSleepState: ObjectSleepState.State + ) : void { + this._onWakingUpCallbacks.length = 0; + this._state = initialSleepState; + this._lastActivityFrameIndex = this._object + .getRuntimeScene() + .getFrameIndex(); + } + + _destroy(): void { + this._state = gdjs.ObjectSleepState.State.Destroyed; + this._onWakingUpCallbacks.length = 0; + } + + canSleep(): boolean { + return ( + this._state === gdjs.ObjectSleepState.State.CanSleepThisFrame || + this._object.getRuntimeScene().getFrameIndex() - + this._lastActivityFrameIndex >= + ObjectSleepState.framesBeforeSleep + ); + } + + isAwake(): boolean { + return this._state === gdjs.ObjectSleepState.State.AWake || + this._state === gdjs.ObjectSleepState.State.CanSleepThisFrame; + } + + wakeUp() { + const object = this._object; + this._lastActivityFrameIndex = object.getRuntimeScene().getFrameIndex(); + if (this.isAwake()) { + return; + } + this._state = gdjs.ObjectSleepState.State.AWake; + for (const onWakingUp of this._onWakingUpCallbacks) { + onWakingUp(object); + } + } + + registerOnWakingUp(onWakingUp: (object: RuntimeObject) => void) { + this._onWakingUpCallbacks.push(onWakingUp); + } + + tryToSleep(): void { + if (this._isNeedingToBeAwake()) { + this._lastActivityFrameIndex = this._object + .getRuntimeScene() + .getFrameIndex(); + } + } + + static updateAwakeObjects( + awakeObjects: Array, + getSleepState: (object: RuntimeObject) => ObjectSleepState, + onFallenAsleep: (object: RuntimeObject) => void, + onWakingUp: (object: RuntimeObject) => void + ) { + let writeIndex = 0; + for (let readIndex = 0; readIndex < awakeObjects.length; readIndex++) { + const object = awakeObjects[readIndex]; + const sleepState = getSleepState(object); + sleepState.tryToSleep(); + if (sleepState.canSleep() || !sleepState.isAwake()) { + if (sleepState.isAwake()) { + // Avoid onWakingUp to be called if some managers didn't have time + // to update their awake object list. + sleepState._onWakingUpCallbacks.length = 0; + } + sleepState._state = gdjs.ObjectSleepState.State.ASleep; + onFallenAsleep(object); + sleepState.registerOnWakingUp(onWakingUp); + } else { + awakeObjects[writeIndex] = object; + writeIndex++; + } + } + awakeObjects.length = writeIndex; + return awakeObjects; + } + } + + export namespace ObjectSleepState { + export enum State { + ASleep, + CanSleepThisFrame, + AWake, + Destroyed, + } + } +} diff --git a/GDJS/Runtime/RuntimeInstanceContainer.ts b/GDJS/Runtime/RuntimeInstanceContainer.ts index 1b65020907f9..1e3d00ad917a 100644 --- a/GDJS/Runtime/RuntimeInstanceContainer.ts +++ b/GDJS/Runtime/RuntimeInstanceContainer.ts @@ -15,6 +15,8 @@ namespace gdjs { /** Contains the instances living on the container */ _instances: Hashtable; + _activeInstances: Array = []; + /** * An array used to create a list of all instance when necessary. * @see gdjs.RuntimeInstanceContainer#_constructListOfAllInstances} @@ -485,13 +487,23 @@ namespace gdjs { return this._allInstancesList; } + getActiveInstances(): gdjs.RuntimeObject[] { + gdjs.ObjectSleepState.updateAwakeObjects( + this._activeInstances, + (object) => object.getLifecycleSleepState(), + (object) => {}, + (object) => this._activeInstances.push(object) + ); + return this._activeInstances; + } + /** * Update the objects before launching the events. */ _updateObjectsPreEvents() { // It is *mandatory* to create and iterate on a external list of all objects, as the behaviors // may delete the objects. - const allInstancesList = this.getAdhocListOfAllInstances(); + const allInstancesList = this.getActiveInstances(); for (let i = 0, len = allInstancesList.length; i < len; ++i) { const obj = allInstancesList[i]; const elapsedTime = obj.getElapsedTime(); @@ -506,7 +518,7 @@ namespace gdjs { obj.update(this); } obj.updateTimers(elapsedTime); - allInstancesList[i].stepBehaviorsPreEvents(this); + obj.stepBehaviorsPreEvents(this); } // Some behaviors may have request objects to be deleted. @@ -521,7 +533,7 @@ namespace gdjs { // It is *mandatory* to create and iterate on a external list of all objects, as the behaviors // may delete the objects. - const allInstancesList = this.getAdhocListOfAllInstances(); + const allInstancesList = this.getActiveInstances(); for (let i = 0, len = allInstancesList.length; i < len; ++i) { allInstancesList[i].stepBehaviorsPostEvents(this); } @@ -535,11 +547,20 @@ namespace gdjs { * @param obj The object to be added. */ addObject(obj: gdjs.RuntimeObject) { - if (!this._instances.containsKey(obj.name)) { - this._instances.put(obj.name, []); + let instances = this._instances.get(obj.name); + if (!instances) { + instances = []; + this._instances.put(obj.name, instances); + } + instances.push(obj); + this._allInstancesList.push(obj); + if (obj.getLifecycleSleepState().isAwake()) { + this._activeInstances.push(obj); + } else { + obj + .getLifecycleSleepState() + .registerOnWakingUp((object) => this._activeInstances.push(object)); } - this._instances.get(obj.name).push(obj); - this._allInstancesListIsUpToDate = false; } /** @@ -717,19 +738,22 @@ namespace gdjs { * Update the objects positions according to their forces */ updateObjectsForces(): void { - for (const name in this._instances.items) { - if (this._instances.items.hasOwnProperty(name)) { - const list = this._instances.items[name]; - for (let j = 0, listLen = list.length; j < listLen; ++j) { - const obj = list[j]; - if (!obj.hasNoForces()) { - const averageForce = obj.getAverageForce(); - const elapsedTimeInSeconds = obj.getElapsedTime() / 1000; - obj.setX(obj.getX() + averageForce.getX() * elapsedTimeInSeconds); - obj.setY(obj.getY() + averageForce.getY() * elapsedTimeInSeconds); - obj.updateForces(elapsedTimeInSeconds); - } - } + for ( + let i = 0, listLen = this._activeInstances.length; + i < listLen; + ++i + ) { + const object = this._activeInstances[i]; + if (!object.hasNoForces()) { + const averageForce = object.getAverageForce(); + const elapsedTimeInSeconds = object.getElapsedTime() / 1000; + object.setX( + object.getX() + averageForce.getX() * elapsedTimeInSeconds + ); + object.setY( + object.getY() + averageForce.getY() * elapsedTimeInSeconds + ); + object.updateForces(elapsedTimeInSeconds); } } } diff --git a/GDJS/Runtime/libs/rbush.js b/GDJS/Runtime/libs/rbush.js index 96c70f1a4354..cba95bc1bf0d 100644 --- a/GDJS/Runtime/libs/rbush.js +++ b/GDJS/Runtime/libs/rbush.js @@ -1,624 +1,575 @@ -(function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.rbush = f()}})(function(){var define,module,exports;return (function e(t,n,r){function s(o,u){if(!n[o]){if(!t[o]){var a=typeof require=="function"&&require;if(!u&&a)return a(o,!0);if(i)return i(o,!0);var f=new Error("Cannot find module '"+o+"'");throw f.code="MODULE_NOT_FOUND",f}var l=n[o]={exports:{}};t[o][0].call(l.exports,function(e){var n=t[o][1][e];return s(n?n:e)},l,l.exports,e,t,n,r)}return n[o].exports}var i=typeof require=="function"&&require;for(var o=0;o left) { + if (right - left > 600) { + var n = right - left + 1; + var m = k - left + 1; + var z = Math.log(n); + var s = 0.5 * Math.exp(2 * z / 3); + var sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (m - n / 2 < 0 ? -1 : 1); + var newLeft = Math.max(left, Math.floor(k - m * s / n + sd)); + var newRight = Math.min(right, Math.floor(k + (n - m) * s / n + sd)); + quickselectStep(arr, k, newLeft, newRight, compare); + } + + var t = arr[k]; + var i = left; + var j = right; + + swap(arr, left, k); + if (compare(arr[right], t) > 0) swap(arr, left, right); + + while (i < j) { + swap(arr, i, j); + i++; + j--; + while (compare(arr[i], t) < 0) i++; + while (compare(arr[j], t) > 0) j--; + } + + if (compare(arr[left], t) === 0) swap(arr, left, j); + else { + j++; + swap(arr, j, right); } - node = nodesToSearch.pop(); + + if (j <= k) left = j + 1; + if (k <= j) right = j - 1; + } + } + + function swap(arr, i, j) { + var tmp = arr[i]; + arr[i] = arr[j]; + arr[j] = tmp; + } + + function defaultCompare(a, b) { + return a < b ? -1 : a > b ? 1 : 0; + } + + class RBush { + constructor(maxEntries = 9) { + // max entries in a node is 9 by default; min node fill is 40% for best performance + this._maxEntries = Math.max(4, maxEntries); + this._minEntries = Math.max(2, Math.ceil(this._maxEntries * 0.4)); + this.clear(); } - - return result; - }, - - collides: function (bbox) { - - var node = this.data, - toBBox = this.toBBox; - - if (!intersects(bbox, node)) return false; - - var nodesToSearch = [], - i, len, child, childBBox; - - while (node) { - for (i = 0, len = node.children.length; i < len; i++) { - - child = node.children[i]; - childBBox = node.leaf ? toBBox(child) : child; - - if (intersects(bbox, childBBox)) { - if (node.leaf || contains(bbox, childBBox)) return true; - nodesToSearch.push(child); + + all() { + return this._all(this.data, []); + } + + search(bbox, result = []) { + let node = this.data; + + if (!intersects(bbox, node)) return result; + + const toBBox = this.toBBox; + const nodesToSearch = []; + + while (node) { + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const childBBox = node.leaf ? toBBox(child) : child; + + if (intersects(bbox, childBBox)) { + if (node.leaf) result.push(child.source || child); + else if (contains(bbox, childBBox)) this._all(child, result); + else nodesToSearch.push(child); + } } + node = nodesToSearch.pop(); } - node = nodesToSearch.pop(); + + return result; } - - return false; - }, - - load: function (data) { - if (!(data && data.length)) return this; - - if (data.length < this._minEntries) { - for (var i = 0, len = data.length; i < len; i++) { - this.insert(data[i]); + + collides(bbox) { + let node = this.data; + + if (!intersects(bbox, node)) return false; + + const nodesToSearch = []; + while (node) { + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const childBBox = node.leaf ? this.toBBox(child) : child; + + if (intersects(bbox, childBBox)) { + if (node.leaf || contains(bbox, childBBox)) return true; + nodesToSearch.push(child); + } + } + node = nodesToSearch.pop(); } - return this; + + return false; } - - // recursively build the tree with the given data from scratch using OMT algorithm - var node = this._build(data.slice(), 0, data.length - 1, 0); - - if (!this.data.children.length) { - // save as is if tree is empty - this.data = node; - - } else if (this.data.height === node.height) { - // split root if trees have the same height - this._splitRoot(this.data, node); - - } else { - if (this.data.height < node.height) { - // swap trees if inserted one is bigger - var tmpNode = this.data; + + load(data) { + if (!(data && data.length)) return this; + + if (data.length < this._minEntries) { + for (let i = 0; i < data.length; i++) { + this.insert(data[i]); + } + return this; + } + + // recursively build the tree with the given data from scratch using OMT algorithm + let node = this._build(data.slice(), 0, data.length - 1, 0); + + if (!this.data.children.length) { + // save as is if tree is empty this.data = node; - node = tmpNode; + + } else if (this.data.height === node.height) { + // split root if trees have the same height + this._splitRoot(this.data, node); + + } else { + if (this.data.height < node.height) { + // swap trees if inserted one is bigger + const tmpNode = this.data; + this.data = node; + node = tmpNode; + } + + // insert the small tree into the large tree at appropriate level + this._insert(node, this.data.height - node.height - 1, true); } - - // insert the small tree into the large tree at appropriate level - this._insert(node, this.data.height - node.height - 1, true); + + return this; } - - return this; - }, - - insert: function (item) { - if (item) this._insert(item, this.data.height - 1); - return this; - }, - - clear: function () { - this.data = createNode([]); - return this; - }, - - remove: function (item, equalsFn) { - if (!item) return this; - - var node = this.data, - bbox = this.toBBox(item), - path = [], - indexes = [], - i, parent, index, goingUp; - - // depth-first iterative tree traversal - while (node || path.length) { - - if (!node) { // go up - node = path.pop(); - parent = path[path.length - 1]; - i = indexes.pop(); - goingUp = true; - } - - if (node.leaf) { // check current node - index = findItem(item, node.children, equalsFn); - - if (index !== -1) { - // item found, remove the item and condense tree upwards - node.children.splice(index, 1); - path.push(node); - this._condense(path); - return this; + + insert(item) { + if (item) this._insert(item, this.data.height - 1); + return this; + } + + clear() { + this.data = createNode([]); + return this; + } + + remove(item, equalsFn) { + if (!item) return this; + + let node = this.data; + const bbox = this.toBBox(item); + const path = []; + const indexes = []; + let i, parent, goingUp; + + // depth-first iterative tree traversal + while (node || path.length) { + + if (!node) { // go up + node = path.pop(); + parent = path[path.length - 1]; + i = indexes.pop(); + goingUp = true; } + + if (node.leaf) { // check current node + const index = findItem(item, node.children, equalsFn); + + if (index !== -1) { + // item found, remove the item and condense tree upwards + node.children.splice(index, 1); + path.push(node); + this._condense(path); + return this; + } + } + + if (!goingUp && !node.leaf && contains(node, bbox)) { // go down + path.push(node); + indexes.push(i); + i = 0; + parent = node; + node = node.children[0]; + + } else if (parent) { // go right + i++; + node = parent.children[i]; + goingUp = false; + + } else node = null; // nothing found } - - if (!goingUp && !node.leaf && contains(node, bbox)) { // go down - path.push(node); - indexes.push(i); - i = 0; - parent = node; - node = node.children[0]; - - } else if (parent) { // go right - i++; - node = parent.children[i]; - goingUp = false; - - } else node = null; // nothing found + + return this; + } + + toBBox(item) { return item; } + + compareMinX(a, b) { return a.minX - b.minX; } + compareMinY(a, b) { return a.minY - b.minY; } + + toJSON() { return this.data; } + + fromJSON(data) { + this.data = data; + return this; } - - return this; - }, - - toBBox: function (item) { return item; }, - - compareMinX: compareNodeMinX, - compareMinY: compareNodeMinY, - - toJSON: function () { return this.data; }, - - fromJSON: function (data) { - this.data = data; - return this; - }, - - _all: function (node, result) { - var nodesToSearch = []; - while (node) { - if (node.leaf) result.push.apply(result, node.children); - else nodesToSearch.push.apply(nodesToSearch, node.children); - - node = nodesToSearch.pop(); + + _all(node, result) { + const nodesToSearch = []; + while (node) { + if (node.leaf) node.children.forEach(child => result.push(child.source || child)); + else nodesToSearch.push(...node.children); + + node = nodesToSearch.pop(); + } + return result; } - return result; - }, - - _build: function (items, left, right, height) { - - var N = right - left + 1, - M = this._maxEntries, - node; - - if (N <= M) { - // reached leaf level; return leaf - node = createNode(items.slice(left, right + 1)); + + _build(items, left, right, height) { + + const N = right - left + 1; + let M = this._maxEntries; + let node; + + if (N <= M) { + // reached leaf level; return leaf + node = createNode(items.slice(left, right + 1)); + calcBBox(node, this.toBBox); + return node; + } + + if (!height) { + // target height of the bulk-loaded tree + height = Math.ceil(Math.log(N) / Math.log(M)); + + // target number of root entries to maximize storage utilization + M = Math.ceil(N / Math.pow(M, height - 1)); + } + + node = createNode([]); + node.leaf = false; + node.height = height; + + // split the items into M mostly square tiles + + const N2 = Math.ceil(N / M); + const N1 = N2 * Math.ceil(Math.sqrt(M)); + + multiSelect(items, left, right, N1, this.compareMinX); + + for (let i = left; i <= right; i += N1) { + + const right2 = Math.min(i + N1 - 1, right); + + multiSelect(items, i, right2, N2, this.compareMinY); + + for (let j = i; j <= right2; j += N2) { + + const right3 = Math.min(j + N2 - 1, right2); + + // pack each entry recursively + node.children.push(this._build(items, j, right3, height - 1)); + } + } + calcBBox(node, this.toBBox); + return node; } - - if (!height) { - // target height of the bulk-loaded tree - height = Math.ceil(Math.log(N) / Math.log(M)); - - // target number of root entries to maximize storage utilization - M = Math.ceil(N / Math.pow(M, height - 1)); + + _chooseSubtree(bbox, node, level, path) { + while (true) { + path.push(node); + + if (node.leaf || path.length - 1 === level) break; + + let minArea = Infinity; + let minEnlargement = Infinity; + let targetNode; + + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + const area = bboxArea(child); + const enlargement = enlargedArea(bbox, child) - area; + + // choose entry with the least area enlargement + if (enlargement < minEnlargement) { + minEnlargement = enlargement; + minArea = area < minArea ? area : minArea; + targetNode = child; + + } else if (enlargement === minEnlargement) { + // otherwise choose one with the smallest area + if (area < minArea) { + minArea = area; + targetNode = child; + } + } + } + + node = targetNode || node.children[0]; + } + + return node; } - - node = createNode([]); - node.leaf = false; - node.height = height; - - // split the items into M mostly square tiles - - var N2 = Math.ceil(N / M), - N1 = N2 * Math.ceil(Math.sqrt(M)), - i, j, right2, right3; - - multiSelect(items, left, right, N1, this.compareMinX); - - for (i = left; i <= right; i += N1) { - - right2 = Math.min(i + N1 - 1, right); - - multiSelect(items, i, right2, N2, this.compareMinY); - - for (j = i; j <= right2; j += N2) { - - right3 = Math.min(j + N2 - 1, right2); - - // pack each entry recursively - node.children.push(this._build(items, j, right3, height - 1)); + + _insert(item, level, isNode) { + const bbox = isNode ? item : this.toBBox(item); + const insertPath = []; + + // find the best node for accommodating the item, saving all nodes along the path too + const node = this._chooseSubtree(bbox, this.data, level, insertPath); + + // put the item into the node + node.children.push(item); + extend(node, bbox); + + // split on node overflow; propagate upwards if necessary + while (level >= 0) { + if (insertPath[level].children.length > this._maxEntries) { + this._split(insertPath, level); + level--; + } else break; } + + // adjust bboxes along the insertion path + this._adjustParentBBoxes(bbox, insertPath, level); + } + + // split overflowed node into two + _split(insertPath, level) { + const node = insertPath[level]; + const M = node.children.length; + const m = this._minEntries; + + this._chooseSplitAxis(node, m, M); + + const splitIndex = this._chooseSplitIndex(node, m, M); + + const newNode = createNode(node.children.splice(splitIndex, node.children.length - splitIndex)); + newNode.height = node.height; + newNode.leaf = node.leaf; + + calcBBox(node, this.toBBox); + calcBBox(newNode, this.toBBox); + + if (level) insertPath[level - 1].children.push(newNode); + else this._splitRoot(node, newNode); + } + + _splitRoot(node, newNode) { + // split root node + this.data = createNode([node, newNode]); + this.data.height = node.height + 1; + this.data.leaf = false; + calcBBox(this.data, this.toBBox); } - - calcBBox(node, this.toBBox); - - return node; - }, - - _chooseSubtree: function (bbox, node, level, path) { - - var i, len, child, targetNode, area, enlargement, minArea, minEnlargement; - - while (true) { - path.push(node); - - if (node.leaf || path.length - 1 === level) break; - - minArea = minEnlargement = Infinity; - - for (i = 0, len = node.children.length; i < len; i++) { - child = node.children[i]; - area = bboxArea(child); - enlargement = enlargedArea(bbox, child) - area; - - // choose entry with the least area enlargement - if (enlargement < minEnlargement) { - minEnlargement = enlargement; + + _chooseSplitIndex(node, m, M) { + let index; + let minOverlap = Infinity; + let minArea = Infinity; + + for (let i = m; i <= M - m; i++) { + const bbox1 = distBBox(node, 0, i, this.toBBox); + const bbox2 = distBBox(node, i, M, this.toBBox); + + const overlap = intersectionArea(bbox1, bbox2); + const area = bboxArea(bbox1) + bboxArea(bbox2); + + // choose distribution with minimum overlap + if (overlap < minOverlap) { + minOverlap = overlap; + index = i; + minArea = area < minArea ? area : minArea; - targetNode = child; - - } else if (enlargement === minEnlargement) { - // otherwise choose one with the smallest area + + } else if (overlap === minOverlap) { + // otherwise choose distribution with minimum area if (area < minArea) { minArea = area; - targetNode = child; + index = i; } } } - - node = targetNode || node.children[0]; + + return index || M - m; } - - return node; - }, - - _insert: function (item, level, isNode) { - - var toBBox = this.toBBox, - bbox = isNode ? item : toBBox(item), - insertPath = []; - - // find the best node for accommodating the item, saving all nodes along the path too - var node = this._chooseSubtree(bbox, this.data, level, insertPath); - - // put the item into the node - node.children.push(item); - extend(node, bbox); - - // split on node overflow; propagate upwards if necessary - while (level >= 0) { - if (insertPath[level].children.length > this._maxEntries) { - this._split(insertPath, level); - level--; - } else break; + + // sorts node children by the best axis for split + _chooseSplitAxis(node, m, M) { + const compareMinX = node.leaf ? this.compareMinX : compareNodeMinX; + const compareMinY = node.leaf ? this.compareMinY : compareNodeMinY; + const xMargin = this._allDistMargin(node, m, M, compareMinX); + const yMargin = this._allDistMargin(node, m, M, compareMinY); + + // if total distributions margin value is minimal for x, sort by minX, + // otherwise it's already sorted by minY + if (xMargin < yMargin) node.children.sort(compareMinX); } - - // adjust bboxes along the insertion path - this._adjustParentBBoxes(bbox, insertPath, level); - }, - - // split overflowed node into two - _split: function (insertPath, level) { - - var node = insertPath[level], - M = node.children.length, - m = this._minEntries; - - this._chooseSplitAxis(node, m, M); - - var splitIndex = this._chooseSplitIndex(node, m, M); - - var newNode = createNode(node.children.splice(splitIndex, node.children.length - splitIndex)); - newNode.height = node.height; - newNode.leaf = node.leaf; - - calcBBox(node, this.toBBox); - calcBBox(newNode, this.toBBox); - - if (level) insertPath[level - 1].children.push(newNode); - else this._splitRoot(node, newNode); - }, - - _splitRoot: function (node, newNode) { - // split root node - this.data = createNode([node, newNode]); - this.data.height = node.height + 1; - this.data.leaf = false; - calcBBox(this.data, this.toBBox); - }, - - _chooseSplitIndex: function (node, m, M) { - - var i, bbox1, bbox2, overlap, area, minOverlap, minArea, index; - - minOverlap = minArea = Infinity; - - for (i = m; i <= M - m; i++) { - bbox1 = distBBox(node, 0, i, this.toBBox); - bbox2 = distBBox(node, i, M, this.toBBox); - - overlap = intersectionArea(bbox1, bbox2); - area = bboxArea(bbox1) + bboxArea(bbox2); - - // choose distribution with minimum overlap - if (overlap < minOverlap) { - minOverlap = overlap; - index = i; - - minArea = area < minArea ? area : minArea; - - } else if (overlap === minOverlap) { - // otherwise choose distribution with minimum area - if (area < minArea) { - minArea = area; - index = i; - } + + // total margin of all possible split distributions where each node is at least m full + _allDistMargin(node, m, M, compare) { + node.children.sort(compare); + + const toBBox = this.toBBox; + const leftBBox = distBBox(node, 0, m, toBBox); + const rightBBox = distBBox(node, M - m, M, toBBox); + let margin = bboxMargin(leftBBox) + bboxMargin(rightBBox); + + for (let i = m; i < M - m; i++) { + const child = node.children[i]; + extend(leftBBox, node.leaf ? toBBox(child) : child); + margin += bboxMargin(leftBBox); } + + for (let i = M - m - 1; i >= m; i--) { + const child = node.children[i]; + extend(rightBBox, node.leaf ? toBBox(child) : child); + margin += bboxMargin(rightBBox); + } + + return margin; } - - return index; - }, - - // sorts node children by the best axis for split - _chooseSplitAxis: function (node, m, M) { - - var compareMinX = node.leaf ? this.compareMinX : compareNodeMinX, - compareMinY = node.leaf ? this.compareMinY : compareNodeMinY, - xMargin = this._allDistMargin(node, m, M, compareMinX), - yMargin = this._allDistMargin(node, m, M, compareMinY); - - // if total distributions margin value is minimal for x, sort by minX, - // otherwise it's already sorted by minY - if (xMargin < yMargin) node.children.sort(compareMinX); - }, - - // total margin of all possible split distributions where each node is at least m full - _allDistMargin: function (node, m, M, compare) { - - node.children.sort(compare); - - var toBBox = this.toBBox, - leftBBox = distBBox(node, 0, m, toBBox), - rightBBox = distBBox(node, M - m, M, toBBox), - margin = bboxMargin(leftBBox) + bboxMargin(rightBBox), - i, child; - - for (i = m; i < M - m; i++) { - child = node.children[i]; - extend(leftBBox, node.leaf ? toBBox(child) : child); - margin += bboxMargin(leftBBox); + + _adjustParentBBoxes(bbox, path, level) { + // adjust bboxes along the given tree path + for (let i = level; i >= 0; i--) { + extend(path[i], bbox); + } } - - for (i = M - m - 1; i >= m; i--) { - child = node.children[i]; - extend(rightBBox, node.leaf ? toBBox(child) : child); - margin += bboxMargin(rightBBox); + + _condense(path) { + // go through the path, removing empty nodes and updating bboxes + for (let i = path.length - 1, siblings; i >= 0; i--) { + if (path[i].children.length === 0) { + if (i > 0) { + siblings = path[i - 1].children; + siblings.splice(siblings.indexOf(path[i]), 1); + + } else this.clear(); + + } else calcBBox(path[i], this.toBBox); + } } - - return margin; - }, - - _adjustParentBBoxes: function (bbox, path, level) { - // adjust bboxes along the given tree path - for (var i = level; i >= 0; i--) { - extend(path[i], bbox); + } + + function findItem(item, items, equalsFn) { + if (!equalsFn) return items.indexOf(item); + + for (let i = 0; i < items.length; i++) { + if (equalsFn(item, items[i])) return i; } - }, - - _condense: function (path) { - // go through the path, removing empty nodes and updating bboxes - for (var i = path.length - 1, siblings; i >= 0; i--) { - if (path[i].children.length === 0) { - if (i > 0) { - siblings = path[i - 1].children; - siblings.splice(siblings.indexOf(path[i]), 1); - - } else this.clear(); - - } else calcBBox(path[i], this.toBBox); + return -1; + } + + // calculate node's bbox from bboxes of its children + function calcBBox(node, toBBox) { + distBBox(node, 0, node.children.length, toBBox, node); + } + + // min bounding rectangle of node children from k to p-1 + function distBBox(node, k, p, toBBox, destNode) { + if (!destNode) destNode = createNode(null); + destNode.minX = Infinity; + destNode.minY = Infinity; + destNode.maxX = -Infinity; + destNode.maxY = -Infinity; + + for (let i = k; i < p; i++) { + const child = node.children[i]; + extend(destNode, node.leaf ? toBBox(child) : child); } - }, - - _initFormat: function (format) { - // data format (minX, minY, maxX, maxY accessors) - - // uses eval-type function compilation instead of just accepting a toBBox function - // because the algorithms are very sensitive to sorting functions performance, - // so they should be dead simple and without inner calls - - var compareArr = ['return a', ' - b', ';']; - - this.compareMinX = new Function('a', 'b', compareArr.join(format[0])); - this.compareMinY = new Function('a', 'b', compareArr.join(format[1])); - - this.toBBox = new Function('a', - 'return {minX: a' + format[0] + - ', minY: a' + format[1] + - ', maxX: a' + format[2] + - ', maxY: a' + format[3] + '};'); + + return destNode; } -}; - -function findItem(item, items, equalsFn) { - if (!equalsFn) return items.indexOf(item); - - for (var i = 0; i < items.length; i++) { - if (equalsFn(item, items[i])) return i; + + function extend(a, b) { + a.minX = Math.min(a.minX, b.minX); + a.minY = Math.min(a.minY, b.minY); + a.maxX = Math.max(a.maxX, b.maxX); + a.maxY = Math.max(a.maxY, b.maxY); + return a; } - return -1; -} - -// calculate node's bbox from bboxes of its children -function calcBBox(node, toBBox) { - distBBox(node, 0, node.children.length, toBBox, node); -} - -// min bounding rectangle of node children from k to p-1 -function distBBox(node, k, p, toBBox, destNode) { - if (!destNode) destNode = createNode(null); - destNode.minX = Infinity; - destNode.minY = Infinity; - destNode.maxX = -Infinity; - destNode.maxY = -Infinity; - - for (var i = k, child; i < p; i++) { - child = node.children[i]; - extend(destNode, node.leaf ? toBBox(child) : child); + + function compareNodeMinX(a, b) { return a.minX - b.minX; } + function compareNodeMinY(a, b) { return a.minY - b.minY; } + + function bboxArea(a) { return (a.maxX - a.minX) * (a.maxY - a.minY); } + function bboxMargin(a) { return (a.maxX - a.minX) + (a.maxY - a.minY); } + + function enlargedArea(a, b) { + return (Math.max(b.maxX, a.maxX) - Math.min(b.minX, a.minX)) * + (Math.max(b.maxY, a.maxY) - Math.min(b.minY, a.minY)); } - - return destNode; -} - -function extend(a, b) { - a.minX = Math.min(a.minX, b.minX); - a.minY = Math.min(a.minY, b.minY); - a.maxX = Math.max(a.maxX, b.maxX); - a.maxY = Math.max(a.maxY, b.maxY); - return a; -} - -function compareNodeMinX(a, b) { return a.minX - b.minX; } -function compareNodeMinY(a, b) { return a.minY - b.minY; } - -function bboxArea(a) { return (a.maxX - a.minX) * (a.maxY - a.minY); } -function bboxMargin(a) { return (a.maxX - a.minX) + (a.maxY - a.minY); } - -function enlargedArea(a, b) { - return (Math.max(b.maxX, a.maxX) - Math.min(b.minX, a.minX)) * - (Math.max(b.maxY, a.maxY) - Math.min(b.minY, a.minY)); -} - -function intersectionArea(a, b) { - var minX = Math.max(a.minX, b.minX), - minY = Math.max(a.minY, b.minY), - maxX = Math.min(a.maxX, b.maxX), - maxY = Math.min(a.maxY, b.maxY); - - return Math.max(0, maxX - minX) * - Math.max(0, maxY - minY); -} - -function contains(a, b) { - return a.minX <= b.minX && - a.minY <= b.minY && - b.maxX <= a.maxX && - b.maxY <= a.maxY; -} - -function intersects(a, b) { - return b.minX <= a.maxX && - b.minY <= a.maxY && - b.maxX >= a.minX && - b.maxY >= a.minY; -} - -function createNode(children) { - return { - children: children, - height: 1, - leaf: true, - minX: Infinity, - minY: Infinity, - maxX: -Infinity, - maxY: -Infinity - }; -} - -// sort an array so that items come in groups of n unsorted items, with groups sorted between each other; -// combines selection algorithm with binary divide & conquer approach - -function multiSelect(arr, left, right, n, compare) { - var stack = [left, right], - mid; - - while (stack.length) { - right = stack.pop(); - left = stack.pop(); - - if (right - left <= n) continue; - - mid = left + Math.ceil((right - left) / n / 2) * n; - quickselect(arr, mid, left, right, compare); - - stack.push(left, mid, mid, right); + + function intersectionArea(a, b) { + const minX = Math.max(a.minX, b.minX); + const minY = Math.max(a.minY, b.minY); + const maxX = Math.min(a.maxX, b.maxX); + const maxY = Math.min(a.maxY, b.maxY); + + return Math.max(0, maxX - minX) * + Math.max(0, maxY - minY); } -} - -},{"quickselect":2}],2:[function(require,module,exports){ -'use strict'; - -module.exports = partialSort; - -// Floyd-Rivest selection algorithm: -// Rearrange items so that all items in the [left, k] range are smaller than all items in (k, right]; -// The k-th element will have the (k - left + 1)th smallest value in [left, right] - -function partialSort(arr, k, left, right, compare) { - - while (right > left) { - if (right - left > 600) { - var n = right - left + 1; - var m = k - left + 1; - var z = Math.log(n); - var s = 0.5 * Math.exp(2 * z / 3); - var sd = 0.5 * Math.sqrt(z * s * (n - s) / n) * (m - n / 2 < 0 ? -1 : 1); - var newLeft = Math.max(left, Math.floor(k - m * s / n + sd)); - var newRight = Math.min(right, Math.floor(k + (n - m) * s / n + sd)); - partialSort(arr, k, newLeft, newRight, compare); - } - - var t = arr[k]; - var i = left; - var j = right; - - swap(arr, left, k); - if (compare(arr[right], t) > 0) swap(arr, left, right); - - while (i < j) { - swap(arr, i, j); - i++; - j--; - while (compare(arr[i], t) < 0) i++; - while (compare(arr[j], t) > 0) j--; - } - - if (compare(arr[left], t) === 0) swap(arr, left, j); - else { - j++; - swap(arr, j, right); + + function contains(a, b) { + return a.minX <= b.minX && + a.minY <= b.minY && + b.maxX <= a.maxX && + b.maxY <= a.maxY; + } + + function intersects(a, b) { + return b.minX <= a.maxX && + b.minY <= a.maxY && + b.maxX >= a.minX && + b.maxY >= a.minY; + } + + function createNode(children) { + return { + children, + height: 1, + leaf: true, + minX: Infinity, + minY: Infinity, + maxX: -Infinity, + maxY: -Infinity + }; + } + + // sort an array so that items come in groups of n unsorted items, with groups sorted between each other; + // combines selection algorithm with binary divide & conquer approach + + function multiSelect(arr, left, right, n, compare) { + const stack = [left, right]; + + while (stack.length) { + right = stack.pop(); + left = stack.pop(); + + if (right - left <= n) continue; + + const mid = left + Math.ceil((right - left) / n / 2) * n; + quickselect(arr, mid, left, right, compare); + + stack.push(left, mid, mid, right); } - - if (j <= k) left = j + 1; - if (k <= j) right = j - 1; } -} - -function swap(arr, i, j) { - var tmp = arr[i]; - arr[i] = arr[j]; - arr[j] = tmp; -} - -function defaultCompare(a, b) { - return a < b ? -1 : a > b ? 1 : 0; -} - -},{}]},{},[1])(1) -}); + + return RBush; + + })); \ No newline at end of file diff --git a/GDJS/Runtime/runtimebehavior.ts b/GDJS/Runtime/runtimebehavior.ts index 41baabf1a341..ad9669a7976b 100644 --- a/GDJS/Runtime/runtimebehavior.ts +++ b/GDJS/Runtime/runtimebehavior.ts @@ -16,18 +16,18 @@ namespace gdjs { minY: float = 0; maxX: float = 0; maxY: float = 0; - behavior: T; + source: T; constructor(behavior: T) { - this.behavior = behavior; + this.source = behavior; this.updateAABBFromOwner(); } updateAABBFromOwner() { - this.minX = this.behavior.owner.getAABB().min[0]; - this.minY = this.behavior.owner.getAABB().min[1]; - this.maxX = this.behavior.owner.getAABB().max[0]; - this.maxY = this.behavior.owner.getAABB().max[1]; + this.minX = this.source.owner.getAABB().min[0]; + this.minY = this.source.owner.getAABB().min[1]; + this.maxX = this.source.owner.getAABB().max[0]; + this.maxY = this.source.owner.getAABB().max[1]; } } diff --git a/GDJS/Runtime/runtimeobject.ts b/GDJS/Runtime/runtimeobject.ts index 7d84b6064a14..b14cee5ebe54 100644 --- a/GDJS/Runtime/runtimeobject.ts +++ b/GDJS/Runtime/runtimeobject.ts @@ -147,6 +147,8 @@ namespace gdjs { return true; }; + type RuntimeObjectCallback = (object: gdjs.RuntimeObject) => void; + /** * RuntimeObject represents an object being used on a RuntimeScene. * @@ -164,9 +166,12 @@ namespace gdjs { layer: string = ''; protected _nameId: integer; protected _livingOnScene: boolean = true; + protected _lifecycleSleepState: ObjectSleepState; readonly id: integer; private destroyCallbacks = new Set<() => void>(); + // HitboxChanges happen a lot, an Array is faster to iterate. + private hitBoxChangedCallbacks: Array = []; _runtimeScene: gdjs.RuntimeInstanceContainer; /** @@ -181,12 +186,15 @@ namespace gdjs { * not "thread safe" or "re-entrant algorithm" safe. */ pick: boolean = false; + pickingId: integer = 0; //Hit boxes: protected _defaultHitBoxes: gdjs.Polygon[] = []; protected hitBoxes: gdjs.Polygon[]; protected hitBoxesDirty: boolean = true; + // TODO use a different AABB for collision mask and rendered image. protected aabb: AABB = { min: [0, 0], max: [0, 0] }; + protected _isIncludedInParentCollisionMask = true; //Variables: @@ -212,6 +220,7 @@ namespace gdjs { * are never used. */ protected _behaviors: gdjs.RuntimeBehavior[] = []; + protected _activeBehaviors: gdjs.RuntimeBehavior[] = []; /** * Contains the behaviors of the object by name. * @@ -229,10 +238,11 @@ namespace gdjs { instanceContainer: gdjs.RuntimeInstanceContainer, objectData: ObjectData & any ) { + const scene = instanceContainer.getScene(); this.name = objectData.name || ''; this.type = objectData.type || ''; this._nameId = RuntimeObject.getNameIdentifier(this.name); - this.id = instanceContainer.getScene().createNewUniqueId(); + this.id = scene.createNewUniqueId(); this._runtimeScene = instanceContainer; this._defaultHitBoxes.push(gdjs.Polygon.createRectangle(0, 0)); this.hitBoxes = this._defaultHitBoxes; @@ -241,8 +251,14 @@ namespace gdjs { ); this._totalForce = new gdjs.Force(0, 0, 0); this._behaviorsTable = new Hashtable(); + this._timers = new Hashtable(); + this._lifecycleSleepState = new gdjs.ObjectSleepState( + this, + () => this.isNeedingLifecycleFunctions(), + gdjs.ObjectSleepState.State.ASleep + ); for (let i = 0; i < objectData.effects.length; ++i) { - this._runtimeScene + scene .getGame() .getEffectsManager() .initializeEffect(objectData.effects[i], this._rendererEffects, this); @@ -253,12 +269,13 @@ namespace gdjs { const autoData = objectData.behaviors[i]; const Ctor = gdjs.getBehaviorConstructor(autoData.type); const behavior = new Ctor(instanceContainer, autoData, this); + this._behaviors.push(behavior); if (behavior.usesLifecycleFunction()) { - this._behaviors.push(behavior); + this._activeBehaviors.push(behavior); + this._lifecycleSleepState.wakeUp(); } this._behaviorsTable.put(autoData.name, behavior); } - this._timers = new Hashtable(); } //Common members functions related to the object and its runtimeScene : @@ -322,10 +339,12 @@ namespace gdjs { this.aabb.max[1] = 0; this._variables = new gdjs.VariablesContainer(objectData.variables); this.clearForces(); + this._lifecycleSleepState._reinitialize(gdjs.ObjectSleepState.State.ASleep); // Reinitialize behaviors. this._behaviorsTable.clear(); const behaviorsDataCount = objectData.behaviors.length; + let behaviorsCount = 0; let behaviorsUsingLifecycleFunctionCount = 0; for ( let behaviorDataIndex = 0; @@ -336,17 +355,29 @@ namespace gdjs { const Ctor = gdjs.getBehaviorConstructor(behaviorData.type); // TODO: Add support for behavior recycling with a `reinitialize` method. const behavior = new Ctor(runtimeScene, behaviorData, this); + if (behaviorsCount < this._behaviors.length) { + this._behaviors[behaviorsCount] = behavior; + } else { + this._behaviors.push(behavior); + } + behaviorsCount++; if (behavior.usesLifecycleFunction()) { - if (behaviorsUsingLifecycleFunctionCount < this._behaviors.length) { - this._behaviors[behaviorsUsingLifecycleFunctionCount] = behavior; + this._lifecycleSleepState.wakeUp(); + if ( + behaviorsUsingLifecycleFunctionCount < this._activeBehaviors.length + ) { + this._activeBehaviors[ + behaviorsUsingLifecycleFunctionCount + ] = behavior; } else { - this._behaviors.push(behavior); + this._activeBehaviors.push(behavior); } behaviorsUsingLifecycleFunctionCount++; } this._behaviorsTable.put(behaviorData.name, behavior); } - this._behaviors.length = behaviorsUsingLifecycleFunctionCount; + this._behaviors.length = behaviorsCount; + this._activeBehaviors.length = behaviorsUsingLifecycleFunctionCount; // Reinitialize effects. for (let i = 0; i < objectData.effects.length; ++i) { @@ -439,6 +470,22 @@ namespace gdjs { return false; } + isNeedingLifecycleFunctions(): boolean { + return ( + this._activeBehaviors.length > 0 || + !this.hasNoForces() || + !!this._timers.firstKey() + ); + } + + getLifecycleSleepState(): ObjectSleepState { + return this._lifecycleSleepState; + } + + isAlive(): boolean { + return this._livingOnScene; + } + /** * Remove an object from a scene. * @@ -449,6 +496,7 @@ namespace gdjs { if (this._livingOnScene) { instanceContainer.markObjectForDeletion(this); this._livingOnScene = false; + this._lifecycleSleepState._destroy(); } } @@ -486,6 +534,30 @@ namespace gdjs { onDestroyed(): void {} + registerHitboxChangedCallback(callback: RuntimeObjectCallback) { + if (this.hitBoxChangedCallbacks.includes(callback)) { + return; + } + this.hitBoxChangedCallbacks.push(callback); + } + + /** + * Send a signal that the object hitboxes are no longer up to date. + * + * The signal is propagated to parents so + * {@link gdjs.RuntimeObject.hitBoxesDirty} should never be modified + * directly. + */ + invalidateHitboxes(): void { + // TODO EBO Check that no community extension set hitBoxesDirty to true + // directly. + this.hitBoxesDirty = true; + this._runtimeScene.onChildrenLocationChanged(); + for (const callback of this.hitBoxChangedCallbacks) { + callback(this); + } + } + /** * Called whenever the scene owning the object is paused. * This should *not* impact objects, but some may need to inform their renderer. @@ -570,20 +642,6 @@ namespace gdjs { this.invalidateHitboxes(); } - /** - * Send a signal that the object hitboxes are no longer up to date. - * - * The signal is propagated to parents so - * {@link gdjs.RuntimeObject.hitBoxesDirty} should never be modified - * directly. - */ - invalidateHitboxes(): void { - // TODO EBO Check that no community extension set hitBoxesDirty to true - // directly. - this.hitBoxesDirty = true; - this._runtimeScene.onChildrenLocationChanged(); - } - /** * Get the X position of the object. * @@ -1363,6 +1421,7 @@ namespace gdjs { // (or the 1st instant force). this._instantForces.push(this._getRecycledForce(x, y, multiplier)); } + this._lifecycleSleepState.wakeUp(); } /** @@ -1781,8 +1840,8 @@ namespace gdjs { stepBehaviorsPreEvents( instanceContainer: gdjs.RuntimeInstanceContainer ): void { - for (let i = 0, len = this._behaviors.length; i < len; ++i) { - this._behaviors[i].stepPreEvents(instanceContainer); + for (let i = 0, len = this._activeBehaviors.length; i < len; ++i) { + this._activeBehaviors[i].stepPreEvents(instanceContainer); } } @@ -1792,8 +1851,8 @@ namespace gdjs { stepBehaviorsPostEvents( instanceContainer: gdjs.RuntimeInstanceContainer ): void { - for (let i = 0, len = this._behaviors.length; i < len; ++i) { - this._behaviors[i].stepPostEvents(instanceContainer); + for (let i = 0, len = this._activeBehaviors.length; i < len; ++i) { + this._activeBehaviors[i].stepPostEvents(instanceContainer); } } @@ -1870,9 +1929,17 @@ namespace gdjs { return false; } behavior.onDestroy(); - const behaviorIndex = this._behaviors.indexOf(behavior); - if (behaviorIndex !== -1) { - this._behaviors.splice(behaviorIndex, 1); + { + const behaviorIndex = this._behaviors.indexOf(behavior); + if (behaviorIndex !== -1) { + this._behaviors.splice(behaviorIndex, 1); + } + } + { + const behaviorIndex = this._activeBehaviors.indexOf(behavior); + if (behaviorIndex !== -1) { + this._activeBehaviors.splice(behaviorIndex, 1); + } } this._behaviorsTable.remove(name); return true; @@ -1926,6 +1993,7 @@ namespace gdjs { timerElapsedTime(timerName: string, timeInSeconds: float): boolean { if (!this._timers.containsKey(timerName)) { this._timers.put(timerName, new gdjs.Timer(timerName)); + this._lifecycleSleepState.wakeUp(); return false; } return this.getTimerElapsedTimeInSeconds(timerName) >= timeInSeconds; @@ -1950,6 +2018,7 @@ namespace gdjs { resetTimer(timerName: string): void { if (!this._timers.containsKey(timerName)) { this._timers.put(timerName, new gdjs.Timer(timerName)); + this._lifecycleSleepState.wakeUp(); } this._timers.get(timerName).reset(); } @@ -1961,6 +2030,7 @@ namespace gdjs { pauseTimer(timerName: string): void { if (!this._timers.containsKey(timerName)) { this._timers.put(timerName, new gdjs.Timer(timerName)); + this._lifecycleSleepState.wakeUp(); } this._timers.get(timerName).setPaused(true); } @@ -1972,6 +2042,7 @@ namespace gdjs { unpauseTimer(timerName: string): void { if (!this._timers.containsKey(timerName)) { this._timers.put(timerName, new gdjs.Timer(timerName)); + this._lifecycleSleepState.wakeUp(); } this._timers.get(timerName).setPaused(false); } diff --git a/GDJS/Runtime/runtimescene.ts b/GDJS/Runtime/runtimescene.ts index 4c4422675e0c..fcb6c2e12bd7 100644 --- a/GDJS/Runtime/runtimescene.ts +++ b/GDJS/Runtime/runtimescene.ts @@ -42,6 +42,8 @@ namespace gdjs { // Set to `new gdjs.Profiler()` to have profiling done on the scene. _onProfilerStopped: null | ((oldProfiler: gdjs.Profiler) => void) = null; + private _frameIndex: integer = 0; + _cachedGameResolutionWidth: integer; _cachedGameResolutionHeight: integer; @@ -427,6 +429,7 @@ namespace gdjs { if (this._profiler) { this._profiler.endFrame(); } + this._frameIndex++; return !!this.getRequestedChange(); } @@ -734,6 +737,10 @@ namespace gdjs { sceneJustResumed(): boolean { return this._isJustResumed; } + + getFrameIndex(): integer { + return this._frameIndex; + } } //The flags to describe the change request by a scene: diff --git a/GDJS/Runtime/spriteruntimeobject.ts b/GDJS/Runtime/spriteruntimeobject.ts index aa5acf77f8a7..cff76f244107 100644 --- a/GDJS/Runtime/spriteruntimeobject.ts +++ b/GDJS/Runtime/spriteruntimeobject.ts @@ -348,6 +348,10 @@ namespace gdjs { ); this._updateAnimationFrame(); + if (this.isNeedingLifecycleFunctions()) { + this.getLifecycleSleepState().wakeUp(); + } + // *ALWAYS* call `this.onCreated()` at the very end of your object constructor. this.onCreated(); } @@ -452,6 +456,24 @@ namespace gdjs { } } + isNeedingLifecycleFunctions(): boolean { + if (super.isNeedingLifecycleFunctions()) { + return true; + } + if ( + this.isAnimationPaused() || + this.hasAnimationEnded() || + this._currentAnimation >= this._animations.length + ) { + return false; + } + const animation = this._animations[this._currentAnimation]; + if (this._currentDirection > animation.directions.length) { + return false; + } + return animation.directions[this._currentDirection].frames.length > 1; + } + /** * Update the current frame of the object according to the elapsed time on the scene. */ @@ -617,6 +639,7 @@ namespace gdjs { this._currentAnimation = newAnimation; this._currentFrame = 0; this._animationElapsedTime = 0; + this.getLifecycleSleepState().wakeUp(); //TODO: This may be unnecessary. this._renderer.update(); @@ -868,6 +891,7 @@ namespace gdjs { resumeAnimation(): void { this._animationPaused = false; + this.getLifecycleSleepState().wakeUp(); } getAnimationSpeedScale() { diff --git a/GDJS/Runtime/types/rbush.d.ts b/GDJS/Runtime/types/rbush.d.ts new file mode 100644 index 000000000000..7dd67cedb130 --- /dev/null +++ b/GDJS/Runtime/types/rbush.d.ts @@ -0,0 +1,19 @@ +type SearchArea = { minX: float; minY: float; maxX: float; maxY: float }; +type SearchedItem = { + source: T; + minX: float; + minY: float; + maxX: float; + maxY: float; +}; + +declare class RBush { + constructor(maxEntries?: number); + search(bbox: SearchArea, result?: Array): Array; + insert(item: SearchedItem): RBush; + clear(): RBush; + remove( + item: SearchedItem, + equalsFn?: (item: SearchedItem, otherItem: SearchedItem) => boolean + ): RBush; +} diff --git a/GDJS/tests/karma.conf.js b/GDJS/tests/karma.conf.js index c14839fef2e6..f3695b0ea1c2 100644 --- a/GDJS/tests/karma.conf.js +++ b/GDJS/tests/karma.conf.js @@ -53,6 +53,7 @@ module.exports = function (config) { './newIDE/app/resources/GDJS/Runtime/ResourceCache.js', './newIDE/app/resources/GDJS/Runtime/timemanager.js', './newIDE/app/resources/GDJS/Runtime/polygon.js', + './newIDE/app/resources/GDJS/Runtime/ObjectSleepState.js', './newIDE/app/resources/GDJS/Runtime/runtimeobject.js', './newIDE/app/resources/GDJS/Runtime/RuntimeInstanceContainer.js', './newIDE/app/resources/GDJS/Runtime/runtimescene.js', diff --git a/GDJS/tests/tests/runtimescene.active.js b/GDJS/tests/tests/runtimescene.active.js new file mode 100644 index 000000000000..600819401ed8 --- /dev/null +++ b/GDJS/tests/tests/runtimescene.active.js @@ -0,0 +1,78 @@ +// @ts-check +describe.only('gdjs.RuntimeScene active objects tests', () => { + + const spriteConfiguration = { + name: 'MySprite', + type: 'Sprite', + behaviors: [], + effects: [], + animations: [ + { + name: 'animation', + directions: [ + { + sprites: [ + { + originPoint: { x: 0, y: 0 }, + centerPoint: { x: 0, y: 0 }, + points: [ + { name: 'Center', x: 0, y: 0 }, + { name: 'Origin', x: 0, y: 0 }, + ], + hasCustomCollisionMask: false, + }, + ], + }, + ], + }, + ], + } + + it('can recycle a sprite without duplication in the active objects', () => { + const game = gdjs.getPixiRuntimeGame(); + const scene = new gdjs.TestRuntimeScene(game); + + scene.registerObject(spriteConfiguration); + let object = scene.createObject('MySprite'); + object.resetTimer("MyTimer"); + + scene.renderAndStep(1000 / 60); + expect(scene._activeInstances.length).to.be(1); + + object.deleteFromScene(scene); + scene.renderAndStep(1000 / 60); + expect(scene._activeInstances.length).to.be(0); + // The object is not destroyed because it is recycled. + expect(object.getLifecycleSleepState()._onWakingUpCallbacks.length).to.be(1); + + let object2 = scene.createObject('MySprite'); + expect(object === object2).to.be(true); + object.resetTimer("MyTimer"); + + expect(scene._activeInstances.length).to.be(1); + expect(object.getLifecycleSleepState()._onWakingUpCallbacks.length).to.be(1); + }); + + it('can keep an object awake to handle its timer', () => { + const game = gdjs.getPixiRuntimeGame(); + const scene = new gdjs.TestRuntimeScene(game); + + scene.registerObject(spriteConfiguration); + let object = scene.createObject('MySprite'); + object.resetTimer("MyTimer"); + + for (let index = 0; index < 60; index++) { + scene.renderAndStep(1000 / 60); + expect(scene._activeInstances.length).to.be(1); + } + + object.removeTimer("MyTimer"); + for (let index = 0; index < 59; index++) { + scene.renderAndStep(1000 / 60); + expect(scene._activeInstances.length).to.be(1); + } + scene.renderAndStep(1000 / 60); + expect(scene._activeInstances.length).to.be(0); + }); +}); + \ No newline at end of file