Как избежать круговых зависимостей между игроком и миром?

60

Я работаю над 2D-игрой, где вы можете перемещаться вверх, вниз, влево и вправо. У меня есть два игровых логических объекта:

  • Игрок: имеет позицию относительно мира
  • Мир: рисует карту и игрока

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

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

Самый простой способ, который я могу придумать, - попросить игрока спросить мир, возможно ли предполагаемое движение. Но это привело бы к круговой зависимости между игроком и миром (т. Е. Каждая содержит ссылку на другую), которую, похоже, стоит избегать. Единственный способ, которым я придумал, - заставить Мир перемещать Игрока , но я нахожу это несколько не интуитивным.

Какой мой лучший вариант? Или избегать круговой зависимости не стоит?

futlib
источник
4
Почему вы думаете, что круговая зависимость это плохо? stackoverflow.com/questions/1897537/…
Fuhrmanator
@Fuhrmanator Я не думаю, что они, как правило, плохие вещи, но мне пришлось бы сделать вещи немного более сложными в моем коде, чтобы представить их.
futlib
Я схожу с ума по поводу нашего небольшого обсуждения, но ничего нового: yannbane.com/2012/11/… ...
jcora

Ответы:

61

Мир не должен рисовать сам; Рендерер должен нарисовать мир. Игрок не должен рисовать сам; Рендерер должен нарисовать Игрока относительно Мира.

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

Я думаю, что Мир, вероятно, вообще не должен знать об Игроке; это должен быть примитив низкого уровня, а не божественный объект. Игроку, вероятно, потребуется вызывать некоторые методы World, возможно, косвенно (обнаружение столкновений или проверка интерактивных объектов и т. Д.).

Liosan
источник
25
@ snake5 - Есть разница между «может» и «должен». Все может рисовать что угодно - но когда вам нужно изменить код, связанный с рисованием, гораздо проще перейти к классу "Renderer", чем искать "Anything", который рисует. «одержимость разделением» - это еще одно слово для «сплоченности».
Nate
16
@ Мистер. Нет, нет. Он защищает хороший дизайн. Втиснуть все в одну ошибку класса не имеет смысла.
Jcora
23
Вау, я не думал, что это вызовет такую ​​реакцию :) Мне нечего добавить к ответу, но я могу объяснить, почему я дал его - потому что я думаю, что это проще. Не «правильный» или «правильный». Я не хотел, чтобы это звучало так. Для меня это проще, потому что, если я решаю заняться классами со слишком большим количеством обязанностей, разделение происходит быстрее, чем принуждение существующего кода к чтению. Мне нравится код в кусках, который я могу понять, и рефакторинг в ответ на проблемы, подобные тем, с которыми сталкивается @futlib.
Лиосан
12
@ snake5 Говорить, что добавление большего количества классов приводит к дополнительным расходам для программиста, на мой взгляд, часто совершенно неверно. По моему мнению, классы строк 10х100 с информативными именами и четко определенными обязанностями легче читаются и менее затратны для программиста, чем один класс богов в 1000 строк.
Мартин
7
В качестве примечания о том, что рисует, необходим Rendererнекоторый тип a , но это не означает, что логика того, как каждая вещь отображается, обрабатывается Renderer, каждая вещь, которая должна быть нарисована, вероятно, должна наследоваться от общего интерфейса, такого как IDrawableили IRenderable(или эквивалентный интерфейс на любом языке, который вы используете). RendererПолагаю, мир мог бы быть таким , но кажется, что он переступил бы свою ответственность, особенно если бы он уже был собой IRenderable.
zzzzBov
35

Вот как типичный движок рендеринга обрабатывает эти вещи:

Существует фундаментальное различие между тем, где объект находится в пространстве и как объект рисуется.

  1. Рисование объекта

    Обычно у вас есть класс Renderer, который делает это. Он просто берет объект (модель) и рисует на экране. Он может иметь такие методы, как drawSprite (Sprite), drawLine (..), drawModel (Model), все, что вам нужно. Это рендерер, поэтому он должен делать все эти вещи. Он также использует любой API, который у вас есть, так что вы можете иметь, например, средство визуализации, которое использует OpenGL, и то, которое использует DirectX. Если вы хотите перенести свою игру на другую платформу, вы просто пишете новый рендерер и используете его. Это "так" легко.

  2. Перемещение объекта

    Каждый объект прикреплен к чему-то, что мы хотели бы назвать SceneNode . Вы достигаете этого через композицию. SceneNode содержит объект. Вот и все. Что такое SceneNode? Это простой класс, связывающий все преобразования (положение, вращение, масштаб) объекта (обычно относительно другого SceneNode) вместе с реальным объектом.

  3. Управление объектами

    Как управляются SceneNode? С помощью SceneManager . Этот класс создает и отслеживает каждый SceneNode в вашей сцене. Вы можете запросить конкретный SceneNode (обычно идентифицируемый строковым именем, таким как «Player» или «Table») или список всех узлов.

  4. Рисуя мир

    Это должно быть довольно очевидно к настоящему времени. Просто пройдите каждый SceneNode в сцене и попросите рендерера нарисовать его в нужном месте. Вы можете нарисовать его в нужном месте, если рендерер сохранит преобразования объекта перед его рендерингом.

  5. Обнаружение столкновений

    Это не всегда тривиально. Обычно вы можете запросить сцену о том, какой объект находится в определенной точке пространства, или какие объекты пересекает луч. Таким образом, вы можете создать луч из своего игрока в направлении движения и спросить у менеджера сцены, какой объект пересекает первый луч. Затем вы можете переместить игрока на новую позицию, переместить его на меньшую величину (чтобы он оказался рядом со сталкивающимся объектом) или вообще не перемещать его. Убедитесь, что эти запросы обрабатываются отдельными классами. Им следует запросить у SceneManager список узлов SceneNode, но еще одна задача - определить, охватывает ли этот узел SceneNode точку в пространстве или пересекается с лучом. Помните, что SceneManager только создает и хранит узлы.

Итак, что такое игрок и что такое мир?

Player может быть классом, содержащим SceneNode, который, в свою очередь, содержит модель для рендеринга. Вы перемещаете игрока, изменяя положение узла сцены. Мир - это просто пример SceneManager. Он содержит все объекты (через SceneNodes). Вы обрабатываете обнаружение столкновений, выполняя запросы о текущем состоянии сцены.

Это далеко не полное или точное описание того, что происходит внутри большинства двигателей, но оно должно помочь вам понять основы и то, почему важно соблюдать принципы ООП, подчеркнутые SOLID . Не соглашайтесь с тем, что реструктурировать код слишком сложно или что он вам не поможет. Вы выиграете намного больше в будущем, тщательно разработав свой код.

корневой годограф
источник
+1 - я обнаружил, что создаю свои игровые системы примерно так, и нахожу это достаточно гибким.
Cypher
+1, отличный ответ. Более конкретный и конкретный, чем мой.
Jcora
+1, я так много узнал из этого ответа, и у него даже был вдохновляющий финал. Спасибо @rootlocus
joslinm
16

Почему вы хотите избежать этого? Следует избегать циклических зависимостей, если вы хотите создать класс многократного использования. Но Player - это не тот класс, который вообще нужно многократно использовать. Хотели бы вы когда-нибудь использовать плеер без мира? Возможно нет.

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

Отредактируйте
Хорошо, чтобы ответить на вопрос: вы можете избежать того, что Игроку нужно знать Мир для проверки столкновений, используя обратные вызовы:

World::checkForCollisions()
{
  [...]
  foreach(entityA in entityList)
    foreach(entityB in entityList)
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
}

Player::onCollision(other)
{
  [... react on the collision ...]
}

Вид физики, который вы описали в этом вопросе, может быть обработан миром, если вы выставите скорость сущностей:

World::calculatePhysics()
{ 
  foreach(entityA in entityList)
    foreach(entityB in entityList)
    {
      [... move entityA according to its velocity as far as possible ...]
      if([... entityA has collided with the world ...])
         entityA.onWorldCollision();
      [... calculate the movement of entityB in order to know if A has collided with B ...]
      if([... entityA and entityB have collided ...])
         entityA.onCollision(entityB);
    }
}

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

API-Beast
источник
4
+1 Круговая зависимость здесь на самом деле не проблема. На данном этапе нет причин беспокоиться об этом. Если игра развивается и код становится более зрелым, то, вероятно, будет хорошей идеей реорганизовать эти классы Player и World в подклассах, иметь надлежащую компонентную систему, классы для обработки ввода, возможно, Rendered и т. Д. Но для начало, без проблем.
Лоран Кувиду
4
-1, это определенно не единственная причина не вводить циклические зависимости. Не вводя их, вы облегчаете расширение и изменение вашей системы.
Jcora
4
@Bane Вы не можете ничего кодировать без этого клея. Разница лишь в том, сколько косвенности вы добавляете. Если у вас есть классы Game -> World -> Entity или у вас есть классы Game -> World, SoundManager, InputManager, PhysicsEngine, ComponentManager. Это делает вещи менее читаемыми из-за всех (синтаксических) издержек и из-за этого подразумеваемой сложности. И в какой-то момент вам понадобятся компоненты для взаимодействия друг с другом. И это тот момент, когда один класс склеивания делает вещи проще, чем все, что делится на множество классов.
API-Beast
3
Нет, вы перемещаете ворота. Конечно, что-то должно позвонить render(World). Спор идет о том, должен ли весь код быть помещен в один класс, или же код должен быть разделен на логические и функциональные блоки, которые затем легче поддерживать, расширять и управлять ими. Кстати, удачи в повторном использовании этих менеджеров компонентов, физических движков и менеджеров ввода, все они умно недифференцированы и полностью связаны.
Jcora
1
@Bane Есть и другие способы разделить вещи на логические куски, кроме введения новых классов, кстати. Вы также можете добавлять новые функции или делить файлы на несколько разделов, разделенных блоками комментариев. Простая простота не означает, что код будет беспорядочным.
API-Beast
13

Ваш текущий дизайн, кажется, идет вразрез с первым принципом дизайна SOLID .

Этот первый принцип, называемый «принципом единой ответственности», как правило, является хорошим руководством, которому нужно следовать, чтобы не создавать монолитные, полезные объекты, которые всегда будут вредить вашему дизайну.

Конкретизируя, ваш Worldобъект отвечает как за обновление и поддержание состояния игры, так и за рисование всего.

Что если ваш код рендеринга изменится / должен измениться? Почему вы должны обновить оба класса, которые на самом деле не имеют ничего общего с рендерингом? Как уже сказал Лиосан, вы должны иметь Renderer.


Теперь, чтобы ответить на ваш актуальный вопрос ...

Есть много способов сделать это, и это только один из способов развязки:

  1. Мир не знает, кто такой игрок.
    • Однако у него есть список Objects, в котором находится игрок, но он не зависит от класса игрока (для этого используйте наследование).
  2. Плеер обновляется некоторыми InputManager.
  3. Мир управляет движением и обнаружением столкновений, применяя правильные физические изменения и отправляя обновления объектам.
    • Например, если объект A и объект B сталкиваются, мир сообщит им, и тогда они смогут справиться с этим самостоятельно.
    • Мир по-прежнему будет заниматься физикой (если ваш дизайн такой).
    • Затем оба объекта могли видеть, интересует ли их столкновение или нет. Например, если объект A был игроком, а объект B был шипом, то игрок мог наносить урон самому себе.
    • Это может быть решено другими способами, однако.
  4. RendererРисует все объекты.
jcora
источник
Вы говорите, что мир не знает, что такое игрок, но он обрабатывает обнаружение столкновений, которое может потребовать знать свойства игрока, если это один из объектов, сталкивающихся.
Маркус фон Броади
Наследование, мир должен осознавать какие-то объекты, которые можно описать в общем виде. Проблема не в том, что в мире есть ссылка на игрока, а в том, что он может зависеть от него как от класса (т. Е. Использовать поля, подобные healthкоторым имеет только этот экземпляр Player).
Jcora
Ах, вы имеете в виду, что мир не имеет ссылки на игрока, он просто имеет массив объектов, реализующих интерфейс ICollidable, вместе с игроком, если это необходимо.
Маркус фон Броади
2
+1 Хороший ответ. Но: «пожалуйста, игнорируйте всех людей, которые говорят, что хороший дизайн программного обеспечения не важен». Общие. Никто не сказал это.
Лоран Кувиду
2
Под редакцией! Это все равно казалось ненужным ...
Jcora
1

Игрок должен спросить мир о таких вещах, как обнаружение столкновений. Чтобы избежать циклической зависимости, не нужно, чтобы Мир зависел от Игрока. Мир должен знать, где он рисует сам: вы, вероятно, хотите, чтобы абстракция была удалена, возможно, со ссылкой на объект Camera, который, в свою очередь, может содержать ссылку на некоторый объект для отслеживания.

То, что вы хотите избежать с точки зрения циклических ссылок, это не столько хранение ссылок друг на друга, сколько обращение к друг другу явно в коде.

Том Джонсон
источник
1

Всякий раз, когда два разных типа объектов могут спрашивать друг друга. Они будут зависеть друг от друга, так как им нужно хранить ссылку на другую для вызова ее методов.

Вы можете избежать круговой зависимости, попросив Мир спросить Игрока, но Игрок не может спросить Мир, или наоборот. Таким образом, в Мире есть ссылки на Игроков, но игрокам не нужны ссылки на Мир. Или наоборот. Но это не решит проблему, потому что Мир должен будет спросить игроков, есть ли у них что-то спросить, и сказать им в следующем раунде ...

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

Calmarius
источник
0

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

1) Ввести одноплодной шаблон в свой мировой класс . Это позволит игроку (и любому другому объекту) легко находить объект мира без дорогостоящих поисков или постоянных ссылок. Суть этого паттерна в том, что у класса есть статическая ссылка на единственный экземпляр этого класса, который устанавливается при создании экземпляра объекта и очищается при его удалении.

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

2) Если язык, на котором вы разрабатываете, поддерживает его (многие это делают), используйте Weak Reference . Это ссылка, которая не влияет на такие вещи, как сборка мусора. Это полезно именно в этих случаях, просто не делайте никаких предположений о том, существует ли еще объект, на который вы ссылаетесь слабо.

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

FlintZA
источник
0

Как говорили другие, я думаю, что вы Worldделаете одну вещь слишком много: она пытается одновременно содержать игру Map(которая должна быть отдельной сущностью) и быть Rendererодновременно.

Поэтому создайте новый объект (который GameMap, возможно, называется ) и сохраните в нем данные уровня карты. Напишите в нем функции, которые взаимодействуют с текущей картой.

Тогда вам также нужен Rendererобъект. Вы можете сделать этот Rendererобъект вещью, которая содержит GameMap и PlayerEnemies), и также рисует их.

bobobobo
источник
-6

Вы можете избежать циклических зависимостей, не добавляя переменные в качестве членов. Используйте статическую функцию CurrentWorld () для игрока или что-то в этом роде. Не изобретайте интерфейс, отличный от того, который уже реализован в World, хотя это совершенно не нужно.

Также возможно уничтожить ссылку до / во время уничтожения объекта игрока, чтобы эффективно остановить проблемы, вызванные циклическими ссылками.

snake5
источник
1
Я с вами. ООП слишком переоценен. Учебники и обучение быстро переходят к ОО после изучения базовых элементов управления. ОО-программы, как правило, работают медленнее, чем процедурный код, поскольку между вашими объектами существует бюрократия, у вас много обращений к указателям, что приводит к дурацкой загрузке кэша. Ваша игра работает, но очень медленно. Настоящие, очень быстрые и многофункциональные игры, использующие простые глобальные массивы и оптимизированные вручную, точно настроенные функции для всего, чтобы избежать промахов кэша. Что может привести к десятикратному увеличению производительности.
Кальмариус