ポーカーゲーム作成Step3 ~Playerクラスの実装~
ポーカーには「自分」と「相手」の2種類のプレイヤーが存在します。今回は「自分」は人間が操作し、「相手」はCPUプレイヤーとして動作するように実装します。人間用のプレイヤークラス(HumanPlayerクラス)とCPU用のプレイヤークラス(CpuPlayerクラス)を別々に作る必要がありそうですが、これをより簡単に書く方法について解説していきます。
HumanPlayerとCpuPlayerの違い
HumanPlayerクラスとCpuPlayerクラスの違いは何でしょうか?
このゲームはGUIで動作するので、人間は、「交換したいカードをクリックして選択し、プログラム側はそのクリックを感知してカードを交換する」といったような処理の流れになりそうです。対してCPUは、「ボタンのクリックなどは行わず、交換するカードを選ぶ処理もすべてプログラム側で書く」といった実装になります。
このことから、HumanPlayerクラスとCpuPlayerクラスの違いは捨てるカードを選ぶ処理にありそうです。ですが、重要なのはそれではありません。重要なのは捨てるカードを選ぶこと以外の処理はどちらのクラスも全く同じだということです。例えば、「カードを捨てる処理」、「カードを山札からドローする処理」などです。
このままHumanPlayerクラスとCpuPlayerクラスを実装しようとすると、2つのクラスで共通している全く同じ処理を2回書かなければいけません。これは面倒なので、どうにかして同じ処理は使いまわしたいです。こんなときに使えるのが継承です。
継承
継承とは、共通する部分だけをまとめたクラスを作ることで、他のクラスで共通するフィールドやメソッドをそのまま引き継ぐ(継承する)ことができるというものです。ここで、継承される側のクラスを親クラス、継承する側のクラスを子クラスと呼びます。
今回のHumanPlayerとCpuPlayerはどちらも「プレイヤー」であるという点で共通しています。そのため、共通する処理をPlayerクラスとして実装し、HumanPlayerとCpuPlayerはそれを継承することで、Playerクラスに書いた処理を使い回すことができるようになります。
Playerクラスの実装
まずは親クラスとなるPlayerクラスから実装していきましょう。szpp_poker/modelの中にPlayer.javaを作成してください。
Player.java
package model;
import java.util.ArrayList;
import java.util.List;
public class Player {
protected String name; // 名前
protected List<Card> hand; // 手札
protected int coins; // 賭け金
public Player(String name, int coins) {
this.name = name;
this.coins = coins;
this.hand = new ArrayList<>();
}
// 山札からカードを引いて手札に加える
public void drawCard(Deck deck) {
Card c = deck.draw();
if (c != null) {
hand.add(c);
}
}
// 指定されたカードを手札から捨てる
public void trashCards(List<Card> cardsToTrash) {
hand.removeAll(cardsToTrash);
}
// 手札をリセットする(次のゲーム用)
public void clearHand() {
hand.clear();
}
// 交換するカードを選ぶ
public List<Card> decideExchangeCards() {
return null;
}
public void addCoins(int amount) {
this.coins += amount;
}
public void payCoins(int amount) {
this.coins -= amount;
}
// ゲッター
public String getName() {
return name;
}
public List<Card> getHand() {
return hand;
}
public int getCoins() {
return coins;
}
}プレイヤーが共通して持つフィールドやメソッドはここに全て書いてしまいます。各メソッドがどのような処理をしているのか確認しておきましょう。
フィールドにprotectedという修飾子がついています。これはprivateよりも少しゆるいアクセス制限をかけたいときに使う修飾子で、「このクラスを継承したクラスのみにアクセスを許可する」という意味になります。
decideExchangeCards()メソッドの実装がおかしいことに気づいたでしょうか?これは交換する手札を決定して返すメソッドのはずですが、nullを返して終わっています。(あまりいい実装とは言えません。本来ならばabstractを使用して抽象メソッドとするべきです)
これは先ほど言及した通り、「交換する手札を選ぶ」という処理がhumanPlayerとCpuPlayerで異なっているからこのようになっています。そのため、decideExchangeCards()メソッドは継承した子クラスでそれぞれ上書きしてあげる必要があります。
HumanPlayerクラスの実装
HumanPlayerはPlayerの子クラスとして実装したいので、「これはPlayerクラスを継承しています」と明示しておく必要があります。Javaでは継承を表現する修飾子として、extendsが用意されています。
これを利用して、HumanPlayerクラスはこのように書けます。
package model;
import java.util.ArrayList;
import java.util.List;
public class HumanPlayer extends Player {
// 捨てるカードを保持するリスト
private List<Card> trashCards;
public HumanPlayer(String name, int coins) {
super(name, coins);
this.trashCards = new ArrayList<>();
}
public void setTrashCards(List<Card> trashCards) {
this.trashCards = trashCards;
}
}setTrashCards()は後々使います。ゲーム画面で捨てたいカードをクリックしたら、そのカードをtrashCardsに格納するためのメソッドです。
コンストラクタにsuper()という謎の表現があります。これは親クラスのコンストラクタを呼び出すという意味になります。今回の場合、super(name, coins)はPlayer(name, coins)と全く同じ意味を持ちます。superを使用することで、コンストラクタの中身も簡潔に書くことができます。
Playerクラスを継承しているため、HumanPlayerクラスはコードには何も書かなくてもdrawCard()やtrashCards()といった処理を使うことができます。
decideExchangeCardsメソッドの実装とオーバーライド
親クラスでとりあえず定義していたdecideExchange()メソッドをHumanPlayerクラスように上書きしていきましょう。このように、「親クラスのメソッドの内容を子クラスでやりたい処理に合うように上書きする」ことをオーバーライドといいます。Javaではオーバーライドしていることを明示するために、@Overrideというアノテーションを使います。
@Override
public List<Card> decideExchangeCards() {
List<Card> result = new ArrayList<>(trashCards);
trashCards.clear();
return result;
}オーバーライドと判定される条件は、@Overrideが付与されているメソッドが、親クラスで定義されているメソッドと全く同じ名前・同じ引数・同じ返り値の型であることです。
中身の処理としては、画面側(View)のクリック操作によってあらかじめtrashCardsに格納されたカードを一旦別のリスト(result)にコピーし、元のリストをclear()で空っぽにしてから、コピーしたリストを返しています。これで人間側の「捨てるカードを選ぶ処理」は完成です。
オーバーライドについてちょっと詳しく
先ほど説明した通り、親クラスで定義されているメソッドを子クラス用に上書きすることをオーバーライドと呼びます。オーバーライドをするためには、親クラスと同じメソッド名・同じ引数・同じ返り値の型を持っている必要があります。@Overrideはオーバーライドしているかどうかに全く関係がないことに注意してください!
実は、@Overrideというアノテーションは「コンパイラにちゃんとオーバーライドできているかチェックしてもらう」ために付けます。@Overrideを付与することで、例えばメソッド名をタイポしてしまったり、引数の型が違ったりした場合にコンパイルの段階でJavaがエラーを出してくれます。
@Overrideが無くても正しくコードが書けていればプログラムは問題なく動きますが、意図しない挙動を防ぐためにも絶対書くようにしましょう。
CpuPlayerクラスの実装
人間側のプレイヤーができたので、次は対戦相手となるCpuPlayerクラスを作っていきましょう。同じくszpp_poker/modelの中にCpuPlayer.javaを作成してください。
CpuPlayerもHumanPlayerと同様に、Playerを継承します。ただし、CPUは人間のように画面のクリックを待つことができないため、プログラム側で「自動的に手札を計算して捨てるカードを選ぶアルゴリズム」を組み込んであげる必要があります。
package model;
import java.util.ArrayList;
import java.util.List;
public class CpuPlayer extends Player {
public CpuPlayer(String name, int coins) {
super(name, coins);
}
@Override
public List<Card> decideExchangeCards() {
List<Card> trashCards = new ArrayList<>();
// 捨てるカードを考える処理を頑張って書く
return trashCards;
}
}テストコード
import model.HandEvaluator;
import model.Card;
import model.Deck;
import model.Player;
import model.CpuPlayer;
import java.util.List;
public class Main {
public static void main(String[] args) {
Deck deck = new Deck();
// CPUプレイヤーの作成 (所持金1000コイン)
Player cpu = new CpuPlayer("CPU_1", 1000);
// カードを5枚配る
for (int i = 0; i < 5; i++) {
cpu.drawCard(deck);
}
System.out.println(cpu.getName() + " の初期手札: " + cpu.getHand());
System.out.println("現在の役: " + HandEvaluator.evaluate(cpu.getHand()));
// CPUにどのカードを捨てるか考えさせる
List<Card> discards = cpu.decideExchangeCards();
System.out.println("CPUが捨てるカード: " + discards);
// 実際にカードを捨てて、同じ枚数引き直す
cpu.trashCards(discards);
for (int i = 0; i < discards.size(); i++) {
cpu.drawCard(deck);
}
System.out.println(cpu.getName() + " の交換後手札: " + cpu.getHand());
System.out.println("交換後の役: " + HandEvaluator.evaluate(cpu.getHand()));
}
}実装例
package model;
import java.util.ArrayList;
import java.util.List;
public class CpuPlayer extends Player {
public CpuPlayer(String name, int coins) {
super(name, coins);
}
@Override
public List<Card> decideExchangeCards() {
List<Card> trashCards = new ArrayList<>();
// 手札の各数字とスートが何枚あるかカウントする
int[] counts = new int[14];
int spadeCount = 0;
int cloverCount = 0;
int diaCount = 0;
int heartCount = 0;
for (int i = 0; i < hand.size(); i++) {
Card card = hand.get(i);
int rank = card.getRank();
counts[rank]++;
String mark = card.getMark();
switch (mark) {
case "♠":
spadeCount++;
break;
case "♣":
cloverCount++;
break;
case "♦":
diaCount++;
break;
case "♥":
heartCount++;
break;
}
}
// 役ができているか?
boolean hasPairOrBetter = false;
for (int i = 1; i <= 13; i++) {
if (counts[i] >= 2) {
hasPairOrBetter = true;
}
}
// フラッシュドロー(あと一枚でフラッシュ)か?
String flushDrawMark = null;
if (spadeCount == 4)
flushDrawMark = "♠";
else if (cloverCount == 4)
flushDrawMark = "♣";
else if (diaCount == 4)
flushDrawMark = "♦";
else if (heartCount == 4)
flushDrawMark = "♥";
// ストレートドロー(あと一枚でストレート)か?
int[] effectCounts = new int[15];
for (int i = 1; i <= 13; i++) {
effectCounts[i] = counts[i];
}
effectCounts[14] = counts[1];
boolean hasStraightDraw = false;
int straightStart = 0;
// 幅5の切り取り枠をスライドさせるイメージ
for (int i = 1; i <= 10; i++) {
int continueCount = 0;
for (int j = i; j <= i + 4; j++) {
if (effectCounts[j] > 0) {
continueCount++;
}
}
if (continueCount == 4) {
hasStraightDraw = true;
straightStart = i; // 枠の開始位置を記録する
break;
}
}
// 捨てるカードの選択
if (hasPairOrBetter) {
// 役ができている場合
// ペアやスリーカードを構成しているカードは残し、それ以外をすべて捨てる
for (int i = 0; i < hand.size(); i++) {
Card card = hand.get(i);
int rank = card.getRank();
if (counts[rank] == 1) {
trashCards.add(card);
}
}
} else if (flushDrawMark != null) {
// フラッシュドローの場合
for (Card card : hand) {
if (!card.getMark().equals(flushDrawMark)) {
trashCards.add(card);
}
}
} else if (hasStraightDraw) {
// ストレートドローの場合
for (Card card : hand) {
int rank = card.getRank();
if (rank == 1 && straightStart >= 10) {
rank = 14;
}
if (rank < straightStart || rank > straightStart + 4) {
trashCards.add(card);
}
}
} else {
// 役がない場合
// 一番強い(数字が大きい)カードを1枚だけ残し、残り4枚をすべて捨てる
int maxRank = 0;
Card keepCard = null;
for (int i = 0; i < hand.size(); i++) {
Card card = hand.get(i);
int rank = card.getRank();
// ポーカーでは1(A)が一番強いので、比較のために14として扱う
if (rank == 1) {
rank = 14;
}
if (rank > maxRank) {
maxRank = rank;
keepCard = card;
}
}
for (int i = 0; i < hand.size(); i++) {
Card card = hand.get(i);
if (card != keepCard) {
trashCards.add(card);
}
}
}
return trashCards;
}
}