Композиция против наследования, паттерн Команда и разработка игр в целом
Дисклеймер: По-моему, статья об архитектуре ПО не должна и не может быть идеальной. Любое описанное решение может покрывать необходимый одному программисту уровень недостаточно, а другому программисту — слишком усложнит архитектуру без надобности. Но она должна давать решение тем задачам, которыё поставила перед собой. И этот опыт, вместе со всем остальным багажом знаний программиста, который обучается, систематизирует информацию, оттачивает новыки, и критикует сам себя и окружающих — этот опыт превращается в отличные програмные продукты. Статья будет переключаться между художественой и технической частью. Это небольшой эксперимент и я надеюсь, что он будет интересным.
— Слушай, я тут придумал отличную идею игры! — гейм-дизайнер Вася был взъерошен, а глаза — красные. Я ещё попивал кофе и холиварил на Хабре, чтобы убить время перед стенд-апом. Он выжидательно посмотрел на меня, пока я закончу писать в комментариях человеку, в чем он не прав. Он знал, что пока справедливость не восторжествует, а правда не будет защищена — смысла продолжать со мной разговор нету. Я дописал последнее предложение и перевел на него взгляд.
— В двух словах — маги с маной могут кастовать заклинания, а воины могут сражаться в близком бою и тратить выносливость. И маги и воины могут двигаться. Да, там ещё можно будет грабить корованы, но это уже следующей версии сделаем, короче. Покажешь прототип после стенд-апа, окей?
Он убежал по своим гейм-дизайнерским делам, а я открыл 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.damageValue) ];
}
}
class VampireSpell extends Spell {
manaCost = 5;
constructor (value) {
this.value = value;
}
castTo (source, target) {
return [
new DealDamageCommand(target, this.value),
new HealDamageCommand(source, this.value)
];
}
}
Полтора года спустя
Стенд-ап как всегда затянулся. Я покачивался на стуле и висел в ноуте пока миддл Петя спорил с тестером о заведенном баге. Он со всей искренностью старался убедить тестера, что отсутствие управления через правую кнопку мыши в нашей новой игре не должно отмечаться как баг, ведь такой задачи никогда не стояло и её не прорабатывали ни гейм-дизайнеры, ни юишники. У меня возникло ощущение дежавю, но новое сообщение в дискорде отвлекло меня:
— Слушай — писал гейм-дизайнер — у меня есть отличная идея…