Forum begins after the advertisement:
[Part 27.5] Important bugfixes for Part 27.5
Home › Forums › Video Game Tutorial Series › Creating a Rogue-like Shoot-em Up in Unity › [Part 27.5] Important bugfixes for Part 27.5
- This topic has 3 replies, 1 voice, and was last updated 5 days, 13 hours ago by
Terence.
-
AuthorPosts
-
August 24, 2025 at 1:38 am #18770::
This post will be a summary of bugs that have been found with Part 27 of our video. I will eventually be releasing a Part 27.5 video to address this, but for now, this post will serve as a place for me to collate all the bugs I’ve found:
1. Error after updating
PlayerInventory
script@Grim Rubbish has pointed out that after updating the
PlayerInventory
script in Part 27, he gets this error:Argument “1”: Conversion from “int” to “ItemData.Evolution” not possible.
This error is caused by an undocumented update to the
CanEvolve()
function in theItem
script. The yellow highlights are the additions to the function:// Call this function to get all the evolutions that the weapon // can currently evolve to. public virtual ItemData.Evolution[] CanEvolve(int levelUpAmount = 1) { List<ItemData.Evolution> possibleEvolutions = new List<ItemData.Evolution>(); // Check each listed evolution and whether it is in // the inventory. foreach (ItemData.Evolution e in evolutionData) { if (CanEvolve(e, levelUpAmount)) possibleEvolutions.Add(e); } return possibleEvolutions.ToArray(); }
The reason this update was made is to support
CanEvolve(0)
in the newPlayerInventory
andTreasureChest
script. Previously, theCanEvolve()
could not take any arguments, and in our older codebase,CanEvolve()
always added a level to the item before checking if it could evolve, which isn’t something that we want here. We wanted, in this part, to also check if the item can evolve at its current level — hence the update to theCanEvolve()
function.- 1 anonymous person
August 24, 2025 at 1:45 am #18772::2. Undocumented updates to
GameManager
There were also a couple of changes to Part 27’s project files that were not documented:
- Instead of having the Done button do the
SaveCoinsToStash()
(as we did in Part 26), we instead have theGameManager
callSaveCoinsToStash()
for every player it wheneverGameOver()
is triggered. This makes the action much easier to find, which makes debugging easier; and it prepares our series for multiplayer implementation in future (since it saves coins for every player). - Since the game time and clock speed is now determined by the level data that we key into the level select scene, we’ve also added a default value for both game time and clock speed that the
GameManager
defaults to.
using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.UI; using TMPro; public class GameManager : MonoBehaviour { public static GameManager instance; // Define the different states of the game public enum GameState { Gameplay, Paused, GameOver, LevelUp, TreasureChest } // Store the current state of the game public GameState currentState; // Store the previous state of the game before it was paused public GameState previousState; [Header("Damage Text Settings")] public Canvas damageTextCanvas; public float textFontSize = 20; public TMP_FontAsset textFont; public Camera referenceCamera; [Header("Screens")] public GameObject pauseScreen; public GameObject resultsScreen; public GameObject levelUpScreen; int stackedLevelUps = 0; // If we try to StartLevelUp() multiple times. [Header("Results Screen Displays")] public Image chosenCharacterImage; public TMP_Text chosenCharacterName; public TMP_Text levelReachedDisplay; public TMP_Text timeSurvivedDisplay; const float DEFAULT_TIME_LIMIT = 1800f; const float DEFAULT_CLOCK_SPEED = 1f; float ClockSpeed => UILevelSelector.currentLevel?.clockSpeed ?? DEFAULT_CLOCK_SPEED; float TimeLimit => UILevelSelector.currentLevel?.timeLimit ?? DEFAULT_TIME_LIMIT; [Header("Stopwatch")] public float timeLimit; // The time limit in seconds float stopwatchTime; // The current time elapsed since the stopwatch started public TMP_Text stopwatchDisplay; bool levelEnded = false; // Has the time limit been reached? public GameObject reaperPrefab; // Spawns after time limit has been reached; PlayerStats[] players; // Tracks all players. // Getters for parity with older scripts. public bool isGameOver { get { return currentState == GameState.Paused; } } public bool choosingUpgrade { get { return currentState == GameState.LevelUp; } } // Gives us the time since the level has started. public float GetElapsedTime() { return stopwatchTime; } // Sums up the curse stat of all players and returns the value. public static float GetCumulativeCurse() { if (!instance) return 1; float totalCurse = 0; foreach (PlayerStats p in instance.players) { totalCurse += p.Actual.curse; } return Mathf.Max(1, totalCurse); } // Sum up the levels of all players and returns the value. public static int GetCumulativeLevels() { if (!instance) return 1; int totalLevel = 0; foreach (PlayerStats p in instance.players) { totalLevel += p.level; } return Mathf.Max(1, totalLevel); } void Awake() { players = FindObjectsOfType<PlayerStats>(); //Set the level's Time Limit timeLimit =
UILevelSelector.currentLevel.timeLimitTimeLimit; //Warning check to see if there is another singleton of this kind already in the game if (instance == null) { instance = this; } else { Debug.LogWarning("EXTRA " + this + " DELETED"); Destroy(gameObject); } DisableScreens(); } void Update() { switch (currentState) { case GameState.Gameplay: // Code for the gameplay state Time.timeScale = 1; CheckForPauseAndResume(); UpdateStopwatch(); break; case GameState.Paused: // Code for the paused state CheckForPauseAndResume(); break; case GameState.GameOver: case GameState.TreasureChest: Time.timeScale = 0; break; case GameState.LevelUp: break; default: Debug.LogWarning("STATE DOES NOT EXIST"); break; } } IEnumerator GenerateFloatingTextCoroutine(string text, Transform target, float duration = 1f, float speed = 50f) { // Start generating the floating text. GameObject textObj = new GameObject("Damage Floating Text"); RectTransform rect = textObj.AddComponent<RectTransform>(); TextMeshProUGUI tmPro = textObj.AddComponent<TextMeshProUGUI>(); tmPro.text = text; tmPro.horizontalAlignment = HorizontalAlignmentOptions.Center; tmPro.verticalAlignment = VerticalAlignmentOptions.Middle; tmPro.fontSize = textFontSize; if (textFont) tmPro.font = textFont; rect.position = referenceCamera.WorldToScreenPoint(target.position); // Makes sure this is destroyed after the duration finishes. Destroy(textObj, duration); // Parent the generated text object to the canvas. textObj.transform.SetParent(instance.damageTextCanvas.transform); textObj.transform.SetSiblingIndex(0); // Pan the text upwards and fade it away over time. WaitForEndOfFrame w = new WaitForEndOfFrame(); float t = 0; float yOffset = 0; Vector3 lastKnownPosition = target.position; while (t < duration) { // If the RectTransform is missing for whatever reason, end this loop. if (!rect) break; // Fade the text to the right alpha value. tmPro.color = new Color(tmPro.color.r, tmPro.color.g, tmPro.color.b, 1 - t / duration); // Update the enemy's position if it is still around. if (target) lastKnownPosition = target.position; // Pan the text upwards. yOffset += speed * Time.deltaTime; rect.position = referenceCamera.WorldToScreenPoint(lastKnownPosition + new Vector3(0, yOffset)); // Wait for a frame and update the time. yield return w; t += Time.deltaTime; } } public static void GenerateFloatingText(string text, Transform target, float duration = 1f, float speed = 1f) { // If the canvas is not set, end the function so we don't // generate any floating text. if (!instance.damageTextCanvas) return; // Find a relevant camera that we can use to convert the world // position to a screen position. if (!instance.referenceCamera) instance.referenceCamera = Camera.main; instance.StartCoroutine(instance.GenerateFloatingTextCoroutine( text, target, duration, speed )); } // Define the method to change the state of the game public void ChangeState(GameState newState) { previousState = currentState; currentState = newState; } public void PauseGame() { if (currentState != GameState.Paused) { ChangeState(GameState.Paused); Time.timeScale = 0f; // Stop the game pauseScreen.SetActive(true); // Enable the pause screen } } public void ResumeGame() { if (currentState == GameState.Paused) { ChangeState(previousState); Time.timeScale = 1f; // Resume the game pauseScreen.SetActive(false); //Disable the pause screen } } // Define the method to check for pause and resume input void CheckForPauseAndResume() { if (Input.GetKeyDown(KeyCode.Escape)) { if (currentState == GameState.Paused) { ResumeGame(); } else { PauseGame(); } } } void DisableScreens() { pauseScreen.SetActive(false); resultsScreen.SetActive(false); levelUpScreen.SetActive(false); } public void GameOver() { timeSurvivedDisplay.text = stopwatchDisplay.text; // Set the Game Over variables here. ChangeState(GameState.GameOver); Time.timeScale = 0f; //Stop the game entirely DisplayResults(); // Save all the coins of all the players to the save file. foreach (PlayerStats p in players) { p.GetComponentInChildren<PlayerCollector>().SaveCoinsToStash(); } // Add all players' coins to their save file, since the game has ended. foreach(PlayerStats p in players) { if(p.TryGetComponent(out PlayerCollector c)) { c.SaveCoinsToStash(); } } } void DisplayResults() { resultsScreen.SetActive(true); } public void AssignChosenCharacterUI(CharacterData chosenCharacterData) { chosenCharacterImage.sprite = chosenCharacterData.Icon; chosenCharacterName.text = chosenCharacterData.Name; } public void AssignLevelReachedUI(int levelReachedData) { levelReachedDisplay.text = levelReachedData.ToString(); } public Vector2 GetRandomPlayerLocation() { int chosenPlayer = Random.Range(0, players.Length); return new Vector2(players[chosenPlayer].transform.position.x, players[chosenPlayer].transform.position.y); } void UpdateStopwatch() { stopwatchTime += Time.deltaTime * ClockSpeedUILevelSelector.currentLevel.clockSpeed; UpdateStopwatchDisplay(); if (stopwatchTime >= timeLimit && !levelEnded) { levelEnded = true; // Set the enemy/event spawner GameObject inactive to stop enemies from spawning and kill the remaining enemies onscreen. FindObjectOfType<SpawnManager>().gameObject.SetActive(false); foreach (EnemyStats e in FindObjectsOfType<EnemyStats>()) e.SendMessage("Kill"); // Spawn the Reaper off-camera Vector2 reaperOffset = Random.insideUnitCircle * 50f; Vector2 spawnPosition = GetRandomPlayerLocation() + reaperOffset; Instantiate(reaperPrefab, spawnPosition, Quaternion.identity); } } void UpdateStopwatchDisplay() { // Calculate the number of minutes and seconds that have elapsed int minutes = Mathf.FloorToInt(stopwatchTime / 60); int seconds = Mathf.FloorToInt(stopwatchTime % 60); // Update the stopwatch text to display the elapsed time stopwatchDisplay.text = string.Format("{0:00}:{1:00}", minutes, seconds); } public void StartLevelUp() { ChangeState(GameState.LevelUp); // If the level up screen is already active, record it. if (levelUpScreen.activeSelf) stackedLevelUps++; else { levelUpScreen.SetActive(true); Time.timeScale = 0f; //Pause the game for now foreach (PlayerStats p in players) p.SendMessage("RemoveAndApplyUpgrades"); } } public void EndLevelUp() { Time.timeScale = 1f; //Resume the game levelUpScreen.SetActive(false); ChangeState(GameState.Gameplay); if (stackedLevelUps > 0) { stackedLevelUps--; StartLevelUp(); } } }- 1 anonymous person
August 24, 2025 at 2:01 am #18773::3. UI update glitch
We also had a glitch with the UI update. Specifically, the items would be added to the inventory before the animation finished: https://blog.terresquall.com/wp-content/uploads/2025/08/part-27-rewards-ui-glitch.webm
This was fixed by adding an optional
updateUI
parameter to some of our functions in PlayerInventory (highlighted below), so that we can add or level up items without updating the UI:// Finds an empty slot and adds a weapon of a certain type, returns // the slot number that the item was put in. public int Add(WeaponData data, bool updateUI = true) { int slotNum = -1; // Try to find an empty slot. for (int i = 0; i < weaponSlots.Capacity; i++) { if (weaponSlots[i].IsEmpty()) { slotNum = i; break; } } // If there is no empty slot, exit. if (slotNum < 0) return slotNum; // Otherwise create the weapon in the slot. // Get the type of the weapon we want to spawn. Type weaponType = Type.GetType(data.behaviour); if (weaponType != null) { // Spawn the weapon GameObject. GameObject go = new GameObject(data.baseStats.name + " Controller"); Weapon spawnedWeapon = (Weapon)go.AddComponent(weaponType); spawnedWeapon.transform.SetParent(transform); //Set the weapon to be a child of the player spawnedWeapon.transform.localPosition = Vector2.zero; spawnedWeapon.Initialise(data); spawnedWeapon.OnEquip(); // Assign the weapon to the slot. weaponSlots[slotNum].Assign(spawnedWeapon); if(updateUI) weaponUI.Refresh(); // Close the level up UI if it is on. if (GameManager.instance != null && GameManager.instance.choosingUpgrade) GameManager.instance.EndLevelUp(); return slotNum; } else { Debug.LogWarning(string.Format( "Invalid weapon type specified for {0}.", data.name )); } return -1; } // Finds an empty slot and adds a passive of a certain type, returns // the slot number that the item was put in. public int Add(PassiveData data, bool updateUI = true) { int slotNum = -1; // Try to find an empty slot. for (int i = 0; i < passiveSlots.Capacity; i++) { if (passiveSlots[i].IsEmpty()) { slotNum = i; break; } } // If there is no empty slot, exit. if (slotNum < 0) return slotNum; // Otherwise create the passive in the slot. // Get the type of the passive we want to spawn. GameObject go = new GameObject(data.baseStats.name + " Passive"); Passive p = go.AddComponent<Passive>(); p.Initialise(data); p.transform.SetParent(transform); //Set the weapon to be a child of the player p.transform.localPosition = Vector2.zero; // Assign the passive to the slot. passiveSlots[slotNum].Assign(p); if(updateUI) passiveUI.Refresh(); if (GameManager.instance != null && GameManager.instance.choosingUpgrade) { GameManager.instance.EndLevelUp(); } player.RecalculateStats(); return slotNum; } // If we don't know what item is being added, this function will determine that. public int Add(ItemData data, bool updateUI = true) { if (data is WeaponData) return Add(data as WeaponData, updateUI); else if (data is PassiveData) return Add(data as PassiveData, updateUI); return -1; } // Overload so that we can use both ItemData or Item to level up an // item in the inventory. public bool LevelUp(ItemData data, bool updateUI = true) { Item item = Get(data); if (item) return LevelUp(item, updateUI); return false; } // Levels up a selected weapon in the player inventory. public bool LevelUp(Item item, bool updateUI = true) { // Tries to level up the item. if (!item.DoLevelUp()) { Debug.LogWarning(string.Format( "Failed to level up {0}.", item.name )); return false; } // Update the UI after the weapon has levelled up. if(updateUI) { weaponUI.Refresh(); passiveUI.Refresh(); } // Close the level up screen afterwards. if (GameManager.instance != null && GameManager.instance.choosingUpgrade) { GameManager.instance.EndLevelUp(); } // If it is a passive, recalculate player stats. if (item is Passive) player.RecalculateStats(); return true; }
On top of that though, we’ll also need to add the parameter to the following functions in the
Item
script as well:// AttemptEvolution will spawn a new weapon for the character, and remove all // the weapons that are supposed to be consumed. public virtual bool AttemptEvolution(ItemData.Evolution evolutionData, int levelUpAmount = 1, bool updateUI = true) { if (!CanEvolve(evolutionData, levelUpAmount)) return false; // Should we consume passives / weapons? bool consumePassives = (evolutionData.consumes & ItemData.Evolution.Consumption.passives) > 0; bool consumeWeapons = (evolutionData.consumes & ItemData.Evolution.Consumption.weapons) > 0; // Loop through all the catalysts and check if we should consume them. foreach (ItemData.Evolution.Config c in evolutionData.catalysts) { if (c.itemType is PassiveData && consumePassives) inventory.Remove(c.itemType, true); if (c.itemType is WeaponData && consumeWeapons) inventory.Remove(c.itemType, true); } // Should we consume ourselves as well? if (this is Passive && consumePassives) inventory.Remove((this as Passive).data, true); else if (this is Weapon && consumeWeapons) inventory.Remove((this as Weapon).data, true); // Add the new weapon onto our inventory. inventory.Add(evolutionData.outcome.itemType, updateUI); return true; } // Whenever an item levels up, attempt to make it evolve. public virtual bool DoLevelUp(bool updateUI = true) { if (evolutionData == null) return true; // Tries to evolve into every listed evolution of this weapon, // if the weapon's evolution condition is levelling up. foreach (ItemData.Evolution e in evolutionData) { if (e.condition == ItemData.Evolution.Condition.auto) AttemptEvolution(e, 1, updateUI); } return true; }
This, in turn, affects the overriding of the
DoLevelUp()
function inWeapon
,Passive
,AuraWeapon
, and any otherItem
subclass in your project:// Levels up the weapon by 1, and calculates the corresponding stats. public override bool DoLevelUp(bool updateUI = true) { base.DoLevelUp(updateUI); ... }
Once all that is done, you will also need to add a new
NotifyComplete()
function inTreasureChest
that can be called to refresh the inventory UI when the animation is done, as well as make the following changes to add theupdateUI
parameter:// Function for UITreasureChest to call when animation is complete. public void NotifyComplete() { recipient.weaponUI.Refresh(); recipient.passiveUI.Refresh(); } // Continue down the list until one returns. void Open(PlayerInventory inventory) { if (inventory == null) return; if (possibleDrops.HasFlag(DropType.Evolution) && TryEvolve<Weapon>(inventory, false)) return; if (possibleDrops.HasFlag(DropType.UpgradeWeapon) && TryUpgrade<Weapon>(inventory, false)) return; if (possibleDrops.HasFlag(DropType.UpgradePassive) && TryUpgrade<Passive>(inventory, false)) return; if (possibleDrops.HasFlag(DropType.NewWeapon) && TryGive<WeaponData>(inventory, false)) return; if (possibleDrops.HasFlag(DropType.NewPassive)) TryGive<PassiveData>(inventory, false); } // Try to evolve a random item in the inventory. T TryEvolve<T>(PlayerInventory inventory, bool updateUI = true) where T : Item { // Loop through every evolvable item. T[] evolvables = inventory.GetEvolvables<T>(); foreach (Item i in evolvables) { // Get all the evolutions that are possible. ItemData.Evolution[] possibleEvolutions = i.CanEvolve(0); foreach (ItemData.Evolution e in possibleEvolutions) { // Attempt the evolution and notify the UI if successful. if (i.AttemptEvolution(e, 0, updateUI)) { UITreasureChest.NotifyItemReceived(e.outcome.itemType.icon); return i as T; } } } return null; } // Try to upgrade a random item in the inventory. T TryUpgrade<T>(PlayerInventory inventory, bool updateUI = true) where T : Item { // Gets all weapons in the inventory that can still level up. T[] upgradables = inventory.GetUpgradables<T>(); if (upgradables.Length == 0) return null; // Terminate if no weapons. // Do the level up, and tell the treasure chest which item is levelled. T t = upgradables[Random.Range(0, upgradables.Length)]; inventory.LevelUp(t, updateUI); UITreasureChest.NotifyItemReceived(t.data.icon); return t; } // Try to give a new item to the inventory. T TryGive<T>(PlayerInventory inventory, bool updateUI = true) where T : ItemData { // Get all new item possibilities. T[] possibilities = inventory.GetUnowned<T>(); if (possibilities.Length == 0) return null; // Add a random possibility. T t = possibilities[Random.Range(0, possibilities.Length)]; inventory.Add(t, updateUI); UITreasureChest.NotifyItemReceived(t.icon); return t; }
All the changes above make it so that the UI does not update when the
TreasureChest
awards the player its rewards immediately upon touching it. However, because the UI does not update, we now need to manually trigger the update whenUITreasureChest
is done:public void CloseUI() { //Display Coins earned collector.AddCoins(coins); // Reset UI & VFX to initial state chestCover.SetActive(true); chestButton.SetActive(true); icons.Clear(); beamVFX.SetActive(false); coinText.gameObject.SetActive(false); gameObject.SetActive(false); doneButton.SetActive(false); fireworks.SetActive(false); curvedBeams.SetActive(false); ResetDisplay(); //reset audio audiosource.clip = pickUpSound; audiosource.time = 0f; audiosource.Play(); isAnimating = false; GameManager.instance.ChangeState(GameManager.GameState.Gameplay); currentChest.NotifyComplete(); }
- 1 anonymous person
August 24, 2025 at 2:13 am #18774::4. Prevent issuing upgrades for max level weapons
To prevent the
PlayerInventory
from issuing weapons that we already have maxed out, we need to remove a single character from theApplyUpgradeOptions()
function:// Determines what upgrade options should appear. void ApplyUpgradeOptions() { // <availableUpgrades> is an empty list that will be filtered from // <allUpgrades>, which is the list of ALL upgrades in PlayerInventory. // Not all upgrades can be applied, as some may have already been // maxed out the player, or the player may not have enough inventory slots. List<ItemData> availableUpgrades = new List<ItemData>(); List<ItemData> allUpgrades = new List<ItemData>(availableWeapons); allUpgrades.AddRange(availablePassives); // We need to know how many weapon / passive slots are left. int weaponSlotsLeft = GetSlotsLeft(weaponSlots); int passiveSlotsLeft = GetSlotsLeft(passiveSlots); // Filters through the available weapons and passives and add those // that can possibly be an option. foreach (ItemData data in allUpgrades) { // If a weapon of this type exists, allow for the upgrade if the // level of the weapon is not already maxed out. Item obj = Get(data); if (obj) { if (obj.currentLevel <
=data.maxLevel) availableUpgrades.Add(data); } else { // If we don't have this item in the inventory yet, check if // we still have enough slots to take new items. if (data is WeaponData && weaponSlotsLeft > 0) availableUpgrades.Add(data); else if (data is PassiveData && passiveSlotsLeft > 0) availableUpgrades.Add(data); } } // Show the UI upgrade window if we still have available upgrades left. int availUpgradeCount = availableUpgrades.Count; if (availUpgradeCount > 0) { bool getExtraItem = 1f - 1f / player.Stats.luck > UnityEngine.Random.value; if (getExtraItem || availUpgradeCount < 4) upgradeWindow.SetUpgrades(this, availableUpgrades, 4); else upgradeWindow.SetUpgrades(this, availableUpgrades, 3, "Increase your Luck stat for a chance to get 4 items!"); } else if (GameManager.instance != null && GameManager.instance.choosingUpgrade) { GameManager.instance.EndLevelUp(); } }Specifically, this is the line:
if (obj.currentLevel <
=data.maxLevel) availableUpgrades.Add(data);Previously, when considering whether a weapon is a legitimate option, we would check to see if it was less than or equal to the max level, but if an item is equal to the max level, it should never be upgradable.
- 1 anonymous person
- Instead of having the Done button do the
-
AuthorPosts
- You must be logged in to reply to this topic.
Advertisement below: