Composition against inheritance, the team pattern and game development in general


    Disclaimer: In my opinion, an article on software architecture should not and cannot be perfect. Any solution described may cover the level required by one programmer, and the other programmer will complicate the architecture too unnecessarily. But she must give a solution to the tasks that she set for herself. And this experience, along with the rest of the knowledge of a programmer who is studying, systematizes information, hones new skills, and criticizes himself and others - this experience turns into excellent software products. The article will switch between art and technical part. This is a small experiment and I hope it will be interesting.
    - Listen, I've come up with a great idea for the game! - game designer Vasya was disheveled, and his eyes were red. I still sipped coffee and holivaril on Habré, to kill time before the stand-up. He looked at me expectantly, until I finished writing in the comments to the person what he was wrong about. Vasya knew that while justice will not prevail, and the truth will not be protected - there’s no point in continuing the conversation with me. I finished the last sentence and looked at it.

    - In a nutshell - magicians with mana can cast spells, and warriors can fight in close combat and spend endurance. And magicians and warriors can move. Yes, there it will still be possible to rob the cows, but we will do this in the next version, in short. Will you show the prototype after the stand-up, okay?

    He ran away on his game design affairs, and I opened the IDE.

    In fact, the topic “composition vs. inheritance”, “banana-monkey problem”, “diamond problem (multiple inheritance)” are frequently asked questions during interviews in various formats and for good reason. Incorrect use of inheritance can complicate the architecture, and inexperienced programmers do not know how to deal with this and, as a result, begin to criticize the PLO as a whole and start writing procedural code. Therefore, experienced programmers (or those who have read clever things on the Internet) consider it their duty to ask about such things at an interview in a variety of forms. The universal answer is “composition is better than inheritance, no shades of gray should be used”. Those who have just read each such answer will be 100% satisfied.

    But, as I said at the beginning of the article, each architecture will suit your project, and if inheritance is enough for your project and all you need to do to create a MonkeyBanan is to create a task. Our programmer found himself in a similar situation. It makes no sense to abandon inheritance just because FPShniki will laugh at you.

    class Character {
        x = 0;
        y = 0;
        moveTo (x, y) {
            this.x = x;
            this.y = y;
        }
    }
    class Mage extends Character {
        mana = 100;
        castSpell () {
            this.mana--;
        }
    }
    class Warrior extends Character {
        stamina = 100;
        meleeHit () {
            this.stamina--;
        }
    }
    

    Stand-up as always dragged on. I rocked on a chair and hung in the phone until June Petya tried to convince the tester that the inability to quickly control through the right mouse button was not a bug, because nowhere was this opportunity described, which means you need to throw the task to the pre-production department. The tester argued that once management seems to be mandatory for users via the right button, this is a bug, not a feature. In fact, as the only player of our team to our game on combat servers, he wanted to add this opportunity as soon as possible, but he knew that if you drop it into the pre-production department, the bureaucratic machine will let you release it in release no earlier than 4 Month, and having issued it as a bug - you can get it in the next build. The project manager, as always, was late, and the guys were so fiercely cursing that they had already switched to the mats and, probably, Soon it would have reached a massacre, if the studio director hadn’t come running into abuse and hadn’t taken both to his office. Probably again at 300 bucks fakananut.

    When I came out of the meeting room, a game designer ran up to me and happily said that everyone liked the prototype, he was accepted into the work and now this is our new project for the next six months. While we were going to my table, he enthusiastically told what new features would be in our game. How many different spells he came up with and, of course, there will be a paladin who can fight and throw magic. And the whole department of artists is already working on new animations, and China has already signed an agreement according to which our game will be released in their market. I silently looked at the code of my prototype, I thought deeply, selected everything and deleted it.
    I believe that over time, based on his experience, each programmer begins to see obvious problems that he may encounter. Especially if it works for a long time in a team with one game designer. We have a lot of new requirements and features. And our old "architecture" obviously will not cope with this.

    When you are given a similar task at the interview - they will definitely try to catch you. They can be in various forms - crocodiles, which can swim and run. Tanks that can shoot from a cannon or a machine gun and so on. The most important feature of such tasks is that you have an object that can do several different actions. And your inheritance can not cope, because it is impossible to inherit from FlyingObject and SwimmingObject And different objects can do different things. At this point, we discard inheritance and proceed to the composition:

    class Character {
        abilities  = [];
        addAbility (...abilities) {
            for (const a of abilities) {
                this.abilities.push(a);
            }
            return this;
        }
        getAbility (AbilityClass) {
            for (const a of this.abilities) {
                if (a instanceof AbilityClass) {
                    return a;
                }
            }
            return null;
        }
    }
    ///////////////////////////////////////
    // 
    // Тут будет список абилок, которые могут быть у персонажа
    // Каждая абилка может иметь свое состояние
    // 
    ///////////////////////////////////////
    class Ability {}
    class HealthAbility extends Ability {
        health     = 100;
        maxHealth  = 100;
    }
    class MovementAbility extends Ability {
        x = 0;
        y = 0;
        moveTo(x, y) {
            this.x = x;
            this.y = y;
        }
    }
    class SpellCastAbility extends Ability {
        mana       = 100;
        maxMana    = 100;
        cast () {
            this.mana--;
        }
    }
    class MeleeFightAbility extends Ability {
        stamina    = 100;
        maxStamina = 100;
        constructor (power) {
            this.power = power;
        }
        hit () {
            this.stamina--;
        }
    }
    ///////////////////////////////////////
    // 
    // А тут создаются персонажи со своими абилками
    // 
    ///////////////////////////////////////
    class CharactersFactory {
        createMage () {
            return new Character().addAbility(
                new MovementAbility(),
                new HealthAbility(),
                new SpellCastAbility()
            );
        }
        createWarrior () {
            return new Character().addAbility(
                new MovementAbility(),
                new HealthAbility(),
                new MeleeFightAbility(3)
            );
        }
        createPaladin () {
            return new Character().addAbility(
                new MovementAbility(),
                new HealthAbility(),
                new SpellCastAbility(),
                new MeleeFightAbility(2)
            );
        }
    }
    

    Every possible action now is a separate class with its own state and, if necessary, we can create unique characters, throwing the necessary number of skills for them. For example, it is very easy to create an immortal magical tree:

    createMagicTree () {
        return new Character().addAbility(
            new SpellCastAbility()
        );
    }

    We have lost inheritance and instead of it appeared composition. Now we create a character and list his possible abilities. But this does not mean that inheritance is always bad, just in this case it does not fit. The best way to understand whether inheritance is suitable is to answer for yourself the question of what relationship it represents. If this connection is “is-a”, that is, you indicate that MeleeFightAbility is an abilka, then it is perfect. If the link is created only because you want to add an action and displays “has-a”, then you should think about composition.
    I enjoyed looking at the excellent result. It works elegantly and without bugs, dream architecture! I am sure that it will stand more than one test of time and we will not have to rewrite it for a long time. I was so enthusiastic about my code that I didn’t even notice how June Petya approached me.

    It was already dark outside, which made it even more noticeable how he beamed with happiness. Apparently, he managed to push the task and get rid of the penalty for the mats in the direction of colleagues, which was announced last week.

    - Artists painted simply divine animations - he quickly rattled off - I can’t wait until we have them screwed. Particularly chic departing pluses when a healing spell is applied. They are so green and such pluses!

    I cursed myself, for I completely forgot that we still have to fasten the view. Devil seems to have to rewrite architecture.
    In such articles, only the work with the model is usually described, because it is abstract and adult, and you can give “picture pictures” to the juna and no matter what kind of architecture it will be. However, our model should provide maximum information for the view so that it can do its job. In GameDev, for this purpose, the pattern “Team” is usually used. In a nutshell, we have a state without logic, and any change must occur in the appropriate commands. This may seem to be a complication, but it offers many advantages:
    - They combine perfectly when one command calls another
    - Each command, when executed, is essentially an event to which you can subscribe
    - We can easily serialize them

    For example, the damage command may look like this. It is then that the warrior will use it when striking a sword and the magician when striking with a fire spell. Now, for simplicity, I implemented command validation through exceptions, but then they can be rewritten as return codes.

    class DealDamageCommand extends Command {
        constructor (target, damage) {
            this.target = target;
            this.damage = damage;
        }
        execute () {
            const healthAbility = this.target.getAbility(HealthAbility);
            if (healthAbility == null) {
                throw new Error('NoHealthAbility');
            }
            const resultHealth = healthAbility.health - this.damage;
            healthAbility.health = Math.max( 0, resultHealth );
        }
    }

    I like to make hierarchical commands - when one is executed, it gives birth creates many children, which the engine then executes. So now, when we have the opportunity to cause damage - we can try to implement a melee strike

    class MeleeHitCommand extends Command {
        constructor (source, target, damage) {
            this.source = source;
            this.target = target;
            this.damage = damage;
        }
        execute () {
            const fightAbility = this.source.getAbility(MeleeFightAbility);
            if (fightAbility == null) {
                throw new Error('NoFightAbility');
            }
            this.addChildren([
                new DealDamageCommand(this.target, fightAbility.power);
            ]);
        }
    }

    These two teams have everything you need for our animations. Renderschik can simply subscribe to events and display everything that the artists wish with the following code:

    async onMeleeHit (meleeHitCommand) {
        await view.drawMeleeHit( meleeHitCommand.source, meleeHitCommand.target );
    }
    async onDealDamage (dealDamageCommand) {
        await view.showDamageNumbers( dealDamageCommand.target, dealDamageCommand.damage );
    }

    I lost count, which once in a row I stayed up at work until dark. Since childhood, the development of games has attracted me, seemed to me something magical, and even now, when I have been doing this for many years, I have a reverent attitude to it. Despite the fact that I learned the secret of how they are created - I have not lost faith in magic. And this magic makes me sit at night with such inspiration and write my code. Vasya approached me. He does not know how to program at all, but he shares my attitude towards games.

    - Here is a game designer put in front of me a Talmud of 200 pages printed on A4 sheets. Although the design document was conducted in confluence, we liked to print it out at important stages in order to feel this work in physical embodiment. I opened it on a random page and got on a huge list of a wide variety of spells that a magician and paladin can do, a description of their effects, intelligence requirements, a price in man and an approximate description for artists how to display them. Work for many months, because today I will stay at work again.
    Our architecture makes it easy to create complex spell combinations. Simply, each spell can return a list of commands that must be performed during a caste.

    class CastSpellCommand extends Command {
        constructor (source, target, spell) {
            this.source = source;
            this.target = target;
            this.spell  = spell;
        }
        execute () {
            const spellAbility = this.source.getAbility(SpellCastAbility);
            if (spellAbility == null) {
                throw new Error('NoSpellCastAbility');
            }
            this.addChildren(new PayManaCommand(this.source, this.spell.manaCost));
            this.addChildren(this.spell.getCommands(this.source, this.target));
        }
    }
    class Spell {
        manaCost = 0;
        getCommands (source, target) { return []; }
    }
    class DamageSpell extends Spell {
        manaCost = 3;
        constructor (damageValue) {
            this.damageValue = damageValue;
        }
        getCommands (source, target) {
            return [ new DealDamageCommand(target, this.damageValue) ];
        }
    }
    class HealSpell extends Spell {
        manaCost = 2;
        constructor (healValue) {
            this.healValue = healValue;
        }
        getCommands (source, target) {
            return [ new HealDamageCommand(target, this.healValue) ];
        }
    }
    class VampireSpell extends Spell {
        manaCost = 5;
        constructor (value) {
            this.value = value;
        }
        getCommands (source, target) {
            return [
                new DealDamageCommand(target, this.value),
                new HealDamageCommand(source, this.value)
            ];
        }
    }
    

    A year and a half later, the

    Stand-up was, as always, delayed. I rocked on a chair and hung in a laptop while middle Peter was arguing with a tester about a bug. With all sincerity he tried to convince the tester that the lack of control through the right mouse button in our new game should not be marked as a bug, because such a task never stood and it was not worked out by game designers or lawyers. I had a feeling of deja vu, but a new message in the discord distracted me:

    - Listen - the game designer wrote - I have a great idea ...

    Also popular now: