Forum begins after the advertisement:


[Part 15] Adding New Characters

Viewing 9 posts - 1 through 9 (of 9 total)
  • Author
    Posts
  • #14720
    Nathan
    Participant

    Hello,
    After the finishing the character data changes and weapon revamp, I’m interested in figuring out how to create characters that are not the same. Currently, the character selector selects the data of the character based on what weapon data is holds if I am correct. One confusion I’m currently having is having a different sprite for each character. Should I create a prefab of the player and have different prefabs for each sprite or is there a different way to go about this? Thank you.

    #14722
    Terence
    Keymaster

    Nathan, I can cover this in the upcoming stream on Thursday. It should be a pretty simple implementation.

    Currently, the CharacterData script we have after Part 15 only records the character’s sprites, but all we need to do is have it record the Animator Controller as well, and feed both the sprite and the data into the Sprite Renderer and Animator components respectively in the Game scene, and the corresponding sprite / animations of the character should apply.

    Let me know if this makes sense.

    #14724
    Nathan
    Participant

    Right, I understand. Then for each character, the animations would need to be recorded for the character data. Thank you.

    #14811
    Panjera
    Former Patron

    Hi Terence, just wondering if you got a chance to go over this yet? I’m loving the series so far and have got up to part 6 with the introduction of the different starting weapon selection, like Nathan mentioned above, I would also like to select a different character at the game start screen rather than a starting weapon. My ultimate goal is to have a selection of different characters who all have a default (auto)attack that can then be augmented through the play session with the weapons we have been creating such as the garlic and knife. I’m wondering if I should hold of completing Part 6 if you are going to visit this idea or if it is a simple refactoring of the select weapon code to change it to select character.

    Thanks again for all your effort and time in putting this series together, it is a fantastic learning resource!

    #14814
    Terence
    Keymaster

    Thanks Panjera, I’ll be going through this on stream later. Once it is done, I’ll paste the timestamp here.

    #14824
    Terence
    Keymaster

    To get each character to have unique icons and animations, you first have to modify the CharacterData (after Part 14) or CharacterScriptableObject. Add a new RuntimeAnimatorController variable that will be used to store the animation (the class already stores the character icon).

    using UnityEngine;
    
    [CreateAssetMenu(fileName = "Character Data", menuName = "2D Top-down Rogue-like/Character Data")]
    public class CharacterData : ScriptableObject
    {
        [SerializeField]
        Sprite icon;
        public Sprite Icon { get => icon; private set => icon = value; }
    
        public RuntimeAnimatorController controller;
    
        [SerializeField]
        new string name;
        public string Name { get => name; private set => name = value; }
    
        [SerializeField]
        WeaponData startingWeapon;
        public WeaponData StartingWeapon { get => startingWeapon; private set => startingWeapon = value; }
    
        [System.Serializable]
        public struct Stats
        {
            public float maxHealth, recovery, armor;
            [Range(-1, 10)] public float moveSpeed, might, area;
            [Range(-1, 5)] public float speed, duration;
            [Range(-1, 10)] public int amount;
            [Range(-1, 1)] public float cooldown;
            [Min(-1)] public float luck, growth, greed, curse;
            public float magnet;
            public int revival;
    
            public static Stats operator +(Stats s1, Stats s2)
            {
                s1.maxHealth += s2.maxHealth;
                s1.recovery += s2.recovery;
                s1.armor += s2.armor;
                s1.moveSpeed += s2.moveSpeed;
                s1.might += s2.might;
                s1.area += s2.area;
                s1.speed += s2.speed;
                s1.duration += s2.duration;
                s1.amount += s2.amount;
                s1.cooldown += s2.cooldown;
                s1.luck += s2.luck;
                s1.growth += s2.growth;
                s1.greed += s2.greed;
                s1.curse += s2.curse;
                s1.magnet += s2.magnet;
                return s1;
            }
        }
        public Stats stats = new Stats
        {
            maxHealth = 100, moveSpeed = 1, might = 1, amount = 0,
            area = 1, speed = 1, duration = 1, cooldown = 1,
            luck = 1, greed = 1, growth = 1, curse = 1
        };
    }

    This will create a field on your scriptable object for the character where you can assign a different Animator Controller for your new character.

    Then, you’ll have to make the following changes to the PlayerAnimator and PlayerStats scripts, so that they use the assigned character icons and animations.

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class PlayerAnimator : MonoBehaviour
    {
        //References
        Animator am;
        PlayerMovement pm;
        SpriteRenderer sr;
    
        void Start()
        {
            am = GetComponent<Animator>();
            pm = GetComponent<PlayerMovement>();
            sr = GetComponent<SpriteRenderer>();
        }
    
        void Update()
        {
            if (pm.moveDir.x != 0 || pm.moveDir.y != 0)
            {
                am.SetBool("Move", true);
                SpriteDirectionChecker();
            }
            else
            {
                am.SetBool("Move", false);
            }
        }
    
        void SpriteDirectionChecker()
        {
            if (pm.lastHorizontalVector < 0)
            {
                sr.flipX = true;
            }
            else
            {
                sr.flipX = false;
            }
        }
    	
        // Allows us to update the default sprite and animations.
        public void SetSprites(Sprite sprite, RuntimeAnimatorController controller = null)
        {
            sr = GetComponent<SpriteRenderer>();
            if(controller)
            {
                am = GetComponent<Animator>();
                am.runtimeAnimatorController = controller;
            }
            sr.sprite = sprite;
        }
    }
    
    using System;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.UI;
    using TMPro;
    
    public class PlayerStats : MonoBehaviour
    {
    
        CharacterData characterData;
        public CharacterData.Stats baseStats;
        [SerializeField] CharacterData.Stats actualStats;
    
        public CharacterData.Stats Stats
        {
            get { return actualStats;  }
            set { 
                actualStats = value;
            }
        }
    
        float health;
    
        #region Current Stats Properties
        public float CurrentHealth
        {
    
            get { return health; }
    
            // If we try and set the current health, the UI interface
            // on the pause screen will also be updated.
            set
            {
                //Check if the value has changed
    
                if (health != value)
                {
                    health = value;
                    UpdateHealthBar();
                }
            }
        }
        #endregion
    
        [Header("Visuals")]
        public ParticleSystem damageEffect; // If damage is dealt.
        public ParticleSystem blockedEffect; // If armor completely blocks damage.
    
        //Experience and level of the player
        [Header("Experience/Level")]
        public int experience = 0;
        public int level = 1;
        public int experienceCap;
    
        //Class for defining a level range and the corresponding experience cap increase for that range
        [System.Serializable]
        public class LevelRange
        {
            public int startLevel;
            public int endLevel;
            public int experienceCapIncrease;
        }
    
        //I-Frames
        [Header("I-Frames")]
        public float invincibilityDuration;
        float invincibilityTimer;
        bool isInvincible;
    
        public List<LevelRange> levelRanges;
    
    
        PlayerInventory inventory;
        PlayerCollector collector;
        public int weaponIndex;
        public int passiveItemIndex;
    
        [Header("UI")]
        public Image healthBar;
        public Image expBar;
        public TMP_Text levelText;
    
        PlayerAnimator playerAnimator;
    
        void Awake()
        {
            characterData = CharacterSelector.GetData();
            if(CharacterSelector.instance) 
                CharacterSelector.instance.DestroySingleton();
    
            inventory = GetComponent();
            collector = GetComponentInChildren();
    
            //Assign the variables
            baseStats = actualStats = characterData.stats;
            collector.SetRadius(actualStats.magnet);
            health = actualStats.maxHealth;
        }
    
        void Start()
        {
            //Spawn the starting weapon
            inventory.Add(characterData.StartingWeapon);
    
            //Initialize the experience cap as the first experience cap increase
            experienceCap = levelRanges[0].experienceCapIncrease;
    
            GameManager.instance.AssignChosenCharacterUI(characterData);
    
            UpdateHealthBar();
            UpdateExpBar();
            UpdateLevelText();
    
            // Assign the sprite for the new character.
            playerAnimator = GetComponent<PlayerAnimator>();
            playerAnimator.SetSprites(characterData.Icon, characterData.controller);
        }
    
        void Update()
        {
            if (invincibilityTimer > 0)
            {
                invincibilityTimer -= Time.deltaTime;
            }
            //If the invincibility timer has reached 0, set the invincibility flag to false
            else if (isInvincible)
            {
                isInvincible = false;
            }
    
            Recover();
        }
    
        public void RecalculateStats()
        {
            actualStats = baseStats;
            foreach (PlayerInventory.Slot s in inventory.passiveSlots)
            {
                Passive p = s.item as Passive;
                if (p)
                {
                    actualStats += p.GetBoosts();
                }
            }
    
            // Update the PlayerCollector's radius.
            collector.SetRadius(actualStats.magnet);
        }
    
        public void IncreaseExperience(int amount)
        {
            experience += amount;
    
            LevelUpChecker();
            UpdateExpBar();
        }
    
        void LevelUpChecker()
        {
            if (experience >= experienceCap)
            {
                //Level up the player and reduce their experience by the experience cap
                level++;
                experience -= experienceCap;
    
                //Find the experience cap increase for the current level range
                int experienceCapIncrease = 0;
                foreach (LevelRange range in levelRanges)
                {
                    if (level >= range.startLevel && level <= range.endLevel)
                    {
                        experienceCapIncrease = range.experienceCapIncrease;
                        break;
                    }
                }
                experienceCap += experienceCapIncrease;
    
                UpdateLevelText();
    
                GameManager.instance.StartLevelUp();
    
                // If the experience still exceeds the experience cap, level up again.
                if(experience >= experienceCap) LevelUpChecker();
            }
        }
    
        void UpdateExpBar()
        {
            // Update exp bar fill amount
            expBar.fillAmount = (float)experience / experienceCap;
        }
    
        void UpdateLevelText()
        {
            // Update level text
            levelText.text = "LV " + level.ToString();
        }
    
        public void TakeDamage(float dmg)
        {
            //If the player is not currently invincible, reduce health and start invincibility
            if (!isInvincible)
            {
                // Take armor into account before dealing the damage.
                dmg -= actualStats.armor;
    
                if (dmg > 0)
                {
                    // Deal the damage.
                    CurrentHealth -= dmg;
    
                    // If there is a damage effect assigned, play it.
                    if (damageEffect) Destroy(Instantiate(damageEffect, transform.position, Quaternion.identity), 5f);
    
                    if (CurrentHealth <= 0)
                    {
                        Kill();
                    }
                }
                else
                {
                    // If there is a blocked effect assigned, play it.
                    if (blockedEffect) Destroy(Instantiate(blockedEffect, transform.position, Quaternion.identity), 5f);
                }
    
                invincibilityTimer = invincibilityDuration;
                isInvincible = true;
            }
        }
    
        void UpdateHealthBar()
        {
            //Update the health bar
            healthBar.fillAmount = CurrentHealth / actualStats.maxHealth;
        }
    
        public void Kill()
        {
            if (!GameManager.instance.isGameOver)
            {
                GameManager.instance.AssignLevelReachedUI(level);
                GameManager.instance.GameOver();
            }
        }
    
        public void RestoreHealth(float amount)
        {
            // Only heal the player if their current health is less than their maximum health
            if (CurrentHealth < actualStats.maxHealth)
            {
                CurrentHealth += amount;
    
                // Make sure the player's health doesn't exceed their maximum health
                if (CurrentHealth > actualStats.maxHealth)
                {
                    CurrentHealth = actualStats.maxHealth;
                }
            }
        }
    
        void Recover()
        {
            if (CurrentHealth < actualStats.maxHealth)
            {
                CurrentHealth += Stats.recovery * Time.deltaTime;
    
                // Make sure the player's health doesn't exceed their maximum health
                if (CurrentHealth > actualStats.maxHealth)
                {
                    CurrentHealth = actualStats.maxHealth;
                }
            }
        }
    }

    For the animations themselves, you can create an Animator Override Controller instead of new Animator Controllers. The Animator Override Controllers allow you to create a copy of an existing Animator Controller with the same structure, but with a different animation file: https://docs.unity3d.com/Manual/AnimatorOverrideController.html

    If you are following our tutorial, make sure you assign an animation to the Idle state of the default Animator as well! Otherwise, the idle sprite will always be the default player sprite.

    I’ll make a recording of this later and post it here.

    #14825
    Terence
    Keymaster

    31 May 2024 update: Updated some of the scripts to match the stream content.

    To get each character to have unique icons and animations, you first have to modify the CharacterData (after Part 14) or CharacterScriptableObject. Add a new RuntimeAnimatorController variable that will be used to store the animation (the class already stores the character icon).

    using UnityEngine;
    
    [CreateAssetMenu(fileName = "Character Data", menuName = "2D Top-down Rogue-like/Character Data")]
    public class CharacterData : ScriptableObject
    {
        [SerializeField]
        Sprite icon;
        public Sprite Icon { get => icon; private set => icon = value; }
    
        public RuntimeAnimatorController controller;
    
        [SerializeField]
        new string name;
        public string Name { get => name; private set => name = value; }
    
        [SerializeField]
        WeaponData startingWeapon;
        public WeaponData StartingWeapon { get => startingWeapon; private set => startingWeapon = value; }
    
        [System.Serializable]
        public struct Stats
        {
            public float maxHealth, recovery, armor;
            [Range(-1, 10)] public float moveSpeed, might, area;
            [Range(-1, 5)] public float speed, duration;
            [Range(-1, 10)] public int amount;
            [Range(-1, 1)] public float cooldown;
            [Min(-1)] public float luck, growth, greed, curse;
            public float magnet;
            public int revival;
    
            public static Stats operator +(Stats s1, Stats s2)
            {
                s1.maxHealth += s2.maxHealth;
                s1.recovery += s2.recovery;
                s1.armor += s2.armor;
                s1.moveSpeed += s2.moveSpeed;
                s1.might += s2.might;
                s1.area += s2.area;
                s1.speed += s2.speed;
                s1.duration += s2.duration;
                s1.amount += s2.amount;
                s1.cooldown += s2.cooldown;
                s1.luck += s2.luck;
                s1.growth += s2.growth;
                s1.greed += s2.greed;
                s1.curse += s2.curse;
                s1.magnet += s2.magnet;
                return s1;
            }
        }
        public Stats stats = new Stats
        {
            maxHealth = 100, moveSpeed = 1, might = 1, amount = 0,
            area = 1, speed = 1, duration = 1, cooldown = 1,
            luck = 1, greed = 1, growth = 1, curse = 1
        };
    }

    This will create a field on your scriptable object for the character where you can assign a different Animator Controller for your new character.

    Then, you’ll have to make the following changes to the PlayerAnimator and PlayerStats scripts (see the highlighted sections), so that they use the assigned character icons and animations.

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class PlayerAnimator : MonoBehaviour
    {
        //References
        Animator am;
        PlayerMovement pm;
        SpriteRenderer sr;
    
        void Start()
        {
            am = GetComponent<Animator>();
            pm = GetComponent<PlayerMovement>();
            sr = GetComponent<SpriteRenderer>();
        }
    
        void Update()
        {
            if (pm.moveDir.x != 0 || pm.moveDir.y != 0)
            {
                am.SetBool("Move", true);
                SpriteDirectionChecker();
            }
            else
            {
                am.SetBool("Move", false);
            }
        }
    
        void SpriteDirectionChecker()
        {
            if (pm.lastHorizontalVector < 0)
            {
                sr.flipX = true;
            }
            else
            {
                sr.flipX = false;
            }
        }
    	
        public void SetAnimatorController(RuntimeAnimatorController c)
        {
            if (!am) am = GetComponent<Animator>();
            am.runtimeAnimatorController = c;
        }
    }
    
    using System;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.UI;
    using TMPro;
    
    public class PlayerStats : MonoBehaviour
    {
    
        CharacterData characterData;
        public CharacterData.Stats baseStats;
        [SerializeField] CharacterData.Stats actualStats;
    
        public CharacterData.Stats Stats
        {
            get { return actualStats;  }
            set { 
                actualStats = value;
            }
        }
    
        float health;
    
        #region Current Stats Properties
        public float CurrentHealth
        {
    
            get { return health; }
    
            // If we try and set the current health, the UI interface
            // on the pause screen will also be updated.
            set
            {
                //Check if the value has changed
    
                if (health != value)
                {
                    health = value;
                    UpdateHealthBar();
                }
            }
        }
        #endregion
    
        [Header("Visuals")]
        public ParticleSystem damageEffect; // If damage is dealt.
        public ParticleSystem blockedEffect; // If armor completely blocks damage.
    
        //Experience and level of the player
        [Header("Experience/Level")]
        public int experience = 0;
        public int level = 1;
        public int experienceCap;
    
        //Class for defining a level range and the corresponding experience cap increase for that range
        [System.Serializable]
        public class LevelRange
        {
            public int startLevel;
            public int endLevel;
            public int experienceCapIncrease;
        }
    
        //I-Frames
        [Header("I-Frames")]
        public float invincibilityDuration;
        float invincibilityTimer;
        bool isInvincible;
    
        public List<LevelRange> levelRanges;
    
    
        PlayerInventory inventory;
        PlayerCollector collector;
        public int weaponIndex;
        public int passiveItemIndex;
    
        [Header("UI")]
        public Image healthBar;
        public Image expBar;
        public TMP_Text levelText;
    
        PlayerAnimator playerAnimator;
    
        void Awake()
        {
            characterData = CharacterSelector.GetData();
            if(CharacterSelector.instance) 
                CharacterSelector.instance.DestroySingleton();
    
            inventory = GetComponent();
            collector = GetComponentInChildren();
    
            //Assign the variables
            baseStats = actualStats = characterData.stats;
            collector.SetRadius(actualStats.magnet);
            health = actualStats.maxHealth;
    
            
            playerAnimator = GetComponent<PlayerAnimator>();
            if(characterData.controller)
                playerAnimator.SetAnimatorController(characterData.controller);
        }
    
        void Start()
        {
            //Spawn the starting weapon
            inventory.Add(characterData.StartingWeapon);
    
            //Initialize the experience cap as the first experience cap increase
            experienceCap = levelRanges[0].experienceCapIncrease;
    
            GameManager.instance.AssignChosenCharacterUI(characterData);
    
            UpdateHealthBar();
            UpdateExpBar();
            UpdateLevelText();
        }
    
        void Update()
        {
            if (invincibilityTimer > 0)
            {
                invincibilityTimer -= Time.deltaTime;
            }
            //If the invincibility timer has reached 0, set the invincibility flag to false
            else if (isInvincible)
            {
                isInvincible = false;
            }
    
            Recover();
        }
    
        public void RecalculateStats()
        {
            actualStats = baseStats;
            foreach (PlayerInventory.Slot s in inventory.passiveSlots)
            {
                Passive p = s.item as Passive;
                if (p)
                {
                    actualStats += p.GetBoosts();
                }
            }
    
            // Update the PlayerCollector's radius.
            collector.SetRadius(actualStats.magnet);
        }
    
        public void IncreaseExperience(int amount)
        {
            experience += amount;
    
            LevelUpChecker();
            UpdateExpBar();
        }
    
        void LevelUpChecker()
        {
            if (experience >= experienceCap)
            {
                //Level up the player and reduce their experience by the experience cap
                level++;
                experience -= experienceCap;
    
                //Find the experience cap increase for the current level range
                int experienceCapIncrease = 0;
                foreach (LevelRange range in levelRanges)
                {
                    if (level >= range.startLevel && level <= range.endLevel)
                    {
                        experienceCapIncrease = range.experienceCapIncrease;
                        break;
                    }
                }
                experienceCap += experienceCapIncrease;
    
                UpdateLevelText();
    
                GameManager.instance.StartLevelUp();
    
                // If the experience still exceeds the experience cap, level up again.
                if(experience >= experienceCap) LevelUpChecker();
            }
        }
    
        void UpdateExpBar()
        {
            // Update exp bar fill amount
            expBar.fillAmount = (float)experience / experienceCap;
        }
    
        void UpdateLevelText()
        {
            // Update level text
            levelText.text = "LV " + level.ToString();
        }
    
        public void TakeDamage(float dmg)
        {
            //If the player is not currently invincible, reduce health and start invincibility
            if (!isInvincible)
            {
                // Take armor into account before dealing the damage.
                dmg -= actualStats.armor;
    
                if (dmg > 0)
                {
                    // Deal the damage.
                    CurrentHealth -= dmg;
    
                    // If there is a damage effect assigned, play it.
                    if (damageEffect) Destroy(Instantiate(damageEffect, transform.position, Quaternion.identity), 5f);
    
                    if (CurrentHealth <= 0)
                    {
                        Kill();
                    }
                }
                else
                {
                    // If there is a blocked effect assigned, play it.
                    if (blockedEffect) Destroy(Instantiate(blockedEffect, transform.position, Quaternion.identity), 5f);
                }
    
                invincibilityTimer = invincibilityDuration;
                isInvincible = true;
            }
        }
    
        void UpdateHealthBar()
        {
            //Update the health bar
            healthBar.fillAmount = CurrentHealth / actualStats.maxHealth;
        }
    
        public void Kill()
        {
            if (!GameManager.instance.isGameOver)
            {
                GameManager.instance.AssignLevelReachedUI(level);
                GameManager.instance.GameOver();
            }
        }
    
        public void RestoreHealth(float amount)
        {
            // Only heal the player if their current health is less than their maximum health
            if (CurrentHealth < actualStats.maxHealth)
            {
                CurrentHealth += amount;
    
                // Make sure the player's health doesn't exceed their maximum health
                if (CurrentHealth > actualStats.maxHealth)
                {
                    CurrentHealth = actualStats.maxHealth;
                }
            }
        }
    
        void Recover()
        {
            if (CurrentHealth < actualStats.maxHealth)
            {
                CurrentHealth += Stats.recovery * Time.deltaTime;
    
                // Make sure the player's health doesn't exceed their maximum health
                if (CurrentHealth > actualStats.maxHealth)
                {
                    CurrentHealth = actualStats.maxHealth;
                }
            }
        }
    }

    For the animations themselves, you can create an Animator Override Controller instead of new Animator Controllers. The Animator Override Controllers allow you to create a copy of an existing Animator Controller with the same structure, but with a different animation file: https://docs.unity3d.com/Manual/AnimatorOverrideController.html

    If you are following our tutorial, make sure you assign an animation to the Idle state of the default Animator as well! Otherwise, the idle sprite will always be the default player sprite.

    I’ll make a recording of this later and post it here.

    #14849
    Panjera
    Former Patron

    Hi Terence, thank you very much for your replies, I will crack on with the series and implement these changes at the relevant points :)

    #14850
    Terence
    Keymaster

    No problem Panjera. Did a quick stream to explain the codes:

Viewing 9 posts - 1 through 9 (of 9 total)
  • You must be logged in to reply to this topic.

Go to Login Page →


Advertisement below: