I'm making an RPG in Unity with an ability-based combat system, think Guild Wars 2 or World of Warcraft. I'm implementing abilities in an inheritance tree structure which makes creating multiple similar abilities very easy and quick, but the whole system feels like it could be structured better.
Any tips for improving this system? I'm not averse a big rewrite if it means more scalable/maintainable code. I've considered rewriting it using a decorator design pattern. What are your opinions?
Also feel free to point out any bad habits in my code. I already know fields in C# shouldn't be public and capitalized and I should use properties instead, but Unity doesn't serialize properties and I don't want to write a [SerializeField] tag over every protected/private field.
Note: I've used Unity's ScriptableObject system on other projects and really dislike it, so I wouldn't want to implement abilities using that.
I'll show you the usual chain of inheritance in order to implement some abilities.
Base Ability Class:
public abstract class Ability
{
public string Name;
public string ImagePath;
public float PowerCoefficient;
public float TotalPower { get { return Owner.AbilityPower * PowerCoefficient; } }
/// <summary>
/// Total cast time is Owner's GCD + Cast Add
/// e.g. For a 2 second casted spell, make CastAdd = 0.5f
/// </summary>
public float CastAdd;
public float CastRemaining;
/// <summary>
/// Spell's total cast time with CastAdd and Owner's GCD factored in
/// </summary>
public float CastTime { get { return IsCastedAbility ? Owner.GlobalCooldown + CastAdd : 0; } }
/// <summary>
/// Gives a float 0-1 to indicate percentage of cast completion.
/// Used for cast bar.
/// </summary>
public float CastProgress { get { return 1.0f - (CastRemaining / CastTime); } }
public bool IsCastedAbility { get { return CastAdd > 0; } }
public bool IsBeingCasted { get { return CastRemaining > 0; } }
public bool CastReady { get { return CastRemaining == 0; } }
public float Cooldown;
public float CooldownRemaining;
/// <summary>
/// Gives a float 0-1 to indicate percentage of cooldown completion.
/// Used for cooldown indicator.
/// </summary>
public float CooldownProgress { get { return 1.0f - (CooldownRemaining / Cooldown); } }
public bool OffCooldown { get { return CooldownRemaining <= 0f; } }
public Entity Owner;
public List<Entity> TargetList;
// Shorthand for TargetList[0] for single-target abilities
public Entity Target { get { return TargetList.Count > 0 ? TargetList[0] : null; } }
public Ability(Entity owner = null)
{
Owner = owner;
TargetList = new List<Entity>();
}
/// <summary>
/// Called every frame to lower cooldown remaining.
/// Override for channeled spells and HoTs/DoTs to tick healing/damage.
/// </summary>
public virtual void Tick()
{
// Being casted, tick cast timer and check if ready or if target is dead
if(IsBeingCasted)
{
CastRemaining = Mathf.Max(CastRemaining - Time.deltaTime, 0.0f);
if (TargetList.All(t => t.IsDead))
{
CancelCast();
}
else if (CastReady)
{
Do();
}
}
// Not being casted, tick cooldown
else
{
CooldownRemaining = Mathf.Max(CooldownRemaining - Time.deltaTime, 0.0f);
}
}
/// <summary>
/// Default behavior for single target cast, adds target to Targets.
/// Override for chain/splash/aoe abilities.
/// </summary>
/// <param name="target"></param>
public virtual void StartCast(Entity target = null)
{
if (target != null)
{
TargetList.Add(target);
}
if (IsCastedAbility)
{
CastRemaining = CastTime;
}
else
{
Do();
}
}
/// <summary>
/// Clears Targets, doesn't trigger cooldown. Call base.CancelCast() last in overridden function.
/// </summary>
public virtual void CancelCast()
{
TargetList.Clear();
CastRemaining = 0f;
}
/// <summary>
/// Override to make ability do action. base.Do() should be called at end of overridden function.
/// Default behavior logs action for each Target, clears Targets, and triggers its cooldown
/// </summary>
/// <param name="target"></param>
protected virtual void Do()
{
if(TargetList.Count > 0)
{
foreach (var entity in TargetList)
{
Owner.Mgr.LogAction(Owner, entity, this);
}
TargetList.Clear();
}
CooldownRemaining = Cooldown;
Owner.FinishCast();
}
/// <summary>
/// Adds heal predict to each target.
/// Call when starting to cast a heal.
/// </summary>
public void AddHealPredict()
{
foreach(var target in TargetList)
{
target.HealPredict += TotalPower;
}
}
/// <summary>
/// Removes heal predict from each target.
/// Call when finishing or canceling a heal cast.
/// </summary>
public void RemoveHealPredict()
{
foreach (var target in TargetList)
{
target.HealPredict -= TotalPower;
}
}
}
Base Healing Ability class:
public class HealAbility : Ability
{
public HealAbility(Entity owner = null)
: base(owner)
{
}
public override void StartCast(Entity target = null)
{
base.StartCast(target);
if(IsCastedAbility)
{
AddHealPredict();
}
}
public override void CancelCast()
{
if(IsCastedAbility)
{
RemoveHealPredict();
}
base.CancelCast();
}
protected override void Do()
{
foreach (var raider in TargetList)
{
raider.TakeHeal(TotalPower);
}
if(IsCastedAbility)
{
RemoveHealPredict();
}
base.Do();
}
}
A standard single-target healing ability:
public class Restore : HealAbility
{
public Restore(Entity owner = null)
: base(owner)
{
Name = "Restore";
CastAdd = 1.0f;
PowerCoefficient = 1.25f;
ImagePath = "Image/Cleric/restore";
}
}
An chain-heal type ability:
public class Prayer : HealAbility
{
int TargetCount;
public Prayer(Entity owner = null)
: base(owner)
{
Name = "Prayer";
CastAdd = 0.5f;
PowerCoefficient = 0.33f;
Cooldown = 0f;
ImagePath = "Image/Cleric/prayer";
TargetCount = 3;
}
public override void StartCast(Entity target)
{
TargetList = Owner.Group.GetSmartChain(target, TargetCount);
base.StartCast(null);
}
}