Pull to refresh

Композиция против наследования, паттерн Команда и разработка игр в целом

Reading time 8 min
Views 18K

Дисклеймер: По-моему, статья об архитектуре ПО не должна и не может быть идеальной. Любое описанное решение может покрывать необходимый одному программисту уровень недостаточно, а другому программисту — слишком усложнит архитектуру без надобности. Но она должна давать решение тем задачам, которые поставила перед собой. И этот опыт, вместе со всем остальным багажом знаний программиста, который обучается, систематизирует информацию, оттачивает новыки, и критикует сам себя и окружающих — этот опыт превращается в отличные програмные продукты. Статья будет переключаться между художественой и технической частью. Это небольшой эксперимент и я надеюсь, что он будет интересным.
— Слушай, я тут придумал отличную идею игры! — гейм-дизайнер Вася был взъерошен, а глаза — красные. Я ещё попивал кофе и холиварил на Хабре, чтобы убить время перед стенд-апом. Он выжидательно посмотрел на меня, пока я закончу писать в комментариях человеку, в чем он не прав. Вася знал, что пока справедливость не восторжествует, а правда не будет защищена — смысла продолжать со мной разговор нету. Я дописал последнее предложение и перевел на него взгляд.

— В двух словах — маги с маной могут кастовать заклинания, а воины могут сражаться в близком бою и тратить выносливость. И маги и воины могут двигаться. Да, там ещё можно будет грабить корованы, но это уже следующей версии сделаем, короче. Покажешь прототип после стенд-апа, окей?

Он убежал по своим гейм-дизайнерским делам, а я — открыл IDE.

На самом деле тема «композиция против наследования», «banana-monkey problem», «проблема ромба (множественное наследование)» — частые вопросы на собеседовании в разных форматах и не зря. Неправильное использование наследования может усложнить архитектуру, а неопытные программисты не знаю, как с этим побороться и, в итоге, начинают критиковать ООП в целом и начинают писать процедурный код. Потому опытные программисты (или те, которые прочитали умные вещи в интернете) считают своим долгом спросить о таких вещах на собеседовании в самых разных формах. Универсальный ответ — «композиция лучше наследования, должна применяться и никаких оттенков серого». Тех, кто просто начитался всякого такой ответ устроит на все 100%.

Но, как я и говорил в начале статьи, каждая архитектура подойдет своему проекту и если для вашего проекта вполне достаточно наследования и все, что нужно, чтобы решить задачу — это создать ОбезьянуСБананом — создавайте. Наш программист оказался в похожей ситуации. Нет смысла отказываться от наследования только потому что ФПшники будут над вами смеяться.

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--;
    }
}

Стенд-ап как всегда затянулся. Я покачивался на стуле и висел в телефоне пока джун Петя старался убедить тестера, что невозможность быстрого управления через правую кнопку мыши не баг, ведь нигде такой возможности описано не было, а значит нужно бросать задачу на отдел пре-продакшена. Тестер утверждал, что раз для пользователей управление через правую кнопку кажется обязательным, то это баг, а не фича. На самом деле, как единственный из нашей команды игрок в нашу игру на боевых серваках он хотел скорейшего добавления этой возможности, но знал, что если закинуть её в отдел пре-продакшена, то бюрократическая машина позволит выпустить её в релиз не раньше, чем через 4 месяца, а оформив её как баг — можно получить её уже в следующем билде. Менеджер проекта, как всегда, опаздывал, а ребята настолько яро ругались, что уже перешли на маты и, наверное, скоро дело дошло бы до мордобоя, если бы на ругань не прибежал директор студии и не увел бы обоих в свой кабинет. Наверное, снова на 300 баксов штрафанут.

Когда я вышел с митинг-рума, ко мне подбежал гейм-дизайнер и радосно сказал, что прототип всем понравился, его приняли в работу и теперь это наш новый проект на следующих полгода. Пока мы шли к моему столу он воодушевленно рассказывал, какие новые фичи будут в нашей игре. Сколько разных заклинаний он придумал и, конечно, что будет паладин, который может и сражаться, и магией кидаться. И весь отдел художников уже работает над новыми анимациями, а Китай уже подписал договор, по которому наша игра выйдет на их рынке. Я молча посмотрел на код своего прототипа, глубоко задумался, выделил всё и удалил.
Я верю, что со временем каждый программист на базе своего опыта начинает видеть очевидные проблемы, с которыми он может столкнуться. Особенно, если долго работает в команде с одним гейм-дизайнером. У нас появилось куча новых требований и фич. И наша старая «архитектура» очевидно с этим не справится.

Когда вам зададут подобную задачу на собеседовании — обязательно постараются вас подловить. Они может быть в самых разных формах — крокодилы, которые могут и плавать, и бегать. Танки, которые могут стрелять из пушки или из пулемета и так далее. Самое главное свойство таких задач — у вас есть объект, который может делать несколько разных действий. И ваше наследование никак не может справится, ведь невозможно унаследоваться и от FlyingObject и от SwimmingObject И разные объекты могут делать разные действия. В этот момент мы отказываемся от наследования и переходим к композиции:

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)
        );
    }
}


Каждое возможное действие теперь — это отдельный класс со своим состоянием и при необходимости мы можем создавать уникальных персонажей, накидывая им необходимое количество абилок. К примеру, очень легко создать бессмертное магическое дерево:

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

У нас пропало наследование и вместо него появилась композиция. Теперь мы создаем персонажа и перечисляем его возможные абилки. Но это не значит, что наследование — всегда плохо, просто в даном случае оно не подходит. Лучший способ понять, подходит ли наследование — ответить для себя на вопрос, какую связь оно отображает. Если эта связь «is-a», то есть вы указываете, что MeleeFightAbility — это абилка, то оно идеально подходит. Если же связь создается только потому что вы хотите добавить действие и отображает «has-a», то стоит подумать о композиции.
Я с удовольствием посмотрел на прекрасный результат. Он шикарно и без багов работает, архитектура мечты! Уверен, что она выдержит не одно испытание временем и нам ещё долго не придется её переписывать. Я так восторгался своим кодом, что даже не заметил, как ко мне подошел Джун Петя.

На улице уже потемнело, из-за чего было ещё более заметно, как он сиял от счастья. Видимо, ему удалось спихнуть задачу и отделаться от штрафа за маты в сторону коллег, о котором объявили ещё на прошлой неделе.

— Художники нарисовали просто божественные анимации — быстро затараторил он — не могу дождаться, когда мы их уже прикрутим. Особо шикарные вылетающие плюсики, когда применяется заклинание лечения. Они такие зелёные и такие плюсики!

Я про себя выругался, ведь совершенно забыл о том, что нам ещё прикручивать вьюшку. Черт, кажется, придется переписывать архитектуру.
В подобных статьях обычно описывается только работа с моделью, потому что она абстрактная и взрослая, а «картиночки показывать» можно отдать и джуну и неважно, какая там будет архитектура. Тем не менее, наша модель должна предоставлять максимум информации для вьюшки, чтобы та могла сделать свое дело. В ГеймДеве для этого, обычно, используется паттерн «Команда». В двух словах — мы имеем стейт без логики, а любое изменение должно происходить в соответствующих командах. Это может казаться усложнением, но это дает множество преимуществ:
— Они отлично комбятся, когда одна команда вызывает другую
— Каждая команда, когда выполняется является, по сути, событием, на которое можно подписаться
— Мы их можем легко сериализировать

К примеру, так может выглядеть команда нанесения урона. Именно её потом будут использовать воин при ударе мечем и маг при ударе заклинанием огня. Сейчас, для простоты, я реализовал валидацию команд через исключения, но потом их можно переписать а коды возврата.

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 );
    }
}

Я люблю делать иерархические команды — когда одна выполняется, она рожает создает много детей, которые движок потом выполняет. Так что теперь, когда у нас есть возможность наносить урон — мы можем попробовать реализовать удар в ближнем бою

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);
        ]);
    }
}

В этих двух командах есть все необходимое для наших анимаций. Рендерщик может просто подписаться на события и отобразить всё, что пожелают художники следующим кодом:

async onMeleeHit (meleeHitCommand) {
    await view.drawMeleeHit( meleeHitCommand.source, meleeHitCommand.target );
}

async onDealDamage (dealDamageCommand) {
    await view.showDamageNumbers( dealDamageCommand.target, dealDamageCommand.damage );
}

Я сбился со счета, который раз подряд засиживаюсь на работе до темноты. С самого детства разработка игр меня манила, казалась мне чем-то магическим и даже сейчас, когда я много лет этим занимаюсь — трепетно отношусь к этому. Несмотря на то, что я узнал секрет о том, как они создаются — я не потерял веру в магию. И эта магия заставляет меня с таким вдохновением сидеть по ночам и писать свой код. Ко мне подошел Вася. Он совершенно не умеет программировать, но разделает мое отношение к играм.

— Вот — гейм-дизайнер положил передо мною талмуд страниц на 200, распечатанные на листах А4. Хотя дизайн-документ велся в конфлюэнсе мы любили на важных этапах распечатать его, чтобы почувствовать эту работу в физическом воплощении. Я открыл его на случайной странице и попал на огромный список самых разных заклинаний, которые могут сделать маг и паладин, описание их эффектов, требований к интеллекту, цену в мане и приблизительное описание для художников, как необходимо их отобразить. Работы на много месяцев, потому сегодня я снова задержусь на работе.
Наша архитектура легко позволяет создавать сложные комбинации заклинаний. Просто каждое заклинание может возвращать список команд, которые необходимо выполнить при касте

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)
        ];
    }
}

Полтора года спустя

Стенд-ап как всегда затянулся. Я покачивался на стуле и висел в ноуте пока миддл Петя спорил с тестером о заведенном баге. Он со всей искренностью старался убедить тестера, что отсутствие управления через правую кнопку мыши в нашей новой игре не должно отмечаться как баг, ведь такой задачи никогда не стояло и её не прорабатывали ни гейм-дизайнеры, ни юишники. У меня возникло ощущение дежавю, но новое сообщение в дискорде отвлекло меня:

— Слушай — писал гейм-дизайнер — у меня есть отличная идея...
Tags:
Hubs:
+49
Comments 45
Comments Comments 45

Articles