Jak uniknąć okrągłych zależności między Graczem a Światem?

60

Pracuję nad grą 2D, w której możesz poruszać się w górę, w dół, w lewo i w prawo. Mam zasadniczo dwa obiekty logiki gry:

  • Gracz: ma pozycję względem świata
  • Świat: Rysuje mapę i gracza

Jak dotąd świat zależy od gracza (tzn. Ma odniesienie do niego), potrzebuje swojej pozycji, aby dowiedzieć się, gdzie narysować postać gracza i jaką część mapy narysować.

Teraz chcę dodać wykrywanie kolizji, aby uniemożliwić graczowi poruszanie się przez ściany.

Najprostszym sposobem, jaki mogę wymyślić, jest poproszenie gracza o spytanie świata, czy zamierzony ruch jest możliwy. Wprowadziłoby to jednak cykliczną zależność między Graczem a Światem (tzn. Każda zawiera odniesienie do drugiej), co wydaje się warte uniknięcia. Jedynym sposobem, w jaki wpadłem na pomysł, jest to, żeby Świat poruszył Graczem , ale wydaje mi się to nieco nieintuicyjne.

Jaka jest moja najlepsza opcja? A może unikanie cyklicznej zależności nie jest tego warte?

futlib
źródło
4
Dlaczego uważasz, że zależność cykliczna jest złą rzeczą? stackoverflow.com/questions/1897537/...
Fuhrmanator,
@Fuhrmanator Nie sądzę, że są one ogólnie rzeczą złą, ale musiałbym sprawić, by w moim kodzie było trochę bardziej skomplikowanie, aby je wprowadzić.
futlib,
Oszalałem post o naszej małej dyskusji, ale nic nowego: yannbane.com/2012/11/… ...
jcora,

Odpowiedzi:

61

Świat nie powinien się rysować; Renderujący powinien narysować Świat. Gracz nie powinien się losować; Renderujący powinien narysować Gracza względem Świata.

Gracz powinien zapytać Świat o wykrycie kolizji; a może kolizje powinny być obsługiwane przez osobną klasę, która sprawdzałaby wykrywanie kolizji nie tylko ze statycznym światem, ale także z innymi aktorami.

Myślę, że Świat prawdopodobnie nie powinien być świadomy Gracza; powinien to być prymityw na niskim poziomie, a nie obiekt boga. Gracz prawdopodobnie będzie musiał wywołać niektóre metody świata, być może pośrednio (wykrywanie kolizji lub sprawdzanie interaktywnych obiektów itp.).

Liosan
źródło
25
@ snake5 - Istnieje różnica między „może” a „powinna”. Wszystko może rysować cokolwiek - ale kiedy trzeba zmienić kod dotyczący rysowania, o wiele łatwiej jest przejść do klasy „Renderer” niż szukać „Cokolwiek”, który rysuje. „obsesja na punkcie podziału” to inne słowo „spójność”.
Nate
16
@ Mr.Beast, nie, on nie jest. Opowiada się za dobrym projektem. Wrzucanie wszystkiego w jeden błąd klasy nie ma sensu.
jcora,
23
Zaraz, nie sądziłem, że wywoła taką reakcję :) Nie mam nic do dodania do odpowiedzi, ale mogę wyjaśnić, dlaczego jej udzieliłem - ponieważ uważam, że jest to prostsze. Nieprawidłowe lub prawidłowe. Nie chciałem, żeby to tak brzmiało. Jest to dla mnie prostsze, ponieważ jeśli zajmuję się zajęciami ze zbyt wieloma obowiązkami, podział jest szybszy niż wymuszanie czytelności istniejącego kodu. Lubię kod w zrozumiałych fragmentach i refaktoryzuję w reakcji na problemy takie jak ten, który napotyka @futlib.
Liosan
12
@ snake5 Powiedzenie, że dodanie większej liczby klas powoduje dodatkowe obciążenie dla programisty, często jest całkowicie błędne w moim doświadczeniu. Moim zdaniem klasy liniowe 10x100 z informacyjnymi nazwami i dobrze zdefiniowanymi obowiązkami są łatwiejsze do odczytania i mniej obciążają programistę niż pojedyncza boga 1000 linii.
Martin
7
Notatka o tym, co rysuje, Rendererwymaga pewnego rodzaju, ale to nie znaczy, że logika, w jaki sposób każda rzecz jest renderowana, jest obsługiwana przez Renderer, każda rzecz, którą należy narysować, powinna prawdopodobnie odziedziczyć po wspólnym interfejsie, takim jak IDrawablelub IRenderable(lub równoważny interfejs w dowolnym języku). Świat może być Renderer, jak sądzę, ale wygląda na to, że przekroczyłby swoją odpowiedzialność, zwłaszcza gdyby był już IRenderablesobą.
zzzzBov,
35

Oto jak typowy silnik renderujący obsługuje te rzeczy:

Istnieje zasadnicza różnica między miejscem, w którym obiekt znajduje się w przestrzeni, a sposobem jego rysowania.

  1. Rysowanie obiektu

    Zwykle masz klasę renderującą , która to robi. Po prostu bierze obiekt (Model) i rysuje na ekranie. Może mieć metody takie jak drawSprite (Sprite), drawLine (..), drawModel (Model), co tylko chcesz. Jest to Renderer, więc powinien robić wszystkie te rzeczy. Używa również dowolnego interfejsu API, który masz pod spodem, dzięki czemu możesz na przykład mieć renderer korzystający z OpenGL i taki, który korzysta z DirectX. Jeśli chcesz przenieść swoją grę na inną platformę, po prostu napisz nowy renderer i użyj go. To jest takie proste.

  2. Przenoszenie obiektu

    Każdy obiekt jest dołączony do czegoś, co lubimy nazywać Sceną . Osiągasz to poprzez kompozycję. SceneNode zawiera obiekt. Otóż ​​to. Co to jest SceneNode? Jest to prosta klasa zawierająca wszystkie przekształcenia (położenie, obrót, skalę) obiektu (zwykle względem innego obiektu SceneNode) wraz z rzeczywistym obiektem.

  3. Zarządzanie obiektami

    Jak zarządzane są SceneNodes? Za pomocą menedżera scen . Ta klasa tworzy i śledzi każdy SceneNode w twojej scenie. Możesz poprosić o konkretny SceneNode (zwykle identyfikowany przez ciąg znaków, taki jak „Player” lub „Table”) lub listę wszystkich węzłów.

  4. Rysowanie świata

    To powinno być już dość oczywiste. Po prostu przejdź przez każdy węzeł SceneNode w scenie i poproś Renderer, aby narysował go we właściwym miejscu. Możesz narysować go we właściwym miejscu, ponieważ renderer przechowuje przekształcenia obiektu przed jego renderowaniem.

  5. Wykrywanie kolizji

    To nie zawsze jest banalne. Zwykle można zapytać scenę o to, jaki obiekt znajduje się w pewnym punkcie przestrzeni lub jakie obiekty przecinają promienie. W ten sposób możesz stworzyć promień z odtwarzacza w kierunku ruchu i zapytać kierownika sceny, jaki jest pierwszy obiekt, który przecina promień. Następnie możesz przesunąć gracza na nową pozycję, przesunąć go o mniejszą ilość (aby zbliżyć go do kolidującego obiektu) lub nie ruszać go wcale. Upewnij się, że te zapytania są obsługiwane przez osobne klasy. Powinni poprosić menedżera SceneManager o listę węzłów SceneNodes, ale kolejnym zadaniem jest ustalenie, czy ten węzeł SceneNode pokrywa punkt w przestrzeni, czy przecina się z promieniem. Pamiętaj, że SceneManager tworzy i zapisuje tylko węzły.

Czym więc jest gracz i jaki jest świat?

Gracz może być klasą zawierającą węzeł SceneNode, który z kolei zawiera model do renderowania. Poruszasz odtwarzaczem, zmieniając pozycję węzła sceny. Świat jest po prostu instancją programu SceneManager. Zawiera wszystkie obiekty (poprzez SceneNodes). Wykrywasz kolizje, wykonując zapytania o bieżący stan sceny.

Jest to dalekie od pełnego lub dokładnego opisu tego, co dzieje się w większości silników, ale powinno pomóc ci zrozumieć podstawy i dlaczego ważne jest przestrzeganie zasad OOP podkreślonych przez SOLID . Nie pogódź się z myślą, że zbyt trudno jest zrestrukturyzować kod lub że tak naprawdę nie pomoże. W przyszłości zyskasz znacznie więcej, starannie projektując swój kod.

rootlocus
źródło
+1 - przyłapałem się na budowaniu takich systemów gier i uważam, że jest dość elastyczny.
Cypher,
+1, świetna odpowiedź. Bardziej konkretny i konkretny niż mój własny.
jcora
+1, nauczyłem się bardzo wiele z tej odpowiedzi i miało nawet inspirujące zakończenie. Dzięki @rootlocus
joslinm
16

Dlaczego miałbyś tego unikać? Jeśli chcesz stworzyć klasę wielokrotnego użytku, należy unikać zależności cyklicznych. Ale Gracz nie jest klasą, która w ogóle musi być wielokrotnego użytku. Czy kiedykolwiek chciałbyś korzystać z odtwarzacza bez świata? Prawdopodobnie nie.

Pamiętaj, że klasy to nic innego jak kolekcje funkcjonalności. Pytanie brzmi, jak podzielić funkcjonalność. Rób wszystko, co musisz zrobić. Jeśli potrzebujesz dekadencji kołowej, niech tak będzie. (Nawiasem mówiąc, to samo dotyczy wszystkich funkcji OOP. Koduj rzeczy w taki sposób, aby służyły celowi, a nie ślepo przestrzegaj paradygmatów.)

Edytuj
OK, aby odpowiedzieć na pytanie: możesz uniknąć sytuacji, w której gracz musi znać świat w celu sprawdzenia kolizji, korzystając z oddzwaniania:

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 ...]
}

Świat, który opisałeś w pytaniu, może być obsługiwany przez świat, jeśli ujawnisz prędkość bytów:

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

Pamiętaj jednak, że prędzej czy później będziesz potrzebować zależności od świata, to znaczy za każdym razem, gdy potrzebujesz funkcjonalności Świata: chcesz wiedzieć, gdzie jest najbliższy wróg? Chcesz wiedzieć, jak daleko jest następna półka? To jest zależność.

API-Beast
źródło
4
+1 Zależność cykliczna nie jest tak naprawdę problemem. Na tym etapie nie ma powodu się tym martwić. Jeśli gra rośnie i kod dojrzewa, prawdopodobnie dobrym pomysłem będzie zrefaktoryzowanie klas Player i World w podklasy, posiadanie odpowiedniego systemu opartego na komponentach, klasy do obsługi danych wejściowych, może Rendered itp. Ale dla początek, nie ma problemu.
Laurent Couvidou
4
-1, to zdecydowanie nie jedyny powód, aby nie wprowadzać zależności cyklicznych. Nie wprowadzając ich, ułatwisz rozbudowę i zmianę systemu.
jcora
4
@Bane Nie można nic kodować bez tego kleju. Różnica polega na tym, ile dodasz pośredniości. Jeśli masz klasy Gra -> Świat -> Podmiot lub jeśli masz klasy Gra -> Świat, SoundManager, InputManager, PhysicsEngine, ComponentManager. Sprawia, że ​​rzeczy są mniej czytelne z powodu całego (syntaktycznego) narzutu i wynikającej z tego złożoności. W pewnym momencie potrzebne będą komponenty do interakcji. I to jest punkt, w którym jedna klasa kleju ułatwia rzeczy niż wszystko podzielone na wiele klas.
API-Beast,
3
Nie, przesuwasz bramki. Oczywiście, że coś musi zadzwonić render(World). Debata dotyczy tego, czy cały kod powinien być wciśnięty w jedną klasę, czy też kod powinien być podzielony na jednostki logiczne i funkcjonalne, które są łatwiejsze w utrzymaniu, rozszerzaniu i zarządzaniu. BTW, powodzenia w ponownym użyciu tych menedżerów komponentów, silników fizyki i menedżerów danych wejściowych, wszystkie sprytnie niezróżnicowane i całkowicie powiązane.
jcora
1
@Bane Istnieją inne sposoby podziału rzeczy na logiczne części niż wprowadzenie nowych klas, btw. Równie dobrze możesz dodawać nowe funkcje lub dzielić pliki na wiele sekcji oddzielonych blokami komentarzy. Utrzymanie prostoty nie oznacza, że ​​kod będzie bałaganem.
API-Beast,
13

Twój obecny projekt wydaje się być sprzeczny z pierwszą zasadą SOLID .

Ta pierwsza zasada, zwana „zasadą pojedynczej odpowiedzialności”, jest ogólnie dobrą wskazówką, której należy przestrzegać, aby nie tworzyć monolitycznych obiektów typu „wszystko do zrobienia”, które zawsze zaszkodzą Twojemu projektowi.

Konkretując, twój Worldobiekt jest odpowiedzialny zarówno za aktualizację i utrzymanie stanu gry, jak i za rysowanie wszystkiego.

Co się stanie, jeśli Twój kod renderowania zmieni się / będzie musiał się zmienić? Dlaczego warto aktualizować obie klasy, które w rzeczywistości nie mają nic wspólnego z renderowaniem? Jak już powiedział Liosan, powinieneś mieć Renderer.


Teraz, aby odpowiedzieć na twoje aktualne pytanie ...

Można to zrobić na wiele sposobów, a to tylko jeden sposób na oddzielenie płatności:

  1. Świat nie wie, kim jest gracz.
    • Ma jednak listę Objects, w których znajduje się gracz, ale nie zależy to od klasy gracza (aby to osiągnąć, użyj dziedziczenia).
  2. Odtwarzacz jest aktualizowany przez niektórych InputManager.
  3. Świat obsługuje wykrywanie ruchu i kolizji, wprowadzając odpowiednie zmiany fizyczne i wysyłając aktualizacje do obiektów.
    • Na przykład, jeśli obiekt A i obiekt B zderzą się, świat poinformuje ich, a następnie będą mogli sobie z tym poradzić.
    • Świat nadal poradziłby sobie z fizyką (jeśli taki jest twój projekt).
    • Następnie oba obiekty mogą sprawdzić, czy kolizja ich interesuje, czy nie. Np. Jeśli obiekt A był graczem, a obiekt B był kolcem, wówczas gracz mógł zadać sobie obrażenia.
    • Można to jednak rozwiązać na inne sposoby.
  4. RendererZwraca wszystkie obiekty.
jcora
źródło
Mówisz, że świat nie wie, kim jest gracz, ale obsługuje wykrywanie kolizji, które może wymagać znajomości właściwości gracza, jeśli jest to jeden z obiektów zderzających się.
Markus von Broady,
Dziedzictwo świat musi być świadomy pewnego rodzaju obiektów, które można opisać w ogólny sposób. Problem nie polega na tym, że świat ma po prostu odniesienie do odtwarzacza, ale że może zależeć od niego jako klasy (tzn. Używać pól takich, healthjakie ma tylko ta instancja Player).
jcora
Ach, masz na myśli, że świat nie ma odniesienia do gracza, po prostu ma szereg obiektów implementujących interfejs ICollidable, wraz z odtwarzaczem w razie potrzeby.
Markus von Broady
2
+1 Dobra odpowiedź. Ale: „zignoruj ​​wszystkich ludzi, którzy twierdzą, że dobry projekt oprogramowania nie jest ważny”. Wspólny. Nikt tego nie powiedział.
Laurent Couvidou
2
Edytowane! W każdym razie wydawało się to niepotrzebne ...
jcora,
1

Gracz powinien zapytać świat o takie rzeczy, jak wykrywanie kolizji. Sposobem na uniknięcie okrągłej zależności jest brak uzależnienia świata od Gracza. Świat musi wiedzieć, gdzie się rysuje: prawdopodobnie chcesz, aby był on bardziej abstrakcyjny, być może z odniesieniem do obiektu Camera, który z kolei może zawierać odniesienie do jakiejś Istoty do śledzenia.

To, czego chcesz uniknąć w odniesieniu do cyklicznych odniesień, to nie tyle trzymanie odniesień do siebie, ale raczej bezpośrednie nawiązywanie do siebie w kodzie.

Tom Johnson
źródło
1

Ilekroć dwa różne typy obiektów mogą zadawać sobie pytania. Będą od siebie zależeć, ponieważ muszą mieć odniesienie do drugiego, aby wywołać jego metody.

Możesz uniknąć okrągłej zależności, każąc Światowi pytać Gracza, ale Gracz nie może pytać Świata lub odwrotnie. W ten sposób Świat ma odniesienia do Graczy, ale gracze nie potrzebują odniesienia do Świata. Lub odwrotnie. Ale to nie rozwiąże problemu, ponieważ świat będzie musiał zapytać graczy, czy mają coś o co zapytać, i powiedzieć im w następnym zaproszeniu ...

Tak więc nie można naprawdę obejść tego „problemu” i myślę, że nie trzeba się tym martwić. Niech projekt będzie głupi prosty tak długo, jak to możliwe.

Calmarius
źródło
0

Po usunięciu szczegółów dotyczących gracza i świata masz prosty przypadek, że nie chcesz wprowadzać okrągłej zależności między dwoma obiektami (które w zależności od twojego języka mogą nawet nie mieć znaczenia, zobacz link w komentarzu Fuhrmanatora). Istnieją co najmniej dwa bardzo proste rozwiązania strukturalne, które można zastosować do tego i podobnych problemów:

1) Wprowadź wzór singletonu do swojej światowej klasy . Umożliwi to graczowi (i każdemu innemu obiektowi) łatwe znalezienie obiektu świata bez kosztownych wyszukiwań lub trwałych łączy. Istotą tego wzorca jest to, że klasa ma statyczne odniesienie do jedynego wystąpienia tej klasy, które jest ustawiane na podstawie wystąpienia obiektu i usuwane po jego usunięciu.

W zależności od języka programowania i złożoności, którą chcesz, możesz łatwo wdrożyć go jako nadklasę lub interfejs i ponownie użyć go dla wielu głównych klas, w których nie spodziewasz się, że będziesz mieć więcej niż jedną w swoim projekcie.

2) Jeśli język, w którym się rozwijasz, obsługuje go (wiele osób to robi), użyj Słabego odniesienia . Jest to odwołanie, które nie wpływa na takie rzeczy, jak zbieranie śmieci. Jest to przydatne w tych właśnie przypadkach, pamiętaj jednak, aby nie przyjmować żadnych założeń dotyczących tego, czy obiekt, do którego odwołujesz się słabo, nadal istnieje.

W twoim szczególnym przypadku, Twoi Gracze mogą mieć słabe odniesienie do świata. Zaletą tego (jak w przypadku singletonu) jest to, że nie trzeba szukać obiektu świata w jakiś sposób w każdej ramce ani mieć stałego odwołania, które utrudni procesy związane z odwołaniami cyklicznymi, takimi jak zbieranie śmieci.

FlintZA
źródło
0

Jak mówili inni, myślę, że Worldrobi jedną rzecz za dużo: to stara się zarówno zawierać grę Map(która powinna być odrębną jednostką) i być Rendererw tym samym czasie.

Utwórz nowy obiekt (zwany GameMapprawdopodobnie) i zapisz w nim dane na poziomie mapy. Napisz w nim funkcje, które współdziałają z bieżącą mapą.

Wtedy też potrzebują Rendererobiektu. Państwo mogłoby uczynić ten Rendererprzedmiot rzecz, która zarówno zawiera GameMap i Player(także Enemies), a także przyciąga je.

Bobobobo
źródło
-6

Można uniknąć zależności cyklicznych, nie dodając zmiennych jako członków. Użyj statycznej funkcji CurrentWorld () dla odtwarzacza lub czegoś podobnego. Nie należy jednak wymyślać interfejsu innego niż już zaimplementowany w świecie, jest to całkowicie niepotrzebne.

Możliwe jest również zniszczenie referencji przed / podczas niszczenia obiektu gracza, aby skutecznie zatrzymać problemy spowodowane przez cykliczne referencje.

snake5
źródło
1
Jestem z tobą. OOP jest zbyt przereklamowany. Samouczki i edukacja szybko przechodzą do OO po nauczeniu się podstawowych rzeczy z zakresu sterowania. Programy OO są na ogół wolniejsze niż kod proceduralny, ponieważ między twoimi obiektami jest biurokracja, masz dużo dostępu do wskaźników, co powoduje spore straty pamięci podręcznej. Twoja gra działa, ale bardzo wolno. Prawdziwe, bardzo szybkie i bogate w funkcje gry wykorzystujące zwykłe globalne tablice i zoptymalizowane ręcznie, dopracowane funkcje dla wszystkiego, aby uniknąć błędów w pamięci podręcznej. Co może spowodować dziesięciokrotny wzrost wydajności.
Calmarius 18.04.13