Как работает сущностное общение?

115

У меня есть два пользовательских случая:

  1. Как entity_Aотправить take-damageсообщение на entity_B?
  2. Как бы entity_Aзапросить entity_BHP?

Вот с чем я столкнулся до сих пор:

  • Очередь сообщений
    1. entity_Aсоздает take-damageсообщение и отправляет его в entity_Bочередь сообщений.
    2. entity_Aсоздает query-hpсообщение и сообщения его в entity_B. entity_Bвзамен создает response-hpсообщение и публикует его entity_A.
  • Публикация / подписка
    1. entity_Bподписывается на take-damageсообщения (возможно, с некоторой упреждающей фильтрацией, поэтому доставляются только соответствующие сообщения). entity_Aвыдает take-damageсообщение, на которое ссылается entity_B.
    2. entity_Aподписывается на update-hpсообщения (возможно, отфильтрованные). Каждый кадр entity_Bтранслирует update-hpсообщения.
  • Сигнал / Слоты
    1. ???
    2. entity_Aсоединяет update-hpслот с entity_Bроссийским update-hpсигналом.

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

deft_code
источник

Ответы:

67

Хороший вопрос! Прежде чем перейти к конкретным вопросам, которые вы задали, я скажу: не стоит недооценивать силу простоты. Тенпн прав. Имейте в виду, что все, что вы пытаетесь сделать с помощью этих подходов, - это найти элегантный способ отложить вызов функции или отделить вызывающего абонента от вызываемого. Я могу рекомендовать сопрограммы как удивительно интуитивный способ облегчить некоторые из этих проблем, но это немного не по теме. Иногда вам лучше просто вызвать функцию и жить с тем фактом, что сущность A связана непосредственно с сущностью B. См. YAGNI.

Тем не менее, я использовал и был доволен моделью сигнал / слот в сочетании с простой передачей сообщений. Я использовал его в C ++ и Lua для довольно успешного названия для iPhone с очень плотным графиком.

В случае с сигналом / слотом, если я хочу, чтобы объект A делал что-то в ответ на то, что сделал объект B (например, открывает дверь, когда что-то умирает), я мог бы попросить объект A подписаться непосредственно на событие смерти объекта B. Или, возможно, субъект А подписывался на каждую группу объектов, увеличивал счетчик на каждое запущенное событие и открывал дверь после того, как N из них умерло. Кроме того, «группа объектов» и «N из них» обычно определяются конструктором в данных уровня. (Кроме того, это одна область, где сопрограммы могут действительно сиять, например, WaitForMultiple («Умирающий», entA, entB, entC); door.Unlock ();)

Но это может быть обременительным, когда дело доходит до реакций, которые тесно связаны с кодом C ++, или по сути эфемерных игровых событий: нанесение урона, перезагрузка оружия, отладка, управляемая игроками обратная связь с ИИ на основе определения местоположения. Именно здесь передача сообщений может заполнить пробелы. По сути, это сводится к чему-то вроде: «Скажите всем сущностям в этой области нанести урон через 3 секунды» или «Когда вы закончите физику, чтобы выяснить, кого я застрелил, скажите им запустить эту функцию сценария». Трудно понять, как это сделать, используя публикацию / подписку или сигнал / слот.

Это легко может быть излишним (по сравнению с примером tenpn). Это также может быть неэффективным раздутием, если у вас много действий. Но, несмотря на свои недостатки, этот подход «сообщения и события» очень хорошо сочетается со скриптовым игровым кодом (например, в Lua). Код сценария может определять свои собственные сообщения и события и реагировать на них, не обращая внимания на код C ++. И код сценария может легко отправлять сообщения, которые запускают код C ++, например, изменение уровней, воспроизведение звуков или даже просто разрешение оружию установить, какой урон наносит сообщение TakeDamage. Это сэкономило мне массу времени, потому что мне не приходилось постоянно дурачиться с Луабиндом. И это позволило мне хранить весь мой luabind-код в одном месте, потому что его было немного. Когда правильно соединены,

Кроме того, мой опыт использования варианта № 2 заключается в том, что вам лучше обрабатывать его как событие в другом направлении. Вместо того, чтобы спрашивать, каково состояние объекта, запускайте событие / отправляйте сообщение всякий раз, когда здоровье вносит существенные изменения.

Что касается интерфейсов, между прочим, я реализовал три класса для реализации всего этого: EventHost, EventClient и MessageClient. EventHosts создает слоты, EventClients подписываются / подключаются к ним, а MessageClients связывают делегата с сообщением. Обратите внимание, что целевой объект делегата MessageClient не обязательно должен быть тем же объектом, который владеет ассоциацией. Другими словами, MessageClients могут существовать исключительно для пересылки сообщений другим объектам. FWIW, метафора хост / клиент является неуместной. Источник / Раковина могли бы быть лучшими понятиями.

Извините, я там немного побродил. Это мой первый ответ :) Надеюсь, это имело смысл.

BRaffle
источник
Спасибо за ответ. Великолепные идеи. Причина, по которой я закончил проектировать передачу сообщений, из-за Луа. Я хотел бы иметь возможность создавать новое оружие без нового кода C ++. Итак, ваши мысли ответили на некоторые из моих вопросов.
deft_code
Что касается сопрограмм, я тоже большой сторонник сопрограмм, но я никогда не играю с ними в C ++. У меня была смутная надежда на использование сопрограмм в коде lua для обработки блокирующих вызовов (например, ожидание смерти). Стоило ли это усилий? Я боюсь, что я могу быть ослеплен моим сильным желанием сопрограмм в c ++.
deft_code
И наконец, что это была за игра для iPhone? Могу ли я получить больше информации о системе сущностей, которую вы использовали?
deft_code
2
Система сущностей была в основном на C ++. Так, например, был класс Imp, который обрабатывал поведение Imp. Lua может изменить параметры Imp при появлении или в сообщении. Цель с Lua состояла в том, чтобы уложиться в сжатые сроки, а отладка кода Lua занимает очень много времени. Мы использовали Lua для написания сценариев уровней (какие объекты куда попадают, события, которые происходят, когда вы нажимаете триггеры). Так что в Lua мы бы сказали что-то вроде SpawnEnt («Imp»), где Imp - это зарегистрированная вручную фабричная ассоциация. Он всегда будет появляться в одном глобальном пуле сущностей. Красиво и просто. Мы использовали много smart_ptr и weak_ptr.
BRaffle
1
Итак, BananaRaffle: Вы бы сказали, что это точное резюме вашего ответа: «Все 3 решения, которые вы опубликовали, имеют свое применение, как и другие. Не ищите одно идеальное решение, просто используйте то, что вам нужно, где это имеет смысл «.
Ipsquiggle
76
// in entity_a's code:
entity_b->takeDamage();

Вы спросили, как комерческие игры делают это. ;)

tenpn
источник
8
Голосование вниз? Серьезно, вот как это обычно делается! Системы сущностей великолепны, но они не помогают преодолеть ранние этапы.
Tenpn
Я делаю флеш игры профессионально, и так я это делаю. Вы называете врага.даж (10) и затем ищите любую информацию, которая вам нужна, от публичных получателей.
Иан
7
Это серьезно, как это делают коммерческие игровые движки. Он не шутит. Target.NotifyTakeDamage (DamageType, DamageAmount, DamageDealer и т. Д.) Обычно так и происходит.
А. А. Грапсас
3
Не наносят ли коммерческие игры "ущерб" тоже? :-P
Рикет
15
Да, они наносят урон от ошибок, кроме всего прочего. :)
LearnCocos2D
17

Более серьезный ответ:

Я видел, как часто использовали доски. Простые версии - это не что иное, как распорки, которые обновляются такими вещами, как HP объекта, которые затем могут запрашивать объекты.

Ваши классные доски могут быть либо представлением мира об этой сущности (спросите у доски B, каков его HP), либо представлением субъекта о мире (A запрашивает свою доску, чтобы узнать, какова цель HP A).

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

Более сложные классные доски могут больше походить на хеш-таблицы, отображающие строки в значения. Это более ремонтопригодно, но, очевидно, требует затрат времени выполнения.

Классическая доска традиционно является только односторонней связью - она ​​не справится с нанесением ущерба.

tenpn
источник
Я никогда не слышал о модели доски до сих пор.
deft_code
Они также хороши для уменьшения зависимостей, так же, как делает очередь событий или модель публикации / подписки.
Tenpn
2
Это также каноническое «определение» того, как «идеальная» система E / C / S «должна работать». Компоненты составляют доску; Системы - это код, действующий на него. (Объекты, конечно, просто long long intсхожи или похожи в чистой системе ECS.)
BRPocock
6

Я немного изучил эту проблему и нашел хорошее решение.

В основном это все о подсистемах. Это похоже на идею классной доски, упомянутую tenpn.

Сущности состоят из компонентов, но они являются только имущественными пакетами. В самих сущностях поведение не реализовано.

Допустим, у сущностей есть компонент Health и компонент Damage.

Затем у вас есть несколько MessageManager и три подсистемы: ActionSystem, DamageSystem, HealthSystem. В какой-то момент ActionSystem вычисляет игровой мир и генерирует событие:

HIT, source=entity_A target=entity_B power=5

Это событие публикуется в MessageManager. Теперь в определенный момент времени MessageManager просматривает ожидающие сообщения и обнаруживает, что DamageSystem подписалась на сообщения HIT. Теперь MessageManager доставляет сообщение HIT в DamageSystem. DamageSystem просматривает список сущностей, имеющих компонент Damage, вычисляет очки урона в зависимости от силы удара или какого-либо другого состояния обеих сущностей и т. Д. И публикует событие

DAMAGE, source=entity_A target=entity_B amount=7

HealthSystem подписалась на сообщения DAMAGE, и теперь, когда MessageManager публикует сообщение DAMAGE в HealthSystem, HealthSystem имеет доступ к обеим сущностям entity_A и entity_B со своими компонентами Health, поэтому снова HealthSystem может выполнять свои вычисления (и, возможно, публиковать соответствующее событие в MessageManager).

В таком игровом движке формат сообщений является единственной связью между всеми компонентами и подсистемами. Подсистемы и объекты полностью независимы и не знают друг друга.

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

JustAMartin
источник
Это гораздо лучший ответ, чем принятый ответ ИМО. Отделенный, поддерживаемый и расширяемый (а также не связывающий бедствие как ответ на шутку entity_b->takeDamage();)
Дэнни Ярославский
4

Почему бы не иметь глобальную очередь сообщений, что-то вроде:

messageQueue.push_back(shared_ptr<Event>(new DamageEvent(entityB, 10, entityA)));

С участием:

DamageEvent(Entity* toDamage, uint amount, Entity* damageDealer);

И в конце игрового цикла / обработки событий:

while(!messageQueue.empty())
{
    Event e = messageQueue.front();
    messageQueue.pop_front();
    e.Execute();
}

Я думаю, что это шаблон командования. И Execute()является чисто виртуальным в Event, производные которого определяют и делают вещи. Так вот:

DamageEvent::Execute() 
{
    toDamage->takeDamage(amount); // Or of course, you could now have entityA get points, or a recognition of damage, or anything.
}
Коммунистическая утка
источник
3

Если ваша игра однопользовательская, просто используйте метод целевых объектов (как предложил tenpn).

Если вы (или хотите поддерживать) многопользовательский режим (если быть точным, мультиклиент), используйте очередь команд.

  • Когда A наносит урон B на клиенте 1, просто ставит в очередь событие повреждения.
  • Синхронизировать очереди команд через сеть
  • Обработка команд в очереди с обеих сторон.
Andreas
источник
2
Если вы серьезно относитесь к тому, чтобы избежать мошенничества, A вообще не наносит вреда B на клиенте. Клиент, владеющий A, отправляет на сервер команду «атака B», которая делает именно то, что сказал tenpn; Затем сервер синхронизирует это состояние со всеми соответствующими клиентами.
@Joe: Да, если есть сервер, который является допустимой точкой для рассмотрения, но иногда вполне можно доверять клиенту (например, на консоли), чтобы избежать большой нагрузки на сервер.
Андреас
2

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

Получающий урон объект / компонент / что-либо должно выдвигать события либо в локальную очередь событий, либо в систему на равном уровне, который содержит повреждения-события.

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

Саймон
источник
1

Просто позвони. Не делайте request-hp, следующий за query-hp - если вы будете следовать этой модели, вы попадете в мир боли.

Возможно, вы захотите взглянуть и на Mono Continuations. Я думаю, что это было бы идеально для NPC.

Джеймс Беллингер
источник
1

Так что же произойдет, если у нас есть игрок A и B, пытающийся ударить друг друга в одном и том же цикле update ()? Предположим, что обновление () для игрока A происходит до обновления () для игрока B в цикле 1 (или тика, или как вы его называете). Есть два сценария, о которых я могу думать:

  1. Немедленная обработка через сообщение:

    • игрок A.Update () видит, что игрок хочет нажать B, игрок B получает сообщение, уведомляющее о повреждении.
    • игрок B.HandleMessage () обновляет точки попадания для игрока B (он умирает)
    • игрок B.Update () видит, что игрок B мертв ... он не может атаковать игрока A

Это несправедливо, игрок A и B должны ударить друг друга, игрок B умер до удара A только потому, что этот объект / объект игры получил update () позже.

  1. Очередь сообщения

    • Игрок A.Update () видит, что игрок хочет нажать B, игрок B получает сообщение с уведомлением о повреждении и сохраняет его в очереди
    • Player A.Update () проверяет свою очередь, она пуста
    • игрок B.Update () сначала проверяет ходы, поэтому игрок B посылает сообщение игроку A с повреждением.
    • игрок B.Update () также обрабатывает сообщения в очереди, обрабатывает урон от игрока A
    • Новый цикл (2): игрок А хочет выпить зелье здоровья, чтобы вызвать игрока А. Обновление () и ход был обработан.
    • Player A.Update () проверяет очередь сообщений и обрабатывает ущерб от игрока B

Опять же, это несправедливо. Игрок А должен брать очки жизни в один и тот же ход / цикл / тик!


источник
4
Вы на самом деле не отвечаете на вопрос, но я думаю, что ваш ответ сам по себе станет отличным вопросом. Почему бы не пойти дальше и спросить, как решить такую ​​«несправедливую» расстановку приоритетов?
bummzack
Я сомневаюсь, что большинство игр заботятся об этой несправедливости, потому что они обновляются так часто, что это редко является проблемой. Один простой обходной путь - это чередование итераций вперед и назад по списку сущностей при обновлении.
Kylotan
Я использую 2 вызова, поэтому я вызываю Update () для всех сущностей, затем после цикла повторяю итерацию и вызываю что-то вроде pEntity->Flush( pMessages );. Когда entity_A генерирует новое событие, он не читается entity_B в этом кадре (у него тоже есть шанс принять зелье), затем оба получают урон, и после этого они обрабатывают сообщение об исцелении зельем, которое будет последним в очереди , Игрок B все равно умирает, поскольку сообщение с зельем является последним в очереди: P, но оно может быть полезно для других видов сообщений, таких как очистка указателей для мертвых объектов.
Пабло Ариэль
Я думаю, что на уровне фреймов большинство реализаций игр просто несправедливы. как сказал Килотан.
v.oddou
Эту проблему безумно легко решить. Просто нанесите ущерб друг другу в обработчиках сообщений или как угодно. Вы определенно не должны помечать игрока как мертвого внутри обработчика сообщений. В «Update ()» вы просто делаете «if (hp <= 0) die ();» (в начале "Update ()", например). Таким образом, оба могут убить друг друга одновременно. Также: часто вы не наносите урон игроку напрямую, а через какой-то промежуточный объект, например, пулю.
Тара