Reputation: 728
I recently came across a great article covering the benefits of object composition VS traditional inheritance.
Hopefully my question is not going to be flagged as opinionated but I'd like to know a good approach to using composition for when an object changes based on a user's game interaction.
Using the articles code as an example:
const canCast = (state) => ({
cast: (spell) => {
console.log(`${state.name} casts ${spell}!`);
state.mana--;
}
})
const canFight = (state) => ({
fight: () => {
console.log(`${state.name} slashes at the foe!`);
state.stamina--;
}
})
const fighter = (name) => {
let state = {
name,
health: 100,
stamina: 100
}
return Object.assign(state, canFight(state));
}
const mage = (name) => {
let state = {
name,
health: 100,
mana: 100
}
return Object.assign(state, canCast(state));
}
scorcher = mage('Scorcher')
scorcher.cast('fireball'); // Scorcher casts fireball!
console.log(scorcher.mana) // 99
slasher = fighter('Slasher')
slasher.fight(); // Slasher slashes at the foe!
console.log(slasher.stamina) // 99
How do I use composition to change the state of the Character object during run-time? Instead of the Mage object already existing I want the Character object to change based on a game event eg. Character picks up a staff and now becomes a "Mage" who can now Cast spells. First thing that comes to mind is to have a state property in Character that changes based on the interaction and the Character somehow "inherits" the ability to now Cast and gains a mana state property.
Upvotes: 3
Views: 206
Reputation: 2583
The decorator pattern solves situations exactly like this.
class Character {
constructor(name) {
this.name = name;
this.health = 100;
this.items = [];
}
}
const fighterDecorator = character => {
return Object.setPrototypeOf({
character,
stamina: 100,
fight() {
console.log(`${this.name} slashes at the foe!`);
this.stamina--;
}
}, character);
}
const mageDecorator = character => {
return Object.setPrototypeOf({
character,
mana: 100,
cast(spell) {
console.log(`${this.name} casts ${spell}!`);
this.mana--;
}
}, character);
}
let character = new Character("Bob");
// Can't fight; can't cast
// character.fight(); // TypeError: not a function
// character.cast(); // TypeError: not a function
// Character becomes a fighter at runtime
// Equiping an item and decorating new behavior are separate statements
character.items.push("sword");
character = fighterDecorator(character);
character.fight(); // Bob slashes at the foe!
console.log(character.stamina) // 99
console.log(character.items) // ["sword"]
// Character becomes normal unit again
// Remove decoration and remove item
character = character.character;
character.items = character.items.filter(item => item !== "sword");
// Once again, can't fight, can't cast
// character.fight(); // TypeError: not a function
// character.cast(); // TypeError: not a function
// Character becomes a mage at runtime
// Equiping an item and decorating new behavior are separate statements
character.items.push("staff");
character = mageDecorator(character);
character.cast("fireball"); // Bob casts fireball!
console.log(character.mana) // 99
console.log(character.items) // ["staff"]
Upvotes: 1
Reputation: 74244
For this particular problem it's better to use duck typing instead of object composition. Duck typing makes use of the duck test to ensure type safety:
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
For this problem, we'll make use of an analogous "mage test" and "fighter test" respectively:
Note that we can still use object composition to keep our code modular. We'll create prototypes for character
, fighter
and mage
and then compose them together to get the final prototype:
const character = {
health: 100,
right: null,
left: null,
equip(item) {
const {name, right, left} = this;
if (right === null) this.right = item;
else if (left === null) this.left = item;
else console.error(`${name} is already holding ${right} and ${left}.`);
}
};
First, we have the prototype for characters. Every character has at least four properties: name
, health
, right
(i.e. the item equipped in the right hand) and left
(i.e. the item equipped in the left hand). We provide default values for health
, right
and left
. However, we don't provide any default value for name
. Hence, when we create a new character we must provide it a name.
const fighter = {
stamina: 100,
fight(foe) {
const {name, stamina, right, left} = this;
if (right !== "a sword" && left !== "a sword")
console.error(`${name} is not holding a sword.`);
else if (stamina === 0) console.error(`${name} has no stamina.`);
else { this.stamina--; console.log(`${name} slashes at ${foe}.`); }
}
};
Then, we have the prototype for fighters. Note that since a fighter is also a character, we can use the name
, right
and left
properties in the fight
method. In addition, fighters have a stamina
property which has a default value of 100.
const mage = {
mana: 100,
cast(spell) {
const {name, mana, right, left} = this;
if (right !== "a staff" && left !== "a staff")
console.error(`${name} is not holding a staff.`);
else if (mana === 0) console.error(`${name} has no mana.`);
else { this.mana--; console.log(`${name} casts ${spell}.`); }
}
};
Next, we have the prototype for mages. Like fighters, mages are also characters and hence they too can make use of character-specific properties. In addition, mages have a mana
property with a default value of 100.
Object.assign(character, fighter, mage);
Object.prototype.create = function (properties) {
return Object.assign(Object.create(this), properties);
};
const gandalf = character.create({ name: "Gandalf" });
gandalf.equip("a sword");
gandalf.equip("a staff");
gandalf.fight("the goblin");
gandalf.cast("a blinding light");
Finally, we use Object.assign
to compose all the prototypes together by extending the character
prototype with the fighter
and mage
prototypes. We also extend Object.prototype
with a useful create
function to easily create instances of prototypes. We use this method to create an instance of character
named Gandalf and we make him fight a goblin.
const mage = {
mana: 100,
cast(spell) {
const {name, mana, right, left} = this;
if (right !== "a staff" && left !== "a staff")
console.error(`${name} is not holding a staff.`);
else if (mana === 0) console.error(`${name} has no mana.`);
else { this.mana--; console.log(`${name} casts ${spell}.`); }
}
};
const fighter = {
stamina: 100,
fight(foe) {
const {name, stamina, right, left} = this;
if (right !== "a sword" && left !== "a sword")
console.error(`${name} is not holding a sword.`);
else if (stamina === 0) console.error(`${name} has no stamina.`);
else { this.stamina--; console.log(`${name} slashes at ${foe}.`); }
}
};
const character = {
health: 100,
right: null,
left: null,
equip(item) {
const {name, right, left} = this;
if (right === null) this.right = item;
else if (left === null) this.left = item;
else console.error(`${name} is already holding ${right} and ${left}.`);
}
};
Object.assign(character, fighter, mage);
Object.prototype.create = function (properties) {
return Object.assign(Object.create(this), properties);
};
const gandalf = character.create({ name: "Gandalf" });
gandalf.equip("a sword");
gandalf.equip("a staff");
gandalf.fight("the goblin");
gandalf.cast("a blinding light");
Above is the demo of the entire script put together, demonstrating how it works. As you can see, you can break up your character prototype into several different prototypes such as mage
and fighter
and then put them all back together using Object.assign
. This makes adding new character types much easier and much more manageable. Duck typing is used to ensure that a fighter (a character equipped with a sword) can't cast a spell, etc. Hope that helps.
Upvotes: 0
Reputation: 2862
If I understood your question properly, its better to have one functional object composition which has multiple methods, like below . Here you can create individual objects which can have either have one or all the functionalities defined in the below main object . Basically, can
here is function which provides set of operation methods and can be used while creating your objects at run time. well I have give some example below . hopefully this helps
note even this is opinionated
const can = (state) => {
return {
canFight : (spell) => {
console.log(`${state.name} slashes at the foe!`);
state.stamina--;
},
cast: (spell) => {
console.log(`${state.name} casts ${spell}!`);
state.mana--;
}
}
}
Usages
const fighter = (name) => {
let state = {
name,
health: 100,
stamina: 100
}
return Object.assign(state, can(state));
}
const mage = (name) => {
let state = {
name,
health: 100,
mana: 100
}
return Object.assign(state, can(state));
}
const soldier = (name) => {
let state = {
name,
health: 100,
stamina: 100
}
return Object.assign(state, {fight: can(state).canFight(name)});
}
Upvotes: 1