トリックテイキングをプログラミングで一人回しする【前編】




 この記事は、ボードゲーム・パズルプログラミング Advent Calendar 2022の21日目の記事です。

 カズマと申します。趣味で同人ボードゲームを7年ほど作っています。哲学専攻だった文系です。三流エンジニアから転職して10年ほどが経ち、そろそろ技術的な知識を忘れつつあります。そんな素人レベルのプログラミングしかできない私ですが、枯れ木も山の賑わいと申しますから、今回は自作のテストで使っているコードの話をします。
 代表作はサークルサイトを、詳細な作品一覧はこちらをご覧ください。

 本記事は、トリックテイキング・ゲームをプログラミングで作る、というテーマで喋っていきます

1.目的と背景
2.概要
3.汎用ロジックの実装:トランプのデッキ
4.汎用ロジックの実装:ゲーム用の下準備
5.汎用ロジックの実装:メイン処理と各フェーズ
続く

※12/30追記:複数ディールのプレイに対応するため、「player.py」「procdeal.py」「proctrickresult.py」を更新しました。

1.目的と背景

 トリックテイキングというトランプゲームのジャンルがありまして、ヨーロッパでは中世以来の伝統がありゲームも数百種類あります。基本ルールが簡単なので私も自作をよく発表します。後述しますがルールに共通点が多いため、共通ライブラリを組んで汎用化できないかなというのが出発点で、実際少しは自分でぽちぽちプレイして暇つぶしになってテストの役に立っています。
 目的は、一人回しテストに使うためのプログラムを作ることです。やることは最低限動くGUIの作成です。言語はPythonです。思考ルーチンとかは知識がないので組めません。じゃあなんで作るかというと、3~4人ゲームを子供4歳が寝ついたあとの夜中2時に人力で一人回ししてるとあまりの虚しさに世を儚むので、相手の手札が見えない状態で乱数使って適当に相手してもらうだけでも精神的にだいぶラクだし、プレイヤー心理を理解しやすいからです。

 もちろん既存のライブラリを使って省力してもいいのですが、趣味だし、車輪の再発明は楽しいじゃないですか、ねえ? ねえじゃないよ。
 そういうライブラリや環境ないかなって思って、今年はこちらのカレンダーの記事もざっとだけ拝読してみたのですが、やはり2人用アブストラクトが多いようです。私は囲碁やTwixTなどのアブストラクトも少し遊びますが、主に遊ぶのはいわゆるドイツゲーム/ユーロゲームのジャンルです。カタンやカルカソンヌなどですね。ドイツゲームはボードありカードありサイコロあり、ルールも個々のゲームによって様々です。そういう環境ってまだBoard Game Arenaくらいしかなくて、あれもテスト用ではなく対人戦のルールとインターフェースを提供するものですから、ドイツゲーム向けのテスト環境はまだ少ないという認識です。便利なやつあったら教えてください。
 作りたいのはあくまでドイツ/ユーロなので、だったら自分用に拵えよう、と数年前に思った背景があります。

 そういうわけで、プログラミングとして目新しい部分はありません。許して。ボードゲーム制作は7年やって少しだけ慣れましたが、あくまで趣味ですし大して上手くもありません。0を1にするための記事だとご理解ください。類似のノウハウが少ないと感じるので恥を忍んで私が発表すると、こういうわけです。使う方はいい感じにコードを修正してください。

2.概要

 プログラミングの順番は、次のとおりです。
 最初に、トランプの山札やゲームルールといった汎用ロジックを作ります。ここだけ作ったらCUIでテスト可能です。APとPRを分離するのは常套手段ですが、偏った戦法の繰り返しシミュレーションをかけたいときにロジックだけ回してファイル吐かせたらデータを簡単に取れる、といった実用的な意味合いもあります。
 次に、自分で回すときの気分をアゲるためにGUIを作ります。今回はトランプゲームですが、自作カードを使用するゲームでグラフィックを作ったときに、紙印刷せずにグラフィックデータだけでプレイ感を一旦確認できるメリットもあります。
 最後に、実際のゲーム用の独自ロジックや見せ方を上書きするコードを書きます。これでゲームは完成です。クロンダイク(ソリティア)程度にポチポチして遊べます。やったー

 今回の【前編】では、最初の「汎用ロジック」に相当する部分を説明します
 使うのはPython 3です。3.nのバージョンに依存するほどの処理は書いてないはずですが、エラーが出たら教えてください。実際のコードには if __name__ == '__main__': を使った簡単なテストも書いてますが、ここでは省略しました。

 パッケージ構成は、AP層では
modules
 ├ cardgame
 └ playingcards
 としており、「トランプのデッキ」のクラスはplayingcardsに、残りはcardgameに入れています。modulesフォルダ全体をimportの参照先にするよう設定していますので、これは同様の設定であればどこでもかまいません。

 トリックテイキングのルールも概略説明しておきます。
 ディーラーの左隣から順に、手札のカードを1枚ずつプレイします。打ち出しの1枚は何でも構いませんが、以降は同じスートを持っていればその中からしか選べません。(ズルしたら? 持ってないはずのスートが出てくるので後で必ずわかります。ロジックで禁止できるのでここでは考慮しません。)全員が1枚ずつ出すまで続けます。この一巡を「トリック(trick)」と呼びます。
 トリックの勝敗は、打ち出したカードと同じスートの中で最もランクが高いカードが勝ちます。切札(trump。トランプの語源)となるスートを決めているゲームでは、打ち出しよりも切札スートのほうが強く、なければ打ち出しのスートが勝ちます。トリックに勝つことを「トリックを取る(take)」と言います。トリックを取った人から次のカードを打ち出します。
 流れはこれだけで、トリックテイキングの大半が上記のルールに準拠します。ゲームのバリエーションはプレイングのルールではなく勝敗の決め方に準拠することが多く、トリックを沢山取ったら勝ち、トリックを取らなければ勝ち、特定のカードに付いている点数を沢山集めたら勝ち、取るトリック数やカード点数を競りにかけて切札を決める権利を得るビッド(bid。競り)方式、など様々です。前編で作る汎用処理では、取ったトリック数が多いほうが勝ち、といういちばん原始的な実装を採用します。

3.汎用ロジックの実装:トランプのデッキ

 まずはトランプのデッキを作ります。ランクとスートを定義し、そこからカードを定義し、カードを集めて山札にします。4クラスです。

【rank.py】はランク(数字)の定義です。
# coding: UTF-8
from enum import IntEnum

class Rank(IntEnum):
    u'''トランプのランクを表す整数列挙型。Aが14、数字は数字通り、JQKは11,12,13。'''
    Ace = 14
    Two = 2
    Three = 3
    Four = 4
    Five = 5
    Six = 6
    Seven = 7
    Eight = 8
    Nine = 9
    Ten = 10
    Jack = 11
    Queen = 12
    King = 13

    # 自分の名前を正式の(長い)文字列で返す
    def getFullName(self):
        return str(self).split('.')[-1]
    
    # 自分の名前を頭文字で返す
    def getName(self):
        if self.value == 0:
            return str(self).split('.')[-1]
        elif self.value < 11:
            return str(self.value)
        else:
            return str(self).split('.')[-1][0]

 Aが一番強く、次にK、Q、J、10、…と続きます。この順序は決して自明ではなく、ドイツのゲームだと2~6がなくAの次は10が強い、イタリアのゲームだと8~10がなくAの次は3が強い、などゲームによって変わるので、その場合は適宜このクラスを継承して書き換えます。getFullName()とgetName()は表示を簡略化するためだけで、なくてもいいです。

【suit.py】はスート定義です。
# coding: UTF-8
from enum import IntEnum

class Suit(IntEnum):
    u'''トランプのスートを表す整数列挙型。ブリッジオーダー。'''
    X = 5   #ジョーカー
    Spades = 4
    Hearts = 3
    Diamonds = 2
    Clubs = 1

    def getMark(self):
        suits = {Suit.Spades:u'♠', Suit.Hearts:u'♡', Suit.Diamonds:u'♢', Suit.Clubs:u'♣', Suit.X:u'J'}
        return suits[self]

 カードを画面で並べ替えたり、同じトリック数のビッド間で強弱を決めたりするために、数字で順序をつけています。並べ替えは赤と黒を交互にやるほうがいいかもですが、とりあえずブリッジオーダー(SHDC)にしておけば無難でしょう。伝統ゲームだとスートの呼び方も違いますし、モダンゲームでもスートは色など独自定義になるでしょう。必ず負けるカード、必ず勝つカードなんかも作りたい場合があると思います。そうした場合は同様にクラスを上書きします。

【card.py】で、ランクとスートを組み合わせてカードを作ります。
class Card():
    u'''カードゲームのカードを表すクラス。
    ランクとスートを設定して生成し、並べ替えや強さの比較が可能。
    大小比較は、スートの強さを比べ、同じならランクの強さを比べる。
    ランクとスートの型指定はなく、ダックタイピングに基づく独自実装でもよい。'''
    
    rank = ''
    suit = ''
    
    def __init__(self, rank, suit):
        u'''コンストラクタ。ランクとスートをセットする。'''
        self.rank = rank
        self.suit = suit
    
    # 文字列表現(解析用としてスートは文字出力)
    def __str__(self):
        return str(self.suit).split('.')[-1] + '.' + str(self.rank.value)
    
    # ソート比較用
    def __eq__(self, obj):
        return (self.suit == obj.suit) and (self.rank == obj.rank)
    def __ne__(self, obj):
        return (self.suit != obj.suit) or (self.rank != obj.rank)
    def __lt__(self, obj):
        if (self.suit != obj.suit):
            judge = (self.suit < obj.suit)
        else:
            judge = (self.rank < obj.rank)
        return judge
    def __le__(self, obj):
        if (self.suit != obj.suit):
            judge = (self.suit <= obj.suit)
        else:
            judge = (self.rank <= obj.rank)
        return judge
    def __gt__(self, obj):
        if (self.suit != obj.suit):
            judge = (self.suit > obj.suit)
        else:
            judge = (self.rank > obj.rank)
        return judge
    def __ge__(self, obj):
        if (self.suit != obj.suit):
            judge = (self.suit >= obj.suit)
        else:
            judge = (self.rank >= obj.rank)
        return judge
    
    # ゲッターとセッター
    def setRank(self, rank):
        u'''ランクのセッター。'''
        self.rank = rank
    def getRank(self):
        u'''ランクのゲッター。'''
        return self.rank
    def setSuit(self, suit):
        u'''スートのセッター。'''
        self.suit = suit
    def getSuit(self):
        u'''スートのゲッター。'''
        return self.suit
    def setPhoto(self, photo):
        u'''画像イメージのセッター。'''
        self.photo = photo
    def getPhoto(self):
        u'''画像イメージのゲッター。'''
        return self.photo
    # カード内容を文字列で返す(CUI等の表示用にスートは記号)
    def string(self):
        return (self.suit.getMark() + self.rank.getName())

 ソート用のメソッドを沢山切ってます。あとは__str__()があるとログ出力時に見るのが楽です。セッターとゲッターは昔Javaを組んでた頃の脊髄反射で作ってますが、Pythonだと変数に直接アクセスするほうが読みやすいので無駄なコードです。

【deck.py】は、カードを生成して山札にします。
# coding: UTF-8

import random
from playingcards.rank import Rank
from playingcards.suit import Suit
from playingcards.card import Card

class Deck():
    u'''カードゲームの山札を表すクラス。
    指定したランク・スートに基づく山札を生成し、各種操作を行う。'''
    
    deck = []
    
    def __init__(self, joker=False):
        u'''コンストラクタ。ランク×スートの総当たりデッキを作成する。'''
        ranks = [rank for rank in Rank]
        suits = [suit for suit in Suit if suit is not Suit.X]
        self.deck = [Card(rank, suit) for rank in ranks for suit in suits]
        if joker:
            self.deck.append(Card(Rank.Ace, Suit.X))
            self.deck.append(Card(Rank.Two, Suit.X))
    
    def __setitem__(self, key, value):
        u'''添字アクセス用のセッター。'''
        self.deck[key] = value
    
    def __getitem__(self, key):
        u'''添字アクセス用のゲッター。'''
        return self.deck[key]
    
    def __len__(self):
        u'''len()関数用のゲッター。'''
        return len(self.deck)
    
    def shuffle(self):
        u'''山札をシャッフルし、山札自体を返す。'''
        random.shuffle(self.deck)
        return self
    
    def pick(self):
        u'''山札の上から1枚抜いて返す。'''
        return self.deck.pop(0)
    
    def add(self, card):
        u'''山札の下に1枚追加する。'''
        self.deck.append(card)
        return self
    
    def extend(self, cards):
        u'''山札にカードのリストを追加する。'''
        self.deck.extend(cards)
        return self
    
    def size(self):
        u'''山札の枚数を返す。'''
        return len(self.deck)

 山札の実体は単なるリスト deck[] です。ここに定義してあるのはシャッフルやドローなど専ら山札操作の関数で、直感的に山札を触るのに便利です。
 トランプゲームの中には同じカードを2枚用意するもの(ピノクル、キャンセレーション・ハーツ、ドッペルコップ等)があり、このクラスを継承して__init__()だけを変えれば事が足ります。

4.汎用ロジックの実装:ゲーム用の下準備

 次に、ゲームプレイ用のプレイヤー、ルール、データクラスを作ります。

【player.py】というプレイヤー定義をまず書きます。このとき、人間かCPUかのプレイヤー種類もあわせて定義します。
# coding: UTF-8
import random
from cardgame.playertype import PlayerType

class Player():
    u'''カードゲームのプレイヤーを表すクラス。
    手札をリストで保持して操作を行う(リストのシンタックスシュガー)。'''
    
    def __init__(self, ptype=PlayerType.AI_RANDOM, hands=[], pname='Default'):
        u'''コンストラクタ。プレイヤータイプと初期手札をセットする。'''
        self.ptype = ptype
        self.hands = hands
        self.pname = pname
        self.pointCards = []
    
    def setType(self, ptype):
        u'''プレイヤータイプのセッター。0が人間、1がコンピュータ。'''
        self.ptype = ptype
    
    def getType(self):
        u'''プレイヤータイプのゲッター。0が人間、1がコンピュータ。'''
        return self.ptype
    
    def getHands(self):
        u'''全手札のゲッター。手札からは抜かず、単に参照を返す。'''
        return self.hands
    
    def getHand(self, i):
        u'''手札1枚のゲッター。手札からは抜かず、単に参照を返す。'''
        return self.hands[i]
    
    def setHands(self, hands):
        u'''手札のリストをセットする。'''
        self.hands = hands
    
    def getPointCards(self):
        u'''全獲得札のゲッター。'''
        return self.pointCards
    
    def getPointCard(self, i):
        u'''獲得札1枚のゲッター。'''
        return self.pointCards[i]
    
    def setPointCard(self, card):
        u'''獲得札1枚をセットする。'''
        self.pointCards.append(card)
    
    def sort(self):
        u'''手札のリストをソートする。ソートした手札を返す。'''
        self.hands.sort()
    
    def addCard(self, card):
        u'''カードを1枚、手札の末尾に追加する。'''
        self.hands.append(card)
    
    def playCard(self, i):
        u'''選んだカードを1枚プレイする。手札から抜いて(インデックス, カード)のタプルを返す。
        intを設定すれば順番指定、カードを入れたら手札の最初に該当するものを選ぶ。
        存在しないものを指定するとNoneを返す。'''
        if isinstance(i, int):
            return (i, self.hands.pop(i))
        if i in self.hands:
            return (self.hands.index(i), self.hands.pop(self.hands.index(i)))
        return None
    
    def clear(self):
        u'''手札と獲得札のリストをクリアする。'''
        del self.hands[:]
        del self.pointCards[:]
    
    def chooseCard(self, table):
        u'''ルールを参照し、プレイ可能なカードを手札からランダムに選び、そのカードを返す。
        選び方を別途実装する場合、このメソッドを上書きする。'''
        return random.choice(
            [card for i, card in enumerate(self.hands) if table.rule.isPlayable(table.playedCards, self.hands, i)]
            )

 大半は手札リスト hands[] をやり取りするだけですが、選んだカードをplayCard()で返す、CPUの場合は着手をchooseCard()で選ばせる、などもここに書いています。私は思考ルーチンを作れない(というか自分が一所懸命ルールベースのロジックを考えたところで拙劣な手しか打てそうにない)のでchooseCard()ではrandomを使って選ばせていますが、思考ルーチンを作る場合はこのメソッドをオーバーライドすればいい、のかな……。
 chooseCard()の中にあるtableはゲーム卓=データクラス、ruleはゲームルールのクラスです。各クラスの内容は次に説明しますが、ここでimportとかは不要で、ダックタイピングでいい気がします。table(下記)がPlayerのデータを持っているので(プレイヤーが先にいて卓に着席しているという扱い)、chooseCard()でPlayerがtableのデータを見るときは引数で貰っています。具体的にはtableにセットされたルール定義を参照し、プレイ可能かどうかを判別します。

【playertype.py】はプレイヤーの種類です。
# coding: UTF-8
from enum import IntEnum

class PlayerType(IntEnum):
    u'''プレイヤーの種類を表す整数列挙型。'''
    HUMAN = 0
    AI_RANDOM = 1
    AI_MODEST = 2
    AI_LOSE = 3

 プレイヤーが人間かCPUかはplayertypeの定義で判定させていますが、わざわざクラスを切る必要があるのかどうかはよくわかりません。迷ったら分けろと聞きました。

 次はゲームのルールです。トリックテイキングの基本処理の流れはここでなく次節のゲームフロー処理(ttgame.py)で行うため、ここでの「ルール」とはトリックテイキングのスートフォローや勝敗判定の意味です。
【ttrule.py】
# coding: UTF-8
from playingcards.rank import Rank
from playingcards.suit import Suit
from playingcards.card import Card
from playingcards.deck import Deck

class TTRule(object):
    u'''トリックテイキングのプレイ規則、トリック勝敗を定義する。
    マストフォロー、指定した切札スートを用いて判定を行う。
    切札はセッターでカードを指定し、ノートランプはゲッターがNoneを返す。'''
    
    def __init__(self, handsize=0):
        u'''コンストラクタ。手札枚数を設定する。'''
        self.trump = None
        self.handsize = handsize
    
    def setHandSize(self, handsize):
        u'''手札枚数を設定する。'''
        self.handsize = handsize
    
    def getHandSize(self):
        u'''手札枚数を取得する。'''
        return self.handsize
    
    def setTrump(self, card):
        u'''切り札指定のカードを設定する。'''
        self.trump = card
    
    def getTrump(self):
        u'''切り札指定のカードを取得する。'''
        return self.trump
    
    def isPlayable(self, played, hands, i):
        u'''渡された場札、手札、選んだカードから、そのカードが出せるかどうかを判定する。
        引数は場札(リスト)、手札(リスト)、選ぶカード(手札のインデクス)。'''
        # 場が空なら、何でもOK
        if len(played) < 1:
            return True
        # リードスートを取得
        leadsuit = played[0].getSuit()
        # リードスートが手札にあればそれを選んでいること、なければ何でもOK
        if leadsuit in [hand.getSuit() for hand in hands]:
            return hands[i].getSuit() == leadsuit
        else:
            return True
    
    def whoWins(self, played):
        u'''プレイ済カードリストから、勝ったカードのインデックスを返す。'''
        winner = played[0]
        for card in played:
            # ウィナーとスートが一致して、かつランクがより高ければ勝ち
            if (card.getSuit() == winner.getSuit()) and (card.getRank().value > winner.getRank().value):
                winner = card
            # 切札がある場合、ウィナーが切札でなく、かつこのカードが切札でも勝ち
            elif (self.trump is not None) \
                    and (winner.getSuit() != self.trump.getSuit()) \
                    and (card.getSuit() == self.trump.getSuit()):
                winner = card
        return played.index(winner)

 ゲッターとセッターは上述の理由で特に要りません。見なかったことにしてください。objectを継承してるのもよくわかりません。なんででしょうね。
 isPlayable()は、ルールの「同じスートを持っていればその中からしか選べません」に該当します。日本語ではこのルールを俗に「マストフォロー」と呼んでいます。
 whoWins()は、ルールの「打ち出したカードと同じスートの中で最もランクが高いカードが勝ちます。切札[…]スートを決めているゲームでは、打ち出しよりも切札スートのほうが強く、なければ打ち出しのスートが勝ちます」に該当します。切札の有無はゲームによっても変わるし、同じゲームの中でもありなし両方のモードが混在することもあるので、最初から両方のケースに対応しています。

 続いて、データクラスです。
【table.py】
# coding: UTF-8
from cardgame.player import Player

class Table():
    u'''ゲーム卓。ゲームに必要なデータを保持する。'''
    
    def __init__(self, rule, deck, inData):
        u'''コンストラクタ。インスタンス変数を生成する。'''
        # 必須データ
        self.rule = rule
        self.deck = deck
        self.inData = inData
        # プレイヤーはinDataから作成
        self.players = [Player(ptype=ptype, pname=pname) for ptype, pname in zip(inData['player_types'], inData['player_names'])]
        # 初期化
        self.event = {}
        self.dealer = None
        self.turn = None
        self.playedCards = []
        self.scores = [0] * len(self.players)

【inData.py】
# coding: utf-8

inData = {
    'player_names': [],
    'player_types': [],
    'choice': -1,
    'clear': False
}

def setNames(names):
    inData['player_names'] = names

def setTypes(types):
    inData['player_types'] = types

def setChoice(choice):
    inData['choice'] = choice

def setClear(clear):
    inData['clear'] = clear

def makeTestData(number=4):
    from cardgame.playertype import PlayerType as T
    setNames(['Kazuma', 'Abigail', 'Benjamin', 'Camille', 'Dennis'])
    setTypes([T.HUMAN, T.AI_RANDOM, T.AI_RANDOM, T.AI_RANDOM, T.AI_RANDOM])
    del inData['player_names'][number:5]
    del inData['player_types'][number:5]
    return inData

 table(ゲーム卓)は複数回のゲームを通じて持ち回るゲームの基本データ(ルール、プレイヤー等)、inDataは各ディール(=1回のゲーム)でのみ使い都度上書きされるPR→AP層へのデータ受け渡し用辞書、という使い分けをしてます。JavaBeans的なものですが、我ながらこの辺のコードがとても下手というか不格好で、もっと良い実装がある気がします。とりあえず今茹でてるスパゲティを出しました。
 inDataはゲームによって変わる部分が大きいので、変数を追加するよりも辞書にしてkeyで引っ張れるほうが汎用的かな、と思っています。makeTestData()は本当はいらないんですけど、作っておくと各プログラムをテストするときの初期設定が楽でした。

【eventtype.py】はEnumで、PR層でどの処理をするかを切り分けるのに使います。
# coding: UTF-8
from enum import Enum

class EventType(Enum):
    u'''カードゲームのイベント種類を表す列挙型。文字列によって状態を表す。'''
    BEGIN_DEAL = 'BEGIN_DEAL'
    BEGIN_TRICK = 'BEGIN_TRICK'
    USER_TURN = 'USER_TURN'
    USER_APPROVED = 'USER_APPROVED'
    OPPONENT_TURN = 'OPPONENT_TURN'
    RESOLVE_TRICK = 'RESOLVE_TRICK'
    DEAL_RESULT = 'DEAL_RESULT'

5.汎用ロジックの実装:メイン処理と各フェーズ

 最後に、ゲームを回す流れの定義です。ちょっと面倒ですけど頑張っていきます!

 いわゆるメインルーチンは、こんな感じです。
【ttgame.py】
# coding: UTF-8
from cardgame.table import Table
from cardgame.ttrule import TTRule
from playingcards.deck import Deck

from cardgame.procdeal import ProcDeal
from cardgame.proctrickinit import ProcTrickInit
from cardgame.prochumanplay import ProcHumanPlay
from cardgame.proccompplay import ProcCompPlay
from cardgame.proctrickresult import ProcTrickResult
from cardgame.procdealresult import ProcDealResult
from cardgame.eventtype import EventType as ev
from cardgame.playertype import PlayerType

class TTGame():
    u'''トリックテイキングのゲーム論理手順。各手順を実行し、表示に必要な辞書データを返す。'''
    
    def __init__(self, inData):
        u'''コンストラクタ。ゲーム卓を作成し、プロシージャ定義を行う。引数はプレイ人数。'''
        self.table = Table(TTRule(), Deck(), inData)
        self.defineProc()
    
    def defineProc(self):
        u'''プロシージャ定義。処理をオーバーライドしたら、ここの定義を上書きする。'''
        self.procdic = {
            'deal' : ProcDeal(),
            'trickinit' : ProcTrickInit(),
            'trickresult' : ProcTrickResult(),
            'humanplay' : ProcHumanPlay(),
            'compplay' : ProcCompPlay(),
            'dealresult' : ProcDealResult()
        }
    
    def start(self):
        u'''ディールの開始処理。'''
        self.proc = self.procdic['deal']
        self.proc.do(self.table)
        return self.table.event
    
    def isDealEnd(self):
        u'''ディールの終了判定処理。場札も全員の手札もなくなったらディール終了。
        終了ならTrue、まだならFalseを返す。'''
        return (len(self.table.playedCards) == 0) \
                and sum([len(player.getHands()) for player in self.table.players]) < 1
    
    def next(self, inData):
        u'''ゲームのメイン処理。表示用イベントを返す。'''
        # 入力をテーブルにセット
        self.table.inData = inData
        # 初期値
        self.proc = None
        cardCnt = len(self.table.playedCards)
        
        # プロシージャを設定
        self.setProc(cardCnt)
        
        # 対応するプロシージャを実行し、イベントを返す
        if self.proc is not None:
            self.proc.do(self.table)
        # カードが出ていれば、手番を次に進める
        if len(self.table.playedCards) > cardCnt:
            self.table.turn = (self.table.turn + 1) % len(self.table.players)
        # イベントを返す
        return self.table.event
    
    def setProc(self, cardCnt):
        u'''ゲーム状態に応じて実行すべきプロシージャオブジェクトを設定する。'''
        # ディールが終了したら終了処理
        if self.isDealEnd():
            self.proc = self.procdic['dealresult']
        # クリアフラグがオンなら、トリックのリセット(次トリックの開始処理)
        elif self.table.inData['clear']:
            self.table.inData['clear'] = False
            self.proc = self.procdic['trickinit']
        # トリック未解決で全員が出したら、トリック解決
        elif cardCnt >= len(self.table.players):
            self.proc = self.procdic['trickresult']
        # トリックの途中なら、プレイヤーの手番
        else:
            if self.table.players[self.table.turn].getType() == PlayerType.HUMAN:
                self.proc = self.procdic['humanplay']
            else:
                self.proc = self.procdic['compplay']

 importするのはテーブル、ルール、デッキ、それからゲーム処理を表すproc~というクラスです。これは次項で説明します。

 next()がゲームを進めるメイン処理で、PR層からもらった入力データinDataを使って次に必要な処理を行います。
 各処理の内容をproc~クラスに実装しており、ゲームの進行に応じてどの処理を行うかをsetProc()で切り分けます。Procクラスはオーバーライドするための抽象クラスとして定義しておき、proc.do()の実行部分は共通化しておいてサブクラスのdo()をオーバーライドする、というStateパターンもどきを採用してコードを削っています。next()で最後に返しているself.table.eventというのは、PR層で次にこの処理をしてね、という出力データの辞書です。

【proc.py】は各処理を実装するための抽象クラスです。
# coding: UTF-8
from abc import ABC, abstractmethod

class Proc(ABC):
    u'''カードゲームの手順を定義する親クラス。このクラスをStateパターンで継承して各処理を記述する。'''
    
    def __init__(self):
        u'''コンストラクタ。'''
        pass
    
    @abstractmethod
    def do(self):
        u'''処理の実装。'''
        pass

 ABCはデフォルトで入ってなかった気がするので、必要でしたらインストールしてください。
 以下は、Procクラスを実装したそれぞれの処理を並べていきます。

【procdeal.py】はディール(ラウンド)開始時の処理です。
# coding: UTF-8
import random
from cardgame.proc import Proc
from cardgame.eventtype import EventType as ev

class ProcDeal(Proc):
    u'''ディール処理の実装。'''
    
    def do(self, table):
        u'''ディール処理。山札をシャッフルし、最初のリードを決め、プレイヤーに指定枚数の手札を配る。
        手札枚数はルールに記載がなければデッキ配りきり。'''
        
        # プレイされた札をリセットする
        del table.playedCards[:]
        # 手札と獲得札をすべて集め、プレイヤーのカードを空にする
        for player in table.players:
            table.deck.extend(player.hands)
            table.deck.extend(player.pointCards)
            player.clear()
        # 山札をシャッフル
        table.deck.shuffle()
        # ディーラーがいなければランダムに決め、続きなら左隣にする
        table.dealer = random.randrange(len(table.players)) if table.dealer is None else (table.dealer + 1) % len(table.players)
        # 打ち出しをディーラーの左隣にする
        table.turn = (table.dealer + 1) % len(table.players)
        # プレイヤーに手札を配ってソート
        handsize = int(table.deck.size() / len(table.players)) if (table.rule.getHandSize() < 1) else table.rule.getHandSize()
        for player in table.players:
            player.setHands([table.deck.pick() for i in range(handsize)])
            player.getHands().sort()
        # 初期値設定
        self.setEvent(table)
    
    def setEvent(self, table):
        u'''ゲーム卓の出力用辞書eventに値を設定する。'''
        table.event = {}
        table.event['EVENT_TYPE'] = ev.BEGIN_DEAL
        table.event['DEALER'] = table.dealer
        table.event['OPENING_LEAD'] = table.turn
        table.event['TURN_PLAYER'] = table.turn
        table.event['PLAYER_NAMES'] = table.inData['player_names']
        table.event['WIN_COUNTS'] = [0] * len(table.players)
        table.event['SCORES'] = table.scores
        table.event['HANDS'] = [p.getHands() for p in table.players]
        table.event['PLAYED_CARDS'] = table.playedCards
        table.event['IS_PLAYABLE'] = True

 カードを配り、手札を並べ替えます。ここで見やすく並べ替えるためにcard.pyにソート用の関数を沢山用意しました。
 table.eventは出力用のデータをセットする辞書です。PR層で表示・処理するために必要なデータを渡しています。

【proctrickinit.py】はトリック開始時の処理です。
# coding: UTF-8
from cardgame.proc import Proc
from cardgame.eventtype import EventType as ev

class ProcTrickInit(Proc):
    u'''各トリック開始時の出力データ初期化処理の実装。'''
    
    def do(self, table):
        u'''テーブルのプレイカードと表示用のeventをトリック前の状態にする。'''
        del table.playedCards[:]
        table.event['EVENT_TYPE'] = ev.BEGIN_TRICK
        table.event['IS_PLAYABLE'] = True
        table.event['MY_CHOICE'] = -1
        table.event['HANDS'] = [p.getHands() for p in table.players]
        table.event['TURN_PLAYER'] = table.turn
        table.event['PLAYED_CARDS'] = table.playedCards
        table.event['TRICK_WINNER'] = -1

 主に、トリック終了後の場をきれいにするための処理として入れています。ゲームによってはプレイヤーが宣言を行うとか、切札が変わるといった処理も入るかもしれません。

【prochumanplay.py】は人間がプレイするときの処理です。
# coding: UTF-8
from cardgame.proc import Proc
from cardgame.eventtype import EventType as ev

class ProcHumanPlay(Proc):
    u'''人間プレイ処理の実装。'''
    
    def do(self, table):
        u'''人間のプレイ処理。入力値を判定し、選べるならそれを選ぶ。'''
        # 手番をセット
        table.event['TURN_PLAYER'] = table.turn
        
        # カード未選択なら入力待ちとして終了
        if table.inData['choice'] is None:
            table.event['EVENT_TYPE'] = ev.USER_TURN
            return
        
        # プレイ可否を判定し、不可なら入力待ちとして終了
        human = table.players[table.turn]
        playOK = table.rule.isPlayable(table.playedCards, human.getHands(), table.inData['choice'])
        table.event['IS_PLAYABLE'] = playOK
        if not playOK:
            table.event['EVENT_TYPE'] = ev.USER_TURN
            return
        
        # カードが選択済み、かつプレイ可能であれば実際にプレイ
        table.playedCards.append(human.playCard(table.inData['choice'])[1])
        table.event['MY_CHOICE'] = table.inData['choice']
        table.event['PLAYED_CARDS'] = table.playedCards
        table.event['EVENT_TYPE'] = ev.USER_APPROVED

 カード選択前の入力待ちと、カード選択後の判定とを両方入れています。選択されたカードは入力データの辞書 table.inData['choice'] で受け取る前提にしていて、そのカードがプレイ可能かどうかの判定にルールのクラスを使っています。
 出力データの辞書 table.event['MY_CHOICE'] が選んだカード、つまりPR層で場にプレイされるカードの内容で、table.event['PLAYED_CARDS'] はプレイされた全カードをプレイ順に入れています。
 カードを複数枚プレイしたり、プレイ途中に宣言を行ったりする場合にはオーバーライドが必要です。

【proccompplay.py】はCPUがプレイするときの処理です。
# coding: UTF-8
from cardgame.proc import Proc
from cardgame.eventtype import EventType as ev

class ProcCompPlay(Proc):
    u'''コンピュータプレイ処理の実装。'''
    
    def do(self, table):
        u'''コンピュータのプレイ。手札から1枚プレイする。'''
        cpu = table.players[table.turn]
        card = cpu.playCard(cpu.chooseCard(table))
        table.playedCards.append(card[1])
        
        # イベント設定
        table.event['MY_CHOICE'] = card[0]
        table.event['TURN_PLAYER'] = table.turn
        table.event['PLAYED_CARDS'] = table.playedCards
        table.event['EVENT_TYPE'] = ev.OPPONENT_TURN

 Playerクラスで定義したchooseCard()でカードを選択させ、それを出力に入れるだけです。カードを選ぶロジックはここに実装せず、カードプレイ以外の処理、たとえばプレイ途中での宣言を追加したいときにここをオーバーライドします。

【proctrickresult.py】はトリックの結果判定です。
# coding: UTF-8
from cardgame.proc import Proc
from cardgame.eventtype import EventType as ev

class ProcTrickResult(Proc):
    u'''トリック結果判定の実装。'''
    
    def do(self, table):
        u'''トリック結果を判定し、勝者を記録して次のリードに指定する。'''
        
        # 勝者を判定して次のリードに指定し、カードを取らせる
        table.turn = (table.rule.whoWins(table.playedCards) + table.turn) % len(table.players)
        table.players[table.turn].pointCards.extend(table.playedCards)
        
        # 表示データを更新
        table.event['PLAYED_CARDS'] = table.playedCards
        table.event['TRICK_WINNER'] = table.turn
        table.event['WIN_COUNTS'][table.turn] += 1
        table.event['EVENT_TYPE'] = ev.RESOLVE_TRICK

 誰がそのトリックで勝ったかをルールのクラスに判定してもらい、結果をtable.eventに詰め込みます。table.event['WIN_COUNTS'] は取ったトリック数のリストです。トリック数でなくカード点を使うゲームなどではこの処理をオーバーライドすることになります。

【procdealresult.py】は、全トリックが終わったあとのディールの結果判定です。
# coding: UTF-8
from cardgame.proc import Proc
from cardgame.eventtype import EventType as ev

class ProcDealResult(Proc):
    u'''ディール結果判定の実装。'''
    
    def do(self, table):
        u'''ディール結果判定。デフォルトでは、トリック数を直接スコアに加算する。
        何らかの処理を行う場合、このメソッドを上書きする。'''
        for i, tricks in enumerate(table.event['WIN_COUNTS']):
            table.scores[i] += tricks
        table.event['SCORES'] = table.scores
        table.event['EVENT_TYPE'] = ev.DEAL_RESULT

 単純にトリック数=点数になるようにしています。ゲーム次第で大きく変わるところだと思います。

続く

 AP層の処理はこれで終わりです。あとはPR層さえ作れば普通にゲームとして動きます。
 コードを説明する記事を書くのが初めてで、思いのほか長くなってしまいました。PR層(GUI)のコードは12/23(金)に追加で記事を起こします。

 本当はコマンドベースで簡単に動かすためのコードを入れようかと思ったんですけど、それも次回でお願いします……!



<2022/12/21>


←No.45 引き算型のゲーム作りと、そのための細かい技術 No.47 トリックテイキングをプログラミングで一人回しする【後編】→
コラム一覧へ トップページへ