Як уникнути кругової залежності між гравцем та світом?


60

Я працюю над 2D грою, де можна рухатись вгору, вниз, вліво та вправо. У мене є два логічні об'єкти гри:

  • Гравець: має позицію щодо світу
  • Світ: Малює карту та програвач

Поки світ залежить від гравця (тобто має посилання на нього), потрібна його позиція, щоб зрозуміти, де намалювати гравця і яку частину карти намалювати.

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

Найпростіший спосіб, який я можу придумати, - це змусити гравця запитати у світу, чи можливий передбачуваний рух. Але це призведе до кругової залежності між гравцем та світом (тобто кожен має посилання на іншого), якої, здається, варто уникати. Єдиний спосіб, який я придумав, - це те, щоб Світ перемістив програвача , але я вважаю це дещо неінтуїтивним.

Який найкращий варіант? Або уникати кругової залежності не варто?


4
Чому, на вашу думку, кругова залежність - це погано? stackoverflow.com/questions/1897537 / ...
Fuhrmanator

@Fuhrmanator Я не думаю, що вони взагалі погані речі, але мені доведеться зробити в моєму коді речі дещо складнішими, щоб ввести його.
futlib

Я божевільний пост про нашу маленьку дискусію, хоча нічого нового: yannbane.com/2012/11/… ...
jcora

Відповіді:


61

Світ не повинен малювати себе; Renderer повинен намалювати Світ. Гравець не повинен малювати себе; Renderer повинен намалювати гравця відносно світу.

Гравець повинен запитати Світ про виявлення зіткнення; або, можливо, зіткнення повинні вирішуватися окремим класом, який би перевіряв виявлення зіткнень не тільки проти статичного світу, але і проти інших суб'єктів.

Я думаю, що Світ, мабуть, взагалі не повинен знати гравця; це повинен бути примітив низького рівня, а не об'єкт бога. Гравцю, ймовірно, потрібно буде використовувати деякі методи Світу, можливо, опосередковано (виявлення зіткнень або перевірка на наявність інтерактивних об'єктів тощо).


25
@ snake5 - Існує різниця між "може" і "повинен". Будь-що може намалювати що завгодно - але коли вам потрібно змінити код, який стосується малювання, набагато простіше перейти до класу "Візуалізація", а не шукати "Що-небудь", що малює. "нав'язливість на компартменталізацію" - інше слово для "згуртованості".
Нейт

16
@ Mr.Beast, ні, він ні. Він виступає за гарний дизайн. Забивати все в одному промаху класу немає сенсу.
jcora

23
Ой, я не думав, що це викликає таку реакцію :) Я не маю що додати до відповіді, але можу пояснити, чому я це дав - тому що я думаю, що це простіше. Не "належним" чи "правильним". Я не хотів, щоб це звучало так. Для мене це простіше, тому що якщо я вважаю, що я вирішую заняття з занадто великою кількістю обов'язків, розкол швидше, ніж примушувати наявний код читати. Мені подобається код в шматках, який я можу зрозуміти, і рефактор в реакції на проблеми, подібні до тієї, яку відчуває @futlib.
Ліосан

12
@ snake5 Якщо сказати, що додавання більше класів додає накладні витрати для програміста, то, на моєму досвіді, часто зовсім неправильно. На мою думку, 10x100 лінійні класи з інформативними іменами та чітко визначеними обов'язками легше читати і менше накладних для програміста, ніж один клас 1000 рядків бога.
Мартін

7
Як зауваження про те, що малює що, Rendererпотрібне певне, але це не означає, що логіка того, як кожна рендерінга обробляється Renderer, кожна річ, яка повинна бути намальована, повинна, ймовірно, успадковуватись із загального інтерфейсу, наприклад IDrawableабо IRenderable(або еквівалент інтерфейсу будь-якою мовою). RendererДумаю, світ міг би бути таким , але, здається, він би перевищив свою відповідальність, особливо якби він вже був IRenderableсамим собою.
zzzzBov

35

Ось як типовий механізм візуалізації поводиться з цими речами:

Існує принципова відмінність того, де об’єкт знаходиться в просторі, і того, як об’єкт намальований.

  1. Малювання предмета

    У вас зазвичай клас Renderer, який робить це. Він просто бере об’єкт (Модель) і малює на екрані. У ньому можуть бути такі методи, як drawSprite (Sprite), drawLine (..), drawModel (Model), які б вам не потрібні. Це Рендерер, тому він повинен робити усі ці речі. Він також використовує будь-який API, який у вас є під ним, щоб ви могли мати, наприклад, рендерінг, який використовує OpenGL, і той, який використовує DirectX. Якщо ви хочете перенести свою гру на іншу платформу, просто напишіть новий рендер і скористайтеся цим. Це "так" просто.

  2. Переміщення об’єкта

    Кожен об’єкт додається до чогось, що ми любимо називати SceneNode . Досягаєте цього за допомогою композиції. SceneNode містить об'єкт. Це воно. Що таке SceneNode? Це простий клас, що стосується всіх перетворень (положення, обертання, масштаб) об'єкта (зазвичай відносно іншого SceneNode) разом із фактичним об'єктом.

  3. Управління об’єктами

    Як управляються SceneNodes? Через SceneManager . Цей клас створює та відстежує кожен SceneNode у вашій сцені. Ви можете запитати його для конкретного SceneNode (зазвичай його ідентифікують за назвою рядка типу "Player" або "Таблиця") або списком усіх вузлів.

  4. Малювання світу

    Це вже має бути досить очевидним. Просто пройдіться по кожному SceneNode в сцені і запропонуйте Renderer намалювати його в потрібному місці. Ви можете намалювати його в потрібному місці, якщо рендері зберігають перетворення об'єкта перед його наданням.

  5. Виявлення зіткнення

    Це не завжди банально. Зазвичай ви можете запитати сцену про те, який об’єкт знаходиться у певній точці простору, або які об’єкти буде перетинати промінь. Таким чином, ви можете створити промінь від програвача в напрямку руху і запитати у менеджера сцени, що є першим об'єктом, який промінь перетинає. Потім ви можете перенести гравця на нову позицію, перемістити його на меншу суму (щоб він був поруч із об'єктом, що стикався) або взагалі не переміщувати його. Переконайтеся, що ці запити обробляються окремими класами. Вони повинні запитати у SceneManager список списків SceneNodes, але це ще одне завдання визначити, чи покриває SceneNode точку в просторі або перетинається з променем. Пам'ятайте, що SceneManager створює і зберігає лише вузли.

Отже, що таке гравець, а який світ?

Програвач може бути класом, що містить SceneNode, який, в свою чергу, містить модель для візуалізації. Ви переміщуєте програвач, змінюючи положення вузла сцени. Світ - це просто екземпляр SceneManager. Він містить усі об'єкти (через SceneNodes). Ви обробляєте виявлення зіткнень, роблячи запити про поточний стан сцени.

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


+1 - Я виявив, що будую свої ігрові системи приблизно так, і вважаю це досить гнучким.
Cypher

+1, чудова відповідь. Конкретніше і до речі, ніж моя власна.
jcora

+1, я дізнався так багато з цієї відповіді, і це навіть надихнуло закінчення. Дякуємо @rootlocus
joslinm

16

Чому ви хочете цього уникнути? Кругових залежностей слід уникати, якщо ви хочете зробити клас багаторазового використання. Але програвач - це не той клас, який взагалі потребує багаторазового використання. Чи хотіли б ви використати програвач без світу? Напевно, ні.

Пам'ятайте, що класи - це не що інше, як набір функціональних можливостей. Питання лише в тому, як можна розділити функціонал. Робіть все, що вам потрібно зробити. Якщо вам потрібна кругова декаденція, то так і буде. (Те саме стосується будь-яких функцій OOP. До речі, кодуйте речі таким чином, щоб вони слугували цілі, а не просто сліпо слідувати парадигмам.)

Відредагуйте
Гаразд, щоб відповісти на запитання: ви можете уникнути того, що гравцю потрібно знати світ для перевірки зіткнення, використовуючи зворотні дзвінки:

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);
    }
}

Однак зауважте, що вам, напевно, потрібна залежність від світу рано чи пізно, тобто коли вам потрібна функціональність Світу: ви хочете знати, де знаходиться найближчий ворог? Хочете знати, як далеко знаходиться наступний виступ? Залежність вона є.


4
+1 Кругова залежність насправді тут не проблема. На цьому етапі немає причин для цього хвилюватися. Якщо гра зростає і код дозріває, то, мабуть, буде хорошою ідеєю все-таки відновити ті класи Player і World у підкласах, мати належну систему на основі компонентів, класи для обробки вводу, можливо, Rended тощо. Але для старт, ніяких проблем.
Лоран Кувіду

4
-1, це точно не єдина причина не вводити кругові залежності. Не представляючи їх, ви полегшуєте розширювати та змінювати систему.
jcora

4
@Bane Без цього клею нічого не можна закодувати. Різниця полягає лише в тому, скільки непрямості ви додасте. Якщо у вас є класи Гра -> Світ -> Субстанція або якщо у вас класи Гра -> Світ, SoundManager, InputManager, PhysicsEngine, ComponentManager. Це робить речі менш читабельними через всю (синтаксичну) накладну і тим самим маючи на увазі складність. І в один момент вам потрібні компоненти, щоб взаємодіяти між собою. І це той момент, коли один клас клею робить речі простішими за все, що розділяється між багатьма класами.
API-Beast

3
Ні, ви рухаєтесь по воротах. Звичайно, щось треба дзвонити render(World). Дискусія ведеться навколо того, чи слід весь код забивати всередині одного класу чи чи слід ділити код на логічні та функціональні одиниці, які потім простіше підтримувати, розширювати та керувати ними. BTW, удача з повторним використанням цих менеджерів компонентів, фізичних двигунів та керуючих ресурсами, всі вони вміло недиференційовані та повністю поєднані.
jcora

1
@Bane Є й інші способи розділити речі на логічні шматки, ніж введення нових класів, btw. Ви також можете додавати нові функції або ділити файли на кілька розділів, розділених блоками коментарів. Це просто, не означає, що код буде безлад.
API-Beast

13

Здається, ваш сучасний дизайн суперечить першому принципу дизайну SOLID .

Цей перший принцип, який називають "принципом єдиної відповідальності", є загалом приємним керівництвом, яке слід дотримуватися, щоб не створювати монолітних об'єктів, робити все, що завжди зашкодить вашому дизайну.

Щоб конкретизувати, ваш Worldоб’єкт відповідає як за оновлення та утримання ігрового стану, так і за малювання всього.

Що робити, якщо ваш код візуалізації зміниться / повинен змінитися? Чому ви повинні оновлювати обидва класи, які насправді не мають нічого спільного з візуалізацією? Як уже сказав Ліосан, ви повинні мати Renderer.


Тепер, щоб відповісти на ваше фактичне запитання ...

Існує багато способів зробити це, і це лише один спосіб роз'єднання:

  1. Світ не знає, що таке гравець.
    • У ньому є перелік Objects, в якому знаходиться гравець, однак це не залежить від класу гравців (використовуйте спадщину для досягнення цього).
  2. Програвач оновлюється деякими InputManager.
  3. Світ обробляє рухи та виявлення зіткнень, застосовуючи належні фізичні зміни та надсилаючи оновлення до об’єктів.
    • Наприклад, якщо об'єкт A і об'єкт B стикаються, світ поінформує їх, і тоді вони зможуть впоратися з ним самостійно.
    • Світ все одно поводитиметься з фізикою (якщо ваш дизайн такий).
    • Тоді обидва об'єкти могли бачити, чи зіткнення їх цікавить чи ні. Наприклад, якщо об'єктом A був гравець, а об'єкт B - шип, то гравець міг нанести собі шкоду.
    • Однак це можна вирішити і іншими способами.
  4. RendererМалює всі об'єкти.

Ви говорите, що світ не знає, що таке гравець, але він обробляє виявлення зіткнень, які, можливо, повинні знати властивості гравця, якщо це один з об'єктів, що стикаються.
Маркус фон Броаді

Спадкування, світ повинен усвідомлювати якісь предмети, які можна описати загальним чином. Проблема полягає не в тому, що у світі є просто посилання на плеєр, а в тому, що він може залежати від нього як класу (тобто використовувати поля, подібні до healthяких має лише цей екземпляр Player).
jcora

Ах, ви маєте на увазі, що світ не має посилання на програвача, він просто має масив об'єктів, що реалізують інтерфейс ICollivable, разом із програвачем, якщо це потрібно.
Маркус фон Броаді

2
+1 Хороша відповідь. Але: "проігноруйте всіх людей, які вважають, що хороший дизайн програмного забезпечення не важливий". Поширені. Ніхто цього не сказав.
Лоран Кувіду

2
Відредаговано! І все-таки це видалося непотрібним ...
jcora

1

Гравець повинен запитати Світ про такі речі, як виявлення зіткнення. Спосіб уникнути кругової залежності - це не мати у світі залежність від гравця. Світ повинен знати, де він малює себе: ви, мабуть, хочете, щоб це було абстраговано далі, можливо, з посиланням на об'єкт Camera, який, у свою чергу, може містити посилання на якийсь Entity для відстеження.

Те, що ви хочете уникнути з точки зору кругових посилань, - це не стільки зберігання посилань один на одного, скільки швидше посилання один на одного явно в коді.


1

Кожного разу, коли два різних типи об’єктів можуть запитувати один одного. Вони будуть залежати один від одного, оскільки їм потрібно мати посилання на іншого, щоб викликати його методи.

Ви можете уникнути кругової залежності, якщо Світ запитує гравця, але Гравець не може просити Світу, або навпаки. Таким чином Світ має посилання на Гравців, але гравцям не потрібно посилатися на Світ. Або навпаки. Але це не вирішить проблему, оскільки Світ повинен буде запитати гравців, чи є у них щось запитати, і сказати їм у наступному дзвінку ...

Отже, ви не можете реально обійти цю проблему, і я думаю, що в цьому немає потреби турбуватися. Тримайте дизайн дурним простим, наскільки ви можете.


0

Розкриваючи деталі про гравця та світ, у вас є простий випадок, коли ви не хочете вводити кругову залежність між двома об’єктами (що в залежності від вашої мови може навіть не мати значення, дивіться за посиланням у коментарі Фурманатора). Існують щонайменше два дуже простих структурних рішення, які стосувалися б цієї та подібних проблем:

1) Ввести одноплодной шаблон в свій світовий клас . Це дозволить гравцеві (і кожному іншому об'єкту) легко знайти світовий об’єкт без дорогих пошукових запитів або постійно утримуваних посилань. Суть цього шаблону полягає лише в тому, що клас має статичну посилання на єдиний екземпляр цього класу, який встановлюється при створенні об'єкта і очищається при його видаленні.

Залежно від мови розробки та складності, яку ви хочете, ви можете легко реалізувати це як суперклас або інтерфейс і повторно використовувати його для багатьох основних класів, у яких ви не очікуєте, що у вашому проекті буде більш ніж один.

2) Якщо мова, яку ви розробляєте, підтримує її (багато хто робить), використовуйте слабкий довідник . Це посилання, яке не впливає на такі речі, як збирання сміття. Це корисно саме в цих випадках, просто не забудьте робити жодних припущень щодо того, чи існує об'єкт, на який ви слабко посилаєтесь, все ще існує.

У вашому конкретному випадку ваші гравці можуть слабко посилатися на світ. Перевага цього (як і в одиночному) полягає в тому, що вам не потрібно ходити в пошуках світового об'єкта якось кожен кадр або мати постійну довідку, яка буде перешкоджати процесам, постраждалим від кругових посилань, таких як збирання сміття.


0

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

Тож створіть новий об’єкт (званий GameMap, можливо) та збережіть у ньому дані рівня карти. Запишіть у нього функції, які взаємодіють із поточною картою.

Тоді вам також потрібен Rendererоб’єкт. Ви можете зробити цей Rendererоб'єкт тим, що містить GameMap і Player(а також Enemies), а також малює їх.


-6

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

Також можливо знищити посилання до / під час знищення об'єкта гравця, щоб ефективно зупинити проблеми, викликані круговими посиланнями.


1
Я з тобою. OOP занадто завищений. Підручники та освіта швидко переходять до ОО після засвоєння основних контрольних потоків. Програми OO, як правило, повільніше, ніж процедурний код, оскільки між вашими об'єктами існує бюрократія, у вас є багато доступу до покажчиків, що спричиняє недоліки пропусків кешу. Ваша гра працює, але дуже повільно. Справжні, дуже швидкі та багатофункціональні ігри, що використовують прості глобальні масиви та оптимізовані вручну, тонко налаштовані функції для всього, щоб уникнути пропусків кешу. Що може призвести до збільшення продуктивності в десятки разів.
Кальмарій
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.