This is going to be hard to explain without an example, so I'll use the one from here.
const canCast = (state) => ({
cast: (spell) => {
console.log(`${state.name} casts ${spell}!`);
state.mana--;
}
})
const mage = (name) => {
let state = {
name,
health: 100,
mana: 100
}
return Object.assign(state, canCast(state));
}
Simply put, we have a 'mage' object and a 'cast' behavior.
Now say that we want to have a new type of mage that drains opponent's health on a spell cast. It seems easy enough at first; just create a new behavior:
const canCastDrain = (state) => ({
cast: (spell) => {
console.log(`${state.name} casts ${spell}!`);
state.mana--;
target.health--;
state.health++;
}
})
However, this forces us to duplicate the original cast code. You can imagine that for more complicated behaviors this would be a huge problem. How can this be avoided?
If we used classical inheritance, the drain spell cast could extend the base cast and then call the parent method. But then we are locked into the problems of inheritance. If we add new spell casts, it would be difficult to mix and match them.
This answer is given in sort-of JS pseudocode, because I'm not confident in my object-oriented JS (I typically use TS).
Your mage probably has a base class of Character, or something. Everyone has a name and health, after all. I have omitted that, as it isn't really relevant to the answer. The question is about how your spells are structured.
I feel quite confidently that the Command pattern is what you need.
Mage has a few properties, and two methods for casting. The first determines if the mage can cast that spell. You can have spells have a category (or spell school), or however you want to check permissions.
The methods for pre-cast and post-cast, while not explicitly part of your question, will likely come up. Maybe the spell needs to check if the target is valid before calling its casting method.
class Mage {
mana: number;
health: number;
name: string;
canCast(spell) {
// check if the mage knows the spell, or knows the school of magic, or whatever.
// can also check that the mage has the mana, though since this is common to every cast and doesn't vary, that can be moved into the actual cast method.
// return true/false
// this method can vary as needed
}
// should be the same for all mages.
// we call the spells pre-cast hooks before casting, for composite spells this ensures each sub-spell pre-hook is called before any of the spells
// are cast. This hook can be used to verify the spell *can* be cast (e.g. you have enough health)
cast(spell, target) {
if (spell.getCost() > mana) {
// not enough mana.
// this isn't part of canCast, because this applies to every mage, and canCast can vary.
// return or throw an error
}
console.log("Casting....");
if (!spell.preCast(this, target)) {
// maybe the target isn't valid for this spell?
// we do this before *any* spells are cast, so if one of them is not valid,
// there's nothing to "roll back" or "undo".
// either throw an error or return. either way, don't continue casting.
}
spell.cast(this, target);
spell.postCast(this, target);
this.deductMana(spell.getCost());
console.log("Done casting. Did we win?");
}
}
The base spell, empty of functionality but full of that thing called 'love':
class Spell {
getName(): string;
getCost(): number;
preCast(target, caster, etc.) {}
cast(target, caster, etc.) {}
postCast(target, caster, etc.) {}
}
Your composite spells. One class should let you do any number of combinations, unless you need something very specialized. For example, combining two fire spells might amplify the damage while reducing the total mana cost. That would necessitate a special composite spell, SynergizingCompositeSpell
maybe?
class CompositeSpell : Spell {
spells: Spell[];
getName {
// return the subspell names
}
getCost (
// return sum of subspell costs.
}
preCast(target, caster, etc.) {
// call each subspell's preCast
// if any return false, return false. otherwise, return true.
}
cast(target, caster, etc.) {
// call each spell's cast
}
postCast(target, caster, etc.) {
// call each spells post-cast
}
constructor(spell, spell, spell, etc). // stores the spells into the property
}
An example spell:
class Drain : Spell {
getName() { return "Drain!"; }
getCost() { return 3; } // costs 3 mana
cast(target, caster, etc.) {
target.harm(1); // let the target deduct its hp
console.log(`The ${target.name} was drained for 3 hp and looks hoppin' mad.`)
}
}
The way this looks in use, casting a spell that drains health and makes my teeth shiny and chrome
var mage = ... // a mage
var target = ... // you, obviously
var spellToCast = new CompositeSpell(new Drain(), new ShinyAndChrome());
mage.cast(spellToCast, target);
The CompositeSpell
constructor can check that the spells it is given are "compatible", whatever that might mean in your game. Spells could also have a canBeCastWith(spell)
method to verify compatibility. Maybe combining Drain
and Heal
together makes no sense and shouldn't be allowed? Or one spell accepts a target, but the other does not?
It's worth noting that the preCast
/ cast
/ postCast
methods should take the same arguments, even if they're not always needed. You're using a one-size-fits-all sort of pattern, so you need to include everything any spell might need. I imagine that list is limited to:
One thing I'd like to point out is that instead of directly using addition/subtraction with your health or mana (e.g. state.mana--
), use a function call instead (e.g. state.useMana(1)
. This keeps your options open with future development.
What if, for example, your mage has an ability that triggers when his/her health is reduced? The spell doesn't know it should trigger anything. That's up to the character. This also lets you override the method, something you can't do with simple addition/subtraction.
I hope this answer helps.