mirror of
https://github.com/4ian/GDevelop.git
synced 2025-10-15 10:19:04 +00:00
527 lines
18 KiB
TypeScript
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.';
|
|
}
|
|
}
|
|
}
|
|
}
|