Silnik kart w Javie

Wszystko zaczęło się, gdy zauważyliśmy, że było bardzo niewiele aplikacji do gier karcianych lub apletów napisanych w Javie. Najpierw pomyśleliśmy o napisaniu kilku gier i zaczęliśmy od ustalenia podstawowego kodu i klas potrzebnych do tworzenia gier karcianych. Proces trwa, ale teraz istnieje dość stabilna struktura do tworzenia różnych rozwiązań gier karcianych. Tutaj opisujemy, jak ten framework został zaprojektowany, jak działa oraz jakie narzędzia i triki zostały użyte, aby uczynić go użytecznym i stabilnym.

Faza projektowania

W przypadku projektowania obiektowego niezwykle ważne jest poznanie problemu od wewnątrz i na zewnątrz. W przeciwnym razie można spędzić dużo czasu na projektowaniu zajęć i rozwiązań, które nie są potrzebne lub nie będą działać zgodnie z określonymi potrzebami. W przypadku gier karcianych jednym podejściem jest wizualizacja tego, co się dzieje, gdy jedna, dwie lub więcej osób gra w karty.

Talia kart zwykle zawiera 52 karty w czterech różnych kolorach (karo, kier, trefl, pik), z wartościami od dwójki do króla i asa. Natychmiast pojawia się problem: w zależności od zasad gry, asy mogą mieć najniższą wartość karty, najwyższą lub obie.

Co więcej, są gracze, którzy biorą karty z talii do ręki i zarządzają ręką zgodnie z zasadami. Możesz pokazać karty wszystkim, kładąc je na stole lub spojrzeć na nie prywatnie. W zależności od etapu gry możesz mieć na ręce N kart.

Analiza etapów w ten sposób ujawnia różne wzorce. Teraz stosujemy podejście oparte na przypadkach, jak opisano powyżej, udokumentowane w Object Oriented Software Engineering Ivara Jacobsona . W tej książce jednym z podstawowych pomysłów jest modelowanie zajęć w oparciu o sytuacje z życia wzięte. To znacznie ułatwia zrozumienie, jak działają relacje, co zależy od czego i jak działają abstrakcje.

Mamy klasy takie jak CardDeck, Hand, Card i RuleSet. CardDeck będzie zawierał na początku 52 obiekty Card, a CardDeck będzie miał mniej obiektów Card, ponieważ są one wciągane do obiektu Hand. Przedmioty rąk rozmawiają z obiektem RuleSet, który ma wszystkie zasady dotyczące gry. Pomyśl o zestawie reguł jak o podręczniku do gry.

Klasy wektorowe

W tym przypadku potrzebowaliśmy elastycznej struktury danych, która obsługuje dynamiczne zmiany wprowadzanych danych, co wyeliminowało strukturę danych Array. Chcieliśmy również, aby w łatwy sposób dodać element wstawiania i uniknąć konieczności kodowania, jeśli to możliwe. Dostępne są różne rozwiązania, takie jak różne formy drzew binarnych. Jednak pakiet java.util ma klasę Vector, która implementuje tablicę obiektów, która rośnie i zmniejsza się w miarę potrzeb, co było dokładnie tym, czego potrzebowaliśmy. (Funkcje składowe Vector nie są w pełni wyjaśnione w aktualnej dokumentacji; w tym artykule wyjaśniono dokładniej, w jaki sposób klasa Vector może być używana w przypadku podobnych instancji list obiektów dynamicznych). Wadą klas Vector jest dodatkowe użycie pamięci ze względu na dużą ilość pamięci. kopiowanie odbywa się za kulisami. (Z tego powodu tablice są zawsze lepsze; mają statyczny rozmiar,aby kompilator mógł znaleźć sposoby optymalizacji kodu). Ponadto w przypadku większych zestawów obiektów mogą obowiązywać kary dotyczące czasów wyszukiwania, ale największy wektor, jaki mogliśmy wymyślić, to 52 wpisy. Jest to nadal rozsądne w tym przypadku, a długie czasy wyszukiwania nie były problemem.

Poniżej znajduje się krótkie wyjaśnienie, w jaki sposób każda klasa została zaprojektowana i zaimplementowana.

Klasa karty

Klasa Card jest bardzo prosta: zawiera wartości sygnalizujące kolor i wartość. Może również zawierać wskaźniki do obrazów GIF i podobnych obiektów opisujących kartę, w tym możliwe proste zachowanie, takie jak animacja (odwróć kartę) i tak dalej.

klasa Card implementuje CardConstants {public int color; publiczna wartość int; public String ImageName; }

Te obiekty Card są następnie przechowywane w różnych klasach Vector. Zauważ, że wartości kart, w tym kolor, są zdefiniowane w interfejsie, co oznacza, że ​​każda klasa we frameworku może zaimplementować i w ten sposób zawiera stałe:

interface CardConstants {// pola interfejsu są zawsze publiczne statyczne ostateczne! int HEARTS 1; int DIAMOND 2; int SPADE 3; int CLUBS 4; int JACK 11; int QUEEN 12; int KING 13; int ACE_LOW 1; int ACE_HIGH 14; }

Klasa CardDeck

Klasa CardDeck będzie miała wewnętrzny obiekt Vector, który zostanie wstępnie zainicjowany za pomocą 52 obiektów karty. Odbywa się to za pomocą metody zwanej shuffle. Oznacza to, że za każdym razem, gdy tasujesz, faktycznie zaczynasz grę od zdefiniowania 52 kart. Konieczne jest usunięcie wszystkich możliwych starych obiektów i ponowne rozpoczęcie od stanu domyślnego (52 obiekty karty).

public void shuffle () {// Zawsze wyzeruj wektor decku i inicjalizuj go od zera. deck.removeAllElements (); 20 // Następnie włóż 52 karty. Jeden kolor na raz dla (int i ACE_LOW; i <ACE_HIGH; i ++) {Card aCard new Card (); aCard.color SERCA; aCard.value i; deck.addElement (aCard); } // Zrób to samo dla CLUBS, DIAMONDS i SPADES. }

Kiedy rysujemy obiekt Card z CardDeck, używamy generatora liczb losowych, który zna zbiór, z którego wybierze losową pozycję wewnątrz wektora. Innymi słowy, nawet jeśli obiekty Card są uporządkowane, funkcja losowa wybierze dowolną pozycję w zakresie elementów wewnątrz wektora.

W ramach tego procesu usuwamy również rzeczywisty obiekt z wektora CardDeck, gdy przekazujemy ten obiekt do klasy Hand. Klasa Vector odwzorowuje rzeczywistą sytuację talii kart i ręki, przekazując kartę:

public Card draw () {Card aCard null; int position (int) (Math.random () * (deck.size = ())); try {aCard (Card) deck.elementAt (pozycja); } catch (ArrayIndexOutOfBoundsException e) {e.printStackTrace (); } deck.removeElementAt (pozycja); zwrócić kartę; }

Zauważ, że dobrze jest wychwycić wszelkie możliwe wyjątki związane z zabraniem obiektu z Vectora z nieobecnej pozycji.

Istnieje metoda narzędziowa, która iteruje przez wszystkie elementy w wektorze i wywołuje inną metodę, która zrzuca ciąg znaków ASCII wartość / kolor. Ta funkcja jest przydatna podczas debugowania zarówno klas Deck, jak i Hand. Cechy wyliczeniowe wektorów są często używane w klasie Hand:

public void dump () {Enumeration enum deck.elements (); while (enum.hasMoreElements ()) {Card card (Card) enum.nextElement (); RuleSet.printValue (karta); }}

Klasa ręki

Klasa Hand to prawdziwy koń roboczy w tym frameworku. Większość wymaganych zachowań była czymś bardzo naturalnym do umieszczenia w tej klasie. Wyobraź sobie ludzi trzymających karty w rękach i wykonujących różne operacje, patrząc na obiekty Karty.

Po pierwsze, potrzebujesz także wektora, ponieważ w wielu przypadkach nie wiadomo, ile kart zostanie podniesionych. Chociaż możesz zaimplementować tablicę, dobrze jest też mieć tutaj trochę elastyczności. Najbardziej naturalną metodą, jakiej potrzebujemy, jest wzięcie karty:

public void take (Card theCard) {cardHand.addElement (theCard); }

CardHandjest wektorem, więc po prostu dodajemy obiekt Card do tego wektora. Jednak w przypadku operacji „wyjście” z ręki mamy dwa przypadki: jeden, w którym pokazujemy kartę, i drugi, w którym oboje pokazujemy i dobieramy kartę z ręki. Musimy zaimplementować oba, ale korzystając z dziedziczenia, piszemy mniej kodu, ponieważ rysowanie i pokazywanie karty jest szczególnym przypadkiem od samego pokazania karty:

public Card show (int position) {Card aCard null; spróbuj {aCard (Card) cardHand.elementAt (pozycja); } catch (ArrayIndexOutOfBoundsException e) {e.printStackTrace (); } zwrócić kartę; } 20 public Card draw (int position) {Card aCard show (position); cardHand.removeElementAt (pozycja); zwrócić kartę; }

Innymi słowy, przypadek rysowania jest pokazem, z dodatkowym zachowaniem polegającym na usuwaniu obiektu z wektora Hand.

Pisząc kod testowy dla różnych klas, znaleźliśmy rosnącą liczbę przypadków, w których konieczne było poznanie różnych specjalnych wartości w dłoni. Na przykład czasami musieliśmy wiedzieć, ile kart określonego typu znajduje się w ręce. Albo domyślna wartość asa low, czyli jeden, musiała zostać zmieniona na 14 (najwyższa wartość) iz powrotem. W każdym przypadku wsparcie behawioralne było oddelegowane z powrotem do klasy Hand, ponieważ było to bardzo naturalne miejsce dla takiego zachowania. Znowu było to prawie tak, jakby ludzki mózg był za ręką wykonującą te obliczenia.

Funkcja wyliczania wektorów może posłużyć do sprawdzenia, ile kart o określonej wartości było obecnych w klasie Hand:

 public int NCards (int value) { int n 0; Enumeration enum cardHand.elements (); while (enum.hasMoreElements ()) { tempCard (Card) enum.nextElement (); // = tempCard defined if (tempCard.value= value) n++; } return n; } 

Similarly, you could iterate through the card objects and calculate the total sum of cards (as in the 21 test), or change the value of a card. Note that, by default, all objects are references in Java. If you retrieve what you think is a temporary object and modify it, the actual value is also changed inside the object stored by the vector. This is an important issue to keep in mind.

RuleSet class

The RuleSet class is like a rule book that you check now and then when you play a game; it contains all the behavior concerning the rules. Note that the possible strategies a game player may use are based either on user interface feedback or on simple or more complex artificial intelligence (AI) code. All the RuleSet worries about is that the rules are followed.

Other behaviors related to cards were also placed into this class. For example, we created a static function that prints the card value information. Later, this could also be placed into the Card class as a static function. In the current form, the RuleSet class has just one basic rule. It takes two cards and sends back information about which card was the highest one:

 public int higher (Card one, Card two) { int whichone 0; if (one.value= ACE_LOW) one.value ACE_HIGH; if (two.value= ACE_LOW) two.value ACE_HIGH; // In this rule set the highest value wins, we don't take into // account the color. if (one.value > two.value) whichone 1; if (one.value < two.value) whichone 2; if (one.value= two.value) whichone 0; // Normalize the ACE values, so what was passed in has the same values. if (one.value= ACE_HIGH) one.value ACE_LOW; if (two.value= ACE_HIGH) two.value ACE_LOW; return whichone; } 

You need to change the ace values that have the natural value of one to 14 while doing the test. It's important to change the values back to one afterward to avoid any possible problems as we assume in this framework that aces are always one.

In the case of 21, we subclassed RuleSet to create a TwentyOneRuleSet class that knows how to figure out if the hand is below 21, exactly 21, or above 21. It also takes into account the ace values that could be either one or 14, and tries to figure out the best possible value. (For more examples, consult the source code.) However, it's up to the player to define the strategies; in this case, we wrote a simple-minded AI system where if your hand is below 21 after two cards, you take one more card and stop.

How to use the classes

It is fairly straightforward to use this framework:

 myCardDeck new CardDeck (); myRules new RuleSet (); handA new Hand (); handB new Hand (); DebugClass.DebugStr ("Draw five cards each to hand A and hand B"); for (int i 0; i < NCARDS; i++) { handA.take (myCardDeck.draw ()); handB.take (myCardDeck.draw ()); } // Test programs, disable by either commenting out or using DEBUG flags. testHandValues (); testCardDeckOperations(); testCardValues(); testHighestCardValues(); test21(); 

The various test programs are isolated into separate static or non-static member functions. Create as many hands as you want, take cards, and let the garbage collection get rid of unused hands and cards.

You call the RuleSet by providing the hand or card object, and, based on the returned value, you know the outcome:

 DebugClass.DebugStr ("Compare the second card in hand A and Hand B"); int winner myRules.higher (handA.show (1), = handB.show (1)); if (winner= 1) o.println ("Hand A had the highest card."); else if (winner= 2) o.println ("Hand B had the highest card."); else o.println ("It was a draw."); 

Or, in the case of 21:

 int result myTwentyOneGame.isTwentyOne (handC); if (result= 21) o.println ("We got Twenty-One!"); else if (result > 21) o.println ("We lost " + result); else { o.println ("We take another card"); // ... } 

Testing and debugging

Podczas implementacji rzeczywistego frameworka bardzo ważne jest pisanie kodu testowego i przykładów. W ten sposób zawsze wiesz, jak dobrze działa kod implementacji; zdajesz sobie sprawę z faktów dotyczących funkcji i szczegółów implementacji. Mając więcej czasu, wdrożylibyśmy pokera - taki przypadek testowy zapewniłby jeszcze lepszy wgląd w problem i pokazałby, jak na nowo zdefiniować ramy.