Sposób implementacji systemów wpływających na wszystko

0

Hej. Od dłuższego czasu chodzi mi po głowie problem. Napotykam go od wielu lat, i wciąż nie wypracowałem na niego rozwiązania, liczę, że może ktoś tutaj ma jakiś pomysł, lub coś mi umyka.

Mimo, że zawsze projektuję sobie kod w fajny sposób, najczęściej jako osobne, względnie hermetyczne systemy (np. system levelowania postaci i jej atrybutów, system wznoszenia budynków na gridzie, itd.) nadchodzi taki moment, że trzeba dodać system "R" (od rozp...).

Czyli system, który wpływa na wiele innych rzeczy, oraz ma pełno zależności.
Chociażby:

  • tutorial - w formie interaktywnej, gdzieś czekamy aż coś gracz zrobi, wtedy częstujemy go kolejnymi okienkami/wskazówkami
  • wszelkiej maści perki i techtree - jeśli postać podskoczyła mniej niż 6 razy w ciągu ostatniej minuty, wrogów nie ma w zasięgu 10 metrów i NPC z naszej drużyny podrapał się po tyłku, dodaj graczowi duży fajny miecz do ekwipunku i odegraj hejnał. Dodatkowo wrogowie otrzymują 2x więcej damage przez kolejne 3 minuty - idealny przykład, gdzie nie ma opcji, na wyciągnięcie jakichkolwiek reguł do implementacji. A dajmy, że perków będziemy mieli kilkadziesiąt.

Rozwiazania:

  • brutal force - lecimy po całym kodzie, wstawiamy warunki i odpowiednio implementujemy. Jeśli perk X jest aktywny, to przemnóż damage w 'ReceiveDamage' od Enemy.

  • konkretne implementacje bazowej klasy Perk - każdy perk jest zaimplementowany na swój sposób. Wciąż wymusza to konkretne wypracowane rozwiązania w pozostałych systemach. Enemy musi mieć jakiś DamageMultiplier, który będzie modyfikowany z poziomu implementacji perka. Trochę lepiej, ale imo to wciąż dalekie od rozsądnego rozwiązania?

  • Subclass Sandbox - na to trafiłem w książce 'Programowanie gier. Wzorce', nie za wiele się różni od powyższego rozwiązania. Przekazujemy sobie interfejsy systemów do piaskownicy dla perków, i na nich operujemy.

  • skrypty zewnętrzne / visual scripting - zamiast implementacji konkretnych perków w kodzie. Plus bo wygodniej, minus bo zapewne wolniej działa. Wada rozwiązania taka sama jak w poprzednich - trzeba odpowiednio przygotować pozostałe systemy

1

Observer pattern?

1
Boski napisał(a):
  • tutorial - w formie interaktywnej, gdzieś czekamy aż coś gracz zrobi, wtedy częstujemy go kolejnymi okienkami/wskazówkami

To dobrze się implementuje jeśli masz podejście oparte na eventach.

Jeśli "coś gracz zrobi" generuje jakiś event, to po prostu czekasz na ten event.
Np. chcemy, żeby pojawiło się okienko jak gracz wejdzie do tawerny.
Więc

  1. robimy jakiś trigger - gdy gracz wejdzie do pomieszczenia, zostanie wygenerowany event, że wlazł.
  2. na kolejnych etapach tutoriala możemy czekać na inne eventy. Możesz sobie w jakiejś tablicy umieścić listę eventów do oczekiwania i akcji, które mają się wykonać (jeśli twój język ma async/await, to można to zrobić na poziomie języka programowania nawet):
    pseudokod:
oczekuj GraczWszedłDoObszaru("Tawerna")
wyświetl Komunikat("podejdź do Alicji")
oczekuj GraczBlisko("Alicja")
odpal Rozmowa1
  • wszelkiej maści perki i techtree - jeśli postać podskoczyła mniej niż 6 razy w ciągu ostatniej minuty, wrogów nie ma w zasięgu 10 metrów i NPC z naszej drużyny podrapał się po tyłku, dodaj graczowi duży fajny miecz do ekwipunku i odegraj hejnał. Dodatkowo wrogowie otrzymują 2x więcej damage przez kolejne 3 minuty - idealny przykład, gdzie nie ma opcji, na wyciągnięcie jakichkolwiek reguł do implementacji. A dajmy, że perków będziemy mieli kilkadziesiąt.

To też można sprowadzić do eventów.

  1. niech będzie jakiś licznik skoków postaci, podskoczenie 6 razy w ciągu minuty generuje event w stylu JumpedSixTimes niech skok wyemituje event Jumped
  2. pojawienie się wroga niech generuje event EnemyAppeared, znikanie wroga EnemyDisappeared
  3. drapanie po tyłku tak samo generuje event ScratchButt

a potem sprawdzasz po eventach (i trzymasz gdzieś listę ostatnich eventów, które się pojawiły w grze)

Jeśli w ostatniej minucie nie został wyemitowany event JumpedSixTimes nie został wyemitowany 6 razy event Jumped ani nie było eventu EnemyAppeared (chyba, że po nim będzie event EnemyDisappeared) i był event ScratchButt, to dodaj graczowi duży fajny miecz do ekwipunku i odegraj hejnał..

Enemy musi mieć jakiś DamageMultiplier, który będzie modyfikowany z poziomu implementacji perka.

No, to już kwestia struktur danych i sposobu w jaki trzymasz dane obiektów i w jaki sposób je zmieniasz.

na to trafiłem w książce

Jeśli chodzi o książki, to w którejś części Perełek Programowania Gier jest o tym, jak robić gry oparte o eventy/komunikaty.

Poza tym event sourcing.Greg Young dobrze o tym mówi. Obczaj na Youtube

Niby kontekst talku to jakieś apki biznesowe, ale to jakby wymarzony pattern do gier

A tutaj z kolei masz artykuł typowo pod gamedev:
https://gameprogrammingpatterns.com/event-queue.html

0

Z perspektywy świata aplikacji biznesowych to co opisujesz kojarzy mi się z silnikiem reguł (rule engine). Szybki google sercz pokazuje mi coś takiego dla gier:
https://github.com/paranim/pararules

0

Od wielu lat w grach w celu modyfikacji logiki aktorów stosuje się jakąś wariację ECS (entity component system). Wówczas to komponent, przypisany do aktora, odpowiada za jakąś część logiki, lub zmianę domyślnej, a gdy jest już niepotrzebny, to się go usuwa.
I tak:

  • tutorial: dodajesz odpowiedni komponent, który reaguje na oczekiwaną akcję, co z kolei powoduje jakąś reakcję, np. zmianę GUI
  • perki: dodajesz odpowiedni komponent, który np. modyfikuje damage etc. gdy zajdzie taka potrzeba
0

Dzięki za odpowiedzi!

LukeJL napisał(a):

To też można sprowadzić do eventów.

Jasne, że można, tylko zobacz ile już jest dodatkowego kodu (samych eventów, które powtórzą się może jeszcze kilka razy, a niektóre wcale). A to dopiero pierwszy perk.
Kolejka faktycznie ma o tyle sens, że jest separacja między systemami i zrzucamy wszystko na nią. Implementacje poszczególnych funkcjonalności wciąż musimy przewidzieć wcześniej, przed pracą nad konkretnymi perkami, bądź dorabiać w trakcie, wewnątrz danego systemu.

ECS - do czego należą wtedy implementacje komponentów? Do poszczególnych systemów których dotyczą, czy jakoś inaczej?

To co chciałem osiągnąć chyba zdaje się nie mieć sensu - że jak zamkniemy sobie kwestie SystemWrogów, i po paru miesiącach robimy perki, to już rozszerzamy funkcjonalności w samych perkach, a nie SystemWrogów. Ale jak teraz myślę, idąc tym tokiem, mocno łamiemy srp i kod będzie o wiele cięższy w utrzymaniu.
Rozważam jeszcze połączenie kolejki i visual scripting (nie pod kątem rozwiązania problemu, ale jako mniejsze zło / najlepsze rozwiązanie),

0

@Boski: nie masz tutaj zbyt wiele strategii — albo sprawdzasz absolutnie wszystkie możliwości w każdym kroku aktualizacji logiki i je od razu wykonujesz (czyli brute force), albo implementujesz jakikolwiek system notyfikacji (np. kolejka akcji w formie własnych zdarzeń).

Dla zobrazowania, załóżmy, że mamy grę, w której jest duża mapa, wypełniona najróżniejszymi pierdołami. Zdefiniowanych jest 100 obszarów automatycznie wyzwalających cutscenki, jest w niej 100 budynków z drzwiami oraz 100 NPC-ów, z którymi można pogadać.


Pierwszym i naiwnym podejściem jest aktualizowanie logiki w taki sposób, że reaguje się na input gracza, aktualizuje stan bohatera i przy okazji sprawdza, czy wykonuje jakąś akcję dotyczącą wyzwalaczy. To powoduje, że w każdej klatce gry trzeba sprawdzić:

  • czy bohater znajduje się w obszarze wyzwalających cutscenki i trzeba jakąś odegrać,
  • czy bohater wlazł w drzwi i trzeba go teleportować,
  • czy bohater wszedł w interakcję z NPC-em i trzeba odpalić dialog.

To powoduje, że podczas każdorazowej aktualizacji logiki, musisz sprawdzić 300 różnych rzeczy. Wiadomo, można coś takiego optymalizować, np. sprawdzać istnienie danej akcji co kilka klatek czy zmapować sobie wszystkie wyzwalacze np. w quadtree, żeby nie iterować po nich wszystkich, ale to nadal jest zbyt kosztowne — złożoność O(n), im więcej wyzwalaczy, tym więcej mocy obliczeniowej pójdzie na ich testowanie. Poza tym, takie rozwiązanie nie jest elastyczne. Dla małych gier można z tego skorzystać, dla większych i bardziej złożonych, zdecydowanie nie.

Aby nie marnować zasobów CPU, nie powinno się testować wszystkiego, a stworzyć system notyfikacji — czekać na akcję, zamiast ciągle wszystko sprawdzać. Co prawda tak czy siak trzeba coś sprawdzać podczas każdorazowej aktualizacji logiki, więc im mniej, tym lepiej. W końcu zdecydowanie lepiej mieć złożoność O(1), niż O(n) czy inne wariacje z n.

I tutaj z pomocą przychodzą właśnie eventy. AFAIK zwykle nazywa się je akcjami, wrzucanymi do osobnej kolejki. Podczas aktualizacji logiki, jeśli nastąpiła jakaś specyficzna akcja (jakakolwiek, która musi być notyfikowana — skok, zabicie potworka, wejście w obszar drzwi, wpadnięcie w przepaść itd.), zamiast od razu reagować na to w określony, hardkodowany sposób, po prostu tworzy się obiekt akcji, wypełnia go danymi jej dotyczącymi i wrzuca do kolejki. Tak przygotowuje się kolejkę wydarzeń, którą później się przetwarza.

Biorąc pod uwagę opisany na początku przykład gry, logika powinna emitować akcje dotyczące wejścia w któryś obszar wyzwalający cutscenkę, w drzwi budynku lub gdy gracz wejdzie w interakcję z NPC-em. Jeśli kolejka akcji jest pusta, to na 100% żadna z tych akcji nie powinna się rozpocząć, więc nie ma czego sprawdzać. Podsumowując, dzięki kolejce, w każdej klatce gry sprawdza się czy coś się w niej znajduje — jeśli nie, to nie ma nic do roboty, a jeśli tak, to się przetwarza akcję tak samo jak np. zdarzenia dotyczące okna czy inputu.


Brute force powoduje, że nieważne czy gracz coś robi czy nie, i tak wszystkie wydarzenia są sprawdzane. Wykorzystanie kolejki akcji pozwala zminimalizować nakład pracy praktycznie do zera — jeśli gracz nic nie robi (nie generuje akcji), to logika nie ma nic do roboty, a CPU może odpoczywać. Poza tym jest to rozwiązanie elastyczne, bo taką kolejkę akcji możesz wykorzystać do absolutnie wszystkiego — cutscenek, tutoriali, achievementów, questów, telemetrii i masy innych rzeczy.

Boski napisał(a):

ECS - do czego należą wtedy implementacje komponentów? Do poszczególnych systemów których dotyczą, czy jakoś inaczej?

ECS jest sposobem implementacji struktur danych dla obiektów gry, nie ma to nic wspólnego z wyzwalaczami. Nieistotne jest to czy skorzystasz z ECS-a, czy z klas/interfejsów i dziedziczenia, czy z najprostszych struktur danych — Twoim problemem jest reagowanie na ściśle określone wydarzenia w świecie gry.

0

DISCLAIMER: Nie implementowałem zaproponowanych rozwiązań w grach.

Zamiast robić mocno odseparowane systemy mógłbyś zrobić podział na co jest stanem a co operacją i zastosować double dispatch (bądź visitor pattern jeśli język domyślnie nie obsługuje). Wtedy możesz dodawać, zmieniać i żonlgować operacjami bez bezpośredniej ingerencji w kod, który definiuje Ci stan. Na przykładzie perków, nadal musiałbyś robić konkretne reimplementacje perków na podstawie interfejsu, ale z double dispatch miałbyś łatwy sposób w reużyciu go dla np. jakiegoś bosa, albo innej encji. Pseudoko cpp like

class HumanoidEntity;
class Perk
{
    virtual void applyPerk(HumanoidEntity *entity);
};

class FirePerk : Perk
{
   void applyPerk(HumanoidEntity *entity) override
   {
       entity->pushEffectsStack(Effects::Fire);
       entity->buffNextAttak(m_attackBuff);
   }

private:
   int m_attackBuff{5};
}

class Player : HumanoidEntity
{
    void applyPerk(Perk *perk)
    {
      perk->applyPerk(this);
    }
};

class ScaryBoss : HumanoidEntity
{
    void applyPerk(Perk *perk)
    {
       // tylko cztery linijki i twój boss może teraz korzystać z tych samych perków co gracz!
       perk->applyPerk(this);
    }
};

////////////////////

void logicLoopTick()
{
    Perk *perk = worldEvent();
    player->applyPerk(perk);
    scaryBoss->applyPerk(perk);
}

I teraz możesz sobie dowolnie dodawać kolejne perki, zmieniać ich właściwości i nie musisz dotykać ani implementacji gracza czy bosa, ani logiki samej gry w tiku, poza worldEvent ofcourse. Oczywiście to jest OOPowa konstrukcja, która wciąż może sie okazać zbyt sztywna w zależności od zastosowania - na "papierze" w banalnych przykładach wszystko wygląda łatwo.

Innym sposobem byłaby wewnętrzny system komunikatów ale z silną separacją, tak żeby każdy system mógł się dowolnie zapisać do odczytania lub dystrybucji komunikatów innego systemu. Coś jak linuksowy DBus ale wewnątrz pojedyńczego procesu. Nie wiem jakby wyglądała wydajność tego w na prawdę dużym projekcie ale w "sprajtowej" gierce 2D to chyba nie byłby duży bloker.

0

Zdarzenia.... sorki, eventy, tylko eventy.
Jeśli ma być uniwersalnie to musi być spełnione założenie - gra o niczym nie wie, ale puszcza eventy jak szalona.
Pluginy, perki, dll - jak zwał, tak zwał, potrafią słuchać zdarzeń, a nawet odrużnić te eventy które go interesują od tych których nie zna.
Jeśli perk zrozumie, że ma robotę i musi zmodyfikować - przelicza/modyfikuje to co dostał w parametrze- na wyjściu jako wynik - albo puszcza event - do tej samej kolejki eventów (to niedobre rozwiązanie) - albo pozostaje na modyfikacji obiektu wskazanego w evencie. To drugie rozwiązanie jest lepsze, ponieważ zapobiega pewnemu problemowi o którym za chwilkę, wadą natomiast jest to, że gra puszczając jak szalona te ewenty (jakie, zaraz o tym....) po prostu odpowiada za wywołanie wszystkich delegatów podłączonych do tego eventu, i po wywołaniu wszystkich - jeśli są i słuchają, sama ogarnia co zrobić z wynikiem działania 'perków'. Widać od razu że jest coś takiego jak kolejność wywołania....
Tak otwarta furtka w grze umożliwia - a jakże - zdobycie dwóch nic nie wiedzących o sobie perków, ale działających na to samo zdarzenia np strzał. Jeden dodaje +10 drugi odejmuje -10....
I kolejna poradka :) zdarzenia..... eventy warto oprogramować onPrzedStrzałem, onPoStrzale, i czasami OnStrzał ;) Taka sytuacja. Zgrabne komunikaty, krótkie parametry.... każdy system operacyjny >DOS przetwarza - strzelam - tysiące komunikatów na sek

1 użytkowników online, w tym zalogowanych: 0, gości: 1