
Дисклеймер: По-моему, статья об архитектуре ПО не должна и не может быть идеальной. Любое описанное решение может покрывать необходимый одному программисту уровень недостаточно, а другому программисту — слишком усложнит архитектуру без надобности. Но она должна давать решение тем задачам, которые поставила перед собой. И этот опыт, вместе со всем остальным багажом знаний программиста, который обучается, систематизирует информацию, оттачивает новыки, и критикует сам себя и окружающих — этот опыт превращается в отличные програмные продукты. Статья будет переключаться между художественой и технической частью. Это небольшой эксперимент и я надеюсь, что он будет интересным.
— Слушай, я тут придумал отличную идею игры! — гейм-дизайнер Вася был взъерошен, а глаза — красные. Я ещё попивал кофе и холиварил на Хабре, чтобы убить время перед стенд-апом. Он выжидательно посмотрел на меня, пока я закончу писать в комментариях человеку, в чем он не прав. Вася знал, что пока справедливость не восторжествует, а правда не будет защищена — смысла продолжать со мной разговор нету. Я дописал последнее предложение и перевел на него взгляд.
— В двух словах — маги с маной могут кастовать заклинания, а воины могут сражаться в близком бою и тратить выносливость. И маги и воины могут двигаться. Да, там ещё можно будет грабить корованы, но это уже следующей версии сделаем, короче. Покажешь прототип после стенд-апа, окей?
Он убежал по своим гейм-дизайнерским делам, а я — открыл 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) ]; } }
Полтора года спустя
Стенд-ап как всегда затянулся. Я покачивался на стуле и висел в ноуте пока миддл Петя спорил с тестером о заведенном баге. Он со всей искренностью старался убедить тестера, что отсутствие управления через правую кнопку мыши в нашей новой игре не должно отмечаться как баг, ведь такой задачи никогда не стояло и её не прорабатывали ни гейм-дизайнеры, ни юишники. У меня возникло ощущение дежавю, но новое сообщение в дискорде отвлекло меня:
— Слушай — писал гейм-дизайнер — у меня есть отличная идея...
