Jak mogę uniknąć klas gigantów?

46

W grze prawie zawsze występuje klasa gracza. Gracz może na ogół wiele zrobić w grze, co oznacza, że ​​dla mnie ta klasa jest ogromna z mnóstwem zmiennych, które obsługują każdą funkcję, jaką może wykonać gracz. Każdy kawałek jest sam w sobie dość niewielki, ale w połączeniu z nim powstają tysiące linii kodu i trudno jest znaleźć to, czego potrzebujesz, i straszne jest wprowadzanie zmian. Z czymś, co jest w zasadzie ogólną kontrolą dla całej gry, jak uniknąć tego problemu?

użytkownik441521
źródło
26
Wiele plików lub jeden plik, kod musi gdzieś iść. Gry są złożone. Aby znaleźć to, czego potrzebujesz, napisz dobre nazwy metod i opisowe komentarze. Nie bój się wprowadzać zmian - po prostu przetestuj. I zrób kopię zapasową swojej pracy :)
Chris McFarland
7
Rozumiem, że musi gdzieś iść, ale projektowanie kodu ma znaczenie w elastyczności i konserwacji. Posiadanie klasy lub grupy kodów składającej się z tysięcy linii po prostu mnie nie uderza.
user441521
17
@ChrisMcFarland nie sugeruje tworzenia kopii zapasowych, sugeruje kod wersji XD.
GameDeveloper
1
@ChrisMcFarland Zgadzam się z GameDeveloper. Kontrola wersji, taka jak Git, svn, TFS, ... sprawia, że ​​programowanie jest o wiele łatwiejsze dzięki możliwości łatwiejszego cofania dużych zmian i łatwego odzyskiwania po takich rzeczach, jak przypadkowe usunięcie projektu, awaria sprzętu lub uszkodzenie pliku.
Nzall
3
@TylerH: Zdecydowanie się nie zgadzam. Kopie zapasowe nie pozwalają na łączenie wielu zmian eksploracyjnych razem, ani nie wiążą nigdzie tak wielu użytecznych metadanych z zestawami zmian, ani nie umożliwiają rozsądnego przepływu pracy wielu programistów. Możesz używać kontroli wersji jako bardzo wydajnego systemu tworzenia kopii zapasowych w określonym momencie, ale brakuje w nim dużego potencjału tych narzędzi.
Phoshi

Odpowiedzi:

67

Zwykle używasz systemu komponentu encji (system komponentu encji to architektura oparta na komponentach). Ułatwia to także tworzenie innych bytów i może sprawić, że wrogowie / NPC mają te same elementy, co gracz.

To podejście idzie w dokładnie odwrotnym kierunku niż podejście zorientowane obiektowo. Wszystko w grze jest bytem. Istota jest tylko przypadkiem bez wbudowanej mechaniki gry. Ma listę składników i sposób manipulowania nimi.

Na przykład odtwarzacz ma komponent pozycji, komponent animacji i komponent wejściowy, a gdy użytkownik naciska spację, chcesz, aby odtwarzacz skakał.

Możesz to osiągnąć przez nadanie graczowi elementu skoku, który po wywołaniu powoduje zmianę komponentu animatiom na animację skoku i sprawia, że ​​gracz ma dodatnią prędkość y w składniku pozycji. W komponencie wejściowym nasłuchujesz klawisza spacji i wywołujesz komponent skoku. (To tylko przykład, powinieneś mieć komponent kontrolera do ruchu).

Pomaga to podzielić kod na mniejsze moduły wielokrotnego użytku i może doprowadzić do bardziej zorganizowanego projektu.

Bálint
źródło
Komentarze nie są przeznaczone do rozszerzonej dyskusji; ta rozmowa została przeniesiona do czatu .
MichaelHouse
8
Rozumiem poruszające się komentarze, które należy przenieść, ale nie poruszaj tych, które podważają dokładność odpowiedzi. To powinno być oczywiste, nie?
bug-a-lot
20

Gry nie są w tym wyjątkowe; Boskie klasy są wszędzie anty-wzorcem.

Częstym rozwiązaniem jest rozbicie dużej klasy na drzewo mniejszych klas. Jeśli gracz ma ekwipunek, nie włączaj zarządzania ekwipunkiem class Player. Zamiast tego utwórz plik class Inventory. Jest to jeden element członkowski class Player, ale wewnętrznie class Inventorymoże owinąć dużo kodu.

Kolejny przykład: postać gracza może mieć relacje z postaciami niezależnymi, więc możesz mieć class Relationodniesienie zarówno do Playerobiektu, jak i do NPCobiektu, ale nie należy do żadnego.

MSalters
źródło
Tak, tylko szukałem pomysłów, jak to zrobić. Sposób myślenia polegał na tym, że istnieje wiele małych elementów, więc przy kodowaniu nie jest dla mnie naturalne, aby przełamać te małe elementy. Jednak staje się oczywiste, że wszystkie te małe elementy funkcjonalności sprawiają, że klasa graczy jest ogromna.
user441521
1
Ludzie zwykle mówią, że coś jest boską klasą lub boskim przedmiotem, gdy zawiera i zarządza każdą inną klasą / przedmiotem w grze.
Bálint
11

1) Gracz: architektura stan-maszyna + architektura oparta na komponentach.

Typowe komponenty odtwarzacza: HealthSystem, MovementSystem, InventorySystem, ActionSystem. To są wszystkie klasy class HealthSystem.

Nie polecam go Update()tam używać (zwykle nie ma sensu aktualizować systemu opieki zdrowotnej, chyba że potrzebujesz go do niektórych akcji w każdej klatce, rzadko się zdarzają. Jeden przypadek, o którym możesz pomyśleć - gracz zostaje otruty i potrzebujesz go od czasu do czasu tracić zdrowie - tutaj sugeruję używanie koroutyn. Kolejnym, który stale regeneruje zdrowie lub moc biegania, po prostu bierzesz bieżące zdrowie lub moc i wywołujesz coroutine, aby wypełnić ten poziom, gdy przyjdzie czas. Przełam koroutynę, gdy zdrowie jest pełne lub był uszkodzony lub zaczął biec ponownie itd. OK, to było trochę nie na temat, ale mam nadzieję, że się przydało) .

Stany: LootState, RunState, WalkState, AttackState, IDLEState.

Każdy stan dziedziczy po interface IState. IStatema w naszym przypadku 4 metody tylko dla przykładu.Loot() Run() Walk() Attack()

Ponadto mamy miejsce, w class InputControllerktórym sprawdzamy każde wejście użytkownika.

Teraz prawdziwy przykład: InputControllersprawdzamy, czy gracz naciska którykolwiek z nich, WASD or arrowsa następnie czy on również naciska Shift. Jeśli nacisnął tylko WASDwtedy, dzwonimy, _currentPlayerState.Walk();kiedy to się dzieje i musimy currentPlayerStatebyć równi, WalkStatewięc WalkState.Walk() mamy wszystkie elementy potrzebne do tego stanu - w tym przypadku MovementSystem, więc poruszamy gracza public void Walk() { _playerMovementSystem.Walk(); }- widzisz, co tu mamy? Mamy drugą warstwę zachowania, która jest bardzo dobra do utrzymywania kodu i debugowania.

Przejdźmy teraz do drugiego przypadku: co jeśli naciśniemy WASD+ Shift? Ale nasz poprzedni stan był WalkState. W tym przypadku Run()zostanie wywołany InputController(nie mieszaj tego, Run()jest wywoływany, ponieważ mamy WASD+ Shiftodprawy InputControllernie z powodu WalkState). Kiedy wzywamy _currentPlayerState.Run();w WalkState- wiemy, że mamy do przełącznika _currentPlayerStatedo RunStatei czynimy to w Run()od WalkStatei nazywają to znowu wewnątrz tej metody, ale teraz z innego stanu, ponieważ nie chcemy stracić działania tej ramki. I teraz oczywiście dzwonimy _playerMovementSystem.Run();.

Ale po co, LootStategdy gracz nie może chodzić ani biegać, dopóki nie zwolni przycisku? Cóż, w tym przypadku, kiedy zaczęliśmy grabież, na przykład, kiedy Enaciśniemy przycisk, wywołujemy _currentPlayerState.Loot();, przełączamy się na, LootStatea teraz wywołujemy jego stamtąd. Tam na przykład wywołujemy metodę kolizji, aby uzyskać, jeśli jest coś do zdobycia w zasięgu. I nazywamy coroutine tam, gdzie mamy animację lub gdzie ją uruchamiamy, a także sprawdzamy, czy gracz nadal trzyma przycisk, jeśli nie coroutine się łamie, jeśli tak, dajemy mu łupy na końcu coroutine. Ale co jeśli gracz naciska WASD? - _currentPlayerState.Walk();nazywa się, ale tutaj jest ładna cecha automatu stanów, wLootState.Walk()mamy pustą metodę, która nic nie robi lub tak, jak zrobiłbym to jako funkcję - gracze mówią: „Hej, jeszcze tego nie zrabowałem, możesz poczekać?”. Kiedy skończy grabież, zmieniamy na IDLEState.

Można również wykonać inny skrypt, który jest wywoływany, class BaseState : IStatektóry ma zaimplementowane wszystkie domyślne zachowanie metod, ale ma je virtualtak, aby można overrideje było w class LootState : BaseStateklasach.


System oparty na komponentach jest świetny, jedyne, co mnie martwi, to Instancje, wiele z nich. I zajmuje więcej pamięci i pracy dla śmieciarza. Na przykład, jeśli masz 1000 instancji wroga. Wszystkie mają 4 elementy. 4000 obiektów zamiast 1000. Mb, to nie jest taka wielka sprawa (nie przeprowadzałem testów wydajności), jeśli weźmiemy pod uwagę wszystkie komponenty, które ma jedność gameobject.


2) Architektura oparta na dziedziczeniu. Chociaż zauważysz, że nie możemy całkowicie pozbyć się komponentów - jest to w rzeczywistości niemożliwe, jeśli chcemy mieć czysty i działający kod. Ponadto, jeśli chcemy użyć Wzorów projektowych, które są wysoce zalecane do użycia w odpowiednich przypadkach (nie nadużywaj ich również, nazywa się to nadprodukcją).

Wyobraź sobie, że mamy klasę Gracza, która ma wszystkie właściwości potrzebne do wyjścia z gry. Ma zdrowie, manę lub energię, może poruszać się, biegać i używać umiejętności, posiada ekwipunek, może wytwarzać przedmioty, łupić przedmioty, a nawet budować barykady lub wieżyczki.

Przede wszystkim powiem, że Ekwipunek, Wytwarzanie, Ruch, Budowanie powinny być oparte na komponentach, ponieważ gracz nie jest odpowiedzialny za takie metody AddItemToInventoryArray()- chociaż gracz może mieć taką metodę, PutItemToInventory()która wywoła poprzednio opisaną metodę (2 warstwy - możemy dodaj niektóre warunki w zależności od różnych warstw).

Kolejny przykład z budowaniem. Gracz może zadzwonić OpenBuildingWindow(), ale Buildingzająłby się całą resztą, a kiedy użytkownik zdecyduje się zbudować jakiś konkretny budynek, przekazuje mu wszystkie potrzebne informacje, Build(BuildingInfo someBuildingInfo)a gracz zaczyna budować ze wszystkimi potrzebnymi animacjami.

SOLID - zasady OOP. S - jedna odpowiedzialność: to, co widzieliśmy w poprzednich przykładach. No dobrze, ale gdzie jest Dziedzictwo?

Tutaj: czy zdrowie i inne cechy gracza powinny być obsługiwane przez inny byt? Myślę, że nie. Nie może być gracza bez zdrowia, jeśli taki istnieje, po prostu nie dziedziczymy. Na przykład, mamy IDamagable, LivingEntity, IGameActor, GameActor. IDamagableoczywiście, że ma TakeDamage().

class LivinEntity : IDamagable {

   private float _health; // For fields that are the same between Instances I would use Flyweight Pattern.

   public void TakeDamage() {
       ....
   }
}

class GameActor : LivingEntity, IGameActor {
    // Here goes state machine and other attached components needed.
}

class Player : GameActor {
   // Inventory, Building, Crafting.... components.
}

Więc tutaj nie mogłem właściwie oddzielić składników od dziedziczenia, ale możemy je mieszać, jak widzisz. Możemy również stworzyć klasy bazowe dla systemu Building, na przykład, jeśli mamy różne typy tego systemu i nie chcemy pisać więcej kodu niż to konieczne. Rzeczywiście możemy również mieć różne typy budynków i nie ma właściwie dobrego sposobu, aby zrobić to w oparciu o komponenty!

OrganicBuilding : Building, TechBuilding : Building. Nie musisz tworzyć 2 komponentów i pisać tam kodu dwukrotnie dla typowych operacji lub właściwości budynku. A następnie dodaj je inaczej, możesz użyć mocy dziedziczenia, a później polimorfizmu i inkapsulacji.


Sugerowałbym użycie czegoś pomiędzy. I nie nadużywaj komponentów.


Bardzo polecam przeczytanie tej książki o wzorcach programowania gier - jest ona darmowa w WEB.

Candid Moon _Max_
źródło
Będę kopać później wieczorem, ale do twojej wiadomości Nie używam jedności, więc muszę dostosować niektóre, co jest w porządku.
user441521,
Och, sry, myślałem, że tutaj jest etykieta Unity, mój zły. Jedyną rzeczą jest MonoBehavior - to tylko podstawowa klasa dla każdej Instancji na scenie w edytorze Unity. Co do Physics.OverlapSphere () - jest to metoda, która tworzy zderzacz sfery podczas ramki i sprawdza, czego się dotyka. Korpusy są jak fałszywa aktualizacja, ich rozmowy mogą być zmniejszone do mniejszych ilości niż fps na PC graczy - dobre dla wydajności. Start () - tylko metoda wywoływana raz podczas tworzenia wystąpienia. Wszystko inne powinno mieć zastosowanie wszędzie indziej. Następna część Nie będę używać niczego z Unity. Sry Mam nadzieję, że to coś wyjaśniło.
Candid Moon _Max_
Używałem wcześniej Unity, więc rozumiem ten pomysł. Używam Lua, która ma również coroutines, więc wszystko powinno się tłumaczyć całkiem dobrze.
user441521
Ta odpowiedź wydaje się nieco zbyt specyficzna dla Jedności, biorąc pod uwagę brak znacznika Unity. Jeśli uczynisz to bardziej ogólnym i uczynisz jedność bardziej przykładem, byłaby to znacznie lepsza odpowiedź.
Pharap
@CandidMoon Tak, to lepiej.
Pharap
4

Ten problem nie ma srebrnej kuli, ale istnieją różne podejścia, z których prawie wszystkie oparte są na zasadzie „rozdzielenia obaw”. Inne odpowiedzi już omawiały popularne podejście oparte na komponentach, ale istnieją inne podejścia, które można zastosować zamiast lub razem z rozwiązaniem opartym na komponentach. Będę omawiać podejście podmiot-kontroler, ponieważ jest to jedno z moich preferowanych rozwiązań tego problemu.

Po pierwsze, sama idea Playerklasy wprowadza w błąd. Wiele osób myśli o postaci gracza, postaciach NPC i potworach / wrogach jako o różnych klasach, podczas gdy w rzeczywistości wszystkie one mają ze sobą wiele wspólnego: wszystkie są narysowane na ekranie, wszystkie się poruszają, mogą wszystkie mają zapasy itp.

Ten sposób myślenia prowadzi do podejścia, w którym postacie graczy, postacie inne niż potwory oraz potwory / wrogowie są traktowane Entityraczej jako „ s” niż traktowane inaczej. Oczywiście muszą jednak zachowywać się inaczej - postać gracza musi być kontrolowana przez wejście, a NPC potrzebują ai.

Rozwiązaniem tego jest posiadanie Controllerklas używanych do kontrolowania Entitys. W ten sposób cała ciężka logika kończy się w kontrolerze, a wszystkie dane i cechy wspólne są przechowywane w jednostce.

Ponadto poprzez podklasę Controllerw InputControlleri AIControllerpozwala graczowi skutecznie kontrolować dowolne Entityw pokoju. Takie podejście pomaga również w grze wieloosobowej, ponieważ posiada klasę RemoteControllerlub, NetworkControllerktóra działa za pomocą poleceń ze strumienia sieciowego.

Może to spowodować, że wiele logiki zostanie przekształconych w jedną, Controllerjeśli nie będziesz ostrożny. Sposobem na uniknięcie tego jest posiadanie Controllers, które składają się z innych Controller, lub uzależnienie Controllerfunkcjonalności od różnych właściwości Controller. Na przykład, AIControllermiałby DecisionTreedo niego dołączony, i PlayerCharacterControllermógłby składać się z różnych innych Controllers, takich jak a MovementController, a JumpController(zawierający maszynę stanów ze stanami OnGround, Rosnąco i Malejąco), an InventoryUIController. Dodatkową korzyścią tego jest to, że nowe Controllers mogą być dodawane w miarę dodawania nowych funkcji - jeśli gra rozpocznie się bez systemu ekwipunku i zostanie dodany jeden, kontroler może zostać dodany później.

Pharap
źródło
Podoba mi się ten pomysł, ale wydaje się, że przesłał cały kod do klasy kontrolera, pozostawiając mi ten sam problem.
user441521
@ user441521 Właśnie zdałem sobie sprawę, że mam dodatkowy akapit, który zamierzam dodać, ale zgubiłem go, gdy moja przeglądarka uległa awarii. Dodam to teraz. Zasadniczo możesz mieć różnych kontrolerów, które mogą łączyć je w kontrolery zagregowane, dzięki czemu każdy kontroler obsługuje różne rzeczy. np. AggregateController.Controllers = {JumpController (keybinds), MoveController (keybinds), InventoryUIController (keybinds, uisystem)}
Pharap