Files
GDevelop/GDJS/Runtime/scenestack.ts

527 lines
18 KiB
TypeScript

namespace gdjs {
const logger = new gdjs.Logger('Scene stack');
const debugLogger = new gdjs.Logger('Multiplayer - Debug');
interface PushSceneOptions {
sceneName: string;
externalLayoutName?: string;
skipCreatingInstancesFromScene?: boolean;
skipStoppingSoundsOnStartup?: boolean;
}
interface ReplaceSceneOptions extends PushSceneOptions {
clear: boolean;
}
/**
* Hold the stack of scenes ({@link gdjs.RuntimeScene}) being played.
*/
export class SceneStack {
_runtimeGame: gdjs.RuntimeGame;
_stack: gdjs.RuntimeScene[] = [];
_wasFirstSceneLoaded: boolean = false;
_isNextLayoutLoading: boolean = false;
_sceneStackSyncDataToApply: SceneStackNetworkSyncData | null = null;
_wasDisposed: boolean = false;
/**
* @param runtimeGame The runtime game that is using the scene stack
*/
constructor(runtimeGame: gdjs.RuntimeGame) {
if (!runtimeGame) {
throw 'SceneStack must be constructed with a gdjs.RuntimeGame.';
}
this._runtimeGame = runtimeGame;
}
/**
* Called by the RuntimeGame when the game resolution is changed.
* Useful to notify scene and layers that resolution is changed, as they
* might be caching it.
*/
onGameResolutionResized(): void {
for (let i = 0; i < this._stack.length; ++i) {
this._stack[i].onGameResolutionResized();
}
}
step(elapsedTime: float): boolean {
this._throwIfDisposed();
if (this._isNextLayoutLoading || this._stack.length === 0) {
return false;
}
const hasMadeChangeToStack = this.applyUpdateFromNetworkSyncDataIfAny();
if (hasMadeChangeToStack) {
debugLogger.info(
'Scene stack has been updated from network sync data, skipping step.'
);
// If we have made changes to the stack as part of the network sync,
// we trust the network to be the source of truth for the scene stack,
// and skip the scene rendering (and so any other request to change the scene stack from it)
return true;
}
const currentScene = this._stack[this._stack.length - 1];
if (currentScene.renderAndStep(elapsedTime)) {
const request = currentScene.getRequestedChange();
// A scene change was requested by the current scene.
if (request === gdjs.SceneChangeRequest.STOP_GAME) {
this._runtimeGame.getRenderer().stopGame();
return true;
} else if (request === gdjs.SceneChangeRequest.POP_SCENE) {
this.pop();
} else if (request === gdjs.SceneChangeRequest.PUSH_SCENE) {
this.push(currentScene.getRequestedScene());
} else if (
request === gdjs.SceneChangeRequest.REPLACE_SCENE ||
request === gdjs.SceneChangeRequest.CLEAR_SCENES
) {
this.replace(
currentScene.getRequestedScene(),
request === gdjs.SceneChangeRequest.CLEAR_SCENES
);
} else {
logger.error('Unrecognized change in scene stack: ' + request);
}
}
return true;
}
renderWithoutStep(): boolean {
this._throwIfDisposed();
const currentScene = this.getCurrentScene();
if (!currentScene) {
return false;
}
currentScene.render();
return true;
}
pop(popCount = 1): void {
this._throwIfDisposed();
let hasDoneAnyChanges = false;
for (let i = 0; i < popCount; ++i) {
if (this._stack.length <= 1) {
break;
}
// Unload the current scene
hasDoneAnyChanges = true;
const scene = this._stack.pop();
if (!scene) {
return;
}
this._unloadSceneAndPossiblyResources({
scene,
newSceneName: null,
});
}
// Tell the new current scene it's being resumed
if (hasDoneAnyChanges) {
const currentScene = this._stack[this._stack.length - 1];
if (currentScene) {
currentScene.onResume();
}
}
}
/**
* Pause the scene currently being played and start the new scene that is specified in `options.sceneName`.
* If `options.externalLayoutName` is set, also instantiate the objects from this external layout.
*
* @param options Contains the scene name and optional external layout name to instantiate.
* @param deprecatedExternalLayoutName Deprecated, use `options.externalLayoutName` instead.
*/
push(
options: PushSceneOptions | string,
deprecatedExternalLayoutName?: string
): gdjs.RuntimeScene | null {
this._throwIfDisposed();
const sceneName =
typeof options === 'string' ? options : options.sceneName;
const skipCreatingInstancesFromScene =
typeof options === 'string'
? false
: options.skipCreatingInstancesFromScene;
const skipStoppingSoundsOnStartup =
typeof options === 'string'
? false
: options.skipStoppingSoundsOnStartup;
const externalLayoutName =
deprecatedExternalLayoutName ||
(typeof options === 'string' ? undefined : options.externalLayoutName);
// Tell the scene it's being paused
const currentScene = this._stack[this._stack.length - 1];
if (currentScene) {
currentScene.onPause();
}
// Avoid a risk of displaying an intermediate loading screen
// during 1 frame.
if (this._runtimeGame.areSceneAssetsReady(sceneName)) {
return this._loadNewScene({
sceneName,
externalLayoutName,
skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup,
});
}
this._isNextLayoutLoading = true;
this._runtimeGame.loadSceneAssets(sceneName).then(() => {
this._loadNewScene({
sceneName,
externalLayoutName,
skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup,
});
this._isNextLayoutLoading = false;
});
return null;
}
private _loadNewScene(options: PushSceneOptions): gdjs.RuntimeScene {
this._throwIfDisposed();
// Load the new one
const newScene = new gdjs.RuntimeScene(this._runtimeGame);
newScene.loadFromScene(
this._runtimeGame.getSceneAndExtensionsData(options.sceneName),
{
skipCreatingInstances: options.skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup: options.skipStoppingSoundsOnStartup,
}
);
this._wasFirstSceneLoaded = true;
// Optionally create the objects from an external layout.
if (options.externalLayoutName) {
const externalLayoutData = this._runtimeGame.getExternalLayoutData(
options.externalLayoutName
);
if (externalLayoutData) {
newScene.createObjectsFrom(
externalLayoutData.instances,
0,
0,
0,
/*trackByPersistentUuid=*/
true
);
}
}
this._stack.push(newScene);
return newScene;
}
/**
* Start the scene in `options.sceneName`, replacing the one currently being played.
* If `options.clear` is set to true, all running scenes are also removed from the stack of scenes.
*
* @param options Contains the scene name and optional external layout name to instantiate.
* @param deprecatedClear Deprecated, use `options.clear` instead.
*/
replace(
options: ReplaceSceneOptions | string,
deprecatedClear?: boolean
): gdjs.RuntimeScene | null {
const clear =
deprecatedClear || typeof options === 'string' ? false : options.clear;
const newSceneName =
typeof options === 'string' ? options : options.sceneName;
this._throwIfDisposed();
if (!!clear) {
// Unload all the scenes
while (this._stack.length !== 0) {
let scene = this._stack.pop();
if (scene) {
this._unloadSceneAndPossiblyResources({ scene, newSceneName });
}
}
} else {
// Unload the current scene
if (this._stack.length !== 0) {
let scene = this._stack.pop();
if (scene) {
this._unloadSceneAndPossiblyResources({ scene, newSceneName });
}
}
}
return this.push(options);
}
/**
* Return the current gdjs.RuntimeScene being played, or null if none is run.
*/
getCurrentScene(): gdjs.RuntimeScene | null {
this._throwIfDisposed();
if (this._stack.length === 0) {
return null;
}
return this._stack[this._stack.length - 1];
}
/**
* Return true if a scene was loaded, false otherwise (i.e: game not yet started).
*/
wasFirstSceneLoaded(): boolean {
return this._wasFirstSceneLoaded;
}
getAllScenes(): Array<gdjs.RuntimeScene> {
this._throwIfDisposed();
return this._stack;
}
getAllSceneNames(): Array<string> {
this._throwIfDisposed();
return this._stack.map((scene) => scene.getName());
}
getNetworkSyncData(
syncOptions: GetNetworkSyncDataOptions
): SceneStackNetworkSyncData | null {
const syncedPlayerNumber = syncOptions.playerNumber;
const isHost = syncOptions.isHost;
if (syncedPlayerNumber !== undefined && !isHost) {
// If we are getting sync data of a specific player,
// and they are not the host, we don't sync the scene stack.
return null;
}
// If we are the host, we can take charge of
// generating a networkId for each scene if they don't have one.
// They will be reconciled on the other players' games.
const sceneStackSyncData: SceneStackSceneNetworkSyncData[] = [];
for (let i = 0; i < this._stack.length; ++i) {
const scene = this._stack[i];
sceneStackSyncData.push({
name: scene.getName(),
networkId: scene.getOrCreateNetworkId(),
});
}
return sceneStackSyncData;
}
updateFromNetworkSyncData(
sceneStackSyncData: SceneStackNetworkSyncData
): void {
// Don't directly apply changes to the scene stack. Store them and they will be applied
// in `step` (i.e: at the end of a frame). Otherwise, we would risk doing operations on the scene
// stack, like creating a new scene or unloading a scene, while being inside the `renderAndStep` method
// of the current scene.
this._sceneStackSyncDataToApply = sceneStackSyncData;
}
applyUpdateFromNetworkSyncDataIfAny(
options?: UpdateFromNetworkSyncDataOptions
): boolean {
this._throwIfDisposed();
const sceneStackSyncData = this._sceneStackSyncDataToApply;
let hasMadeChangeToStack = false;
if (!sceneStackSyncData) return hasMadeChangeToStack;
this._sceneStackSyncDataToApply = null;
const skipCreatingInstancesFromScene =
!!options && !!options.preventInitialInstancesCreation;
const skipStoppingSoundsOnStartup =
!!options && !!options.preventSoundsStoppingOnStartup;
if (options && options.clearSceneStack) {
while (this._stack.length !== 0) {
let scene = this._stack.pop();
if (scene) {
scene.unloadScene();
}
}
for (let i = 0; i < sceneStackSyncData.length; ++i) {
const sceneSyncData = sceneStackSyncData[i];
const newScene = this.push({
sceneName: sceneSyncData.name,
skipCreatingInstancesFromScene,
skipStoppingSoundsOnStartup,
});
if (newScene) {
newScene.networkId = sceneSyncData.networkId;
}
}
hasMadeChangeToStack = true;
return hasMadeChangeToStack;
}
// If this method is called, we are a client.
// We trust the host to be the source of truth for the scene stack.
// So we loop through the scenes in the stack given by the host and either:
// - Set the networkId of the scene if it's already in the stack at the right place
// - Add the scene to the stack if it's not there, and set its networkId
// - Remove any scenes that are in the stack but not in the data given by the host
for (let i = 0; i < sceneStackSyncData.length; ++i) {
const sceneSyncData = sceneStackSyncData[i];
const sceneAtThisPositionInOurStack = this._stack[i];
if (!sceneAtThisPositionInOurStack) {
debugLogger.info(
`Scene at position ${i} with name ${sceneSyncData.name} is missing from the stack, adding it.`
);
// We have fewer scenes in the stack than the host, let's add the scene.
const newScene = this.push({
sceneName: sceneSyncData.name,
skipCreatingInstancesFromScene,
});
if (newScene) {
newScene.networkId = sceneSyncData.networkId;
}
hasMadeChangeToStack = true;
// Continue to the next scene in the stack received from the host.
continue;
}
if (sceneAtThisPositionInOurStack.getName() !== sceneSyncData.name) {
debugLogger.info(
`Scene at position ${i} and name ${sceneAtThisPositionInOurStack.getName()} is not the same as the expected ${
sceneSyncData.name
}, replacing.`
);
// The scene does not correspond to the scene at this position in our stack
// Let's unload everything after this position to recreate the stack.
const newScene = this.replace({
sceneName: sceneSyncData.name,
clear: true,
skipCreatingInstancesFromScene,
});
if (newScene) {
newScene.networkId = sceneSyncData.networkId;
}
hasMadeChangeToStack = true;
// Continue to the next scene in the stack received from the host.
continue;
}
if (
!sceneAtThisPositionInOurStack.networkId &&
sceneSyncData.networkId &&
sceneSyncData.name === sceneAtThisPositionInOurStack.getName()
) {
debugLogger.info(
`Scene at position ${i} and name ${sceneAtThisPositionInOurStack.getName()} has no networkId, let's assume it's the right one and reconcile it with the id ${
sceneSyncData.networkId
}.`
);
// The scene is in the stack but has no networkId,
// this can happen at the start of the game on a player that is not the host,
// or if a player switch to another scene before the host has sent the scene stack.
// Let's set the networkId of the scene.
sceneAtThisPositionInOurStack.networkId = sceneSyncData.networkId;
// Continue to the next scene in the stack received from the host.
continue;
}
if (
sceneAtThisPositionInOurStack.networkId !== sceneSyncData.networkId
) {
debugLogger.info(
`Scene at position ${i} and name ${sceneAtThisPositionInOurStack.getName()} has a different networkId ${
sceneAtThisPositionInOurStack.networkId
} than the expected ${sceneSyncData.networkId}, replacing.`
);
// The scene is in the stack but has a different networkId
// This can happen if the host has restarted the scene
// We can't just update the networkId of the scene in the stack
// We need to replace it with a new scene
const newScene = this.replace({
sceneName: sceneSyncData.name,
clear: false,
skipCreatingInstancesFromScene,
});
if (newScene) {
newScene.networkId = sceneSyncData.networkId;
}
hasMadeChangeToStack = true;
// Continue to the next scene in the stack received from the host.
continue;
}
// The scene is in the stack and has the right networkId.
// Nothing to do, just continue to the next scene in the stack received from the host.
}
// Pop any scene not on the host.
// In the future, we could avoid to pop scenes if they are not set to be synchronized.
if (this._stack.length > sceneStackSyncData.length) {
const popCount = this._stack.length - sceneStackSyncData.length;
this.pop(popCount);
hasMadeChangeToStack = true;
}
return hasMadeChangeToStack;
}
/**
* Unload all the scenes and clear the stack.
*/
dispose(): void {
while (this._stack.length > 0) {
const scene = this._stack.pop();
if (scene) {
this._unloadSceneAndPossiblyResources({
scene,
newSceneName: null,
});
}
}
this._wasDisposed = true;
}
private _unloadSceneAndPossiblyResources({
scene,
newSceneName,
}: {
scene: gdjs.RuntimeScene;
newSceneName: string | null;
}): void {
const unloadedSceneName = scene.getName();
const resourcesUnloading = scene.getResourcesUnloading();
const resolvedResourcesUnloading =
resourcesUnloading === 'inherit'
? this._runtimeGame.getSceneResourcesUnloading()
: resourcesUnloading;
const shouldUnloadResources =
resolvedResourcesUnloading === 'at-scene-exit' &&
// Unload resources only if it's the last scene with this name in the stack.
newSceneName !== scene.getName() &&
this._stack.every((scene) => scene.getName() !== unloadedSceneName);
scene.unloadScene();
// After this point, `scene` is no longer valid and should not be used anymore.
// It was "disposed".
if (shouldUnloadResources) {
this._runtimeGame.getResourceLoader().unloadSceneResources({
unloadedSceneName,
newSceneName,
});
}
}
private _throwIfDisposed(): void {
if (this._wasDisposed) {
throw 'The scene stack has been disposed and should not be used anymore.';
}
}
}
}