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

Viewing 3 posts - 1 through 3 (of 3 total)
  • Author
    Posts
  • #18174
    Terence
    Level 31
    Keymaster
    Helpful?
    Up
    0
    ::

    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;
        }
    }
    #18175
    Terence
    Level 31
    Keymaster
    Helpful?
    Up
    0
    ::

    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.

    https://imgur.com/a/9GrVEzk

    #18177
    Terence
    Level 31
    Keymaster
    Helpful?
    Up
    0
    ::

    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 a GameManager started from, its defaultRespawnPoint 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 of GameManager, so that the defaultRespawnPoint is updated when we jump from scene to scene (see green highlight in the Awake() 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();
        }
    }
Viewing 3 posts - 1 through 3 (of 3 total)
  • You must be logged in to reply to this topic.

Go to Login Page →


Advertisement below: