Files
GDevelop/Extensions/SaveState/tests/SaveState.spec.js
D8H 8e50ddefe4 Allow to configure object resources to be loaded independently from the scene (#8514)
- This is an advanced, experimental feature which is subject to changes. It's in most cases not useful, unless you have a large game or low end devices for which controlling memory usage is important. It's mostly useful to delay loading of large custom objects.
- Objects can be configured so that its resources are not loaded when the scene is loaded. Instead, you can use an action to manually load the resources used by it. When the loading is done (this can be checked using a condition), the object can then be created.
  Be careful: if an object assets are not loaded, it will be broken when displayed on screen.
2026-05-04 10:45:51 +02:00

1564 lines
51 KiB
JavaScript

// @ts-check
describe('SaveState', () => {
/**
* @param {{name: string, x: number, y: number}} content
* @returns {InstanceData}
*/
const getFakeInstanceData = ({ name, x, y }) => ({
persistentUuid: '',
layer: '',
locked: false,
name,
x,
y,
angle: 0,
zOrder: 0,
customSize: false,
width: 0,
height: 0,
depth: 0,
numberProperties: [],
stringProperties: [],
initialVariables: [],
});
/**
* @param {{name: string, objects?: gdjs.SpriteObjectData[], instances?: InstanceData[]}} settings
* @returns {LayoutData}
*/
const getFakeSceneData = ({ name, objects, instances }) => ({
layers: [
{
name: '',
visibility: true,
effects: [],
cameras: [],
ambientLightColorR: 0,
ambientLightColorG: 0,
ambientLightColorB: 0,
isLightingLayer: false,
followBaseLayerCamera: true,
},
],
r: 0,
v: 0,
b: 0,
mangledName: name,
name: name,
stopSoundsOnStartup: false,
title: '',
behaviorsSharedData: [],
objects: objects || [
// @ts-ignore - This is a gdjs.SpriteObjectData.
{
type: 'Sprite',
name: 'MySpriteObject',
behaviors: [],
effects: [],
variables: [],
animations: [],
updateIfNotVisible: false,
},
],
objectsGroups: [],
instances: instances || [],
variables: [],
usedResources: [],
uiSettings: {
grid: false,
gridType: 'rectangular',
gridWidth: 0,
gridHeight: 0,
gridOffsetX: 0,
gridOffsetY: 0,
gridColor: 0,
gridAlpha: 0,
snap: false,
},
});
describe('Save State Basics', () => {
it('saves and restores a game with objects at specific positions (without SaveConfiguration behavior)', async () => {
// Start a game.
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
// Create some objects at specific positions.
const object1 = runtimeScene1.createObject('MySpriteObject');
const object2 = runtimeScene1.createObject('MySpriteObject');
if (!object1 || !object2) {
throw new Error('Objects were not created');
}
object1.setX(100);
object1.setY(200);
object2.setX(300);
object2.setY(400);
const object1Id = object1.id;
const object2Id = object2.id;
// Save the game state.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
expect(saveState).not.to.be(null);
expect(saveState.layoutNetworkSyncDatas).to.have.length(1);
expect(
saveState.layoutNetworkSyncDatas[0].objectDatas[object1Id]
).not.to.be(undefined);
expect(
saveState.layoutNetworkSyncDatas[0].objectDatas[object2Id]
).not.to.be(undefined);
// Start a new game.
const runtimeGame2 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame2._resourcesLoader.loadAllResources(() => {});
// Load the saved state.
gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene();
if (!runtimeScene2) throw new Error('No current scene was restored.');
// Verify objects are restored.
const restoredObjects = runtimeScene2.getObjects('MySpriteObject');
expect(restoredObjects.length).to.be(2);
// Find objects by their positions (since IDs might be different).
const restoredObject1 = restoredObjects.find(
(obj) => obj.getX() === 100 && obj.getY() === 200
);
const restoredObject2 = restoredObjects.find(
(obj) => obj.getX() === 300 && obj.getY() === 400
);
if (!restoredObject1 || !restoredObject2) {
throw new Error(
'Objects not found at the proper positions after restore.'
);
}
expect(restoredObject1.getName()).to.be('MySpriteObject');
expect(restoredObject2.getName()).to.be('MySpriteObject');
});
it('saves and restores scene variables', async () => {
// Start a game.
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
// Create scene variables.
const sceneVariable = new gdjs.Variable();
sceneVariable.setString('TestValue');
runtimeScene1.getVariables().add('MyVariable', sceneVariable);
// Save the game state.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// Start a new game.
const runtimeGame2 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame2._resourcesLoader.loadAllResources(() => {});
// Load the saved state.
gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene();
if (!runtimeScene2) throw new Error('No current scene was restored.');
// Verify variable is restored.
expect(runtimeScene2.getVariables().has('MyVariable')).to.be(true);
expect(
runtimeScene2.getVariables().get('MyVariable').getAsString()
).to.be('TestValue');
});
it('saves and restores global variables', async () => {
// Start a game.
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
// Create global variables
const globalVariable = new gdjs.Variable();
globalVariable.setNumber(42);
runtimeGame1.getVariables().add('MyGlobalVariable', globalVariable);
// Save the game state
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// Start a new game.
const runtimeGame2 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame2._resourcesLoader.loadAllResources(() => {});
// Load the saved state.
gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene();
if (!runtimeScene2) throw new Error('No current scene was restored.');
// Verify global variable is restored.
expect(runtimeGame2.getVariables().has('MyGlobalVariable')).to.be(true);
expect(
runtimeGame2.getVariables().get('MyGlobalVariable').getAsNumber()
).to.be(42);
});
});
describe('Save State with initial instances and SaveConfiguration behavior', () => {
it('saves and restores with initial instances and objects having the SaveConfiguration behavior', async () => {
// Start a game.
const sceneData = getFakeSceneData({
name: 'Scene1',
objects: [
{
type: 'Sprite',
name: 'NotSavedSpriteObject',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'DoNotSave',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
{
type: 'Sprite',
name: 'SavedSpriteObject',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'Persisted',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
],
instances: [
// A default instance which will be not removed, because the associated object is marked as not persisted.
getFakeInstanceData({
name: 'NotSavedSpriteObject',
x: 100,
y: 200,
}),
// A default instance which will be removed, because the associated object is marked as persisted.
getFakeInstanceData({
name: 'SavedSpriteObject',
x: 300,
y: 400,
}),
],
});
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [sceneData],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
// Create some objects in addition to initial objects at specific positions.
const object3 = runtimeScene1.createObject('SavedSpriteObject');
const object4 = runtimeScene1.createObject('NotSavedSpriteObject');
if (!object3 || !object4) {
throw new Error('Objects were not created');
}
object3.setX(500);
object3.setY(600);
object4.setX(700);
object4.setY(800);
// Save the game state.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// Start a new game.
const runtimeGame2 = gdjs.getPixiRuntimeGame({
layouts: [sceneData],
});
await runtimeGame2._resourcesLoader.loadAllResources(() => {});
// Load the saved state.
gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene();
if (!runtimeScene2) throw new Error('No current scene was restored.');
// Verify objects are restored: only 2 "SavedSpriteObject" objects should exist
// after being restored (because the initial instance should be never created,
// and the 2 from the saved state should be created).
const restoredSavedObjects =
runtimeScene2.getObjects('SavedSpriteObject');
expect(restoredSavedObjects.length).to.be(2);
// Verify that the initial instance of "NotSavedSpriteObject" is properly created.
const notSavedObjects = runtimeScene2.getObjects('NotSavedSpriteObject');
expect(notSavedObjects.length).to.be(1);
// Find objects by their positions (since IDs might be different).
const restoredObject1 = restoredSavedObjects.find(
(obj) => obj.getX() === 300 && obj.getY() === 400
);
const restoredObject2 = restoredSavedObjects.find(
(obj) => obj.getX() === 500 && obj.getY() === 600
);
const notSavedObject1 = notSavedObjects.find(
(obj) => obj.getX() === 100 && obj.getY() === 200
);
if (!restoredObject1 || !restoredObject2 || !notSavedObject1) {
throw new Error(
'Objects not found at the proper positions after restore.'
);
}
expect(restoredObject1.getName()).to.be('SavedSpriteObject');
expect(restoredObject2.getName()).to.be('SavedSpriteObject');
expect(notSavedObject1.getName()).to.be('NotSavedSpriteObject');
});
});
describe('Save State restored without clearing the running scenes', () => {
it('saves and restores the same running game (keep instances, keep variables)', async () => {
// Start a game.
const sceneData = getFakeSceneData({
name: 'Scene1',
objects: [
{
type: 'Sprite',
name: 'NotSavedSpriteObject',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'DoNotSave',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
{
type: 'Sprite',
name: 'SavedSpriteObject',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'Persisted',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
],
instances: [
// A default instance which will be not removed, because the associated object is marked as not persisted.
getFakeInstanceData({
name: 'NotSavedSpriteObject',
x: 100,
y: 200,
}),
// A default instance which will be removed, because the associated object is marked as persisted.
getFakeInstanceData({
name: 'SavedSpriteObject',
x: 300,
y: 400,
}),
],
});
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [sceneData],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
// Set some variables.
const variable1 = new gdjs.Variable();
variable1.setString('TestValue');
runtimeScene1.getVariables().add('Variable1', variable1);
const variable2 = new gdjs.Variable();
variable2.setBoolean(true);
runtimeScene1.getVariables().add('Variable2', variable2);
const variable3 = new gdjs.Variable();
variable3.setNumber(42);
runtimeScene1.getVariables().add('Variable3', variable3);
gdjs.saveState.setVariableSaveConfiguration(
runtimeScene1,
variable3,
false,
''
);
// Create some objects in addition to initial objects at specific positions.
const object3 = runtimeScene1.createObject('SavedSpriteObject');
const object4 = runtimeScene1.createObject('NotSavedSpriteObject');
const object5 = runtimeScene1.createObject('SavedSpriteObject');
if (!object3 || !object4 || !object5) {
throw new Error('Objects were not created');
}
object3.setX(500);
object3.setY(600);
object4.setX(700);
object4.setY(800);
object5.setX(900);
object5.setY(1000);
// Save the game state.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// Now, modify the game.
// Modify the variables.
variable1.setString('TestValue2');
variable2.setBoolean(false);
variable3.setNumber(43);
// Move instances (both saved/unsaved, and created dynamically or from initial instances),
// to then verify that they are restored at the proper positions (only for the saved ones).
const object1 = runtimeScene1.getObjects('NotSavedSpriteObject')[0];
const object2 = runtimeScene1.getObjects('SavedSpriteObject')[0];
if (!object1 || !object2) {
throw new Error('Objects created from initial instances not found.');
}
object1.setX(101);
object1.setY(201);
object2.setX(301);
object2.setY(401);
object3.setX(502);
object3.setY(602);
object4.setX(701);
object4.setY(801);
// Remember IDs to verify if restored objects are the same objects already living.
const object2Id = object2.id;
const object3Id = object3.id;
const object5Id = object5.id;
// Delete an instance (that was saved and should be restored).
object5.deleteFromScene();
// Create new instances (the saved ones should be removed after restoring the save state).
const object6 = runtimeScene1.createObject('SavedSpriteObject');
if (!object6) throw new Error('Object not created.');
object6.setX(1100);
object6.setY(1200);
const object7 = runtimeScene1.createObject('NotSavedSpriteObject');
if (!object7) throw new Error('Object not created.');
object7.setX(1300);
object7.setY(1400);
const object8 = runtimeScene1.createObject('SavedSpriteObject');
if (!object8) throw new Error('Object not created.');
object8.setX(1500);
object8.setY(1600);
const object6Id = object6.id;
// Render a frame to be sure the deleted object is removed.
runtimeScene1.renderAndStep(1000 / 60);
// Load the saved state on the same game.
gdjs.saveState.restoreGameSaveState(runtimeGame1, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const restoredSavedObjects =
runtimeScene1.getObjects('SavedSpriteObject');
expect(
restoredSavedObjects.map((obj) => ({
x: obj.getX(),
y: obj.getY(),
name: obj.getName(),
}))
).to.eql([
// The initial instance should be restored ("object2").
{ x: 300, y: 400, name: 'SavedSpriteObject' },
// The instance created dynamically before the save should be restored ("object3").
{ x: 500, y: 600, name: 'SavedSpriteObject' },
// "object5", which was deleted after the save, should be restored.
{ x: 900, y: 1000, name: 'SavedSpriteObject' },
// "object6", which was created dynamically after the save, must have been removed.
]);
// Check that the "restored objects" are exactly the same objects already living,
// and that the restored object is a entirely new one.
expect(restoredSavedObjects[0].id).to.be(object2Id);
expect(restoredSavedObjects[1].id).to.be(object3Id);
expect(restoredSavedObjects[0]).to.be(object2);
expect(restoredSavedObjects[1]).to.be(object3);
// This one should be entirely new:
expect(restoredSavedObjects[2].id).not.to.be(object5Id);
expect(restoredSavedObjects[2].id).not.to.be(object6Id);
const restoredNotSavedObjects = runtimeScene1.getObjects(
'NotSavedSpriteObject'
);
expect(
restoredNotSavedObjects.map((obj) => ({
x: obj.getX(),
y: obj.getY(),
name: obj.getName(),
}))
).to.eql([
// The initial instance should be unchanged ("object1").
{ x: 101, y: 201, name: 'NotSavedSpriteObject' },
// "object4", which was created dynamically before the save, should be unchanged.
{ x: 701, y: 801, name: 'NotSavedSpriteObject' },
// "object7", which was created dynamically after the save, should be unchanged.
{ x: 1300, y: 1400, name: 'NotSavedSpriteObject' },
]);
// Check variables were restored, and the excluded one was not.
expect(runtimeScene1.getVariables().get('Variable1').getAsString()).to.be(
'TestValue'
); // Restored
expect(
runtimeScene1.getVariables().get('Variable2').getAsBoolean()
).to.be(true); // Restored
expect(runtimeScene1.getVariables().get('Variable3').getAsNumber()).to.be(
43
); // Unchanged
});
it('saves and restores the same running game (keep the scene stack)', async () => {
// Start a game with multiple scenes.
const scene1Data = getFakeSceneData({ name: 'Scene1' });
const scene2Data = getFakeSceneData({ name: 'Scene2' });
const scene3Data = getFakeSceneData({ name: 'Scene3' });
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [scene1Data, scene2Data, scene3Data],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
// Push 3 scenes onto the stack.
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
const runtimeScene2 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene2',
});
const runtimeScene3 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene3',
});
if (!runtimeScene1 || !runtimeScene2 || !runtimeScene3) {
throw new Error('Scenes were not created.');
}
// Create objects in each scene at specific positions.
const object1 = runtimeScene1.createObject('MySpriteObject');
const object2 = runtimeScene2.createObject('MySpriteObject');
const object3 = runtimeScene3.createObject('MySpriteObject');
if (!object1 || !object2 || !object3) {
throw new Error('Objects were not created');
}
object1.setX(100);
object1.setY(200);
object2.setX(300);
object2.setY(400);
object3.setX(500);
object3.setY(600);
// Save the game state.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// Do some changes in the game:
object1.setX(101);
object1.setY(201);
object2.setX(301);
object2.setY(401);
object3.setX(501);
object3.setY(601);
// Remove the last scene and add a few others (which will be unloaded
// when we restore the save state).
runtimeGame1.getSceneStack().pop();
runtimeGame1.getSceneStack().push({
sceneName: 'Scene2',
});
runtimeGame1.getSceneStack().push({
sceneName: 'Scene2',
});
runtimeGame1.getSceneStack().push({
sceneName: 'Scene2',
});
// Load the saved state on the same game without clearing the scene stack.
gdjs.saveState.restoreGameSaveState(runtimeGame1, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
// Verify all 3 scenes have been restored. The first two are unchanged.
// The last one is a new one.
const allScenes = runtimeGame1.getSceneStack().getAllScenes();
expect(allScenes.length).to.be(3);
expect(allScenes[0].getName()).to.be('Scene1');
expect(allScenes[0]).to.be(runtimeScene1);
expect(allScenes[1].getName()).to.be('Scene2');
expect(allScenes[1]).to.be(runtimeScene2);
expect(allScenes[2].getName()).to.be('Scene3');
expect(allScenes[2]).not.to.be(runtimeScene3);
// Verify objects are restored at their proper positions.
const restoredObjects1 = allScenes[0].getObjects('MySpriteObject');
const restoredObjects2 = allScenes[1].getObjects('MySpriteObject');
const restoredObjects3 = allScenes[2].getObjects('MySpriteObject');
expect(restoredObjects1.length).to.be(1);
expect(restoredObjects2.length).to.be(1);
expect(restoredObjects3.length).to.be(1);
expect(restoredObjects1[0].getX()).to.be(100);
expect(restoredObjects1[0].getY()).to.be(200);
expect(restoredObjects2[0].getX()).to.be(300);
expect(restoredObjects2[0].getY()).to.be(400);
expect(restoredObjects3[0].getX()).to.be(500);
expect(restoredObjects3[0].getY()).to.be(600);
});
});
describe('Save State restored with specified profile(s)', () => {
it('saves and restores the same running game (only objects in the specified profiles)', async () => {
// Start a game with objects configured for different profiles.
const sceneData = getFakeSceneData({
name: 'Scene1',
objects: [
{
type: 'Sprite',
name: 'Profile1Object',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'DoNotSave',
persistedInProfiles: 'profile1',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
{
type: 'Sprite',
name: 'Profile2Object',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'DoNotSave',
persistedInProfiles: 'profile2',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
{
type: 'Sprite',
name: 'Profile3Object',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'DoNotSave',
persistedInProfiles: 'profile3',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
{
type: 'Sprite',
name: 'AllProfilesObject',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'DoNotSave',
persistedInProfiles: 'profile1, profile2, profile3',
},
],
effects: [],
variables: [],
updateIfNotVisible: false,
animations: [],
},
],
instances: [],
});
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [sceneData],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
// Create objects for different profiles.
const profile1Object = runtimeScene1.createObject('Profile1Object');
const profile2Object = runtimeScene1.createObject('Profile2Object');
const profile3Object = runtimeScene1.createObject('Profile3Object');
const bothProfilesObject =
runtimeScene1.createObject('AllProfilesObject');
if (
!profile1Object ||
!profile2Object ||
!profile3Object ||
!bothProfilesObject
) {
throw new Error('Objects were not created');
}
profile1Object.setX(100);
profile1Object.setY(200);
profile2Object.setX(300);
profile2Object.setY(400);
profile3Object.setX(500);
profile3Object.setY(600);
bothProfilesObject.setX(700);
bothProfilesObject.setY(800);
// Save the game state with both profiles.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
// Save with profiles 1 and 2, even if we will restore with only the 'profile1' and 'profile3' profile.
profileNames: ['profile1', 'profile2'],
});
// Do some changes in the game to verify that the saved state is restored.
profile1Object.setX(101);
profile1Object.setY(201);
profile2Object.setX(301);
profile2Object.setY(401);
profile3Object.setX(501);
profile3Object.setY(601);
bothProfilesObject.setX(701);
bothProfilesObject.setY(801);
// Load the saved state with only the 'profile1' and 'profile3 profiles.
gdjs.saveState.restoreGameSaveState(runtimeGame1, saveState, {
profileNames: ['profile1', 'profile3'],
clearSceneStack: false,
});
// Verify only profile1 and profile3 objects are restored.
const restoredProfile1Objects =
runtimeScene1.getObjects('Profile1Object');
const restoredProfile2Objects =
runtimeScene1.getObjects('Profile2Object');
const restoredProfile3Objects =
runtimeScene1.getObjects('Profile3Object');
const restoredAllProfilesObjects =
runtimeScene1.getObjects('AllProfilesObject');
expect(restoredProfile1Objects.length).to.be(1);
expect(restoredProfile1Objects[0].getX()).to.be(100);
expect(restoredProfile1Objects[0].getY()).to.be(200);
expect(restoredAllProfilesObjects.length).to.be(1);
expect(restoredAllProfilesObjects[0].getX()).to.be(700);
expect(restoredAllProfilesObjects[0].getY()).to.be(800);
// Profile3 objects should be restored. There are none in the save state, so they
// are all removed.
expect(restoredProfile3Objects.length).to.be(0);
// Profile2 objects should be left alone.
expect(restoredProfile2Objects.length).to.be(1);
expect(restoredProfile2Objects[0].getX()).to.be(301);
expect(restoredProfile2Objects[0].getY()).to.be(401);
});
it('saves a running game (only game/scene data in the specified profiles)', async () => {
// Start a game with objects configured for different profiles.
const scene1Data = getFakeSceneData({
name: 'Scene1',
});
const scene2Data = getFakeSceneData({
name: 'Scene2',
});
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [scene1Data, scene2Data],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
const scene1Variable1 = new gdjs.Variable();
scene1Variable1.setString('Scene1Variable1TestValue');
runtimeScene1.getVariables().add('Scene1Variable1', scene1Variable1);
const scene1Variable2 = new gdjs.Variable();
scene1Variable2.setString('Scene1Variable2TestValue');
runtimeScene1.getVariables().add('Scene1Variable2', scene1Variable2);
gdjs.saveState.setVariableSaveConfiguration(
runtimeScene1,
scene1Variable2,
false,
'profile1'
);
const runtimeScene2 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene2',
});
if (!runtimeScene2) throw new Error('No current scene was created.');
const scene2Variable1 = new gdjs.Variable();
scene2Variable1.setString('Scene2Variable1TestValue');
runtimeScene2.getVariables().add('Scene2Variable1', scene2Variable1);
gdjs.saveState.setSceneDataSaveConfiguration(
runtimeScene1,
'Scene1',
true,
'profile1'
);
gdjs.saveState.setSceneDataSaveConfiguration(
runtimeScene2,
'Scene2',
false,
'profile2'
);
gdjs.saveState.setGameDataSaveConfiguration(
runtimeScene1,
false,
'game-only'
);
// Save the game state with the different profiles.
const saveStateProfile1 = gdjs.saveState.createGameSaveState(
runtimeGame1,
{
profileNames: ['profile1'],
}
);
const saveStateProfile2 = gdjs.saveState.createGameSaveState(
runtimeGame1,
{
profileNames: ['profile2'],
}
);
const saveStateGameOnly = gdjs.saveState.createGameSaveState(
runtimeGame1,
{
profileNames: ['game-only'],
}
);
// First save state "profile1" should save the first scene data, notably variables:
const gameNetworkSyncData1 = saveStateProfile1.gameNetworkSyncData || {};
expect(gameNetworkSyncData1.var).to.be(undefined);
expect(gameNetworkSyncData1.extVar).to.be(undefined);
expect((gameNetworkSyncData1.ss || []).map(({ name }) => name)).to.eql([
'Scene1',
'Scene2',
]);
expect(saveStateProfile1.layoutNetworkSyncDatas[0].sceneData.var).to.eql([
{
name: 'Scene1Variable2',
value: 'Scene1Variable2TestValue',
type: 'string',
children: undefined,
owner: 0,
},
]);
expect(saveStateProfile1.layoutNetworkSyncDatas[1].sceneData.var).to.be(
undefined
);
// Second save state "profile2" should save the second scene data only:
const gameNetworkSyncData2 = saveStateProfile2.gameNetworkSyncData || {};
expect(gameNetworkSyncData2.var).to.be(undefined);
expect(gameNetworkSyncData2.extVar).to.be(undefined);
expect((gameNetworkSyncData2.ss || []).map(({ name }) => name)).to.eql([
'Scene1',
'Scene2',
]);
expect(saveStateProfile2.layoutNetworkSyncDatas[0].sceneData.var).to.be(
undefined
);
expect(
saveStateProfile2.layoutNetworkSyncDatas[1].sceneData.var
).not.to.be(undefined);
// Third save state "game-only" should save the game data only:
const gameNetworkSyncData3 = saveStateGameOnly.gameNetworkSyncData || {};
expect(gameNetworkSyncData3.var).not.to.be(undefined);
expect(gameNetworkSyncData3.extVar).not.to.be(undefined);
expect((gameNetworkSyncData3.ss || []).map(({ name }) => name)).to.eql([
'Scene1',
'Scene2',
]);
});
});
it('loads a running game (only game/scene data in the specified profiles)', async () => {
// Start a game with objects configured for different profiles.
const scene1Data = getFakeSceneData({
name: 'Scene1',
});
const scene2Data = getFakeSceneData({
name: 'Scene2',
});
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [scene1Data, scene2Data],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
const scene1Variable1 = new gdjs.Variable();
scene1Variable1.setString('Scene1Variable1TestValue');
runtimeScene1.getVariables().add('Scene1Variable1', scene1Variable1);
const scene1Variable2 = new gdjs.Variable();
scene1Variable2.setString('Scene1Variable2TestValue');
runtimeScene1.getVariables().add('Scene1Variable2', scene1Variable2);
gdjs.saveState.setVariableSaveConfiguration(
runtimeScene1,
scene1Variable2,
false,
'profile1'
);
const runtimeScene2 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene2',
});
if (!runtimeScene2) throw new Error('No current scene was created.');
const scene2Variable1 = new gdjs.Variable();
scene2Variable1.setString('Scene2Variable1TestValue');
runtimeScene2.getVariables().add('Scene2Variable1', scene2Variable1);
gdjs.saveState.setVariableSaveConfiguration(
runtimeScene2,
scene2Variable1,
false,
'profile2'
);
// Modify the global volume so that it's different from the one saved in the save state:
runtimeGame1.getSoundManager().setGlobalVolume(33);
// Set what belongs to each profile:
gdjs.saveState.setSceneDataSaveConfiguration(
runtimeScene1,
'Scene1',
true,
'profile1'
);
gdjs.saveState.setSceneDataSaveConfiguration(
runtimeScene2,
'Scene2',
false,
'profile2'
);
gdjs.saveState.setGameDataSaveConfiguration(
runtimeScene1,
false,
'game-only'
);
/** @type {GameSaveState} */
const completeSaveState = {
gameNetworkSyncData: {
ss: [
{
name: 'Scene1',
networkId: 'b68fda7c',
},
{
name: 'Scene2',
networkId: '406dafce',
},
],
sm: {
globalVolume: 75,
cachedSpatialPosition: {},
freeMusics: [],
freeSounds: [],
musics: {},
sounds: {},
},
},
layoutNetworkSyncDatas: [
{
sceneData: {
var: [
{
name: 'Scene1Variable2',
value: 'some-loaded-value',
type: 'string',
owner: 0,
},
],
extVar: {},
id: 'b68fda7c',
color: 0,
layers: {
'': {
timeScale: 1,
defaultZOrder: 0,
hidden: false,
effects: {},
followBaseLayerCamera: true,
clearColor: [0, 0, 0, 1],
cameraX: 400,
cameraY: 300,
cameraZ: 0,
cameraRotation: 0,
cameraZoom: 1,
},
},
time: {
elapsedTime: 0,
timeScale: 1,
timeFromStart: 0,
firstFrame: true,
timers: {
items: {},
},
firstUpdateDone: false,
},
once: {
onceTriggers: {},
lastFrameOnceTriggers: {},
},
tween: {
tweens: {},
},
async: {
tasks: [],
},
},
objectDatas: {},
},
{
sceneData: {
var: [
{
name: 'Scene2Variable1',
value: 'some-other-loaded-value',
type: 'string',
owner: 0,
},
],
extVar: {},
id: '406dafce',
color: 0,
layers: {
'': {
timeScale: 1,
defaultZOrder: 0,
hidden: false,
effects: {},
followBaseLayerCamera: true,
clearColor: [0, 0, 0, 1],
cameraX: 400,
cameraY: 300,
cameraZ: 0,
cameraRotation: 0,
cameraZoom: 1,
},
},
time: {
elapsedTime: 0,
timeScale: 1,
timeFromStart: 0,
firstFrame: true,
timers: {
items: {},
},
firstUpdateDone: false,
},
once: {
onceTriggers: {},
lastFrameOnceTriggers: {},
},
tween: {
tweens: {},
},
async: {
tasks: [],
},
},
objectDatas: {},
},
],
};
// Restore only the profile1 data:
gdjs.saveState.restoreGameSaveState(runtimeGame1, completeSaveState, {
profileNames: ['profile1'],
clearSceneStack: false,
});
// Check scene 1 data was restored:
expect(
runtimeScene1.getVariables().get('Scene1Variable1').getAsString()
).to.be(
'Scene1Variable1TestValue' // Unchanged (not part of the profile)
);
expect(
runtimeScene1.getVariables().get('Scene1Variable2').getAsString()
).to.be(
'some-loaded-value' // Updated (part of the profile)
);
// Scene 2 data was not restored, nor the game data:
expect(
runtimeScene2.getVariables().get('Scene2Variable1').getAsString()
).to.be('Scene2Variable1TestValue');
expect(runtimeGame1.getSoundManager().getGlobalVolume()).to.be(33);
// Now, restore the profile2 data:
gdjs.saveState.restoreGameSaveState(runtimeGame1, completeSaveState, {
profileNames: ['profile2'],
clearSceneStack: false,
});
// Scene 2 data was restored:
expect(
runtimeScene2.getVariables().get('Scene2Variable1').getAsString()
).to.be('some-other-loaded-value');
// But not the game data:
expect(runtimeGame1.getSoundManager().getGlobalVolume()).to.be(33);
// Finally, restore the "game-only" data:
gdjs.saveState.restoreGameSaveState(runtimeGame1, completeSaveState, {
profileNames: ['game-only'],
clearSceneStack: false,
});
// Game data was restored:
expect(runtimeGame1.getSoundManager().getGlobalVolume()).to.be(75);
});
describe('Save State with linked objects', () => {
it('saves and restores linked objects relations (same type)', async () => {
// Start a game.
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
const object1 = runtimeScene1.createObject('MySpriteObject');
const object2 = runtimeScene1.createObject('MySpriteObject');
if (!object1 || !object2) {
throw new Error('Objects were not created');
}
object1.setPosition(100, 200);
object2.setPosition(300, 400);
gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, object1, object2);
// Save the game state.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// Start a new game.
const runtimeGame2 = gdjs.getPixiRuntimeGame({
layouts: [getFakeSceneData({ name: 'Scene1' })],
});
await runtimeGame2._resourcesLoader.loadAllResources(() => {});
// Load the saved state.
gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene();
if (!runtimeScene2) throw new Error('No current scene was restored.');
const restoredObjects = runtimeScene2.getObjects('MySpriteObject');
expect(restoredObjects.length).to.be(2);
const restoredObject1 = restoredObjects.find(
(obj) => obj.getX() === 100 && obj.getY() === 200
);
const restoredObject2 = restoredObjects.find(
(obj) => obj.getX() === 300 && obj.getY() === 400
);
if (!restoredObject1 || !restoredObject2) {
throw new Error(
'Objects not found at the proper positions after restore.'
);
}
const linksManager = gdjs.LinksManager.getManager(runtimeScene2);
const object1Links = Array.from(
linksManager.getObjectsLinkedWith(restoredObject1)
);
const object2Links = Array.from(
linksManager.getObjectsLinkedWith(restoredObject2)
);
expect(object1Links).to.eql([restoredObject2]);
expect(object2Links).to.eql([restoredObject1]);
});
it('saves and restores multiple linked objects across types', async () => {
const sceneData = getFakeSceneData({
name: 'Scene1',
objects: [
// @ts-ignore
{
type: 'Sprite',
name: 'ObjectA',
behaviors: [],
effects: [],
variables: [],
animations: [],
updateIfNotVisible: false,
},
// @ts-ignore
{
type: 'Sprite',
name: 'ObjectB',
behaviors: [],
effects: [],
variables: [],
animations: [],
updateIfNotVisible: false,
},
],
});
// Start a game and create linked objects.
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [sceneData],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
const objectA1 = runtimeScene1.createObject('ObjectA');
const objectA2 = runtimeScene1.createObject('ObjectA');
const objectB1 = runtimeScene1.createObject('ObjectB');
const objectB2 = runtimeScene1.createObject('ObjectB');
if (!objectA1 || !objectA2 || !objectB1 || !objectB2) {
throw new Error('Objects were not created');
}
objectA1.setPosition(10, 20);
objectA2.setPosition(30, 40);
objectB1.setPosition(50, 60);
objectB2.setPosition(70, 80);
// Link objectA1 <-> objectB1, objectA1 <-> objectB2, objectA2 <-> objectB2.
gdjs.evtTools.linkedObjects.linkObjects(
runtimeScene1,
objectA1,
objectB1
);
gdjs.evtTools.linkedObjects.linkObjects(
runtimeScene1,
objectA1,
objectB2
);
gdjs.evtTools.linkedObjects.linkObjects(
runtimeScene1,
objectA2,
objectB2
);
// Save the game state.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// Verify links are in the save state.
const linkedObjects =
saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects;
if (!linkedObjects) {
throw new Error('Linked objects not found in save state.');
}
expect(linkedObjects.length).to.be(3);
// Start a new game and restore.
const runtimeGame2 = gdjs.getPixiRuntimeGame({
layouts: [sceneData],
});
await runtimeGame2._resourcesLoader.loadAllResources(() => {});
gdjs.saveState.restoreGameSaveState(runtimeGame2, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const runtimeScene2 = runtimeGame2.getSceneStack().getCurrentScene();
if (!runtimeScene2) throw new Error('No current scene was restored.');
// Find the restored objects by their positions.
const allA = runtimeScene2.getObjects('ObjectA');
const allB = runtimeScene2.getObjects('ObjectB');
expect(allA.length).to.be(2);
expect(allB.length).to.be(2);
const restoredA1 = allA.find(
(obj) => obj.getX() === 10 && obj.getY() === 20
);
const restoredA2 = allA.find(
(obj) => obj.getX() === 30 && obj.getY() === 40
);
const restoredB1 = allB.find(
(obj) => obj.getX() === 50 && obj.getY() === 60
);
const restoredB2 = allB.find(
(obj) => obj.getX() === 70 && obj.getY() === 80
);
if (!restoredA1 || !restoredA2 || !restoredB1 || !restoredB2) {
throw new Error(
'Objects not found at the proper positions after restore.'
);
}
// Verify links via the public API.
const manager2 = gdjs.LinksManager.getManager(runtimeScene2);
// objectA1 should be linked to objectB1 and objectB2.
const a1Links = Array.from(manager2.getObjectsLinkedWith(restoredA1));
expect(a1Links.length).to.be(2);
expect(a1Links).to.contain(restoredB1);
expect(a1Links).to.contain(restoredB2);
// objectA2 should be linked to objectB2 only.
const a2Links = Array.from(manager2.getObjectsLinkedWith(restoredA2));
expect(a2Links).to.eql([restoredB2]);
// Verify bidirectional: objectB1 linked to objectA1.
const b1Links = Array.from(manager2.getObjectsLinkedWith(restoredB1));
expect(b1Links).to.eql([restoredA1]);
// objectB2 linked to both objectA1 and objectA2.
const b2Links = Array.from(manager2.getObjectsLinkedWith(restoredB2));
expect(b2Links.length).to.be(2);
expect(b2Links).to.contain(restoredA1);
expect(b2Links).to.contain(restoredA2);
});
it('preserves links between non-saved objects when restoring', async () => {
const sceneData = getFakeSceneData({
name: 'Scene1',
objects: [
// @ts-ignore - SavedObject is persisted in the default profile.
{
type: 'Sprite',
name: 'SavedObject',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'Persisted',
},
],
effects: [],
variables: [],
animations: [],
updateIfNotVisible: false,
},
// @ts-ignore - NotSavedObject is excluded from save.
{
type: 'Sprite',
name: 'NotSavedObject',
behaviors: [
{
name: 'SaveConfiguration',
type: 'SaveState::SaveConfiguration',
defaultProfilePersistence: 'DoNotSave',
},
],
effects: [],
variables: [],
animations: [],
updateIfNotVisible: false,
},
],
});
const runtimeGame1 = gdjs.getPixiRuntimeGame({
layouts: [sceneData],
});
await runtimeGame1._resourcesLoader.loadAllResources(() => {});
const runtimeScene1 = runtimeGame1.getSceneStack().push({
sceneName: 'Scene1',
});
if (!runtimeScene1) throw new Error('No current scene was created.');
const saved1 = runtimeScene1.createObject('SavedObject');
const saved2 = runtimeScene1.createObject('SavedObject');
const notSaved1 = runtimeScene1.createObject('NotSavedObject');
const notSaved2 = runtimeScene1.createObject('NotSavedObject');
if (!saved1 || !saved2 || !notSaved1 || !notSaved2) {
throw new Error('Objects were not created');
}
saved1.setPosition(10, 20);
saved2.setPosition(30, 40);
notSaved1.setPosition(50, 60);
notSaved2.setPosition(70, 80);
// Create links:
// saved1 <-> saved2 (both saved — should be preserved)
// saved1 <-> notSaved1 (mixed — link is lost because notSaved1 has no networkId)
// notSaved1 <-> notSaved2 (both not saved — should be preserved)
gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, saved1, saved2);
gdjs.evtTools.linkedObjects.linkObjects(runtimeScene1, saved1, notSaved1);
gdjs.evtTools.linkedObjects.linkObjects(
runtimeScene1,
notSaved1,
notSaved2
);
// Save — only SavedObject instances are included.
const saveState = gdjs.saveState.createGameSaveState(runtimeGame1, {
profileNames: ['default'],
});
// The save state should only contain the link between the two saved objects.
const linkedObjects =
saveState.layoutNetworkSyncDatas[0].sceneData.linkedObjects;
if (!linkedObjects) {
throw new Error('Linked objects not found in save state.');
}
expect(linkedObjects.length).to.be(1);
// Restore into the same game (clearSceneStack: false).
gdjs.saveState.restoreGameSaveState(runtimeGame1, saveState, {
profileNames: ['default'],
clearSceneStack: false,
});
const linksManager = gdjs.LinksManager.getManager(runtimeScene1);
// saved1 <-> saved2 link should be restored.
const saved1Links = Array.from(linksManager.getObjectsLinkedWith(saved1));
expect(saved1Links).to.contain(saved2);
// saved1 <-> notSaved1 link is lost (notSaved1 had no networkId during save).
expect(saved1Links).not.to.contain(notSaved1);
// notSaved1 <-> notSaved2 link should be preserved (neither is managed by save state).
const notSaved1Links = Array.from(
linksManager.getObjectsLinkedWith(notSaved1)
);
expect(notSaved1Links).to.contain(notSaved2);
// notSaved1 <-> saved1 was cleared from both sides.
expect(notSaved1Links).not.to.contain(saved1);
});
});
});