Pull to refresh

Comments 45

Сегодня утром я как раз рекомендовал человеку почитать статью на тему ECS. Я ни в коем случае не говорю, что сказанное в статье — моя идея. Более того, я даже указал в статье, что это известный в гейм-деве паттерн «команда».

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

Значит, я просто потерялся в литературных вставках и к концу статьи уже забыл ее начало и неправильно понял основной посыл. Полностью моя вина, приношу извинения :)
Та нет, ну что вы. Ссылка на ECS как дальнейшее развитие идеи — крайне полезна.
На эту тему пишут странные вещи в любой среде. И довольно часто даже толковые вещи оказываются переусложнёнными недоупрощёнными, а часто ещё и невыносимо синтетическими. А у Вас получилось и предельно упростить, и доходчиво привязать к реальности, и показать последствия выбора. И всё это интересно.

Спасибо, получилось здорово.
Если честно, то я прочитал только желтые вырезки, уж больно интересно они написаны :)

Спасибо за интересную публикацию!
Зачастую даже в умных книгах авторы не всегда утруждаются подачей вразумительных примеров.
У Вас в тексте просто по диагонали глазами пробежался, и вроде ничего нового… но то, что знал ранее, стало гораздо более понятным, как-то лучше в голове уложилось вместе с полезной рекомендацией ("is-a" / "has-a" для выбора между наследованием и композицией)...


P.S.:
Такой магии бы да побольше…
Пешыте исчо!

ведь невозможно унаследоваться и от FlyingObject и от SwimmingObject

Дальше не читал

Почему? Устали? Мама сказала идти обедать? Ребенок попросил с ним поиграть? Позвонили из лотереи и сказали, что вы победили? Дом рухнул из-за взрыва газа, вы сейчас под обломками и не можете читать дальше?
Вероятно, автор подразумевал возможность множественного наследования в некоторых языках программирования. Жаль, не хватило терпения объяснить)
Хотите что-то сказать — говорите. Не пустые «дальше не читал», «отож» и остальную бессмысленную фигню.

Давайте, опишите, в чем я неправ. Расскажите, как удобно все это реализуется в C++ при использовании множественного наследования и, что я зря нагородил огород. Тогда ваши комментарии будут полезны.

Заодно расскажите, как при множественном наследовании можно дать абилку. Ну, к примеру, все танки могут ездить, но если игрок играет за немцев, то они еще и прыгать могут. Но если играет за немцев луны, то только прыгать, а ездить уже не могут.

Именно на С++ я не писал, но насколько я знаю, там тоже множественное наследование стараются избегать. А в Шарпах его нету как и в ЖС.

И да, в тегах явно указан язык, на котором написаны примеры.
>там тоже множественное наследование стараются избегать
Да вовсе нет.

Неправы вы, на мой взгляд, только в том, что пишете игры на JS. Я читал со смартфона и проглядел язык в тегах. Зашел и бомбанул.
Я, в основном, пишу игры на C#, но бывает всякое. Иногда игры и на JS нужно пописать. Да и подход этот может использоваться не только в играх.
UFO just landed and posted this here
Статья понравилась, легко читается и понятные примеры. Есть небольшой вопрос, вот эта часть кода у вас повторяется, стоит ли её вынести и куда лучше вынести если нужно?
if (spellAbility == null) {
    throw new Error('NoSpellCastAbility');
}
Мне кажется, что тут нечего выносить. Ну на самом деле можно реализовать что-то вроде такого, пока мы пользуемся исключениями и не типизируем их:

execute () {
    const spellAbility = this.source.getAbility(SpellCastAbility);

    ensureNotNull(spellAbility, 'NoSpellCastAbility')


Можно даже пойти дальше и создать метод, который вместо null возвращает ошибку, тогда не придется вообще писать этого:

execute () {
    const spellAbility = this.source.requireAbility(SpellCastAbility);


Но, во-первых, в идеале нужно отказаться от исключений в пользу кодов возврата:

execute () {
	const spellAbility = this.source.getAbility(SpellCastAbility);

	if (spellAbility == null) {
		return Status.NoSpellCastAbility;
	}

	this.addChildren(new PayManaCommand(this.source, this.spell.manaCost));
	this.addChildren(this.spell.getCommands(this.source, this.target));
	
	return Status.Success;
}


А тогда не будет возможности вынести это в метод, ведь всё-равно нужно if и return.

Во-вторых, что более важно, я не совсем согласен, что это самоповторение. Там разные абилки и разные коды возврата. И, на самом деле, таких if (!cond) return error у нас на практике будет много совершенно разных. Все мы никак не сможем красиво заDRYить.

Отлично написано! Отличные примеры — помогли лучше усвоить и запомнить! (а ещё они такие жизненные!)

Хочется упомянуть «Узловую систему» Node System.
Когда каждая сущность — это и узел и компонент, знающий об своем родителе и своих детях.
Древовидная структура. И каждый дочерний узел перемещается в родительских координатах, если они есть.

Сейчас наверное самый яркий показатель этой идеологи это Godot движок. Там все — это узлы, а любой узел — это еще и сцена. Упомянул бы cocos2d, но там не все гладко с реализацией.
Судя по вашему описанию и скриншотам на офф странице, Unity3D тоже вполне подходит. Уверен, что и Unreal.

Я глянут на Godot. Хорошо, что он опенсорсный. Но, с другой стороны, в нем явный уклон на свой скриптовый язык (Юнити не зря отказались от ДжаваСкрипта), а еще хуже, что Шарповая версия тоже страдает от динамичной природы. К примеру, тут:

var plusButton = (Button)GetNode("PlusButton");
plusButton.Connect("pressed", this, "ModifyValue", new object[] { 1 });


На шарпах лучше бы выглядело так:

var plusButton = GetNode<Button>("PlusButton");
plusButton.Connect("pressed", ModifyValue);


Или так, если с аргументами:
var plusButton = GetNode<Button>("PlusButton");
plusButton.Connect("pressed", () => ModifyValue(-1));


Мне кажется, им стоит сконцентрироваться на Шарповой версии.

Но почему бы вам не написать о нем статью на Хабру с разбором сильных и слабых сторон?
UE4 все же компонентный (Actor component). И Юнити тоже юзает компоненты, как мне известно. Компоненты не могут существовать без родителя. У годота — может, потому что это не компоненты, а узлы). В godot можно целые ветки сцен менять как вздумается на ходу. Я сам не так давно открыл его для себя, и довольно впечатлен. Простой, легкий, а мощный какой… Ничего лишнего нет.

Его собственный скриптовый язык не плох, строгая типизация, синтаксис питона, но упрощенный.
Ну и надо не забывать, что можно писать на C и C++ нативные модули. (Это как в UE4. Ты можешь блюпринты использовать, а можешь код писать)

Я не так давно на него перешел с cocos2d-x. Лично на кокосе я заколебался для простых вещей писать полотна текста на плюсах. Так еще и баги движка закрывать, который оказываются «Фичами». (например у FastTMX отрисовка тайлов не правильная при перемещении сцены, а не узла).

Но у того же годота нету поведенческого дерева (behavioral tree) из коробки. Нету визуального редактора шейдеров. И т.д.

Для меня это то что надо, гибки инструмент с минимальными наворотами для создания простых 2D игр.
Из-за отсутствия в статье имплементации Command, не очень понятной оказалась идея использовать наследование. Вообще когда я вижу класс с единственным методом, я не очень понимаю, почему это не может быть функцией, которая принимает на вход три параметра (у вас это зачем-то в стейте класса, который надо зарядить конструктором), а на выходе — результат своей работы с кодом завершения. Такое и композируется лучше, и по иерархиям бегать потом не надо, чтобы понять что происходит со стейтом, да и юнит тесты писать проще.
Такое и композируется лучше, и по иерархиям бегать потом не надо, чтобы понять что происходит со стейтом, да и юнит тесты писать проще.
Начнем с того, что это обычная маркетологичная чушь, которой нас пичкают на всех афтепати. Нет, композируется точно так же, по иерархиям бегать надо точно так же, юнит тесты писать точно так же. По сути от замены класса на функцию для всех этих факторов меняется только форма записи. Почему вдруг должны упроститься эти вещи о которых вы говорите — я не знаю. Может вам не понравилось использование addChildren вместо прямого вызова метода класса? Но тут дело не в классах. На функциях тоже можно написать такой код через каррирование.

Вообще когда я вижу класс с единственным методом, я не очень понимаю, почему это не может быть функцией
Жаль, что вы из тех, кто бездумно повторяет за своим кумиром. Видите ли, в топике было раскрыто, зачем нужно классы и почему их нельзя заменить, к примеру, на функции. А если точнее — вы, как и предыдущий автор, совершенно забыли про вьюшку. Да, для простых вещей, которые пишутся на Редакс подойдет подход «изменили всю модель — перерисовали всю вьюшку». Но проблема в том, что этот подход просто отвратительно анимируется. Вот представьте себе настолку и там правило: «когда самурай стает на клетку рядом с противниками — он атакует каждого из них, но контратаку получает только от первого». Любой геймдевелопер знает, что это совсем обычная абилка для пошаговых игр. Итак, у нас получится такая иерархия:

SAMURAY_ID = 100;
ENEMY_1_ID = 201;
ENEMY_2_ID = 202;
ENEMY_3_ID = 203;

Movement( SAMURAY_ID, 3, 5 )
	Attack( SAMURAY_ID, ENEMY_1_ID )
		DealDamage( ENEMY_1_ID, 3 )
		CounterAttack( ENEMY_1_ID, SAMURAY_ID)
			DealDamage( SAMURAY_ID, 1 )
	Attack( SAMURAY_ID, ENEMY_2_ID )
		DealDamage( ENEMY_2_ID, 3 )
			Death( ENEMY_2_ID )
				GiveMoney( PLAYER_1, 300 )
	Attack( SAMURAY_ID, ENEMY_3_ID )
		DealDamage( ENEMY_3_ID, 3 )


При подходе с классами у меня есть вся эта иерархия на клиентской модели или на сервере, я ее сериализую и отправляю во вьюшку, а вьюшка отображает это так:

await animateMovement(SAMURAY_ID, 3, 5);
await animateAttack(SAMURAY_ID, ENEMY_1_ID);
await showDamage(ENEMY_1_ID, 3)
await animateAttack(SAMURAY_ID, ENEMY_1_ID);


Что же будет при вашем «удобном» подходе с функциями? Все функции синхронно выполнятся, И все, что мы можем послать во вьюшку будет:

У самурая новое положение и на 3 меньше хит-поинтов
У двох врагов на 3 меньше хит-поинтов
Один враг умер


Вы посылаете вот эту скупую информацию на клиент и говорите: «у меня все удобно и круто тестируется». А клиентский программист шлет вас подальше и увольняется. А игра никогда не выпускается, потому что программист, который отвечал за модель перечитал, но не понял ФПшников.

А если вы скажете: «так пусть рисует по тому, что получилось в результате в модели» — я вам отвечу: «попробуйте реализовать следующее»:
Самурай имеет абилку телепортации и может ходить на 1 клетку.
Игрок решает сначала телепорироваться с клетки (3, 3) на клетку (3, 4).
А потом походить с клетки (3, 4) на (3, 5).
Художники, неожиданно, нарисовали разные анимации для телепортации и для ходьбы.
Что-то много слов и каких-то левых ссылок. Я всего лишь попросил раскрыть заявленное — показать реализацию базового класса. Потому что без него непонятен поинт использования паттерна Command, который как известно, был придуман для замены отсутствующей на то время у товарищей GoF фичи языка, а именно first-class functions.
Считаю, что с заявленной темой вы не справились, а только показали собственную предубежденность и склонность к некоему карго-культу, используя паттерн только потому, что он описан в широко известной книге. Впрочем, довольно старой.
Реализация базового класса неважна, пусть даже пустая.
Я вам явно рассказал, почему функции не подходят, а нужны классы, которые могут переносить данные. Но сложно говорить с человеком, которому читать так тяжело, что он читает только половину статьи или комментария и сразу бежит отвечать.
Еще хуже, что вы не хотите не только читать, но и думать. Задуматься, почему ваше бессмысленное повторение известной статьи (нет, это не левая ссылка) никак не относится к текущей теме и почему вы своим подходом не решите задачи, которые есть.

Карго-культ именно у вас. Я показываю задачу и рассказываю о ее решение, а вы только кричите: «нееет, классы фигня, функции! пишите все на функциях! вы пишете на классах! фу! зачем, если есть функции?!»

Еще раз. Перечитайте статью и комментарий. Если хоть немного подумаете, то поймете, почему вы предлагаете глупость.
Классы не переносят данные, а группируют данные и операции над ними. Для переноски данных существуют рекорды, в том числе и в любимом вами ФП.
А я спрашивал про паттерн Command, но к сожалению вы даже с третьего раза не смогли этого понять. Увы.
Для переноски данных есть рекорды
Ну давайте, напишите пример. Холиварить — не мешки ворочать. Я явно описал задачу и написал со своей стороны ее решение, но вы отказываетесь рассказать, как же, по вашему, ее нужно решать. Покажите класс. Научите нас, какой должна быть современная архитектура мечты.

Классы не переносят данные, а группируют данные и операции над ними. Для переноски данных есть рекорды
А Рекорды не реализуются через классы в некоторых языках, не?
Всё здорово, но я не очень понял, как это увязывать с UI. Можете описать небольшой пример, как маг бьется с войном. Как отправляются команды, кем? Как инициируется запуск команды, например, MeleeHitCommand.
Вот представьте, у нас есть какая-то модель, которая выполняет этот код. Или он выполняется на сервере, приходит на клиент в виде json и разархивируется в классы. Если произошло сложное действие — на клиент (во вьюшку) сразу приходит вся информация о действии с начала до конца. Представьте, что воин двойным ударом ударил монстра и тот этот удар не пережил. У воина айдишник 3, а у монстра — 5. За это игрок получает 100 монет. С сервера/клиентской модели мы получаем оповещение, которое можно отобразить следующим деревом:
Ability( type: 'DoubleAttack', source: 3, target: 5 )
  Attack( source: 3, target: 5 )
    DealDamage( target: 5, amount: 2 )
  Attack( source: 3, target: 5 )
    DealDamage( target: 5, amount: 2 )
      Death( target: 5 )
        GiveMoney( player: 1, 100 )


Для простоты просто выравниваем его и во вьюшке видим его как список:
Ability   ( source: 3, target: 5, type: 'DoubleAttack' )
Attack    ( source: 3, target: 5 )
DealDamage( target: 5, amount: 2 )
Attack    ( source: 3, target: 5 )
DealDamage( target: 5, amount: 2 )
Death     ( target: 5 )
GiveMoney ( player: 1, 100 )


Итак, у нас во вьюшку сихнронно пришло 7 команд с сервера, которые мы должны заанимировать. Пишем где-то методы, которые описывают, как это все анимируется:

drawAbility (ability: AbilityCommand) {
  if (ability.type == 'DoubleAttack') {
    var unit = this.unitsViews.findById(ability.source);
    await unit.launchPentagramAnimation();
  }
}


Вот, к примеру, описали как необходимо реагировать на приходящую абилку — наш аниматор должен запустить соответствующую анимацию у того, кто эту абилку заюзал.

Описываем дальше, атака:
drawAttack (attack: AttackCommand) {
  var source = this.unitsViews.findById(attack.source);
  var target = this.unitsViews.findById(attack.target);
  
  var angle = source.getAngleTo(target);
  await source.rotateAt( angle );
  await source.launchSwordAnimation();
}


Юнит сперва должен повернуться, а потом — ударить мечём. Но отлетающий урон мы не отрисовываем — за это ответственна другая команда (может он вообще броню не пробил и урона не будет?)
drawDamage (damage: DealDamageCommand) {
  var target = this.unitsViews.findById(attack.target);
  
  this.canvas.createNewFloatingNumber( `-${attack.amount}`, { color: 'red' });
}


Для демеджа мы запустили вылетающую красную цифру, но обратите внимание — никакого await. Ведь нам не нужно ждать, пока цифра окончательно пропадет. Пусть себе летит дальше, а мы уже можем запускать следующую анимацию — снова удар.

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

Приблизительно так устроены всякие Heartstone и подобные игры.
Здорово, это понятно. А как модель узнает, кто source, а кто target? Вьюшка отправляет модели свои команды? А если таргет надо лечить, а не атаковать? Вьюшка должна тоже быть в курсе о всех возможных командах?
Модель может получить запрос от вьюшки (обычно как результат действия пользователя). К примеру: «Юнит Х старается использовать абилку У на юнита Й». Ну то есть пользователь кликнул на своего солдатика, выбрал в выпадающем меню абилку и кликнул на солдатика противника. Вьюшка всё это собрала и послала в модель. Модель — переварила, создала группу команд и вернула назад.

А если таргет надо лечить, а не атаковать?
Тогда вместо Ability( type: 'DoubleAttack', source: 3, target: 5 ) будет, к примеру Heal( source: 3, target: 5 ). И, соответственно, внутри — все наоборот.

Вьюшка должна тоже быть в курсе о всех возможных командах?
О всех командах, которые вы хотите как-то отобразить) Если с сервера пришло, к примеру LaunchFireball, а вьюшка не представляет, что такое фаербол, то никакой анимации рисоваться не будет. Скорее всего эта команда пропустится и отрисуется только цифры использования маны и отлетающий демедж.

Но если вы хотите для большинства возможностей разные анимации — нужно каждую возможность описывать во вьюшке.
Спасибо за ответы, жаль проголосовать не могу, какой то у меня не полноправный аккаунт ((
Ничего, я переживу, главное, чтобы полезно было)
Можно все таки вопрос по поводу:
        this.addChildren(new PayManaCommand(this.source, this.spell.manaCost));
        this.addChildren(this.spell.getCommands(this.source, this.target));


Когда команда добавляет детей во время выполнения, что это значит?
Я понимаю если бы это был какой-то CommandExecutor / Scheduler, в который мы просто кидаем в конец новые команды и они в свое время выполняются. Зачем нужно addChildren?

Это означает, что у нас дерево комманд, и мы например при undo комманды удаляем root commands, и не имеет смысла удалять детей. Или они должны выполняться не по принципу FIFO очереди, а там какой-нибудь depth-first search обходом.

PS: Наверное в VampireSpell опечатка, castTo должно быть getCommands.
PS: Наверное в VampireSpell опечатка, castTo должно быть getCommands.
Да, вы правы, спасибо.

Я понимаю если бы это был какой-то CommandExecutor / Scheduler, в который мы просто кидаем в конец новые команды и они в свое время выполняются. Зачем нужно addChildren?
Да, и тут вы правы. Конечно, у нас есть CommandExecutor. Но тут я ввел addChildren, который нужен, чтобы не делать дополнительный DI. Чтобы не было вопросов — а откуда у нас экзекьютор появился, а как правильно его передать в команду? И так далее.

Например, иерархия удобна для дебага, ты видишь не только последовательность команд, но и кто какую команду вызвал. И меньше проблем с очередностью.

Вот представьте тот пример, который я выше приводил:
Movement( SAMURAY_ID, 3, 5 )
	Attack( SAMURAY_ID, ENEMY_1_ID )
		DealDamage( ENEMY_1_ID, 3 )
		CounterAttack( ENEMY_1_ID, SAMURAY_ID)
			DealDamage( SAMURAY_ID, 1 )
	Attack( SAMURAY_ID, ENEMY_2_ID )
		DealDamage( ENEMY_2_ID, 3 )
			Death( ENEMY_2_ID )
				GiveMoney( PLAYER_1, 300 )
	Attack( SAMURAY_ID, ENEMY_3_ID )
		DealDamage( ENEMY_3_ID, 3 )


Если команда добавляет своих детей в очередь, а не выполняет синхронно, то сперва выполнятся 3 атаки, а только потом их дети.

Или они должны выполняться не по принципу FIFO очереди, а там какой-нибудь depth-first search обходом.
Да, именно так. depth-first search — то, что нужно. Когда мы делаем ундо, то, конечно, нужно сделать ундо и всем детям.

У меня, правда, ундо не делается (все-таки в играх оно редко необходимо), но ничего не мешает его прикрутить. Команды выполняются приблизительно таким методом (псевдокод):


class CommandExecutor {
  RunTree (Command command) {
    TriggerBeforeEvent(command);
    command.Execute();
    TriggerInsideEvent(command);
    foreach (var child in command.children) RunTree (child);
    TriggerAfterEvent(command);
  }
}
Буду теперь думать о применимости/необходимости такого подхода в моем случае. В любом случае информация полезна. Спасибо, за развернутый ответ.
А если юнитов тысячи? Как будет оно работать со всеми этими созданиями классов на каждый чих?
Нормально работает. Подобное крутилось на рядовом сервере и держало тысячи онлайна. Уж одного игрока оно выдержит. Тем более этот движок можно вынести в отдельный поток.
Не-не, когда у одного игрока тысячи юнитов
Если рядовой сервер держит когда у тысяч игроков по 50 юнитов, то почему комп не выдержит когда у одного игрока 1000 юнитов?
Мы говорим о игре в реальном времени или пошаговой?..

Без ссылки на сам проект тяжело :)
Я говорю о пошаговой. Тем не менее даже в риал-тайм игре крайне сомнительно, что тормоза будут в логике, а не в рендере.
Тормоза могут быть где угодно, вот и спросил насколько этот подход затратный по ЦПУ/памяти в сравнении с другими архитектурными подходами.
Я никогда не упирался в производительность логики. Она всегда забирала доли процента от того, что берет рендер.
Sign up to leave a comment.

Articles