This questions is really just out of curiosity, since I know a simple method for creating cards that works for me.
Summary
What I want to know is: Is there a robust card system in which:
- Cards have
effects
, which are any arbitrary behavior that can be coded. - I can define new
effects
(e.g. “dealX
damage”, “drawY
cards”, “blockZ
damage”, any unique behavior). - I can compose new
effects
out other definedeffects
. For example,- I can combine the
effects
“deal damage” and “draw cards” to create a “deal damage and draw cards”effect
- I can also define composed effects with other variables (e.g. an “echo”
effect
, composed of aneffect
and an integern
, will repeat thesub-effect
n
times)
- I can combine the
- I can compose
effects
entirely within the Unity Inspector.- Defining new
effects
will inevitably happen in the code, but I want the ability to create generic composedeffects
that can serialize theirsub-effects
andsub-effect
variables entirely within the Unity Inspector. - That is, if I’ve already defined an “echo”
effect
and “dealX
damage”effect
, I can create a new Card with an “echo”effect
composed of a “dealX
damage”effect
whereX = 8
, without ever having to look at the code or create extra assets.
- Defining new
- I can compose
effects
dynamically during a game. The system supports the creation of new Cards with composedeffects
that the player is free to build out the predefined Cards I have created. - The implementation has minimal redundancy.
- no passing data values that a particular Card or
effect
won’t use. - every unique Card is contained within a single asset. I don’t need to ever create a sub-component asset and then attach it to a card; I want all Unity Inspector action to occur when I create the new Card asset.
- no passing data values that a particular Card or
I haven’t been able to think up a system that has no redundancies at all, and so I’ve come looking for a programmer god to bestow knowledge upon me. If there are any CS topics to look into, data structures, design patterns, etc. I’m willing to do more research.
Details
I’m currently trying to develop a card game, so I’ve been looking at some implementations of other projects. I’ve noticed the following patterns:
- Scriptable Objects for card data
- Extending a base class with a new class for each different card
These card systems work for smaller card libraries, but I’m curious about very handling an extremely large number of cards. For example, see the following card design, which roughly follows the patterns I saw:
public class Card : ScriptableObject
{
public string Description;
public string CardName;
public Sprite Art;
public virtual void ApplyEffect(){}
}
public class AttackCard : Card
{
[SerializeField] private float _damage;
public override void ApplyEffect(){ /*deal damage*/ }
}
public class DrawCard : Card
{
[SerializeField] private int _numCards;
public override void ApplyEffect(){ /*draw cards*/ }
}
I want to create a card that can both draw and attack, while minimizing redundancies and allowing me to customize the card behavior in the Unity Editor. In the systems I found online, there is no real way to do this besides creating a class that is composed of AttackCard
and DrawCard
(or some form of inheritance + composition, ew). However, I don’t really know a system design that would allow this.
A couple of solutions I’ve looked into:
Drag out the ApplyEffect()
function into its own class
Drag out the function ApplyEffect()
into its own base class CardEffect
. Class variables damage
and numCards
are moved into child classes of CardEffect
. Now, there is only a Card
class that contains a CardEffect
, from where we can call CardEffect.ApplyEffect()
.
public class Card : ScriptableObject
{
public string Description;
public string CardName;
public Sprite Art;
public CardEffect Effect;
}
public class CardEffect : ScriptableObject
{
public virtual void ApplyEffect(){}
}
public class AttackEffect : CardEffect
{
[SerializeField] private float _baseDamage;
public override void ApplyEffect(){/*do damage*/}
}
public class DrawEffect : CardEffect
{
[SerializeField] private int _numCards;
public override void ApplyEffect(){/*draw cards*/}
}
Now, I can define a StackedEffect
that has a list of CardEffects
that can be called:
public class StackedEffect : CardEffect
{
public List<CardEffect> Effects;
public override void ApplyEffect()
{
foreach(CardEffect e in Effects){
e.ApplyEffect();
}
}
}
This solution works (and yes I could just define the CardEffect
list in Card
, but I wanted a recursive design where Card
only ever sees “one effect”), but here are the pros and cons:
Pros
- In the original design, I would have to create a subclass
StackedCard
which has aCard
list. But each card hasCardName
,Description
, etc., which doesn’t make sense for sub-behaviors of a combined card. This splits up the functions from the cards, so now you’re only passing what you need to theStackedEffect
.
Cons
- Not Unity Editor Friendly. Since
Card
doesn’t know which subclass is exactly attached to it, you can’t fill in variables like_numCards
and_baseDamage
. - Every time you want to add a
CardEffect
with a different internal value, you have to create a newScriptableObject
asset. For example, If I create a “Rock” that does 3 damage and a “Fireball” that does 6 damage, I would have to create twoAttackEffect
assets with different values, which is pretty redundant (I might as well just create unique scripts for eachCard
, and stick to inheritance though theCard
class). The pain point is thatCardEffect
extendsScriptableObject
, meaning you need to create separate instances whenever you want to change the variables of aCardEffect
.
So CardEffect
as a ScriptableObject
doesn’t work great (author note: don’t listen to ChatGPT ). My second solution was
Create an interface that implements ApplyEffect()
Create an ICardEffect
interface that implements ApplyEffect()
, then classes AttackEffect
and DrawEffect
can implement ICardEffect
.
This implementation is nearly identical to the previous:
public interface ICardEffect
{
public void ApplyEffect();
}
public class Card : ScriptableObject
{
public string Description;
public string CardName;
public Sprite Art;
public ICardEffect Effect;
}
public class AttackEffect : ICardEffect
{
[SerializeField] private float _baseDamage;
public void ApplyEffect(){/*do damage*/}
}
public class DrawEffect : ICardEffect
{
[SerializeField] private int _numCards;
public void ApplyEffect(){/*draw cards*/}
}
public class StackedEffect : ICardEffect
{
public List<ICardEffect> Effects;
public override void ApplyEffect()
{
foreach(ICardEffect e in Effects){
e.ApplyEffect();
}
}
}
Pros
- fixes the problem of the previous solution where you have to create a new asset every time you wanted to change the values
Cons
- Still not Unity Editor friendly.
Card
doesn’t know the actual type of itsEffect
, so you can’t serialize the values in the editor, which is pretty inconvenient. I know that there are cusom Editor scripts that can help with this, but from what I’ve researched, they don’t play too nicely with interfaces and abstract classes.
I can circumvent this by adding an enum
, which is my third solution:
Use an enum
and a place every parameter in a struct
to pass to CardEffect
CardEffect
is a class once again (this could probably work as an interface too, since we’re not directly trying to call the construction of CardEffect
with this solution):
public class CardEffect
{
public virtual void ApplyEffect(){}
}
enum
fixes the issue of CardEffect
and ICardEffect
not showing up in the Editor, since we can serialize the enum
EffectType
to determine which CardEffect
subclass the Card
object will have:
public enum EffectType{
Attack,
Draw,
Stacked
}
[Serializable]
public struct Parameters{
public int NumCards;
public float BaseDamage;
public List<EffectAndParameters> EffectsAndParameters; // just a little bit confusing
}
[Serializable]
public struct EffectAndParameters{
public EffectType EffectType;
public Parameters Parameters;
}
The Parameters
struct
stores the parameters for every subclass. It can be serialized in the Unity Editor.
Now, we can construct the subclass of CardEffect
based on the enum
. By creating an EffectAndParameters
struct
that packages Parameters
and EffectType
, we can define a CardEffect
through a static function:
public static CardEffect GetCardEffect(EffectAndParameters effectAndParameters){
EffectType type = effectAndParameters.EffectType;
Parameters parameters = effectAndParameters.Parameters;
switch(type){
case EffectType.Attack:
return new AttackEffect(parameters.BaseDamage);
case EffectType.Draw:
return new DrawEffect(parameters.NumCards);
case EffectType.Stacked:
return new StackedEffect(GetCardEffects(parameters.EffectsAndParameters));
}
return null;
}
Then we call GetCardEffect()
in OnEnable()
in Card
:
public class Card : ScriptableObject
{
[field:SerializeField] public string Description {get; private set;}
[field:SerializeField] public string CardName {get; private set;}
[field:SerializeField] public Sprite Art {get; private set;}
private CardEffect _cardEffect;
[field:SerializeField] public EffectAndParameters EffectAndParams {get; private set;}
void OnEnable(){
_cardEffect = CardEffect.GetCardEffect(EffectAndParams);
}
}
Subclasses and take the variables they need from Parameters
in their constructor. GetCardEffect()
handles filtering out the parameters they need:
public class AttackEffect : CardEffect
{
[SerializeField] private float _baseDamage;
public AttackEffect(float baseDamage)
{
_baseDamage = baseDamage;
}
public override void ApplyEffect(){/*do damage*/}
}
public class DrawEffect : CardEffect
{
[SerializeField] private int _numCards;
public DrawEffect(int numCards)
{
_numCards = numCards;
}
public override void ApplyEffect(){/*draw cards*/}
}
StackedEffect takes in a List<CardEffect>
. This can be retrieved from Parameters.EffectsAndParameters
(this is why we packaged the two values together: for iteration) using another static function:
public class StackedEffect : CardEffect
{
[SerializeField] private List<CardEffect> _effects;
public StackedEffect(List<CardEffect> effects)
{
_effects = effects;
}
public override void ApplyEffect()
{
foreach(CardEffect e in _effects){
e.ApplyEffect();
}
}
}
public static List<CardEffect> GetCardEffects(List<EffectAndParameters> effectsAndParameters){
List<CardEffect> effects = new List<CardEffect>();
foreach(EffectAndParameters effParams in effectsAndParameters){
effects.Append(GetCardEffect(effParams));
}
return effects;
}
Pros
- this fixes the issue of serialization within the Unity Editor since it works with
structs
. This also still has the same benefit as the Interface method, in that only oneScriptableObject
is needed per card.
Cons - A lot of unused data is passed into every
Card
. Seeing in the example above, every timeGetCardEffect()
is called it will receive a wholeParameters
struct
. It will always containParameters.baseDamage
value, even if the intendedCardEffect
doesn’t deal damage. - Every time I define a new
CardEffect
that needs a new value I have to:- create a new
enum
constant that corresponds to the new class - add the new parameters to the
Parameters
struct - change the
GetCardEffect()
function to include the newenum
constant
- create a new
The GetCardEffect()
code is now coupled together with each of the subclasses, and as the project grows, managing the enums
can get out of hand.
So again my question is, is there a better solution that eliminates the redundancies of all options and creates a solution that works with the Unity Inspector GUI?