Forum begins after the advertisement:
[Part 15] Adding New Characters
Home › Forums › Video Game Tutorial Series › Creating a Rogue-like Shoot-em Up in Unity › [Part 15] Adding New Characters
- This topic has 8 replies, 3 voices, and was last updated 6 months, 4 weeks ago by Terence.
-
AuthorPosts
-
May 20, 2024 at 12:55 pm #14720::
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.
May 20, 2024 at 3:25 pm #14722::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.
May 20, 2024 at 11:09 pm #14724::Right, I understand. Then for each character, the animations would need to be recorded for the character data. Thank you.
May 30, 2024 at 5:08 am #14811::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!
May 30, 2024 at 11:10 am #14814::Thanks Panjera, I’ll be going through this on stream later. Once it is done, I’ll paste the timestamp here.
May 30, 2024 at 2:41 pm #14824::To get each character to have unique icons and animations, you first have to modify the
CharacterData
(after Part 14) orCharacterScriptableObject
. Add a newRuntimeAnimatorController
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
andPlayerStats
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<PlayerInventory>(); collector = GetComponentInChildren<PlayerCollector>(); //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.
May 30, 2024 at 2:41 pm #14825::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) orCharacterScriptableObject
. Add a newRuntimeAnimatorController
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
andPlayerStats
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<PlayerInventory>(); collector = GetComponentInChildren<PlayerCollector>(); //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.
May 30, 2024 at 11:54 pm #14849::Hi Terence, thank you very much for your replies, I will crack on with the series and implement these changes at the relevant points :)
May 31, 2024 at 12:30 am #14850 -
AuthorPosts
- You must be logged in to reply to this topic.