Wancieho
Wancieho

Reputation: 728

Functional "real-time" composition

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

Answers (3)

Jeff M
Jeff M

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

Aadit M Shah
Aadit M Shah

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:

  1. If it has mana and is holding a staff then it is a mage.
  2. If it has stamina and is holding a sword then it is a fighter.

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

Shushanth Pallegar
Shushanth Pallegar

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

Related Questions