Portable Save Game Module [Discussion]

Started by eri0o, Thu 26/10/2017 02:53:35

Previous topic - Next topic

eri0o

Hey,

I have no idea if this is possible to do, the idea would be to create a module to do a custom save game. I will start the thread with some incomplete code (I have no idea how to read and parse yet) to try to gasp what's doable. Also I am looking into the most generic as possible code so the most people can benefit from it.

My design idea would be having an object to hold all rooms information, and update it when the player visits the room. Also have other object to hold other relevant information - I think this is maybe not needed but I like to have it clear which kind of thing is available to be saved. These objects would have handler to convert then to String and also have some way to parse a text file, and find the objects and recover then.

Also it would be necessary to have a way to store global variables - this would mean that for this to work, all room variables would be global, and on room variable state would be discouraged. I have no idea how to go on this.

A plus idea would be some form of text compression before storage and decompression after reading - nothing fancy, stuff as light as text compression on SNES cartridges.

PortableSaveGame.asc
Spoiler
Code: ags

// new module script
#define MAX_REGIONS 15
#define MAX_HOTSPOTS 49
#define MAX_POSSIBLE_ROOMS 100
#define MAX_POSSIBLE_CHARACTERS 100

struct RoomSaveData{
  bool exists;
  bool load_from_save;
  int ObjectCount;
  bool object_isnull[MAX_ROOM_OBJECTS];
  int object_X[MAX_ROOM_OBJECTS];
  int object_Y[MAX_ROOM_OBJECTS];
  bool object_Visible[MAX_ROOM_OBJECTS];
  bool object_Clickable[MAX_ROOM_OBJECTS];
  int object_Transparency[MAX_ROOM_OBJECTS];
  int object_Graphic[MAX_ROOM_OBJECTS];
  int object_View[MAX_ROOM_OBJECTS];
  int object_Loop[MAX_ROOM_OBJECTS];
  int object_Frame[MAX_ROOM_OBJECTS];
  bool hotspot_Enabled[MAX_HOTSPOTS];
  bool region_Enabled[MAX_REGIONS];
};

struct CharacterSaveData{
  bool exists;
  String Name;
  int Room;
  int x;
  int y;
  int z;
  int Transparency;
  bool Clickable;
  int View;
  int Frame;
  int Loop;
  int inventoryQuantity[MAX_GAME_ITEMS];
};

struct GameSaveData{
  int character_count;
  int invetoryitem_count;
  int player_ID;
};


GameSaveData save_data_game;
RoomSaveData save_data_rooms[MAX_POSSIBLE_ROOMS];
CharacterSaveData save_data_charas[MAX_POSSIBLE_CHARACTERS];

//------------------------------------------------------------------------
// PRIVATE FUNCTIONS
//-------o-----------------------------------------------------------------
void storeCharacterData(){
  int i;
  int j;
  i=0;
  save_data_game.character_count = Game.CharacterCount;
  save_data_game.invetoryitem_count = Game.InventoryItemCount+1;
  save_data_game.player_ID = player.ID;
  
  while(i<save_data_game.character_count){
    if(character[i]!=null){
      save_data_charas[i].exists=true;
      save_data_charas[i].Name = character[i].Name;
      save_data_charas[i].Room = character[i].Room;
      save_data_charas[i].x = character[i].x;
      save_data_charas[i].y = character[i].y;
      save_data_charas[i].z = character[i].z;
      save_data_charas[i].Transparency = character[i].Transparency;
      save_data_charas[i].Clickable = character[i].Clickable;
      save_data_charas[i].View = character[i].View;
      save_data_charas[i].Frame = character[i].Frame;
      save_data_charas[i].Loop = character[i].Loop;
      j=1;
      while(j<save_data_game.invetoryitem_count){
        save_data_charas[i].inventoryQuantity[j] = character[i].InventoryQuantity[j];
        j++;
      }
    } else {
      save_data_charas[i].exists=false;
    }
    
    i++;
  }
}

String CharacterSaveData_toString(){
  String rstr="{\"characters\": {[";
  int i;
  int j;
  i=0;
  while(i<save_data_game.character_count){
    if(save_data_charas[i].exists){
      rstr=rstr.Append(String.Format("  \"%d\":{[",i));
      rstr=rstr.Append(String.Format("    \"exists\": %d,[",save_data_charas[i].exists));
      rstr=rstr.Append(String.Format("    \"Name\": \"%s\",[",save_data_charas[i].Name));
      rstr=rstr.Append(String.Format("    \"Room\": %d,[",save_data_charas[i].Room));
      rstr=rstr.Append(String.Format("    \"x\": %d,[",save_data_charas[i].x));
      rstr=rstr.Append(String.Format("    \"y\": %d,[",save_data_charas[i].y));
      rstr=rstr.Append(String.Format("    \"z\": %d,[",save_data_charas[i].z));
      rstr=rstr.Append(String.Format("    \"Transparency\": %d,[",save_data_charas[i].Transparency));
      rstr=rstr.Append(String.Format("    \"Clickable\": %d,[",save_data_charas[i].Clickable));
      rstr=rstr.Append(String.Format("    \"View\": %d,[",save_data_charas[i].View));
      rstr=rstr.Append(String.Format("    \"Frame\": %d,[",save_data_charas[i].Frame));
      rstr=rstr.Append(String.Format("    \"Loop\": %d,[",save_data_charas[i].Loop));
      
      
      rstr=rstr.Append(String.Format("    \"inventoryQuantity\":{["));
      j=1;
      while(j<save_data_game.invetoryitem_count-1){
        rstr=rstr.Append(String.Format("      \"%d\": %d,[",j, save_data_charas[i].inventoryQuantity[j]));
        j++;
      }
      rstr=rstr.Append(String.Format("      \"%d\": %d[",j, save_data_charas[i].inventoryQuantity[j]));
      
      rstr=rstr.Append(String.Format("    }[  },["));
    }
    i++;
  }
  rstr=rstr.Append(String.Format("    }[  }["));
  return rstr;
}

void storeCurrentRoomData(){
  int i;
  int r = player.Room;
  save_data_rooms[r].load_from_save = false;
  save_data_rooms[r].exists = true;
  save_data_rooms[r].ObjectCount = Room.ObjectCount;
  i=0;
  while(i<save_data_rooms[r].ObjectCount){
    if(object[i] != null){
      save_data_rooms[r].object_isnull[i] = false;
      save_data_rooms[r].object_X[i] = object[i].X;
      save_data_rooms[r].object_Y[i] = object[i].Y;
      save_data_rooms[r].object_Visible[i] = object[i].Visible;
      save_data_rooms[r].object_Clickable[i] = object[i].Clickable;
      save_data_rooms[r].object_Transparency[i] = object[i].Transparency;
      save_data_rooms[r].object_Graphic[i] = object[i].Graphic;
      save_data_rooms[r].object_View[i] = object[i].View;
      save_data_rooms[r].object_Loop[i] = object[i].Loop;
      save_data_rooms[r].object_Frame[i] = object[i].Frame;
    }  else {
      save_data_rooms[r].object_isnull[i] = true;
    }
    i++;
  }
  
  i=0;
  while(i<MAX_HOTSPOTS){
    save_data_rooms[r].hotspot_Enabled[i] = hotspot[i].Enabled;
    i++;
  }
  
  i=0;
  while(i<MAX_REGIONS){
    save_data_rooms[r].region_Enabled[i] = region[i].Enabled;
    i++;
  }
}

String RoomSaveData_toString(){
  String rstr="{\"rooms\": {[";
  int i;
  int j;
  i=0;
  while(i<MAX_POSSIBLE_ROOMS){
    if(save_data_rooms[i].exists){
      rstr=rstr.Append(String.Format("  \"%d\":{[",i));
      rstr=rstr.Append(String.Format("    \"exists\": %d,[",save_data_rooms[i].exists));
      rstr=rstr.Append(String.Format("    \"load_from_save\": \"%d\",[",save_data_rooms[i].load_from_save));
      rstr=rstr.Append(String.Format("    \"ObjectCount\": \"%d\",[",save_data_rooms[i].ObjectCount));
      
      
      rstr=rstr.Append(String.Format("    \"objects\":{["));
      j=0;
      while(j<save_data_rooms[i].ObjectCount){
        rstr=rstr.Append(String.Format("      \"%d\": {",j));
        
        rstr=rstr.Append(String.Format("        \"isnull\": %d,[", save_data_rooms[i].object_isnull[j]));
        rstr=rstr.Append(String.Format("        \"X\": %d,[", save_data_rooms[i].object_X[j]));
        rstr=rstr.Append(String.Format("        \"Y\": %d,[", save_data_rooms[i].object_Y[j]));
        rstr=rstr.Append(String.Format("        \"Visible\": %d,[", save_data_rooms[i].object_Visible[j]));
        rstr=rstr.Append(String.Format("        \"Clickable\": %d,[", save_data_rooms[i].object_Clickable[j]));
        rstr=rstr.Append(String.Format("        \"Transparency\": %d,[", save_data_rooms[i].object_Transparency[j]));
        rstr=rstr.Append(String.Format("        \"Graphic\": %d,[", save_data_rooms[i].object_Graphic[j]));
        rstr=rstr.Append(String.Format("        \"View\": %d,[", save_data_rooms[i].object_View[j]));
        rstr=rstr.Append(String.Format("        \"Loop\": %d,[", save_data_rooms[i].object_Loop[j]));
        rstr=rstr.Append(String.Format("        \"Frame\": %d,[", save_data_rooms[i].object_Frame[j]));
        
        if(j<save_data_rooms[i].ObjectCount-1){
          rstr=rstr.Append(String.Format("      },"));
        } else {
          rstr=rstr.Append(String.Format("      }"));
        }
        j++;        
      }
      rstr=rstr.Append(String.Format("    },"));
      
      
      rstr=rstr.Append(String.Format("    \"regions\":{["));
      j=0;
      while(j<MAX_REGIONS){
        rstr=rstr.Append(String.Format("      \"%d\": {  \"Enabled\": %d }, [",j,save_data_rooms[i].region_Enabled[j]));
        j++;
      }
      rstr=rstr.Append(String.Format("    },"));
      
     
      rstr=rstr.Append(String.Format("    \"hotspot\":{["));
      j=0;
      while(j<MAX_HOTSPOTS){
        rstr=rstr.Append(String.Format("      \"%d\": {  \"Enabled\": %d }, [",j,save_data_rooms[i].hotspot_Enabled[j]));
        j++;
      }
      rstr=rstr.Append(String.Format("    },"));
      
    }
    i++;
  }
  rstr=rstr.Append(String.Format("  }[}["));
  return rstr;
  
}

void storeCurrentData(){
  storeCharacterData();
  storeCurrentRoomData();
}

void writeToFile(String content, String filename){
  File *output = File.Open(filename, eFileWrite);
  if (output == null) {
    Display("Error opening file.");
  } else {
    String lineToWrite;
    String restOfContent;
    
    restOfContent = content.Copy();
    
    while(restOfContent.Length > 0 && restOfContent.IndexOf("[")>0){
      lineToWrite = restOfContent.Substring(0, restOfContent.IndexOf("["));
      restOfContent = restOfContent.Substring(restOfContent.IndexOf("[")+1, restOfContent.Length- restOfContent.IndexOf("[")-1);
      output.WriteRawLine(lineToWrite);
    }
    
    
    output.Close();
  }
}

String readFile(String filename){
  
}


//------------------------------------------------------------------------
// PUBLIC FUNCTIONS
//------------------------------------------------------------------------
static void PortableSaveGame::save(int slot, String description){
  storeCurrentData();
  String fstr;
  fstr=CharacterSaveData_toString();
  fstr=fstr.Append(RoomSaveData_toString());
  writeToFile(fstr, "atemp.json");
}

static void PortableSaveGame::restore(int slot){
  
}

//------------------------------------------------------------------------
//
//------------------------------------------------------------------------
function on_event (EventType event, int data)
{
  if(event == eEventLeaveRoom){
    storeCurrentRoomData();
  } else if(event == eEventEnterRoomBeforeFadein){
    //do necessary loading from save
  }
}
[close]

PortableSaveGame.ash
Spoiler
Code: ags

struct PortableSaveGame{
  import static void save(int slot, String description);
  import static void restore(int slot);
};
[close]


Crimson Wizard

#1
If this is all about ability to update game, then I need to note following.

The main issues with built-in saved games are:
1) For historical reasons, in AGS objects are serialized by their order in array and not their unique IDs. Therefore, if size of array or item order has changed, there were no way to tell if object data is loaded into correct object. Actually, it is impossible to tell that even when sizes of arrays stayed the same (because 1 item could have been removed and 1 totally new added).
2) engine has no way to automatically decide what to do in case of conflict between loaded data and game data, therefore some user's script must be run to resolve it anyway.

More on this was explained here: http://www.adventuregamestudio.co.uk/forums/index.php?topic=53753.0
(This thread was dedicated to suspended/cancelled feature, but discussion may be valuable anyway)

Whether in engine, or script, the save system should take care of these two issues, they must be first to consider IMO.

Anyway, if I may tell my opinion on this, IMHO making a module that saves everything from AGS - is a very bad idea. Without any kind of "reflection" mechanism, that would be whole lot of effort for practically duplicating something that already exists in the engine (and with no guarantee that you can actually extract all information).

Why not design a good save API for the engine instead? For example, add Save function to every entity in game, like Character.Save, etc.

On the other hand, perhaps you do not really need to save everything. Maybe saving only logical state of the game is enough for you, to recreate the stage, like: completion progress of the puzzles, items player picked up, player's position in current room. This, of course, depends on the kind of a game you have (e.g. if player can save during arcade minigame).

Unfortunately, I do not have much free time to discuss this further right now, but I guess there are other people who do.

eri0o

Hey Crimson Wizard, this is a good read. My idea to do it as module was: I feel in the AGS community today more people are knowledgeable in AGS Script than C, and have it easily transferable to existing games. But if there is existing code for this in C, this is definitely something worth investigating.

One thing, if I understood, you said that object.ID==i isn't always true, and so matching would require checking object.ID==save_object[j].ID (for all positive integers i and j smaller than MAX_OBJECT) ?

I didn't understand what a reflection mechanism means.

I will take a look on this at night, right now I am not on my computer and the necessary tasks are looking too abstract, I am not sure on the atomic tasks necessary.

Crimson Wizard

#3
Quote from: eri0o on Thu 26/10/2017 11:04:11
But if there is existing code for this in C, this is definitely something worth investigating.

I think there was a misunderstanding, I was not giving that link for a code example. That thread discusses cancelled feature, which was based on a relatively faster hack, and the code itself is not very much relevant. The thread contains explanation of problems that arise when you are trying to load save from another version of the game, - that is what I wanted to point to.


Quote from: eri0o on Thu 26/10/2017 11:04:11
One thing, if I understood, you said that object.ID==i isn't always true, and so matching would require checking object.ID==save_object[j].ID (for all positive integers i and j smaller than MAX_OBJECT) ?
Well, yes and no, to elaborate, in that thread I was refering not to room objects, but to any random type of entity, and "ID" is not real room object's ID property as it is now, but anything that may uniquely identify it (like name, for example). The problem is that you cannot use real object.ID that AGS provides now, because that's a numeric index in array, not a proper unique identifier. Imagine game version 1 that has two room objects: A and B. Someone made a save in that game. Now, developer removed object B and replaced it with object C. In AGS object C will have same ID as B, but that may be conceptually different object. Yet if you load older save from version 1, comparing object's ID won't give you a hint that it is completely different object, and the obsolete object B's data will be written into object C.
So you need some other property to identify these.



Quote from: eri0o on Thu 26/10/2017 11:04:11
I didn't understand what a reflection mechanism means.
To put it simply, the ability to run through script variables without explicitly addressing them. AGS engine/script does not have it.
Wiki article

For instance, how does AGS Editor (made in C#) saves game data to the XML: it uses reflection to run through each object's variables, as if they were items in array, and saves their names and values to the file.

Snarky

I agree that improving the built-in AGS save system would be ideal, but given that it's a pretty high threshold for people to make that kind of contribution, some kind of template for custom save systems would probably be easier as well as useful in its own right. Of course, due to the limitations in AGS scripting (as you say, CW, no reflection), it could never be a 100% self-contained module: you'd always have to write a fair bit of code to adapt it to each game.

I'm thinking a module with functions to serialize all the built-in data types (as well as arrays), with examples for how to extend it for custom data types, then save it to file, and read it from file. Each script would then be responsible for checking a flag in the module (probably in late_repeatedly_execute_always()) and saving or restoring its own state as appropriate using the module API. Of course, the challenge is still how to deal with inconsistencies across game versions: basically, each script would have to decide how to deal with that.

Monkey's Stack Module might be a good starting point, since it provides an API for serializing various data types. But to avoid the same problems the AGS save system has, you'd probably want to store them in some sort of dictionary-style key-value lookup table rather than a linear stack.

Some data could be saved and loaded automatically (you could loop through all Characters, for example), though again you'd need provisions for the case where you've added/removed instances in the editor. There are different ways to deal with this, some that might work better in certain situations (one would be to provide an index-mapping between different save versions; another would be to add a fixed name ID, probably the same as the script name, as a custom property, which can then be used for "reflection").

Monsieur OUXX

If the goal is to implement saved games that can "survive" the upgrade of AGS, then I would recommend targetting something very primitive, or else you're either targetting madness or failure.

I would suggest saving only the things that are relevant at room change. Like some sort of autosave. That would bring the player back at the beginning of the room, before fade in.

This way:
- all you need to do is to save the player's room and some room-related or global variables,
- you don't need to save everything related to characters states (IsWalking, current frame and all that), you don't need to save everything related to sound (current sounds being played, their current time position, etc.)

Since you can't access the values of all your internal variables (without having them at hand at all times), you might want to use the Ultravariables module for every relevant variable in the game (especially: character's progression and adventure states)

And as Snarky suggested, you may choose to use monkey0506's Stack module, for massive serialization.

A difficulty remains, though: the player will still see the broken saved games in the folder after upgrading AGS. Is there a way to intercept that? The only solution I can think of is to generate your custom saved data alongside the regular one every time the player saves his game, and then when your external tool upgrades the game, the formerly saved games are deleted. Only your custom saved data remains (and is offered to the player for loading)
 

eri0o

Hey Monsieur OUXX, the goal isn't being able to survive AGS upgrades, this goal is already achieved by the current implementation. The goal would be surving additional content or bugfix for the Game (made in AGS).

Snarky

To take an example of how I imagine such a module working (assuming we're storing the savegame in something like JSON format), let's say you have a script like this:

Code: ags
// Header
enum PointState
{
  eInactive,
  eTargeted,
  eHit,
  eMissed
};

managed struct Point
{
  int x;
  int y;
};

struct NamedPoint
{
  String name;
  Point* p;
};

import int numbers[100];
import bool isBathroomFlooded;
import NamedPoint target;


Code: ags
// Script
Character* c;
int numbers[100];
bool isBathroomFlooded;

PointState targetState;
NamedPoint targets[100];
Point* point;

export numbers, isBathroomFlooded, target;

void myFunction()
{
  // ....
}

// + other functions, they don't really matter; you only save the variables stored on the base-level of the script namespace


And you want to be able to save and load the script state.

I imagine writing something like this (assuming our custom save module provides an API in a struct called Save):

Code: ags
// Methods to serialize and deserialize our custom structs (corresponding header imports omitted)
String Point::Serialize(String variableName)
{
  String s = Save.AppendCustomStructStart("Point", variableName);
  s = s.Append(Save.AppendIntVariable("x",this.x));
  s = s.Append(Save.AppendIntVariable("y",this.y));
  s = s.Append(Save.AppendCustomStructEnd());
  return s;
}

static Point* Point::Deserialize(String s)
{
  Point* p = new Point;
  p.x = Save.ParseIntVariable(s, "x");
  p.y = Save.ParseIntVariable(s, "y");
  return p;
}

static String NamedPoint::SerializeDeep(String variableName, String name, Point* p)
{
  String s = Save.AppendCustomStructStart("NamedPoint", variableName);
  s = s.Append(Save.AppendStringVariable("name", name));
  s = s.Append(Save.AppendPointerCopy(p.Serialize());    // Deep copy, does not conserve pointer sharing
  s = s.Append(Save.AppendCustomStructEnd());
  return s;
}

void NamedPoint::DeserializeDeep(String s)
{
  this.name = Save.ParseStringVariable("name");
  this.p = Point.Deserialize(Save.ParsePointerVariable("p"));
}


And to actually call it:

Code: ags
void saveScriptState()
{
  Save.SetNameSpace("thisScript");
  Save.SaveCharacterVariable("c", c);
  // Serialize numbers[] array
  String arrayString = Save.AppendArrayStart("numbers", 100);
  for(int i=0; i<100: i++)
    arrayString = arrayString.Append(Save.AppendArrayInt(numbers[i]));
  arrayString = arrayString.Append(Save.AppendArrayEnd());

  Save.SaveSerialization(arrayString);

  Save.SaveBoolVariable("isBathroomFlooded", isBathroomFlooded);
  Save.SaveIntVariable("targetState", targetState); // enums are just ints!
   
  // Serialize targets[] array
  arrayString = Save.AppendArrayStart("targets", 100);
  for(int i=0; i<100; i++)
    arrayString = arrayString.Append(Save.AppendArraySerialization(NamedPoint.SerializeDeep("",targets[i].name, targets[i].p)));
  arrayString = arrayString.Append(Save.AppendArrayEnd());

  Save.SaveSerialization(arrayString);

  Save.SaveSerialization(point.Serialize("point"));
}

void restoreScriptState()
{
  Save.SetNameSpace("thisScript");
  c = Save.LoadCharacterVariable("c");
  String arrayString = Save.LoadArrayVariableString("numbers");
  for(int i=0; i<100; i++)
  {
    String si = Save.NextArrayEntry(arrayString);
    arrayString = Save.SkipArrayEntry(arrayString);
    numbers[i] = Save.ParseIntValue(si);
  }

  isBathroomFlooded = Save.LoadBoolVariable("isBathroomFlooded");
  targetState = Save.LoadIntVariable("targetState");

  arrayString = Save.LoadArrayVariableString("targets");
  for(int i-0; i<100; i++)
  {
    String si = Save.NextArrayEntry(arrayString);
    arrayString = Save.SkipArrayEntry(arrayString);
    targets[i].DeserializeDeep(si);
  }

  point = Point.Deserialize(Save.LoadCustomStructVariableString("point"));
}

function late_repeatedly_execute_always()
{
  if(Save.SaveScriptStateFlag)
    saveScriptState();
  if(Save.RestoreScriptStateFlag)
    restoreScriptState();
}


Hmm... the API to save arrays could be improved.

Monsieur OUXX

Quote from: eri0o on Fri 27/10/2017 10:24:29
the goal isn't being able to survive AGS upgrades, but to survive a game upgrade.
I phrased it badly but that's what I meant. So I still mean what I wrote.
 

eri0o

Question, does tween module and timer module expose the loopcounter they use? I guess they would also be important to save - just remembered I use both with GUIs in lots of room puzzles.

I wouldn't mind saving stuff like isWalking, but stuff like this usually isn't writable, so they wouldn't be recoverable.

I like your approach Snarky.

Crimson Wizard

#10
@Snarky, in the past I wrote StructStream module which did almost similar thing, except it did not write keys (but that could be easily added).
I wanted to note it as an example of somewhat simplier API where you do not have to do "String.Append" all the time, because the string is stored inside the writer object:
http://www.adventuregamestudio.co.uk/forums/index.php?topic=41243.msg545630

SMF spam blocked by CleanTalk