Autor Wątek: [C#] Organizacja klas w grze RTS  (Przeczytany 2567 razy)

Offline ShadowDancer

  • Redaktor

# Styczeń 29, 2012, 21:17:58
Dłubię sobie troszkę przy moim kosmicznym RTS i natrafiłem na problem, którego jedynym rozwiązaniem wydaje mi się wielokrotne dziedziczenie - jak mniemam, mam tu gdzieś błąd projektowy ;)

Aktualnie posiadam takie klasy:
-UnitBase (klasa abstrakcyjna, po której dziedziczą inne klasy - zawiera w sobie dane np. do pokazywania statku w gui, niszczenia go, obliczania HP, kolizji itp.)

Do tego punktu wszystko jest ok, ale dalej:
-ShipBase (obiekt mogący mieć broń i poruszać się, dziedziczy po nim np. intercreptor)
-FactoryBase (pozwala na produkcję jednostek, dziedziczy po nim np: Hanger, MiningStation)

Wygląda ok, nie? Ale teraz mamy np:
Battlecruiser - może sobie latać, strzelać i produkować intercreptory.

Jak radzicie rozwiązać ten problem?
Interface odpadają, bo nie chce duplikować kodu, to nie to.
Ja tu widzę 2 rozwiązania:
1. Wrzucić wszystko do UnitBase i np. inicjalizować kolejne moduły, poprzez jakieś funkcje. Wydaje mi się to niepotrzebne
2. Zastosować DOD i zrobić to na zasadzie komponentów - obiekt jest kontenerem na komponenty i po dodaniu komponentu ma odpowiednie funkcje

Offline Mr. Spam

  • Miłośnik przetworów mięsnych

Offline Kos

  • Użytkownik
    • kos.gd

# Styczeń 29, 2012, 21:30:29
Dłubię sobie troszkę przy moim kosmicznym RTS i natrafiłem na problem, którego jedynym rozwiązaniem wydaje mi się wielokrotne dziedziczenie - jak mniemam, mam tu gdzieś błąd projektowy ;)

Aktualnie posiadam takie klasy:
-UnitBase (klasa abstrakcyjna, po której dziedziczą inne klasy - zawiera w sobie dane np. do pokazywania statku w gui, niszczenia go, obliczania HP, kolizji itp.)

No i tu wg mnie jest cały błąd projektowy :-) Dlaczego jednostka miałaby być abstrakcyjna? Pisałem o tym już gdzieś tam (o, tu!) i pewnie gdzieś jeszcze, nawet notkę na bloga miałem skrobnąć, ale nie skrobnąłem w końcu.

W każdym razie: Jednostka to jasna rzecz, nie rób jej abstrakcyjną. Skomplikowanie kodu wokół niej zmniejsz wywalając funkcjonalności do obiektów powiązanych asocjacją, a nie przez ciągnięcie dziedziczenia w dół.

Cytuj
2. Zastosować DOD i zrobić to na zasadzie komponentów - obiekt jest kontenerem na komponenty i po dodaniu komponentu ma odpowiednie funkcje

Można mówić na to DOD (niepoprawnie zresztą), można mówić komponenty, ale wg mnie to co sugeruję to po prostu poprawnie zastosowany OOP bez żadnych czarów :).




(napisałem w paru słowach i zalinkowałem drugi temat, ale czy to dość jasne? jak nie, to pisz, rozwinę na Twoim konkretnym przykładzie)
« Ostatnia zmiana: Styczeń 29, 2012, 21:33:11 wysłana przez Kos »

Offline Paweł

  • Użytkownik

# Styczeń 29, 2012, 22:03:46
Komponenty! Początkowo implementajca może może nie być oczywista, jak jak już się złapie ideę to jest z górki. Ile ja razy przeglądałem prezentacje zanim mnie oświeciło. Co do samych komponentów to czytałem o 2 podejściach: "object centric" oraz "component centric". Pierwsze to jak napisałeś obiekt trzyma (jest właścicielem) komponenty (instancje komponentów ?). Drugie to poszczególne komponenty - kontenery trzymające instancje obiektów, to mi się szczególnie podoba, ale ma też wady o których mozna poczytać w prezentacjach w podlinkowanym temacie.  Jednak jak się dobrze zrobi dziedziczenie i archetypy to wprowadzenie nowego typu ( w sense gry ) obiektów jest proste. Dziedziczy się tutaj po nie klasie a po obiekcie ( co niesie za sobą szerokie konsekwencje ).

Offline koirat

  • Użytkownik

# Styczeń 29, 2012, 22:08:44
Cytuj
jak mniemam, mam tu gdzieś błąd projektowy ;)
A to masz jakiś projekt ;) ?

Domyślam się iż jednak nie masz zaprojektowanej tej gry i improwizujesz w trakcie kodzenia.
Jeśli robisz to w ten sposób wydaje mi się iż jednak łatwiej ci będzie robić komponenty (ale bez DODy).
Niech komponenty będą w relacji kompozycji z Obiektem ojcem tak wydaje mi się łatwiej :).

Offline ShadowDancer

  • Redaktor

# Styczeń 29, 2012, 22:19:00
@Kos:
Z mojej interpretacji wynika, że uważasz, że lepiej jest umieścić Unit w klasie Intercreptor, zamiast ją dziedziczyć. Wydaje mi się to słuszne, aczkolwiek chciałbym, żebyś mnie po przekonywał jeszcze trochę ;)
O ile się nie mylę, to widzę tu problem komunikacji między różnymi modułami - zarówno wewnętrznymi, jak i zewnętrznymi. Przecież zarówno factory jak i ship muszą mieć jakieś podstawowe dane i komunikować się między sobą.

Prosty przykład: obsługa rozkazów - po otrzymaniu rozkazu jednostka sprawdza, czy to jest jakiś specyficzny (dal niej) rozkaz, a jeśli nie, to "puszcza" go w dół hierarchii (czyli np. MiningStation obsługuje gather albo puszcza do FactoryBase, która obsługuje Train albo do UnitBase, która obsługuje move, stop itp. itd.).
Nie bardzo wiem jak wyglądało by to w przypadku tego co proponujesz.

Kolejny przykład: komunikacja między modułami - załóżmy, że moduł a chce zmienić coś w module b, jednak skoro są one w klasie X, to nie "widzą" siebie nawzajem (chyba, żeby przekazać im na siebie wskaźnik, ale to bez sensu).
Rozwiązaniem jakie tu widzę to w klasie X mieć kolekcję obiektów i przekazać każdemu modułowi wskaźnik na nią, dzięki czemu może poszperać sobie we wszystkich modułach.

UnitBase to de facto nie jest jednostka jako taka - zawiera np. dane do obsługi kolizji, otrzymywania rozkazów, liczenia życia i podobnych - to musi mieć każda "rzecz", ale samo to nie czyni niczego. Dlatego wyodrębniłem każdą klasę.

Chociaż patrząc teraz nie wydaje mi się, aby np ShipBase powinno dziedziczyć po tym. Idealną sytuacją dla mnie by było to, gdyby można było po prostu załączać prosto kolejne moduły mające jakiś podstawowy kod, jednak dając możliwość rozszerzenia go - np. stacja wydobywcza po wyprodukowaniu statku powinna skierować go do najbliższej asteroidy.

@Koriat:
Projekt oczywiście był, jednak w miarę pisania rzeczywistość go brutalnie zrównała z ziemią ;)

Offline Kos

  • Użytkownik
    • kos.gd

# Styczeń 29, 2012, 23:51:06
Cytuj
O ile się nie mylę, to widzę tu problem komunikacji między różnymi modułami

Nie wprowadziliśmy żadnego pojęcia modułu, by mówić o komunikacji między nimi. Projektujemy układ klas dla jednostek w grze i tego się na razie trzymajmy, jeszcze to sobie zdążysz skomplikować :).



Z mojej interpretacji wynika, że uważasz, że lepiej jest umieścić Unit w klasie Intercreptor, zamiast ją dziedziczyć. Wydaje mi się to słuszne, aczkolwiek chciałbym, żebyś mnie po przekonywał jeszcze trochę ;)

Klasa Interceptor? Jedna klasa na jeden obiekt gry? Ależ po co sobie tak strzępić palce! I co, ilekroć będziesz chciał dodać nową jednostkę, to dodać jeszcze jedną nową klasę do już-wielkiej-hierarchii? Przecież to można dużo prościej.

Po kolei.. Od czego wychodzimy? Jednostka. Unit. To jest nasz punkt wyjścia, nasza początkowa abstrakcja, projektujemy jednostki. Wyobraź sobie jedną, wielką, koncepcyjną na razie klasę "Jednostka", która zawiera każdą możliwą jednostkę z Twojej gry.

Co Twoja gra ma robić z jednostką? Ma je trzymać w jakichś kolekcjach. Super. Każda jednostka ma swoją pozycję, rozmiar, swoje HP. Jest z nią powiązana jakaś grafika. Ekstra! Należy do któregoś gracza. Proste! Mnóstwo podobieństw. Już Ci się układa zbiór danych, które razem powiązane dają jednostkę. Jeśli bardzo lubisz enkapsulację, to na wierzch tych danych możesz sobie teraz wstawić jakiś interfejs, np. move(), inflictDamage(), chociaż imo szkoda palców.

Rozwijamy to dalej. Może być budynkiem, może nie być budynkiem - najprostszy wariant to pole z boolem.

Dalej: Może mieć jakieś skille! Załóżmy całe budowanie interceptorów. Proste- wprowadzasz sobie drugą abstrakcję pod tytułem "skill" = "coś, co ma nazwę, obrazek i jak w to klikniesz, to coś się dzieje". Zauważmy, że w przeciwieństwie do jednostek, które są do siebie raczej podobne, każdy skill faktycznie jest na tyle inny od innego, że w stosunku do nich polimorfizm podtypowy może mieć sens - więc robimy sobie interfejs Skill z polami Nazwa, Obrazek i z wirtualną metodą Wykonaj. Teraz możesz wydziedziczyć sobie z niego konkretne skille: Klasa "Skill budujący" (np. budujący interceptory w czasie X za Y kryształów każdy), albo skill włączający niewidzialność za Z many, albo coś w tym stylu. Budynek byłby jednostką ze skillami "rób jednostkę" i "wymyślaj technologię".

I tak dalej, i tak dalej...
Idąc tą metodą dojdziesz do momentu, że nie będziesz potrzebował klasy Town Hall czy Carrier, bo Town Hall to zwykły Unit z isBuilding = True i skillami = new Buduj(peony) i new Wynajduj(koło), a Carrier to unit z isBuilding = false i skillem Buduj(interceptor).

Klasy masz trochę dłuższe, ale sensowne. Mamy na razie całe 2 abstrakcje: Jednostkę oraz Skill. Wprowadzisz na pewno kilka następnych w podobny sposób, ale cały czas je policzysz na palcach jednej ręki. Brzytwa Ockhama działa - nie mnożysz bytów ponad potrzebę. :)

Katalog jednostek możesz teraz zrobić nie jako 100 klas, a jako zwykłą fabrykę, która na życzenie makeCarrier tworzy nową Unit i odpowiednio ją wypełnia. I teraz nagle robisz wielkie "o!" i zauwasz, że tym minimalistycznym designem niechcący stworzyłeś sobie możliwość, by opis każdej jednej jednostki, budynku, itd leżał nie w kodzie, a w plikach z danymi! Możesz sobie składać jakiekolwiek jednostki w obrębie abstrakcji, które stworzyłeś - a im luźniejsze te abstrakcje, tym masz więcej swobody. Możesz z tego skorzystać, jeśli chcesz.


Subtyping jest przydatny, owszem- ale nie zmienia to faktu, że jest najbardziej overusowana i abusowana funkcja OOP ever. Nie trzeba go wciskać wszędzie, gdzie tylko jesteś w stanie wymyślić jakieś "is-a".

Zadanie "jak wprowadzić do tego designu nową abstrakcję" nie jest trywialne, czasem stwierdzisz że jednak subtyping się przyda (jak przy skillach), a czasem stwierdzisz, że byłby psu na budę.




Cytuj
Prosty przykład: obsługa rozkazów - po otrzymaniu rozkazu jednostka sprawdza, czy to jest jakiś specyficzny (dal niej) rozkaz, a jeśli nie, to "puszcza" go w dół hierarchii (czyli np. MiningStation obsługuje gather albo puszcza do FactoryBase, która obsługuje Train albo do UnitBase, która obsługuje move, stop itp. itd.). Nie bardzo wiem jak wyglądało by to w przypadku tego co proponujesz.

Fajny przykład. Pytanie: czy tego typu hierarchia tu coś ułatwia, czy komplikuje? Jeżeli nie masz hierarchii, to masz dane na wierzchu - implementacja danego rozkazu (np. ruchu czy ataku) sama może sprawdzić, czy jednostka go obsługuje (np. bo ma kółka albo broń), czy nie. W tym układzie jednostka i rozkaz to dwie różne rzeczy. W tamtym rozkazy są częścią jednostki. Ja wolę w ten sposób.



Jeśli chcesz sobie trochę skomplikować, to możesz dorzucić dziedziczenie danych. Języki tego nie wspierają w jakiś składniowy sposób, więc sobie będziesz musiał zaimprowizować :-)

Zauważ, że w obecnym designie każdy jeden Marine pamięta nie tylko, ile ma życia, ale też ile ma życia maksymalnego, jaką ma prędkość, itd. To może być zaleta, a może być wada (bo zajętość pamięci). Ale na to rozwiązanie jest proste: oprócz klasy Unit wprowadzasz klasę UnitType, która zawiera odpowiednie dane "ogólne". Proste.
A jeśli za proste, to możesz teraz sobie to zaimplementować w jakiś sprytny sposób i pozwalać UnitType odwoływać się do innego UnitType, i robić sobie łańcuszki typów, albo nawet wielodziedziczenie (samych danych). Nie widzę, jak RTS pokroju Starcrafta by z tego skorzystał, ale tylko pokazuję, że rozbudowa obecnego podejścia o nowe pomysły jest możliwa i łatwa.


Jest jaśniej?


Offline ShadowDancer

  • Redaktor

# Styczeń 30, 2012, 00:39:28
Dużo jaśniej, dzięki!

Możesz jeszcze rozwinąć koncepcję kółek, albo broni (czyli tego, co nazywałem modułami)? Rozumiem, że w prostym przypadku coś takiego jak ma kółka = true, i prędkość = 5 ma sens, ale jak się ma sprawa, gdy kółka wiążą się z jakąś specjalną funkcjonalnością dla danej jednostki? Czy wtedy ta specjalna funkcjonalność powinna się znajdować po prostu w klasie jednostki (i wielgachne ify w zależności od isBuilding? :))?

Właśnie niechęć tego, aby jednostki nie związane z funkcjonalnością miałby do niej dostęp skłoniła mnie do takiej rozbudowy hierarchii klas.

Offline Kos

  • Użytkownik
    • kos.gd

# Styczeń 30, 2012, 02:03:06
No właśnie nie ma tu złotej zasady, jak zrobić wszystko. Trzeba trochę rozumem.

Jak coś jest małe, to nie wprowadzasz na to abstrakcji. A jeśli coś jest duże, może mieć wiele różnych danych i zapala Ci się ostrzegawcza lampka "wielgachne ify", to wtedy abstrakcję wprowadzasz.

Zauważ, że sposób który opisuję, nie potrzebuje dziedziczenia (innego niż implementacja interfejsu) - w szczególności dziedziczenia wielopoziomowego.

Generalnie uprościć i zmodularyzować taki kod jest najłatwiej wyszukując grupy zachowań tej hipotetycznej uber-klasy Jednostka i eksmitując je do osobnych klas - żeby Jednostka nadal była po-prostu-Jednostką, ale już miała różnego rodzaju Skille, różnego rodzaju SposóbAtaku, różnego rodzaju to, tamto. Wtedy masz ładny design oparty o asocjację. Ktoś w krawacie to kiedyś chyba nazwał "loose couplingiem", a może nawet "wzorcem strategii", a kto inny w krawacie przytaknął i powiedział, że jest dobre.

I pamiętaj, co sobie skromnie uważam: Klasy = OK, Hierarchia klas = nie OK!
« Ostatnia zmiana: Styczeń 30, 2012, 02:05:25 wysłana przez Kos »

Offline ShadowDancer

  • Redaktor

# Styczeń 30, 2012, 13:17:11
Dzięki za pomoc, przydała się ;)

Zdecydowałem się na bardzo "pomieszane podejście" - uber klasa jednostka, zawierająca listę komponentów, wskaźnik na typ i jakieś dane (życie, prędkość itp).

Typ zawiera wszystkie dane charakteryzujące jednostkę (np. maksymalna ilość życia, przyśpieszenie, jakie komponenty).

Z kolei komponenty to przytoczone kółka, broń, które zawierają całą funkcjonalność (czyli np. kółka poruszają obiekt, obsługują order move itp.), są częściowo autonomiczne (czyli np. komponent wydobywczy ma informacje którą asteroidę teraz wykonać), ale operują na danych jednostki (prędkość, życie, przenoszone surowce) itp.

Zobaczymy co z tego wyjdzie ;)

Offline siso

  • Użytkownik

# Styczeń 30, 2012, 21:17:18
@ShadowDancer
Ciekawy problem poruszyłeś. Przyłączam się do tego, co Kos Ci zaproponował, też bym kombinował ze strategiami.
Ze swojej strony chciałbym tylko dorzucić tylko kilka hintów:
1. Używaj SCM-a, wszystko jedno jakiego; najlepiej tego, z ktorym Ci jest najwygodniej. Jeśli już to robisz, nie czytaj dalej tego punktu :) Jeśli nie, cóż, zacznij. Szkoda życia na szukanie dziury w całym jak coś zmienisz i reszta przestanie działać.
2. Zanim siądziesz do kodowania, narysuj sobie cały system jak jak go widzisz, na zwykłej kartce. Zwizualizuj zależności między bytami. Unikaj UML-a :)
3. Znajdź odpowiedzialność każdego bytu i nazwij ją. Jeden byt nie powinien mieć więcej niż jednej odpowiedzialności.
4. Spróbuj jednorazowo skupić się jednej rzeczy, jeśli programujesz uruchamianie skilla w unicie, rób tylko to aż samo zacznie działać.
5. Użyj jakiegoś frameworka do unit testów (np gtest/gmock - przyjemny jest) i zaczynaj każdą implementację od napisania testu, który ją sprawdza. Pisz test dla kodu, którego nawet jeszcze nie ma! Wygeneruj kod produkcyjny, by test się skompilował. Napisz logikę, by test przeszedł. Kiedy zadziała, napisz następny test.
Przykładowo, jeśli chcesz oprogramować odpalanie rakiety przez jednostkę, napisz przypadek testowy tworzący jednostkę zdolną wystrzelić pocisk, wywołaj odpalanie pocisku i sprawdź czy poleciał. Będziesz miał 100% pewności, że logika związana z odpalaniem działa.
6. Po każdym działającym przypadku testowym odpal wszystkie po kolei, żeby upewnić się, czy nie wprowadziłeś skutków ubocznych do reszty kodu. Im większy projekt, tym więcej powiązań (no, z dokładnością, do stałej ;) ), a im więcej powiązań, tym łatwiej o skutki uboczne.
7. Doceń unit testy. Jeśli już to robisz, nie czytaj dalej tego punktu :) Jeśli nie, cóż, zacznij. Szkoda życia na szukanie dziury w całym jak coś zmienisz i reszta przestanie działać.
8. Pozwól, by cały kod, który coś znaczy, miał pokrycie w testach i żeby to testy wyznaczały kierunek, w jakim kod powstaje.
9. Nie bój się refaktoryzacji. Skorzystaj z dobrego IDE, korzystaj z bazy testów, które właśnie stworzyłeś. Niech kod staje się coraz prostszy, a testy pokazują, co jest w nim niezbędne.

Pisanie kodu ta metodą to dla mnie czysta przyjemność :)

Jeśli masz jakieś pytania odnośnie szczegółów, chętnie odpowiem.

Offline Kos

  • Użytkownik
    • kos.gd

# Styczeń 31, 2012, 14:25:50
3. Znajdź odpowiedzialność każdego bytu i nazwij ją. Jeden byt nie powinien mieć więcej niż jednej odpowiedzialności.

Hej, masz łeb na karku i pewnie więcej doświadczenia ode mnie, wyjaśnij mi: Jaka jest odpowiedzialność "jednostki"? Jednostka to na moje oko byt rodem z designu data-driven, a Ty tutaj adwokujesz design responsibility-driven.

Rozwiązanie, które poleciłem, sam rozumiem jako mieszane - data-driven dla podstaw modelu danych + responsibility-driven dla niektórych części. Jak wyglądałoby rozwiązanie w pełni responsibility-driven? Czy nadal byłaby tam klasa "jednostka"? Czy da się dla niej wyznaczyć jedna odpowiedzialność?

Offline koirat

  • Użytkownik

# Styczeń 31, 2012, 16:40:19
Ja myślałem iż chodzi mu o Single Responsibility Princilpe.

Offline siso

  • Użytkownik

# Styczeń 31, 2012, 21:09:42
Hej, masz łeb na karku i pewnie więcej doświadczenia ode mnie, wyjaśnij mi: Jaka jest odpowiedzialność "jednostki"? Jednostka to na moje oko byt rodem z designu data-driven, a Ty tutaj adwokujesz design responsibility-driven.

Rozwiązanie, które poleciłem, sam rozumiem jako mieszane - data-driven dla podstaw modelu danych + responsibility-driven dla niektórych części. Jak wyglądałoby rozwiązanie w pełni responsibility-driven? Czy nadal byłaby tam klasa "jednostka"? Czy da się dla niej wyznaczyć jedna odpowiedzialność?
Tak tylko strzeliłem tą jednostką :) Ale mimo wszystko służę pomocą...

Odpowiedzialność jednostki polega na jej wpasowaniu się w cały ten bałagan, czyli w naszym przykładzie powinna ona umieć odpalić cały łańcuch skilli/akcji, jaki tylko jej zadamy, i to pewnie jeszcze w odpowiedniej kolejności. Gdyby wpasowywanie to wymagało oprócz łańcucha akcji odpalania jeszcze jakichś dodatkowych strategii czy innych delegatów, też powinna to umieć.
Unit test można zrealizować w tym wypadku jako test parametryzowany łańcuchami akcji, gdzie zakładamy np. odpalanie łańcucha pustego, z jednym elementem, z wieloma, z jakimiś elementami opcjonalnymi, etc.

@koirat
Tak, chodziło o Single Responsibility Princilpe.
SRP nie oznacza wcale, że klasa implementuje jakąś pojedynczą metodę albo że wykonuje jakąś jedną operację kilka razy w pętli :). Może i wręcz powinna mieć tyle metod, ile jest ich niezbędnych z punktu widzenia jej kontraktu. To kontrakt musi być tak zdefiniowany, by spełniał jeden cel. Kontraktem jednostki może być, jak tutaj, interakcja z otoczeniem. A już np. rendering niekoniecznie. Nawet lepiej, jeśli renderer będzie osobnym bytem, bo wtedy można będzie jednostki renderowac na więcej niż jeden sposób po prostu podmieniając renderer (główna mapa, minimapa, mapy statyczne na jakichś art-screenach, na potrzeby save'ów,  etc.).

Cel kontraktu wyznacza zbiór pokrewnych operacji jakie byt musi implementować. Niepożądane jest, dla przykładu, aby byt potrafił jednocześnie zaparzać kawę i wyliczać pierwiastek z siedemnastu. Niech kawą zajmuje się SerwerKawy, a pierwiastkami jakieś inne cudo.

@Kos
Oczywiście, na samym początku warto postawić sobie pytanie, czy potrzebujemy, by jednostka rzeczywiście była bytem :). Być może wystarczy by zawierała tylko dane, jak zaproponowałeś. Wówczas jakiś updater jednostek wyliczałby ich nowy stan, odpalając te wszystkie łańcuchy skilli, strategie itd. dla wszystkich jednostek po kolei. Ale wówczas to on będzie miał odpowiedzialność, jaką uprzednio przypisalibyśmy jednostce.