Dusan
Dusan

Reputation: 105

How to use strategy design pattern if a certain subclass cannot use some of the strategies?

My task was to implement a game. A part of my task was to implement the behavior of a monster. In the task it says a monster has different ways of attacking and each attack has a certain damage rate. Also I have to randomly generate an attack for each monster.

A Dragon can hit (damage: 5) and breath fire (damage: 20).
A Spider can hit (damage: 5) and bite (damage: 8).

My idea was to create a abstract Monster class and have class Spider and Dragon extend this class.
Then I would create an interface called Attack with a method attack and create subclasses Hit, Fire and Bite which implement this interface as strategies. I also created two generator methods one for the Dragon and one for the Spider but I think this is not good maybe someone knows a better way.

abstract class Monster {
  private health = 200;
  attack: Attack;

  constructor(attack: Attack) {
    this.attack = attack;
  }

  getAttackDamage() {
    return this.attack.attack();
  }

  getHealth() {
    return this.health;
  }

  damage(damage: number) {
    let isDefeated = false;
    this.health -= damage;

    if (this.health <= 0) {
      isDefeated = true;
    }

    return isDefeated;
  }
}

class Dragon extends Monster {
  constructor() {
    super(attackDragonGenerator());
  }

  setAttack() {
    this.attack = attackDragonGenerator();
  }
}

class Spider extends Monster {
  constructor() {
    super(attackSpiderGenerator());
  }

  setAttack() {
    this.attack = attackSpiderGenerator();
  }
}

interface Attack {
  attack: () => number;
  damage: number;
}

class Hit implements Attack {
  damage;

  constructor(damage: number) {
    this.damage = damage;
  }

  attack() {
    return this.damage;
  }
}

class Bite implements Attack {
  damage;

  constructor(damage: number) {
    this.damage = damage;
  }

  attack() {
    return this.damage;
  }
}

class Fire implements Attack {
  damage;

  constructor(damage: number) {
    this.damage = damage;
  }

  attack() {
    return this.damage;
  }
}

const attacksSpider: Attack[] = [new Hit(5), new Bite(8)];
const attacksDragon: Attack[] = [new Hit(5), new Fire(20)];

const attackSpiderGenerator = () => {
  const index = randomIntFromInterval(0, 1);
  return attacksSpider[index];
};

const attackDragonGenerator = () => {
  const index = randomIntFromInterval(0, 1);
  return attacksDragon[index];
};

Upvotes: 0

Views: 195

Answers (1)

Linda Paiste
Linda Paiste

Reputation: 42188

Interactions Between Monsters

Obviously what you have works, but there is room for improvement. There are lots of ways for you to put the pieces together. Right now your Monster can attack which returns a number and can receive damage which takes a number. I would recommend that one of these methods should interact with other objects instead of numbers.

Right now I am going to define an AttackStrategy as a method attack() which returns an object with the damage number as a property rather than just returning the number. I will explain the reasoning in the "Combining Attacks" section.

interface AttackData {
    damage: number;
    name: string;
}

interface AttackStrategy {
    attack(): AttackData;
}

In this version, one Monster calls the takeDamage() method of another Monster with the number from its doAttack() method.

interface CanAttack {
    attack(target: CanTakeDamage): void;
}

interface CanTakeDamage {
    takeDamage(damage: number): void;
}

class Monster implements CanTakeDamage, CanAttack {
    constructor(
        public readonly name: string,
        private strategy: AttackStrategy,
        private _health = 200
    ) { }

    attack(target: CanTakeDamage): void {
        const attack = this.strategy.attack();
        target.takeDamage(attack.damage);
        console.log( `${this.name} used ${attack.name}` );
    }

    takeDamage(damage: number): void {
        // don't allow negative health
        this._health = Math.max(this._health - damage, 0);
    }

    get health(): number {
        return this._health;
    }

    get isDefeated(): boolean {
        return this._health === 0;
    }
}

The inverted approach would be for the takeDamage() method to receive the attacking Monster as an argument and for doAttack() to return the damage number.

I think this doesn't make as much sense in this scenario. But hopefully it illustrates a point. There aren't necessarily "wrong" ways to piece it together, but there are some ways that feel more logical and natural. So go with those! I don't like this one because the target is responsible for calling the attacker's doAttack() method and that feels backwards.

class Monster implements CanTakeDamage, CanAttack {
    constructor(
        public readonly name: string,
        private strategy: AttackStrategy,
        private _health = 200
    ) { }

    attack(): AttackData {
        const attack = this.strategy.attack();
        console.log(`${this.name} used ${attack.name}`);
        return attack;
    }

    takeDamage(attacker: CanAttack): void {
        const { damage } = attacker.attack();
        // don't allow negative health
        this._health = Math.max(this._health - damage, 0);
    }

    get health(): number {
        return this._health;
    }

    get isDefeated(): boolean {
        return this._health === 0;
    }
}

Creating Attacks

I would remove the property damage from interface Attack and consider that to be an implementation detail. This will be important if we want a Monster that has multiple attack types with different damage values. Now Attack just has a single method attack() that executes an attack and returns the amount of damage.

Hit, Bite, and Fire are all identical right now. They either need more differentiation or they need to be instances of a general Attack class. We can still support some amount of differentiation with instances of a general Attack by passing different arguments to the constructor like a message: string, name: string, etc.

Separate Classes

interface Attack {
    attack(): number;
}

class BaseAttack implements Attack {
    constructor(public readonly damage: number) { }

    protected message(): string {
        return "You've been attacked!";
    }

    attack(): number {
        console.log(this.message());
        return this.damage;
    }
}

class Hit extends BaseAttack {
    protected message(): string {
        return `POW! Strength ${this.damage} punch incoming!`;
    }
}

class Bite extends BaseAttack {
    protected message(): string {
        return "Chomp!";
    }
}

class Fire extends BaseAttack {
    protected message(): string {
        return "Burn baby, burn!";
    }
}

Single Class

class Attack implements AttackStrategy {
    constructor(private damage: number, private name: string) { }

    attack(): AttackData {
        return {
            name: this.name,
            damage: this.damage
        }
    }
}

const attack1 = new Attack(10, "Chomp!");
const attack2 = new Attack(5, "Slap");

The single class is more flexible because we can create infinite attacks.


Combining Attacks

Your attackSpiderGenerator and attackDragonGenerator don't allow a single monster instance to switch between attacks. The constructor randomly chooses one and that is the attack for this instance.

We want to create a helper that combines attacks while still conforming to the same interface as a single attack.

If we want to know the name of the attack that was called, then our method attack(): number isn't sufficient as the name varies for the combined attack. So let's change up the interfaces a bit. We define an AttackData that contains the name and damage properties. An AttackStrategy has a function attack() that returns an AttackData.

interface AttackData {
    damage: number;
    name: string;
}

interface AttackStrategy {
    attack(): AttackData;
}

I'm having the AttackSwitcher constructor take a variable amount of arguments where each argument is either an AttackStrategy or a tuple of an AttackStrategy and a weight for the frequency. The default weight for each attack is 1. We will normalize the values in order to pick a random attack with the correct probability.

type AttackArg = AttackStrategy | [AttackStrategy, number];

class AttackSwitcher implements AttackStrategy {
    private attacks: [AttackStrategy, number][];

    // must have at least one arg
    constructor(...args: [AttackArg, ...AttackArg[]]) {
        // default weight is 1 per attack if not assigned
        const tuples = args.map<[AttackStrategy, number]>((arg) =>
            Array.isArray(arg) ? arg : [arg, 1]
        );
        // normalize so that the sum of all weights is 1
        const sum = tuples.reduce((total, [_, weight]) => total + weight, 0);
        this.attacks = tuples.map(([attack, weight]) => [attack, weight / sum]);
    }

    private getRandomAttack(): AttackStrategy {
        // compare a random number to the rolling sum of weights
        const num = Math.random();
        let sum = 0;
        for (let i = 0; i < this.attacks.length; i++) {
            const [attack, weight] = this.attacks[i];
            sum += weight;
            if (sum >= num) {
                return attack;
            }
        }
        // should not be here except due to rounding errors
        console.warn("check your math");
        return this.attacks[0][0];
    }

    attack(): AttackData {
        return this.getRandomAttack().attack();
    }
}

Creating Monsters

Do all spiders have the same weights for the same attacks with the same damage values? Those sorts of choices are up to you.

This Spider and Dragon don't really need to be a class at all because all we are really doing is constructing a particular instance of Monster with specific args.

class Spider extends Monster {
    constructor(name: string = "Spider") {
        super(
            name,
            new AttackSwitcher(
                // 5:1 ratio of Bite to Hit
                new Attack(5, "8-Legged Slap"),
                [new Attack(8, "Spider Bite"), 5]
            ),
            // 100 base health
            100
        );
    }
}

class Dragon extends Monster {
    constructor(name: string = "Dragon") {
        super(
            name,
            new AttackSwitcher(
                // equal incidence of both attacks
                new Attack(5, "Tail Whip"),
                new Attack(20, "Fire  Breath")
            )
        );
    }
}

Monster Teams

Our monsters aren't evenly matched, so I had to give the Spider way more attack chances to get any variance in the battle outcomes. We call either spider.attack(dragon) or dragon.attack(spider).

function testAttacks() {
    const spider = new Spider();
    const dragon = new Dragon();

    let i = 0;
    while (! spider.isDefeated && ! dragon.isDefeated ) {
        if ( i % 5 ) {
            spider.attack(dragon);
            console.log(`dragon health: ${dragon.health}`);
        } else {
            dragon.attack(spider);
            console.log(`spider health: ${spider.health}`);
        }
        i++;
    }
    console.log( spider.isDefeated ? "DRAGON WINS!" : "SPIDER WINS!" );
}

How about instead we allow a team of spiders to go up against a single dragon? Using the same approach as with combining attacks, we define an interface CanBattle that is shared by both a single Monster and a BattleTeam of monsters.

interface CanBattle extends CanAttack, CanTakeDamage {
    health: number;
    isDefeated: boolean;
    name: string;
}
class BattleTeam implements CanBattle {
    private monsters: CanBattle[];
    private currentIndex: number;

    // must have at least one monster
    constructor(
        public readonly name: string,
        ...monsters: [CanBattle, ...CanBattle[]]
    ) {
        this.monsters = monsters;
        this.currentIndex = 0;
    }

    // total health for all monsters
    get health(): number {
        return this.monsters.reduce(
            (total, monster) => total + monster.health
            , 0);
    }

    // true if all monsters are defeated
    get isDefeated(): boolean {
        return this.health === 0;
    }

    // the current attacker/defender
    get current(): CanBattle {
        return this.monsters[this.currentIndex];
    }

    // damage applies to the current monster only
    takeDamage(damage: number): void {
        this.current.takeDamage(damage);
        // maybe move on to the next monster
        if (this.current.isDefeated) {
            console.log(`${this.current.name} knocked out`);
            if (this.currentIndex + 1 < this.monsters.length) {
                this.currentIndex++;
                console.log(`${this.current.name} up next`);
            }
        }
    }

    // current monster does the attack
    attack(target: CanTakeDamage): void {
        this.current.attack(target);
    }
}

Battling

A battle requires two CanBattle objects which take turns attacking each other until one is defeated. This series of attacks is an iteration, so we can use iterator protocol.

A battle can be seen as an iterator where an attack occurs in each iteration from alternating sides

class Battle implements Iterator<CanBattle, CanBattle>, Iterable<CanBattle> {

    private leftIsAttacker: boolean = true;
    private _winner: CanBattle | undefined;

    constructor(public readonly left: CanBattle, public readonly right: CanBattle) { }

    // returns the target of the current attack as `value`
    public next(): IteratorResult<CanBattle, CanBattle> {
        const attacker = this.leftIsAttacker ? this.left : this.right;
        const target = this.leftIsAttacker ? this.right : this.left;
        if (!this.isCompleted) {
            attacker.attack(target);
            this.leftIsAttacker = !this.leftIsAttacker;
        }
        if (target.isDefeated) {
            this._winner = attacker;
        }
        return {
            done: this.isCompleted,
            value: target,
        }
    }

    [Symbol.iterator]() {
        return this;
    }

    get winner(): CanBattle | undefined {
        return this._winner;
    }

    get isCompleted(): boolean {
        return this._winner !== undefined;
    }
}
function testBattle() {
    const dragon = new Dragon("Dragon");
    const spiderTeam = new BattleTeam(
        "Spider Team",
        // @ts-ignore warning about needed at least 1
        ...[1, 2, 3, 4].map(n => new Spider(`Spider ${n}`))
    )

    const battle = new Battle(dragon, spiderTeam);
    for (let target of battle) {
        console.log(`${target.name} health: ${target.health}`);
    }
    console.log(spiderTeam.isDefeated ? "DRAGON WINS!" : "SPIDER WINS!");
}

Complete Code

Typescript Playground - click "Run" to see who wins!

Upvotes: 1

Related Questions