Zaprojektowanie gry turowej, w której działania mają skutki uboczne

19

Piszę komputerową wersję gry Dominion . To turowa gra karciana, w której karty akcji, karty skarbów i karty punktów zwycięstwa są gromadzone w osobistej talii gracza. Struktura klas jest dość dobrze rozwinięta i zaczynam projektować logikę gry. Używam Pythona i mogę później dodać prosty GUI z pygame.

Sekwencją tur graczy jest bardzo prosta machina stanowa. Tury przechodzą zgodnie z ruchem wskazówek zegara, a gracz nie może wyjść z gry przed jej zakończeniem. Rozgrywka jednej tury jest także maszyną państwową; ogólnie gracze przechodzą przez „fazę akcji”, „fazę zakupu” i „fazę czyszczenia” (w tej kolejności). Na podstawie odpowiedzi na pytanie Jak wdrożyć turowy silnik gry? , automat stanowy jest standardową techniką w tej sytuacji.

Mój problem polega na tym, że podczas fazy akcji gracza może użyć karty akcji, która wywołuje skutki uboczne na sobie lub na jednym lub kilku innych graczach. Na przykład jedna karta akcji pozwala graczowi wykonać drugą turę natychmiast po zakończeniu bieżącej tury. Kolejna karta akcji powoduje, że wszyscy inni gracze odrzucają dwie karty ze swoich rąk. Jeszcze jedna karta akcji nic nie robi w bieżącej turze, ale pozwala graczowi dobrać dodatkowe karty w następnej turze. Aby jeszcze bardziej skomplikować sprawę, często pojawiają się nowe rozszerzenia do gry, które dodają nowe karty. Wydaje mi się, że zakodowanie wyników każdej karty akcji w maszynie stanu gry byłoby zarówno brzydkie, jak i niemożliwe do dostosowania. Odpowiedź na turową pętlę strategiczną nie wchodzi w poziom szczegółowości, który dotyczy projektów w celu rozwiązania tego problemu.

Jakiego modelu programowania powinienem użyć, aby uwzględnić fakt, że ogólny wzorzec wykonywania zakrętów może być modyfikowany przez działania, które mają miejsce w trakcie tury? Czy obiekt gry powinien śledzić efekty każdej karty akcji? Lub, jeśli karty powinny implementować własne efekty (np. Poprzez implementację interfejsu), jaka konfiguracja jest wymagana, aby zapewnić im wystarczającą moc? Wymyśliłem kilka rozwiązań tego problemu, ale zastanawiam się, czy istnieje standardowy sposób jego rozwiązania. W szczególności chciałbym wiedzieć, jaki obiekt / klasa / cokolwiek odpowiada za śledzenie działań, które każdy gracz musi wykonać w wyniku zagrania karty akcji, a także w jaki sposób odnosi się to do tymczasowych zmian w normalnej sekwencji maszyna stanu kolei.

Apis Utilis
źródło
2
Witaj Apis Utilis i witaj w GDSE. Twoje pytanie jest dobrze napisane i wspaniale, że odwoływałeś się do powiązanych pytań. Jednak twoje pytanie obejmuje wiele różnych problemów i aby je w pełni uwzględnić, pytanie prawdopodobnie musiałoby być ogromne. Nadal możesz uzyskać dobrą odpowiedź, ale Ty i strona skorzystacie, jeśli jeszcze bardziej rozwiążecie swój problem. Może zacznij od zbudowania prostszej gry i rozbuduj ją do Dominion?
michael.bartnett
1
Zacznę od nadania każdej karcie skryptu, który zmienia stan gry, a jeśli nic dziwnego się nie dzieje, powróć do domyślnych zasad tury ...
Jari Komppa

Odpowiedzi:

11

Zgadzam się z Jari Komppa, że ​​zdefiniowanie efektów kart za pomocą potężnego języka skryptowego jest dobrym rozwiązaniem. Uważam jednak, że kluczem do maksymalnej elastyczności jest obsługa zdarzeń za pomocą skryptów.

Aby umożliwić kartom interakcję z późniejszymi wydarzeniami w grze, można dodać interfejs API skryptów, aby dodać „haki skryptów” do niektórych zdarzeń, takich jak początki i zakończenia faz gry lub pewne działania, które gracze mogą wykonać. Oznacza to, że skrypt wykonywany przy zagraniu karty może zarejestrować funkcję, która jest wywoływana przy następnym przejściu do określonej fazy. Liczba funkcji, które można zarejestrować dla każdego zdarzenia, powinna być nieograniczona. Gdy jest ich więcej niż jeden, są oni wywoływani w kolejności rejestracji (chyba że istnieje podstawowa zasada gry, która mówi coś innego).

Powinno być możliwe zarejestrowanie tych haków dla wszystkich graczy lub tylko dla niektórych graczy. Sugerowałbym również dodanie możliwości, aby haki decydowały same, czy powinny być wywoływane, czy nie. W tych przykładach do wyrażenia tego użyto wartości zwracanej funkcji hook (true lub false).

Twoja karta z podwójną turą zrobiłaby wtedy coś takiego:

add_event_hook('cleanup_phase_end', current_player, function {
     setNextPlayer(current_player); // make the player take another turn
     return false; // unregister this hook afterwards
});

(Nie mam pojęcia, czy Dominion ma coś w rodzaju „fazy czyszczenia” - w tym przykładzie jest to hipotetyczna ostatnia faza tury graczy)

Karta, która pozwala każdemu graczowi na dobranie dodatkowej karty na początku fazy dobierania, wygląda następująco:

add_event_hook('draw_phase_begin', NULL, function {
    drawCard(current_player); // draw a card
    return true; // keep doing this until the hook is removed explicitely
});

Karta, która powoduje, że docelowy gracz traci punkt wytrzymałości za każdym razem, gdy zagrywa kartę, wygląda następująco:

add_event_hook('play_card', target_player, function {
    changeHitPoints(target_player, -1); // remove a hit point
    return true; 
});

Nie będziesz omijał kodowania niektórych akcji, takich jak ciągnięcie kart lub tracenie punktów wytrzymałości, ponieważ ich pełna definicja - co dokładnie oznacza „wyciągnięcie karty” - jest częścią podstawowej mechaniki gry. Na przykład znam niektóre gry TCG, w których kiedy musisz wyciągnąć kartę z dowolnego powodu, a talia jest pusta, przegrywasz grę. Ta reguła nie jest drukowana na każdej karcie, która powoduje, że dobierasz karty, ponieważ jest w księdze reguł. Więc nie powinieneś również sprawdzać, czy nie ma stanu utraty w skrypcie każdej karty. Sprawdzanie takich rzeczy powinno być częścią zakodowanej drawCard()funkcji (która, nawiasem mówiąc, byłaby również dobrym kandydatem na zdarzenie przechwytujące).

Nawiasem mówiąc: jest mało prawdopodobne, że będziesz w stanie planować z wyprzedzeniem dla każdej niejasnej mechaniki, którą mogą wymyślić przyszłe edycje , więc bez względu na to, co zrobisz, będziesz musiał od czasu do czasu dodawać nowe funkcje do przyszłych edycji (w tym skrzynka, minigra rzucająca konfetti).

Philipp
źródło
1
Łał. Ta konfetti z chaosu.
Jari Komppa
Doskonała odpowiedź, @Philipp, a to zajmuje się wieloma rzeczami zrobionymi w Dominion. Istnieją jednak działania, które muszą nastąpić natychmiast po zagraniu karty, tzn. Zagrana karta zmusza innego gracza do przewrócenia górnej karty swojej biblioteki i umożliwienia obecnemu graczowi powiedzenia „Zatrzymaj” lub „Odrzuć”. Czy napisałbyś haczyki na wydarzenia, aby zająć się takimi natychmiastowymi działaniami, czy też musiałbyś wymyślić dodatkowe metody skryptowania kart?
fnord,
2
Gdy coś musi się wydarzyć natychmiast, skrypt powinien wywołać odpowiednie funkcje bezpośrednio i nie rejestrować funkcji przechwytującej.
Philipp
@JariKomppa: Zestaw Unglued był celowo bezsensowny i pełen szalonych kart, które nie miały sensu. Moją ulubioną była karta, która sprawiła, że ​​wszyscy odnieśli obrażenia, kiedy wypowiedzieli określone słowo. Wybrałem „the”.
Jack Aidley
9

Dałem ten problem - elastyczny komputerowy silnik gier karcianych - niektórzy myśleli jakiś czas temu.

Po pierwsze, złożona gra karciana, taka jak Chez Geek lub Fluxx (i, jak sądzę, Dominion) wymagałaby, aby karty były skryptowalne. Zasadniczo każda karta ma własny zestaw skryptów, które mogą zmieniać stan gry na różne sposoby. Pozwoliłoby to zapewnić systemowi pewne zabezpieczenie na przyszłość, ponieważ skrypty mogą robić rzeczy, o których teraz nie myślisz, ale mogą pojawić się w przyszłości.

Po drugie, sztywny „zwrot” może powodować problemy.

Potrzebujesz pewnego rodzaju „stosu tur”, który zawiera „tury specjalne”, takiego jak „odrzuć 2 karty”. Gdy stos jest pusty, domyślna normalna tura jest kontynuowana.

W Fluxx jest całkiem możliwe, że jedna tura idzie w stylu:

  • Wybierz karty N (zgodnie z obowiązującymi zasadami, zmieniane za pomocą kart)
  • Zagraj w karty N (zgodnie z obowiązującymi zasadami, zmieniane za pomocą kart)
    • Jedną z kart może być „weź 3, zagraj 2 z nich”
      • Jedną z tych kart może być „kolejna tura”
    • Jedną z kart może być „odrzucenie i dobranie”
  • Jeśli zmienisz zasady, aby wybrać więcej kart niż w momencie rozpoczęcia tury, wybierz więcej kart
  • Jeśli zmienisz zasady dotyczące mniejszej liczby kart w ręku, wszyscy inni muszą natychmiast odrzucić karty
  • Kiedy twoja tura się skończy, odrzuć karty, dopóki nie będziesz mieć N kart (ponownie zmienianych za pomocą kart), a następnie wykonaj kolejną turę (jeśli zagrałeś kiedyś kartę „weź kolejną turę” w powyższym bałaganie).

..i tak dalej i tak dalej. Dlatego zaprojektowanie struktury kolei, która poradzi sobie z powyższym nadużyciem, może być dość trudne. Dodaj do tego liczne gry z kartami „kiedykolwiek” (jak w „chez geek”), w których karty „kiedykolwiek” mogą zakłócać normalny przepływ, na przykład poprzez anulowanie dowolnej karty, która była ostatnio grana.

Zasadniczo zacznę od zaprojektowania bardzo elastycznej struktury tur, zaprojektowania jej tak, aby można ją opisać jako skrypt (ponieważ każda gra potrzebuje własnego „skryptu głównego” obsługującego podstawową strukturę gry). Następnie każda karta powinna być skryptowalna; większość kart prawdopodobnie nie robi nic dziwnego, ale inne robią. Karty mogą mieć również różne atrybuty - czy można je trzymać pod ręką, grać „za każdym razem”, czy można je przechowywać jako aktywa (takie jak „opiekunowie” fluxxa, czy różne rzeczy w „chez geek” jak jedzenie) ...

Nigdy tak naprawdę nie zacząłem tego wdrażać, więc w praktyce możesz napotkać wiele innych wyzwań. Najprostszym sposobem na rozpoczęcie jest rozpoczęcie od dowolnego systemu, który chcesz wdrożyć, i wdrożenie ich w sposób możliwy do skryptu, ustawienie jak najmniej kamienia, więc kiedy pojawi się rozszerzenie, nie będziesz musiał zmieniać system podstawowy - dużo. =)

Jari Komppa
źródło
To świetna odpowiedź i przyjąłbym obie, gdybym mógł. Zerwałem remis, akceptując odpowiedź osoby o niższej reputacji :)
Apis Utilis
Nie ma problemu, jestem do tego przyzwyczajony .. =)
Jari Komppa 11.01.13
0

Wydaje się, że Hearthstone robi rzeczy powiązane i szczerze mówiąc, myślę, że najlepszym sposobem na osiągnięcie elastyczności jest silnik ECS o konstrukcji zorientowanej na dane. Próbowałem stworzyć klon paleniska, a poza tym okazało się to niemożliwe. Wszystkie skrzynki krawędziowe. Jeśli masz do czynienia z wieloma dziwnymi przypadkami na krawędzi, to prawdopodobnie najlepszy sposób, aby to zrobić. Jestem jednak dość stronniczy na podstawie ostatnich doświadczeń z wypróbowaniem tej techniki.

Edycja: ECS może nie być nawet potrzebny, w zależności od rodzaju elastyczności i optymalizacji, jakiej chcesz. To tylko jeden ze sposobów na osiągnięcie tego. DOD Błędnie myślałem o programowaniu proceduralnym, choć wiele z nich się wiąże. Chodzi mi o to że. Że powinieneś rozważyć zrezygnowanie z OOP całkowicie lub w większości, a zamiast tego skoncentrować swoją uwagę na danych i sposobie ich organizacji. Unikaj dziedziczenia i metod. Zamiast tego skup się na funkcjach publicznych (systemach), aby manipulować danymi karty. Każde działanie nie jest szablonem ani żadną logiką, ale jest surowymi danymi. Gdzie twoje systemy używają go do wykonywania logiki. Obudowa przełącznika liczb całkowitych lub użycie liczby całkowitej w celu uzyskania dostępu do tablicy wskaźników funkcji pomaga skutecznie ustalić pożądaną logikę na podstawie danych wejściowych.

Podstawowe zasady, których należy przestrzegać, to unikanie wiązania logiki bezpośrednio z danymi, unikanie uzależniania danych od siebie w jak największym stopniu (mogą obowiązywać wyjątki) oraz, że gdy chcesz elastycznej logiki, która wydaje się poza zasięgiem ... Rozważ przekształcenie go w dane.

Można z tego skorzystać. Każda karta może mieć wartość wyliczeniową lub ciąg znaków reprezentujący ich działanie. Ten stażysta umożliwia projektowanie kart za pomocą plików tekstowych lub json i pozwala programowi na automatyczne importowanie ich. Jeśli uczynisz działania gracza listą danych, daje to jeszcze większą elastyczność, szczególnie jeśli karta zależy od logiki przeszłości, tak jak robi to palenisko, lub jeśli chcesz w dowolnym momencie zapisać grę lub powtórkę gry. Istnieje możliwość łatwiejszego tworzenia AI. Zwłaszcza gdy używa się „systemów narzędziowych” zamiast „drzewa zachowań”. Tworzenie sieci staje się również łatwiejsze, ponieważ zamiast musieć wymyślić, jak uzyskać całe potencjalnie polimorficzne obiekty do przesłania przewodem i jak ustawić serializację po tym fakcie, Twoje obiekty gry są już niczym innym jak zwykłymi danymi, które w końcu można łatwo przenosić. I na koniec, ale zdecydowanie nie mniej ważne, pozwala to łatwiej optymalizować, ponieważ zamiast marnować czas na martwienie się o kod, możesz lepiej uporządkować swoje dane, aby procesor miał łatwiejszy czas na radzenie sobie z tym. Python może mieć tutaj problemy, ale sprawdź „linię pamięci podręcznej” i jej związek z twórcą gry. Może nie jest to ważne przy tworzeniu prototypów, ale na dłuższą metę przyda się.

Kilka przydatnych linków.

Uwaga: ECS pozwala dynamicznie dodawać / usuwać zmienne (zwane komponentami) w czasie wykonywania. Przykładowy program typu „jak” może wyglądać ECS (istnieje mnóstwo sposobów na zrobienie tego).

unsigned int textureID = ECSRegisterComponent("texture", sizeof(struct Texture));
unsigned int positionID = ECSRegisterComponent("position", sizeof(struct Point2DI));
for (unsigned int i = 0; i < 10; i++) {
    void *newEnt = ECSGetNewEntity();
    struct Point2DI pos = { 0 + i * 64, 0 };
    struct Texture tex;
    getTexture("test.png", &tex);
    ECSAddComponentToEntity(newEnt, &pos, positionID);
    ECSAddComponentToEntity(newEnt, &tex, textureID);
}
void *ent = ECSGetParentEntity(textureID, 3);
ECSDestroyEntity(ent);

Tworzy wiązkę elementów z danymi tekstury i położenia, a na końcu niszczy element, który ma składnik tekstury, który akurat znajduje się pod trzecim indeksem tablicy składników tekstury. Wygląda dziwacznie, ale jest jednym ze sposobów robienia rzeczy. Oto przykład, w jaki sposób renderujesz wszystko, co ma składnik tekstury.

unsigned int textureCount;
unsigned int positionID = ECSGetComponentTypeFromName("position");
unsigned int textureID = ECSGetComponentTypeFromName("texture");
struct Texture *textures = ECSGetAllComponentsOfType(textureID, &textureCount);
for (unsigned int i = 0; i < textureCount; i++) {
    void *parentEntity = ECSGetParentEntity(textureID, i);
    struct Point2DI *drawPos = ECSGetComponentFromEntity(positionID, parentEntity);
    if (drawPos) {
        struct Texture *t = &textures[i];
        drawTexture(t, drawPos->x, drawPos->y);
    }
}
Blue_Pyro
źródło
1
Ta odpowiedź byłaby lepsza, gdyby zawierała bardziej szczegółowe informacje na temat tego , jak zaleciłbyś skonfigurowanie ECS zorientowanego na dane i zastosowanie go do rozwiązania tego konkretnego problemu.
DMGregory
Zaktualizowano, dziękuję za zwrócenie na to uwagi.
Blue_Pyro
Ogólnie rzecz biorąc, myślę, że źle jest powiedzieć komuś „jak” skonfigurować takie podejście, ale zamiast tego pozwolić mu zaprojektować własne rozwiązanie. Jest to zarówno dobry sposób ćwiczenia, jak i potencjalnie lepsze rozwiązanie problemu. Kiedy myślimy o danych bardziej niż o logice w ten sposób, kończy się to tym, że istnieje wiele sposobów osiągnięcia tego samego i wszystko zależy od potrzeb aplikacji. Jak również czas / wiedza programisty.
Blue_Pyro