How to make a "charm spell" in a RPG?

Hello Everyone,
For a while, I’ve been trying to make a “charm spell” to give more flavor to people that choose the charisma trait and create a bard-like playestyle/skill set available to the players in my RPG.

For this, I would like to change the current system we’ve built that the AI only detects the game object with the “Player” or “Enemy” tag on it to an inheritance class called ControllerBase.cs with a public “Team” integer that both the PlayerController & AIController share. Making the AI search for the integer between objects rather than the tags, for example, the player is on team 0 and enemies are on team 1, as stated in an earlier conversation here. This reworked system can also make for additional game mechanics as adding a “neutral” side or various other teams within the game world that fight one another and are still hostile towards the player.

I’m just having difficulty implementing this since the systems in place are mostly built on returning GameObjects and not integers (and I would like to ask for advice first before trying to completely rework the Friend-Foe system currently in place as fiddling with these systems could be disastrous).

Then for the “charm” ability itself, I’m assuming that the approach would be to detect enemies within a ray or raycastsphere (for higher levels of the spell) cast by the player and change the enemy team integer from 1 to 0 for a set amount of time and then change the team integer back to its original team after the set amount of time has finished.

Any and all advice would be welcome and thank you for your time reading.

After reviewing this, I think you can use the system you’ve linked to just fine.

What you want to do is to mask the real team behind a property. So presuming that the BaseController has an int team; we’ll add a few more variables and methods to make charm happen:

int charmedTeam = team;
int charmedTimeRemaining = 0;

public int Team => charmedTeam>0? charmedTeam : team;

public void Charm(int newTeam, float charmTime)
{
    if(charmedTimeRemaining>0 && charmedTeam==newTeam)
    {
         charmedTimeRemaining+=charmTime;  //Extend exisiting charm
     }
     else
     {
          charmedTeam = newTeam;
          charmedTimeRemaining = charmTime;
      }
}

And finally a quick change to Update
in BaseController:

protected virtual void Update() //This lets us use the updates both here and in inherited controllers
{
     if(charmedTimeRemaining>0) charmedTimeRemaining-=Time.deltaTime;
}

Then in both PlayerController and AIController add the override keyword to the methods

public override void Update()
{
    base.Update();
    /// rest of method

Now your charm ability just needs to call the targetted controller’s Charm() method with the new team and the duration of the charm.

Okay @Brian_Trotter, I’ve managed to change most of the Friend-Foe System, I’m struggling with the AIController.cs since it needs requires a game object with the “player” tag.

These are the changes that I have made so far;
For ControllerBase.cs

using UnityEngine;

namespace RPG.Control
{
    public abstract class ControllerBase : MonoBehaviour
    {
        public int team;
        int charmedTeam = 0; //<- doesn't accept team;
        float charmedTimeRemaining = 0;

        public int Team => charmedTeam>0? charmedTeam : team;

        public void Charm(int newTeam, float charmTime)
        {
            if(charmedTimeRemaining>0 && charmedTeam==newTeam)
            {
                charmedTimeRemaining+=charmTime;  //Extend exisiting charm
            }
            else
            {  
                charmedTeam = newTeam;
                charmedTimeRemaining = charmTime;
            }
        }
        public virtual void Update()
        {
            if(charmedTimeRemaining > 0) charmedTimeRemaining -= Time.deltaTime;
        }
    }
}

in CombatTarget.cs I added

if(callingController.GetComponent().team == gameObject.GetComponent().team) return false;

Within the HandleRaycast() Method, then in PlayerController.cs I changed enemiesInRange from a gameobject to an int and changed TriggerAttackMode() from returning a gameobject to returning an int as well making the method look like this:

 public bool TriggerAttackMode(int combatTarget)
         {
              if (combatTarget == team) return false;
             RaycastHit[] hits = Physics.SphereCastAll(transform.position, detectEnemyRange, Vector3.up, 0);
             foreach (RaycastHit hit in hits)
             {
                 Health target = hit.collider.GetComponent<Health>();
                 ControllerBase enemies = hit.collider.GetComponent<ControllerBase>();
 
                 if(enemies.team == team) return false;
                 if (enemies == null) return false;
                 if(target== null) return false;
                 if (target.IsDead()) return false;
 
                 return true;
             }
             return false;
         }

Finally I added in the AIController.cs

public bool CheckTeam()
        {
            RaycastHit[] hits = Physics.SphereCastAll(transform.position, chaseDistance, Vector3.up, 0);
            foreach (RaycastHit hit in hits)
            {
                ControllerBase enemy = hit.collider.GetComponent<ControllerBase>();
                if (enemy == null) continue;  
                
                if(enemy.Team != Team) return true;
                
            }
            return false;
}

then adding it to the AttackPlayer() method to look like this:

if (IsAggrevated()&& CheckTeam() && fighter.CanAttack(hostile))
            {
                AttackBehaviour();
            }

So my main issue is that in the AIController.cs is returning a gameobject to trigger the Fighter.cs (and I don’t think converting everything from a gameobject into an int within Fighter.cs is efficient nor the solution) so as is this is what it looks like:

//Cache 
GameObject hostile;
private void Awake()

        {
            hostile = GameObject.FindGameObjectWithTag("Player"); // need to change to return object with ControllerBase
        }

fighter.CanAttack(hostile) //<- within the AttackPlayer(),

private void AttackBehaviour()
        {
            timeSinceLastSawPlayer = 0;
            if (fighter.CanAttack(hostile))
            {
                fighter.Attack(hostile);
            }
            else
            {
                return;
            }
            AggrevateNearbyEnemies();
        }

and

public bool IsAggrevated()

        {

            float distanceToPlayer = Vector3.Distance(hostile.transform.position, transform.position);
             //^^ needs to return specific gameobject or else null exception error.
            return distanceToPlayer < chaseDistance|| timeSinceAggrevated < aggroCoolDown;      

        }

Everything else but the AIController works, the closest I’ve gotten causes the AI to attack itself if the player aggravates it. Is there something I’m missing or does this require a larger overhaul than I expected? Thank you again for your time.

Looking over this, I’m seeing a lot of cross-dependency being introduced that we do not want… I’m going to propose an alternate solution: Let’s roll all those classes back to where they started, and introduce a new Faction class.

Faction.cs
using UnityEngine;

namespace RPG.Core
{
    public class Faction: MonoBehaviour
    {
        [SerializeField] int team;
        
        int charmedTeam;
        float charmedTimeRemaining=0;
          
        public int Team => charmedTimeRemaining>0? charmedTeam:team;
   
        public void  Charm(int newTeam, float charmedDuration)
        {
            if(Team == newTeam) charmedTimeRemaining+=charmedDuration; 
            else
            {
                charmedTeam = newTeam;
                charmedTimeRemaining = charmedDuration;
            }
        }
     
        void Update()
        {
            if(charmedTimeRemaining>0) charmedTimeRemaining-=Time.deltaTime;
        }
    }
}

Now in Fighter.CanAttack() after if(combatTarget==null) {return false;}, add

            if (combatTarget.TryGetComponent(out Faction otherFaction) && TryGetComponent(out Faction ourFaction))
            {
                if (ourFaction.Team == otherFaction.Team) return false;
            }

This should cover all cases, assuming that we only have the two factions Player (0) and Enemies (1). If we want to get more technical, then we’ll need some sort of matrix… For example, you could do this:

Complex Factions.cs
using System.Collections.Generic;
using UnityEngine;

namespace RPG.Core
{
    public enum Factions
    {
        Player,
        Guards,
        Beasts,
        Bandits,
        Peasants
    }
    
    public class Faction: MonoBehaviour
    {
        [SerializeField] Factions team;

        private HashSet<Factions> Player = new HashSet<Factions>() { Factions.Beasts, Factions.Bandits };
        private HashSet<Factions> Guards = new HashSet<Factions>() { Factions.Beasts, Factions.Bandits };
        private HashSet<Factions> Beasts = new HashSet<Factions>() { Factions.Player, Factions.Guards, Factions.Bandits, Factions.Peasants };
        private HashSet<Factions> Bandits = new HashSet<Factions>() { Factions.Player, Factions.Guards, Factions.Beasts, Factions.Peasants };
        private HashSet<Factions> Peasants = new HashSet<Factions>() { Factions.Bandits, Factions.Beasts };
        
        Factions charmedTeam;
        float charmedTimeRemaining=0;

        private Dictionary<Factions, HashSet<Factions>> HashSets = new Dictionary<Factions, HashSet<Factions>>();
        
        public Factions Team => charmedTimeRemaining>0? charmedTeam:team;

        void Awake()
        {
            HashSets.Add(Factions.Player, Player);
            HashSets.Add(Factions.Bandits, Bandits);
            HashSets.Add(Factions.Beasts, Beasts);
            HashSets.Add(Factions.Guards, Guards);
            HashSets.Add(Factions.Peasants, Peasants);
        }
        
        public void  Charm(Factions newTeam, float charmedDuration)
        {
            if(Team == newTeam) charmedTimeRemaining+=charmedDuration; 
            else
            {
                charmedTeam = newTeam;
                charmedTimeRemaining = charmedDuration;
            }
        }

        public bool CanAttack(Factions otherFaction)
        {
            //edit, original post forgot to include the !
            return !HashSets[otherFaction].Contains(otherFaction);
        }
        
        void Update()
        {
            if(charmedTimeRemaining>0) charmedTimeRemaining-=Time.deltaTime;
        }
    }
}

And change the modification to Fighter.CanAttack to read:

            if (combatTarget.TryGetComponent(out Faction otherFaction) && TryGetComponent(out Faction ourFaction))
            {
                if (!ourFaction.CanAttack(otherFaction.Team)) return false;
            }

The best part of both approaches is that this is it, it’s all you need to do to implement factions. The AIController will still test for the player, but if the Team is the same (because the AIController is charmed) then it will do nothing. If the AIController is charmed, then the Player will no longer see the character as hostile.

Now if you wanted the AIController to see out other classes it deems as hostile, then once you’ve determined that the Player can’t be attacked (if you can attack the player, always attack the player), then you could do a Spherecast to the attack radius and test each AIController found (but not this AIController) for the ability to attack.

I tried both solutions that you have presented (deleting the ControllerBase.cs and starting over) and when adding the Faction.cs to both player and enemy gameobjects the player cannot select the enemies regardless of the faction selected and the enemies ignore the player. If Faction.cs is removed from one or the other everything goes back to normal, but the faction type/number is still ignored (i.e. enemies are aggressive towards the player regardless of faction).

I’ve tried to rectify this by adding the following to AIController.cs, everything else is added as you suggested

//Cache 

GameObject otherHostile;

       private void AttackBehaviour()
        {
            timeSinceLastSawPlayer = 0;
            if (fighter.CanAttack(hostile))
            {
                fighter.Attack(hostile);
            }
            else if(!fighter.CanAttack(hostile))
            {
                if(CheckTeam(otherHostile))
                {
                    fighter.CanAttack(otherHostile);
                }
            }
            else
            {
                return;
            }
            AggrevateNearbyEnemies();
        }

public bool CheckTeam(GameObject target)
        {
            RaycastHit[] hits = Physics.SphereCastAll(transform.position, chaseDistance, Vector3.up, 0);
            foreach (RaycastHit hit in hits)
            {
                AIController other = hit.collider.GetComponent<AIController>();
                if (other == null) continue;  
                
                if(other.GetComponent<Faction>().Team != this.GetComponent<Faction>().Team) return true;
                return other == target;
            }
            target = null;
            return false;
        }

And I’ve tried to create the charm spell ability with a CharmEnemyEffect.cs using the targeting circle from the fire blast lecture as a quick way to get the targets.

using System;

using RPG.Core;

using UnityEngine;

namespace RPG.Abilities.Effects

{
    [CreateAssetMenu(fileName = "Charm Enemy Effect", menuName = "Abilities/Effects/Charm Enemy", order = 0)]

    public class CharmEnemyEffect : EffectStrategy

    {
        [SerializeField] Factions changedTeam;
        [SerializeField] float charmDuration;

        public override void StartEffect(AbilityData data, Action finished)

        {

            foreach (var targets in data.GetTargets())

            {
                var faction = targets.GetComponent<Faction>();

                faction.Charm(changedTeam, charmDuration);
            }
            finished();
        }
    }
}

This ability is still ineffective as of yet (for obvious reasons), but I just wanted to know if I’m on the right track. My apologies if this is becoming more complicated than intended, but I do appreciate the assistance.

Both would need to have a Faction or the system would ignore factions altogether

This doesn’t make sense, where is otherHostile set? fighter.CanAttack() is a boolean function, not an action (i.e. it doesn’t do anything, just returns true or false)

I tested things with the simple (team number == otherteam number, not worry about multiple factions) and the player is able to attack the enemies and vice versa (With the multi-faction code, I did make a mistake, the result should have been ! contains in Faction.CanAttack().

For now, get to the point that you have the factions with the team 0 or 1 and make sure that you can still attack the enemy and that the enemy can attack you, then we’ll deal with the charm method.

Alright, I redid everything from scratch and left out my additions to the AIController.cs, now the numerical teams work as intended. I don’t know what it was that had originally blocked my initial attempt, but I probably shouldn’t have tried to extend beyond what you’d already laid down and I apologize.

Currently, the charm ability works well with the original numerical teams as it causes enemies to stop attacking the player and return to their original positions if the charmed duration lasts longer than their aggro cooldown. It currently operates as a pseudo “Jedi Mind Trick” ability which is still pretty cool, but I would like to expand it further than that.

The multiple factions enum version continues to elude me as it still allows the player and enemies to attack each other despite belonging to the same faction. For example, if both player and enemy belong to the “Player” faction the enemies can be targeted by the player and the enemies still move to attack the player once they are in range.

Silly me, one more edit…
Change CanAttack to read:

public bool CanAttack(Factions otherFaction)
{
    return HashSets[Team].Contains(otherFaction);
}

I was comparing otherFaction’s hashset to otherFaction, when it should have been Team to otherFaction, meaning also the ! goes away.

That did it! Now the everything works as the spell gives pause to the enemies and they to go back to their original paths/positions after a certain time.

So, how would I make the enemies attack one another when switching their factions? Should that logic be inside the AIController.cs to check if other objects on different teams within range and call fighter.CanAttack() or should it be in the Fighter.cs in a similar structure to the FindNewTargetInRange() & FindAllTargetsInRange() methods to take in the Faction component instead of the Health component and compare the teams there?

No, that’s definitely something that should be in the AIController.

If the check fails for attacking the player, then we need to check for anything else in range that can be attacked. You can do this with a Spherecast at the AI position for any CombatTargets in range. Then (ruling out this, find the nearest target that the Fighter.CanAttack.

Think I finally got it down, the charmed enemies now attack the normal enemies instead of the player, but they attack the player if the player are the only one left in range. Inside AIController.cs I made these changes:

//Cache
CombatTarget combatTarget

private void AttackPlayer()

        {

            if (health.IsDead()) { return; }

            if (IsAggrevated() && fighter.CanAttack(hostile))
            {
                AttackBehaviour();
            }
           //Additional code added
            else if(!fighter.CanAttack(hostile))

            {
                combatTarget = FindNewTargetInRange();
            }
           //... continues as normal
private CombatTarget FindNewTargetInRange()

        {
            CombatTarget best = null;
            float bestDistance = Mathf.Infinity;
            foreach (var candidate in FindAllOtherTargetsInRange())
            {
                float candidateDistance = Vector3.Distance(transform.position, candidate.transform.position);
                if(candidateDistance < bestDistance)
                {
                    best = candidate;
                    bestDistance = candidateDistance;
                }
            }
            return best;
        }

        private IEnumerable<CombatTarget> FindAllOtherTargetsInRange()
        {
            RaycastHit[] hits = Physics.SphereCastAll(transform.position, chaseDistance, Vector3.up);
            foreach (var hit in hits)
            {
                CombatTarget target = hit.transform.GetComponent<CombatTarget>();
                if (target == null) continue;
                if (target.GetComponent<Health>().IsDead()) continue;
                if(target.GetComponent<Faction>().Team == this.GetComponent<Faction>().Team) continue;
                if(target.GetComponent<Faction>().Team != this.GetComponent<Faction>().Team) yield return target;
               
                if (fighter.CanAttack(target.gameObject))
                {
                    fighter.Attack(target.gameObject);
                }
            }
        }

Is this correct or should I make it less of a “Frenzy” type of spell? Also, I noticed that if the charmed enemy defeats a normal enemy, the player does not get the experience from the fallen enemy.

Edit: Changed AwardExperience() in Health.cs so that exp goes to the player no matter who gets the final blow on the enemy target.

 private void AwardExperience(GameObject instigator)
         {    
             //Experience experience = instigator.GetComponent<Experience>();
             instigator = GameObject.FindGameObjectWithTag("Player");
             Experience experience = instigator.GetComponent<Experience>();
             if (experience == null) return;
 
             experience.GainExperience(GetComponent<BaseStats>().GetStat(Stat.ExpReward));
        }

This’ll probably be my last reply in a while since I will be participating in the Game Jam throughout the weekend and next week. Thank you again for your help.

This topic was automatically closed 24 hours after the last reply. New replies are no longer allowed.

Privacy & Terms