Forum begins after the advertisement:
[Part 7] Fix for game breaking when SaveData is edited
Home › Forums › Video Game Tutorial Series › Creating a Metroidvania in Unity › [Part 7] Fix for game breaking when SaveData is edited
- This topic has 2 replies, 1 voice, and was last updated 2 weeks, 4 days ago by
Terence.
-
AuthorPosts
-
May 15, 2025 at 2:43 pm #18174::
When working on this project, one of the things I noticed was that, whenever I modified the
SaveData
script from Part 7, this would break the game. The reason for this is that,SaveData
saves the game data in binary, so if you add new properties for the file to save, this will inadvertently shift the binary positions of the old save file, and cause the wrong values to be loaded.Take the following example, where we save the player’s health, max health and the number of heart shards collected:
playerHealth = PlayerController.Instance.Health; writer.Write(playerHealth); playerMaxHealth = PlayerController.Instance.maxHealth; writer.Write(playerMaxHealth); playerHeartShards = PlayerController.Instance.heartShards; writer.Write(playerHeartShards);
Because the values are saved in this order, when we retrieve them, we will have to do it in the exact same order.
playerHealth = reader.ReadInt32(); playerMaxHealth = reader.ReadInt32(); playerHeartShards = reader.ReadInt32();
But if we add a new attribute before these 3, the first time we load the game after, it will be with the old save file. Hence, the game will load the incorrect values.
playerNewAttribute = reader.ReadInt32(); // This reads the playerHealth value. playerHealth = reader.ReadInt32(); playerMaxHealth = reader.ReadInt32(); playerHeartShards = reader.ReadInt32();
And because the game is automatically coded to load the player’s position and move the player to the last saved position, adding new attributes will cause the player’s position to be read as very whacky, impossible values, like in the example shown below:
View post on imgur.com
To prevent this, I added some extra lines of code in my
CameraManager
script from Part 6, which checks if the player is outside of the map’s confines. If so, it simply puts the player back into the playable game, making it impossible for the player to spawn outside the map’s confines, rendering the game unplayable. The changes are highlighted in the code below:using System.Collections; using UnityEngine; using Cinemachine; public class CameraManager : MonoBehaviour { [SerializeField] CinemachineVirtualCamera[] allVirtualCameras; private CinemachineVirtualCamera currentCamera; private CinemachineFramingTransposer framingTransposer; [Header("Y Damping Settings for Player Jump/Fall:")] [SerializeField] private float panAmount = 0.1f; [SerializeField] private float panTime = 0.2f; public float playerFallSpeedTheshold = -10; public bool isLerpingYDamping; public bool hasLerpedYDamping; private float normalYDamp; public static CameraManager Instance { get; private set; } private void Awake() { if(Instance == null) { Instance = this; } for(int i = 0; i < allVirtualCameras.Length; i++) { if (allVirtualCameras[i].enabled) { currentCamera = allVirtualCameras[i]; framingTransposer = currentCamera.GetCinemachineComponent<CinemachineFramingTransposer>(); } } normalYDamp = framingTransposer.m_YDamping; } private void Start() { for (int i = 0; i < allVirtualCameras.Length; i++) { allVirtualCameras[i].Follow = PlayerController.Instance.transform; } Invoke("ReconfinePlayer", .1f); } public void ReconfinePlayer() { ReconfinePlayer(GameManager.Instance.defaultRespawnPoint); } public void ReconfinePlayer(Vector3 position) { CinemachineConfiner2D[] confiners = GetComponentsInChildren<CinemachineConfiner2D>(); if (confiners.Length > 0) { Vector2 closestPoint = PlayerController.Instance.transform.position; float minDist = float.MaxValue; foreach (CinemachineConfiner2D c in confiners) { // If confiner is misconfigured, skip. if (!c.m_BoundingShape2D) continue; // If the collider is a composite one, check to make sure it is a polygon. if(c.m_BoundingShape2D is CompositeCollider2D) { if ((c.m_BoundingShape2D as CompositeCollider2D).geometryType != CompositeCollider2D.GeometryType.Polygons) Debug.LogWarning( "Reconfine player will not work as the composite collider serving as the confiner doesn't have its Geometry Type set to Polygons." ); } // If player is within any confiner, we don't have to reconfine. if (c.m_BoundingShape2D.OverlapPoint(PlayerController.Instance.transform.position)) { return; } else { // If the player is outside of the confiner, we reconfine the player. Vector2 possibleClosestPoint = c.m_BoundingShape2D.ClosestPoint(position); float dist = Vector2.Distance(PlayerController.Instance.transform.position, possibleClosestPoint); if(dist < minDist) { minDist = dist; closestPoint = possibleClosestPoint; } } } PlayerController.Instance.transform.position = closestPoint; } } public void SwapCamera(CinemachineVirtualCamera _newCam) { currentCamera.enabled = false; currentCamera = _newCam; currentCamera.enabled = true; } public IEnumerator LerpYDamping(bool _isPlayerFalling) { isLerpingYDamping = true; //take start y damp amount float _startYDamp = framingTransposer.m_YDamping; float _endYDamp = 0; //determine end damp amount if (_isPlayerFalling) { _endYDamp = panAmount; hasLerpedYDamping = true; } else { _endYDamp = normalYDamp; } //lerp panAmount float _timer = 0; while (_timer < panTime) { _timer += Time.deltaTime; float _lerpedPanAmount = Mathf.Lerp(_startYDamp, _endYDamp, (_timer / panTime)); framingTransposer.m_YDamping = _lerpedPanAmount; yield return null; } isLerpingYDamping = false; } }
May 15, 2025 at 3:06 pm #18175::How the new additions work
The code I’ve shared above basically calls the
ReconfinePlayer()
function whenever we enter a new scene:public void ReconfinePlayer(Vector3 position) { CinemachineConfiner2D[] confiners = GetComponentsInChildren<CinemachineConfiner2D>(); if (confiners.Length > 0) { Vector2 closestPoint = PlayerController.Instance.transform.position; float minDist = float.MaxValue; foreach (CinemachineConfiner2D c in confiners) { // If confiner is misconfigured, skip. if (!c.m_BoundingShape2D) continue; // If the collider is a composite one, check to make sure it is a polygon. if(c.m_BoundingShape2D is CompositeCollider2D) { if ((c.m_BoundingShape2D as CompositeCollider2D).geometryType != CompositeCollider2D.GeometryType.Polygons) Debug.LogWarning( "Reconfine player will not work as the composite collider serving as the confiner doesn't have its Geometry Type set to Polygons." ); } // If player is within any confiner, we don't have to reconfine. if (c.m_BoundingShape2D.OverlapPoint(PlayerController.Instance.transform.position)) { return; } else { // If the player is outside of the confiner, we reconfine the player. Vector2 possibleClosestPoint = c.m_BoundingShape2D.ClosestPoint(position); float dist = Vector2.Distance(PlayerController.Instance.transform.position, possibleClosestPoint); if(dist < minDist) { minDist = dist; closestPoint = possibleClosestPoint; } } } PlayerController.Instance.transform.position = closestPoint; } }
The function may look long and complicated, but all it does is search the GameObjects under
CameraManager
to see if there are any Cinemachine Confiners. If there are, we check whether the player is in at least 1 of the colliders of the Cinemachine Confiner. If it is not, it will find a point that is touch the collider of the nearest Cinemachine Confiner.Important note:
For the
ReconfinePlayer()
function to work properly, if you are using Composite Collider 2D for your confiners, you will need to ensure that their Geometry Type is Polygons, instead of Outlines. If you use Outlines, they will be unable to reliably detect collisions between the player and the confines, which will cause the function to not work entirely.May 15, 2025 at 3:21 pm #18177::Updating the default respawn point in different scenes
Currently, the
GameManager
is a singleton class that does not get replaced whenever we enter a new scene. This can create a problem, because depending on which scene aGameManager
started from, itsdefaultRespawnPoint
will be configured differently.This
defaultRespawnPoint
doesn’t get updated when we change scenes, which means that if it is used in a different scene, it will teleport the player to the wrong point.Hence, we can modify the
Awake()
function ofGameManager
, so that thedefaultRespawnPoint
is updated when we jump from scene to scene (see green highlight in theAwake()
function):using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.SceneManagement; [DisallowMultipleComponent] public class GameManager : MonoBehaviour { public string transitionedFromScene; public Vector2 platformingRespawnPoint; public Vector2 respawnPoint; public Vector2 defaultRespawnPoint; [SerializeField] Bench bench; public GameObject shade; [SerializeField] private UIScreen pauseMenu; public bool isPaused; float lastTimeScale = -1f; static Coroutine stopGameCoroutine; public static bool isStopped { get { return stopGameCoroutine != null; } } public bool THKDefeated = false; public static GameManager Instance { get; private set; } private void Awake() { SaveData.Instance.Initialize(); if (Instance != null && Instance != this) { Instance.defaultRespawnPoint = defaultRespawnPoint; Destroy(gameObject); } else { Instance = this; } if (PlayerController.Instance != null) { if (PlayerController.Instance.halfMana) { SaveData.Instance.LoadShadeData(); if (SaveData.Instance.sceneWithShade == SceneManager.GetActiveScene().name || SaveData.Instance.sceneWithShade == "") { Instantiate(shade, SaveData.Instance.shadePos, SaveData.Instance.shadeRot); } } } SaveScene(); DontDestroyOnLoad(gameObject); bench = FindObjectOfType<Bench>(); SaveData.Instance.LoadBossData(); } private void Update() { if (Input.GetKeyDown(KeyCode.P)) { SaveData.Instance.SavePlayerData(); } if (Input.GetKeyDown(KeyCode.Escape)) { Pause(!isPaused); } } public void Pause(bool b) { // Prevent pause spamming from causing the pause screen to glitch. if (pauseMenu.IsAnimating()) return; if (b) { // Save the timescale we will restore to. if (lastTimeScale < 0) lastTimeScale = Time.timeScale; Time.timeScale = 0f; pauseMenu.Activate(); } else { if (!isStopped) { Time.timeScale = lastTimeScale > 0f ? lastTimeScale : 1f; lastTimeScale = -1; } pauseMenu.Deactivate(); } isPaused = b; } public static void Stop(float duration = .5f, float restoreDelay = .1f, float slowMultiplier = 0f) { if (stopGameCoroutine != null) return; stopGameCoroutine = Instance.StartCoroutine(HandleStopGame(duration, restoreDelay, slowMultiplier)); } // Used to create the hit stop effect. // <duration> specifies how long it lasts for. // <restoreDelay> specifies how quickly we go back to the original time scale. // <stopMultiplier> lets us control how much the stop is. static IEnumerator HandleStopGame(float duration, float restoreDelay, float slowMultiplier = 0f) { if (Instance.lastTimeScale < 0) Instance.lastTimeScale = Time.timeScale; // Saves the original time scale for restoration later. Time.timeScale = Mathf.Max(0, Instance.lastTimeScale * slowMultiplier); // Counts down every frame until the stop game is finished. WaitForEndOfFrame w = new WaitForEndOfFrame(); while (duration > 0) { // Don't count down if the game is paused, and don't loop as well. if (Instance.isPaused) { yield return w; continue; } // Set the time back to zero, since unpausing sets it back to 1. Time.timeScale = Mathf.Max(0, Instance.lastTimeScale * slowMultiplier); // Counts down. duration -= Time.unscaledDeltaTime; yield return w; } // Save the last time scale we want to restore to. float timeScaleToRestore = Instance.lastTimeScale; // Signal that the stop is finished. Instance.lastTimeScale = -1; stopGameCoroutine = null; // If a restore delay is set, restore the time scale gradually. if (restoreDelay > 0) { // Moves the timescale from the value it is set to, to its original value. float currentTimeScale = timeScaleToRestore * slowMultiplier; float restoreSpeed = (timeScaleToRestore - currentTimeScale) / restoreDelay; while (currentTimeScale < timeScaleToRestore) { // Stop this if the game is paused. if (Instance.isPaused) { yield return w; continue; } // End this coroutine if another stop has started. if (isStopped) yield break; // Set the timescale to the current value this frame. currentTimeScale += restoreSpeed * Time.unscaledDeltaTime; Time.timeScale = Mathf.Max(0, currentTimeScale); // Wait for a frame. yield return w; } } // Only restore the timeScale if it is not stopped again. // Can happen if another stop fires while restoring the time scale. if (!isStopped) Time.timeScale = Mathf.Max(0, timeScaleToRestore); } public void SaveScene() { string currentSceneName = SceneManager.GetActiveScene().name; SaveData.Instance.sceneNames.Add(currentSceneName); } public void SaveGame() { SaveData.Instance.SavePlayerData(); } public void RespawnPlayer() { print("RespawnPlayer() is called."); SaveData.Instance.LoadBench(); if (SaveData.Instance.benchSceneName != null) //load the bench's scene if it exists. { SceneManager.LoadScene(SaveData.Instance.benchSceneName); } if (!Mathf.Approximately(SaveData.Instance.benchPos.sqrMagnitude, 0)) { respawnPoint = SaveData.Instance.benchPos; } else { respawnPoint = defaultRespawnPoint; } PlayerController.Instance.transform.position = respawnPoint; UIManager.Instance.deathScreen.Deactivate(); PlayerController.Instance.Respawned(); } }
-
AuthorPosts
- You must be logged in to reply to this topic.
Advertisement below: