2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポケモンカードゲームの一人回し調整をPythonでシミュレートする

Posted at

私は趣味でポケモンカードゲームというTCGをプレイしております。
ご存知のかたも多いかと思いますが、
・60枚のカードの束(デッキ)をつかう
・カードゲーム上でポケモン同士の対戦を行う
・タイプ相性や、カード同士のコンボがあつい
・2人対戦型のカードゲーム
という特徴のカードゲームです。

対戦で勝つには、環境のデッキやプレイを学んで
”何十回も”テストプレイしながら、カードを入れ替えて最強のデッキを目指して行く
ことが重要になります。
(調整とよばれています)

しかしながら、周りにともだちが少ない育児等であまり外出する時間がなくなかなか対戦の機会に恵まれません。
そういうときは、一人で二人分のデッキを持って、対戦シミュレートすることで調整を行っています
(一人回しとよばれています)


さてポケカプレイヤーにおける調整は
概ね個々人の感覚に基づいてカードの優劣を決めて、デッキから抜くカード、入れるカードを決めますが
調整が進むと数%の違いを、たかだか1〜2回の対戦シミュレートで優劣をつける段階にはいってきます。

皆様に置かれましては、そんな1〜2回のシミュレートで数%の結果の違いに有意差を見出すなんてありえへんとお思いでしょう。
私もそうでした。

幸いPythonが少しだけわかるので、この一人回しをPythonで実装してみたので紹介したいとおもいます。


わかりやすさのため、一昔前のデッキ「レジギガス」(D~Fレギュ)の例を出します。
このデッキは、簡単にいうと、場に6種類のレジが揃うと超強いのです。
ですので、なるべくはやくレジを6種類揃える(後攻1ターン目か先行2ターン目まで)ことで勝率があがりますし、レジ6種を揃えられる確率が計測目標になります。

さて、シミュレーターは「デッキ」「プレイヤー行動方針」「ゲームロジック+目標値の計測」の3項目(4?)で成り立ってます。
それぞれ実装して行きます。

デッキ

## デッキ定義
deck_recipe = [
    # ポケ
    'レジギガス',   'レジギガス',
    'レジアイス',   'レジアイス',
    'レジロック',   'レジロック',
    'レジスチル',   'レジスチル',
    'レジエレキ',   'レジエレキ',
    'レジドラゴ',   'レジドラゴ',
    # エネ
    'オーロラエネルギー',       'オーロラエネルギー',   
    'オーロラエネルギー',       'オーロラエネルギー',   
    'スピード雷エネルギー',     'スピード雷エネルギー', 
    'キャプチャーエネルギー',    'キャプチャーエネルギー',
    'キャプチャーエネルギー', 'キャプチャーエネルギー',
    'ギフトエネルギー',         'ギフトエネルギー',         
    # サポ
    '博士の研究', '博士の研究', '博士の研究', '博士の研究',
    'マリィ', 'マリィ', 'マリィ','マリィ',
    'シロナ', 
    'セレナ', 'ボス',
    # グッズ
    'クイックボール','クイックボール','クイックボール','クイックボール',
    'ハイパーボール','ハイパーボール','ハイパーボール','ハイパーボール',
    'ヒスイのヘビーボール','ヒスイのヘビーボール',
    '回収ネット','回収ネット',    'あなぬけのひも','あなぬけのひも',
    'ふつうのつりざお', 'ふつうのつりざお', 'ふつうのつりざお', 'ふつうのつりざお',
    'こだわりベルト','こだわりベルト','ポケギア',
    # スタジアム
    '雪道','雪道','雪道', '嵐の山脈'
]

# デッキチェック
print(len(deck_recipe))
assert(len(deck_recipe) == 60)

# デッキクラス
class deck_class:
    def __init__(self, deck_list):
        self.deck_list = deck_list
        self.my_deck = copy.deepcopy(self.deck_list)

    # シャッフル
    def shuffle(self):
        random.shuffle(self.my_deck)

    # n枚引く
    def draw(self, n):
        drawn_card = self.my_deck[:n]
        self.my_deck = self.my_deck[n:]
        return drawn_card

    # リセット
    def reset(self):
        self.my_deck = copy.deepcopy(self.deck_list)

    # サーチ操作。カードがあればそれを返す。なければNone
    def get(self, card):
        for c in self.my_deck:
            if (c == card):
                self.my_deck.remove(c)
                self.shuffle()
                return c
        self.shuffle()
        return None

プレイヤー行動

# 場の定義。バトル場とベンチ
class field:
    def __init__(self):
        self.top = None
        self.bench = []

    def set_top(self, pokemon):
        assert(self.top is None)
        self.top = pokemon

    def set_bench(self, pokemon):
        self.bench.append(pokemon)
        assert(len(self.bench) <= 5)

    def exists(self, pokemon):
        return (pokemon in self.bench) or (pokemon == self.top)


# よく使う定数定義
REGIS = ['レジギガス','レジアイス','レジロック','レジスチル','レジドラゴ','レジエレキ']
REGIGATES = ['レジアイス','レジロック','レジスチル']
DRAW_SUPPORTS = ['博士の研究', 'セレナ', 'マリィ', 'シロナ', 'ヒガナ']
GUDS = ['クイックボール','ハイパーボール','ヒスイのヘビーボール','エネくじ','ポケギア',
    '回収ネット','あなぬけのひも','ふつうのつりざお','こだわりベルト',
]
class player_class:
    def __init__(self, deck_list):
        self.deck = deck_class(deck_list)
        self.field = field()
        self.hands = []
        self.trash = []
        self.sidecards = []

    def setup(self):
        self.deck.reset()
        self.deck.shuffle()
        self.field = field()
        self.hands = self.deck.draw(7)
        self.sidecards = self.deck.draw(6)
        self.used_support = False
        self.used_energy = False
        self.used_hihou = False
        self.used_studium = False
        self.can_use_kotobukimura = False
        self.set_energys = []
        self.set_regigates_energy = None
        self.set_gigas_energys = []

        top_pokemon = self.choose_top_pokemon()
        if top_pokemon is None:
            return False
        self.field.set_top(top_pokemon)

        return True

# 手札の構成によって、最初に出すポケモンを決める。たねなしならNone
def choose_top_pokemon(self):
    ## 手札の構成によって選出するポケモンを変えるロジックを実装する
    ## 長いので略
    
    if (energy_num > 0):
        if has_regirock:
            return 'レジロック'
        elif has_registeel:
            return 'レジスチル'
        elif has_regiice:
            return 'レジアイス'

    if has_regidrago:
        return 'レジドラゴ'
    elif has_regirock:
        return 'レジロック'
    elif has_registeel:
        return 'レジスチル'
    elif has_regiice:
        return 'レジアイス'
    elif has_regieleki:
        return 'レジエレキ'
    elif has_regigigas:
        return 'レジギガス'

    return None

def turn_start(self, s, turn):
    self.draw(1)
    self.used_support = (s == '先攻') and (turn == 0)
    self.used_energy = False
    self.used_hihou = False
    self.used_studium = False
    self.can_use_kotobukimura = False

### ユーティリティ

def draw(self, n):
    self.hands += self.deck.draw(n)

def has_support(self):
    for card in self.hands:
        if card in DRAW_SUPPORTS:
            return True
    return False

def hand_to_trash(self, card):
    self.hands.remove(card)
    self.trash.append(card)

#####
# 不在レジのリスト
def get_unexists_regis(self):
    need_regis = []

    for regi in REGIS:
        if (self.field.exists(regi) == False):
            need_regis.append(regi)
    # もしスピード雷効果が使えそうなら、レジエレキをサーチ順の最初に変更する。
    if (self.used_energy == False) and ('スピード雷エネルギー' in self.hands) and ('レジエレキ' in need_regis):
        need_regis.remove('レジエレキ')
        need_regis = ['レジエレキ'] + need_regis            

    return need_regis

# 必要なレジを1体、山札からサーチしてベンチに出す操作。
def call_regi(self, need_regis):

    for regi in need_regis:
        card = self.deck.get(regi)
        if card is not None:
            self.field.set_bench(card)
            break

# 手札から不要なカードを選出するロジックを実装する
# can_call ... 山札からサーチできる札がある場合はTrue
def search_trashable_cards(self, need_num, can_call):
    trash_cards = []

    # 不要なエネのサーチ
    # 不要なサポのサーチ
    # それ以外のカードのサーチ
    # をそれぞれ実装する。ながいので略。

    return trash_cards

# 必要なレジを集めつつ、エネをトラッシュに落とすことを考えてプレイする。
# まだ、できるターン内アクションがあればTrueを返す。
def do_action(self, turn):

    # どのレジが欲しいか
    need_regis = self.get_unexists_regis()

    # ハンドにレジが入ればベンチに出す
    for regi in need_regis:
        if (regi in self.hands):
            self.hands.remove(regi)
            self.field.set_bench(regi)
            return True
    # .. 移行、1ターン内に行動する指針をロジックに落として実装する。
    # ながいので略。

    return False

# 技宣言。ここではレジゲート・コトブキ村のみ
def do_attack(self):
    if self.can_use_kotobukimura:
        self.deck.my_deck += self.hands
        self.hands = []
        self.deck.shuffle()
        self.draw(5)

    else:
        can_attack = (self.field.top in REGIGATES) and (self.set_regigates_energy is not None)

        if (can_attack):
            need_regis = self.get_unexists_regis()
            self.call_regi(need_regis)       
        
# 目標達成。6体揃って、必要なエネが揃って、ワザが打てる状態。
def goal(self):
    for regi in REGIS:
        if (self.field.exists(regi) == False):
            return False

    aurola_count = self.trash.count('オーロラエネルギー') + self.set_energys.count('オーロラエネルギー')
    eleki_count =  self.trash.count('スピード雷エネルギー') + self.set_energys.count('スピード雷エネルギー')
    energy_count = sum('エネルギー' in x for x in self.trash) + len(self.set_energys)
    gigas_ene_num = len(self.set_gigas_energys)
    for c in self.trash:
        if c == 'ツインエネルギー':
            gigas_ene_num += 1
            energy_count += 1
    for c in self.set_gigas_energys:
        if c == 'ツインエネルギー':
            gigas_ene_num += 1
            energy_count += 1 
    can_attack_aurola = (aurola_count >= 2) and (energy_count >= 3)
    can_attack_eleki =  ((aurola_count + eleki_count) >= 2) and (energy_count >= 3)
    can_attack_gigas =  (energy_count >= 5) and (gigas_ene_num >= 2)

    if ('回収ネット' in self.hands) or ('あなぬけのひも' in self.hands):
        return (can_attack_aurola or can_attack_eleki or can_attack_gigas)
    if (self.field.top == 'レジギガス') and can_attack_gigas:
        return True
    if (self.field.top == 'レジエレキ') and can_attack_eleki:
        return True
    if can_attack_aurola:
        return True

    return False

ゲームロジックと計測目標の実装

if __name__ == "__main__":
    player = player_class(deck_recipe)
    try_num = 10000
    for s in ['先攻', '後攻']:
        print(s)
        ok = [0, 0, 0]
        has_support_count = [0, 0, 0]
        ng = 0

        for i in tqdm(range(try_num)):
            retval = player.setup()
            if (retval is False):
                ng += 1
                continue

            # ターンの行動を定義する
            for turn in range(len(ok)):
                player.turn_start(s, turn)

                while (player.do_action(turn)):
                    pass

                if (((s == '先攻') and (turn == 0)) is False) and (player.used_support):
                    has_support_count[turn] += 1

                if (player.goal()):
                    ok[turn] += 1
                    break

                player.do_attack()

        # 計測する目標値は、
        # レジが6種揃う確率に加えて、最初にたたかうポケモンがいないマリガン率と、
        # 1ターン目をブーストするサポートカードを使える確率を見ています。
        print('マリガン率', str(np.round(ng / try_num * 100, 2)) + "%")
        ok_try_num = try_num - ng
        first_support_turn = 1 if (s=='先攻') else 0
        print('サポ打てる率', str(np.round(has_support_count[first_support_turn] / ok_try_num * 100, 3)) + "%")
        print('レジ揃った率 1ターン目', str(np.round(ok[0] / ok_try_num * 100, 3)) + "%")
        print('レジ揃った率 2ターン目', str(np.round(ok[1] / ok_try_num * 100, 3)) + "%")
        print('レジ揃った率 3ターン目', str(np.round(ok[2] / ok_try_num * 100, 3)) + "%")

    pass

動かしてみる

$ python gigas.py 
60
先攻
100%|████████████████████████████████████| 10000/10000 [01:24<00:00, 117.80it/s]
マリガン率 19.06%
サポ打てる率 81.814%
レジ揃った率 1ターン目 3.496%
レジ揃った率 2ターン目 57.611%
レジ揃った率 3ターン目 28.811%
後攻
100%|█████████████████████████████████████| 10000/10000 [04:25<00:00, 37.62it/s]
マリガン率 18.39%
サポ打てる率 78.557%
レジ揃った率 1ターン目 30.266%
レジ揃った率 2ターン目 48.523%
レジ揃った率 3ターン目 13.834%

やったね!


効果

  • 何時間という調整が(一回つくってしまえば)数分で終わる
  • 統計的に十分に有意差の出る試行回数を実行できる
  • このシーズンの日本チャンプが使っていたレジギガスデッキをこのプログラムにかけたところ、後1で目標達成する確率は20%台前半だった(と記憶している)が、
    このシミュレータを使って調整していた私のレジギガスは30%まで改善できた。
    (つまり日本チャンプよりも、自分のプレイングにあってる構築に調整することできた)

というところで、ちゃんと効果のでたシミュレータをPythonで組むことができました。

ただ、現状ではあまり応用が効かないやり方だし、実装のロジックもごりっと書かないといけないので
汎用性をもたせたやりかたを模索したいなーと考えています。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?