Storing the state of a collectible in save-file?

With apologies, my Google-fu has failed me. I’ve found dozens of topics on collectibles and not one has been able to address my actual question.

What is the ideal method for saving a “Collectible” game object’s state in a save-file?

What I’m trying to do right now is modify the “RollerBall” tutorial with a pause menu that allows you to save/load the level, preserving the player’s position, score (“count”) and the state of the various collectible cubes. I managed to get the pause menu in place and have code skeletons in place for saving & loading, but I have no idea how to go about preserving the Active/Inactive state of each specific collectible object.

Since I’m using this as a primer and test-bed to master more complex concepts down the road, I’m really looking for a method or structure that could handle multiple different types of collectibles (say a player picks up two objects worth 100pts and a third worth 500pts. Score-save is easy, knowing which collectibles were picked up and disabling them on reload is my stumbling block).

To be clear, I’m using serialization and have no intention of calling PlayerPrefs if I can help it.

EDIT:
So this is the code for my Save/Load class, which gets called from “MenuEsc” by method. Works beautifully for saving player position, but I’m still having a miserable time trying to wrap my brain around the problem of saving collectibles’ “picked-up” status.

using UnityEngine;
using System;
using System.Collections;
using System.Runtime.Serialization.Formatters.Binary;
using System.IO;

public class MenuSaveLoad : MonoBehaviour
{

    public static void SaveGame()
    {
        BinaryFormatter bf = new BinaryFormatter();
        FileStream file = File.Create(Application.persistentDataPath + "/savegame.sav");

        LevelData data = new LevelData();
        
        //Write game data into save structure here
          
        data.PositionX = PlayerController.Instance.PlayerPosition.x;
        data.PositionY = PlayerController.Instance.PlayerPosition.y;
        data.PositionZ = PlayerController.Instance.PlayerPosition.z;

        bf.Serialize(file, data);
        file.Close();
        print("Saved");
    }

    public static void LoadGame()
    {
        if (File.Exists(Application.persistentDataPath + "/savegame.sav"))
        {
            BinaryFormatter bf = new BinaryFormatter();
            FileStream file = File.Open(Application.persistentDataPath + "/savegame.sav",FileMode.Open);

            LevelData data = (LevelData)bf.Deserialize(file);
            file.Close();

            //Sets player position straight from load data.                        
            //Setting via Vector3 variable is preferable, but Roll-a-ball code handles player movement oddly.

            GameObject.Find("Player").transform.position = new Vector3(data.PositionX,data.PositionY,data.PositionZ);
            
            print("Loaded");
        }

    }

}

[Serializable]
class LevelData
{
    //store "public" variables for saving & loading
    public float PositionX, PositionY, PositionZ;    
}

The tutorial code is kind of a pain this way, since it lumps player movements, interactions, and the “count” all into the PlayerController class. The only script on the collectibles themselves is the Rotator that makes them spin.
-I’m not adverse to moving things around if needed, just saying “this is my situation, please be clear about what I should add and where.”

Posting this here for an answer, in case anyone else had the same kind of question.
Keep in mind, this is based on a modification of the Roll-A-Ball tutorial. I feel certain there are more… elegant ways to structure the whole system.

First up, GameControl.cs

using UnityEngine;
using System.Collections;
using System.Collections.Generic;

public class GameControl : MonoBehaviour
{
    public static GameControl Instance;
    public static int TC = 12;
    public GameObject[] Pickups = new GameObject[TC];
    public bool[] Collected = new bool[TC];
    public int Count;
    public Vector3 PlayerPosition;
    
    void Awake()
    {
        if (Instance == null)
        {
            DontDestroyOnLoad(gameObject);
            Instance = this;
        }
        else if (Instance != this)
            Destroy(gameObject);

        var i = 0;
        foreach (GameObject go in GameObject.FindGameObjectsWithTag("Pick-up"))
        {
            go.SetActive(true);
            print("Pickup " + i + " initialized");
            go.name = i.ToString();
            Pickups *= go;*

Collected = false;
i++;
}
}

public void InitializeNR()
{
var i = 0;
foreach (GameObject go in GameObject.FindGameObjectsWithTag(“Pick-up”))
{
go.SetActive(true);
print(“Pickup " + i + " initialized”);
Pickups = go;
Collected = false;
i++;
}
}
}
GameControl is an empty game object holding this script, which is meant to act as a level manager, and persists between reloads or game load/save.
- ‘TC’ is ‘Total Collectibles’ (or ‘Pick-ups’ I guess, as the tutorial preferred to call them). A single static int means only having to change one number to affect the Array and For()-loop sizes.
- Awake() initializes the collectibles in three ways. It renames each collectible to a simple integer, which can double as its array index value later on. It then stores the GameObject in the ‘Pickups’ array, and resets the ‘Collected’ state to false.
- The ‘InitializeNR’ method is called from the pause menu’s “Reset” and “Load Game” methods. It does the same thing as the ‘Awake’ initialization, but omits the renaming of the game objects (No Rename).
- GameControl also takes the scorekeeping function away from PlayerController
Next, changes to PlayerController.cs
private int goID;
void OnTriggerEnter(Collider other)
{
if (other.gameObject.CompareTag (“Pick-up”))
{
other.gameObject.SetActive(false);
goID = int.Parse(other.gameObject.name);
GameControl.Instance.Collected[goID] = true;
print("Got Pickup " + goID);
GameControl.Instance.Count = GameControl.Instance.Count + 1;
}
}
- goID = “game object ID.” Now that collectibles are renamed as integers, we can parse them as int values and track their array position. When the player ‘collects’ one, it updates the Collected[] array in GameControl, for data-saving.
Last, the MenuSaveLoad.cs
using UnityEngine;
using UnityEngine.SceneManagement;
using System;
using System.Collections;
using System.Runtime.Serialization.Formatters.Binary;
using System.IO;

public class MenuSaveLoad : MonoBehaviour
{
public static void SaveGame()
{
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Create(Application.persistentDataPath + “/savegame.sav”);

LevelData data = new LevelData();

//Write game data into save structure here
GameControl.Instance.PlayerPosition = PlayerController.Instance.transform.position;

data.PositionX = GameControl.Instance.PlayerPosition.x;
data.PositionY = GameControl.Instance.PlayerPosition.y;
data.PositionZ = GameControl.Instance.PlayerPosition.z;

data.score = GameControl.Instance.Count;

for (int x = 0; x < GameControl.TC; x++ )
{
data.SaveCollected[x] = GameControl.Instance.Collected[x];
}

bf.Serialize(file, data);
file.Close();
print(“Saved”);
}

public static void LoadGame()
{

if (File.Exists(Application.persistentDataPath + “/savegame.sav”))
{
GameControl.Instance.InitializeNR(); //No Rename
BinaryFormatter bf = new BinaryFormatter();
FileStream file = File.Open(Application.persistentDataPath + “/savegame.sav”,FileMode.Open);

LevelData data = (LevelData)bf.Deserialize(file);
file.Close();

//Sets player position straight from load data.
//Setting via Vector3 variable is preferable, but Roll-a-ball code handles player movement oddly.

GameControl.Instance.PlayerPosition = new Vector3(data.PositionX,data.PositionY,data.PositionZ);
GameObject.Find(“Player”).transform.position = GameControl.Instance.PlayerPosition;
GameControl.Instance.Count = data.score;

//Write out “collected” flag to pickups
for (int x = 0; x < GameControl.TC; x++)
{
GameControl.Instance.Collected[x] = data.SaveCollected[x];
if (data.SaveCollected[x] == true)
{
GameControl.Instance.Pickups[x].SetActive(false);
print(“Pickup " + x + " disabled”);
}
else
{
GameControl.Instance.Pickups[x].SetActive(true);
print(“Pickup " + x + " enabled”);
}

}

print(“Loaded”);
}
}
}

[Serializable]
class LevelData
{
//store “public” variables for saving & loading
public float PositionX, PositionY, PositionZ;
public int score;
public bool[] SaveCollected = new bool[GameControl.TC];
}
With the structures in place, saving & loading is fairly straightforward. Because of Roll-a-Ball’s method of moving the player (AddForce, rather than direct translation of coordinates), I’ve opted to set the player’s position directly on load. Ideally, I should have reset the vector forces as well - currently, reloading causes the ball to continue rolling in whatever direction it was when you paused, rather than stay still or resume its direction at the moment of saving.
Note that LoadGame() is the only place where GameControl’s “Pickups[]” game-object array comes into play. Initializing them into an array made it simpler to handle the SetActive calls, and ensures the correct collectibles get disabled.
There is one significant bug with the above method (…at least, one I know about)
The first load works fine, but if the player continues to reload (without resetting the scene first), more collectibles randomly disable themselves. That will be the subject of my next question, however.