PySimpleGui (Python GUI) で麻雀ソリティア作成

Python

はじめに

PySimpleGUIを使用して麻雀ソリティアを作成しました。Python の GUI プログラミングの参考になればと思います。

ダウンロードと実行方法

ダウンロード

ソースは、https://github.com/tostos5963/PySimpleGUI-MahjongSolitaire で公開していますのでダウンロードしてください。

Windowsの場合

pip install pysimplegui
python MahjongSolitaire.py

Linux (ubuntu)の場合

pip3 install pysimplegui
python3 MahjongSolitaire.py

以下のエラーが出た場合

ModuleNotFoundError: No module named 'tkinter'

python3-tk をインストールしてください

sudo apt-get install python3-tk

Macの場合

すみません。動作すると思いますがMacを所持していないため確認できていません。

設計

小さなプログラムですが、プログラミングを開始する前に簡単な設計を行いました。

麻雀牌の配置について

麻雀牌の数は、萬子=9個、筒子 =9個 ,索子 =9個 、東南西北=4個、白發中=3個、計34個×4セット = 計136個。

16行x24列のエリアに下図のように配置します。
牌1つ分のサイズは2行x2列にしています。
1行x1列にしていないのは、下図(7, 0), (7, 11), (7, 22) のように牌の半分ずれた位置に配置できるようにするためです。各位置の数字は何個牌を積むかを表します。

麻雀牌の配置

各位置の牌を並べると下図のようになります。中央(7,11)の牌は1つですが5段目に積まれるため少し特別な処理が必要となります。

各麻雀牌の画像ファイル作成

麻雀牌のダウンロード

麻雀牌の画像はILLUST BOX イラストボックス https://www.illust-box.jp/sozai/127891/ を使用させていただきました。

麻雀牌の画像を1つずつ切り抜く

切り抜きにはImageMagick のconvert を使用します。

magick.exe convert 入力ファイル名 -crop 幅x高さ+X+Y! 出力ファイル名

麻雀牌を1つずつ切り抜くバッチファイルを作成するpythonスクリプトを作成します。(直接バッチファイルを作成してもOK)

#!/usr/bin/env python3

print('mkdir crop')
x_idx = 0
for x in (0, 165, 331, 497, 662, 828, 994, 1160, 1326):
    x_idx += 1
    y_idx = 0
    for y in (0, 261, 522, 783):
        y_idx += 1
        print('magick.exe convert input.jpg -crop 139x199+{:d}+{:d}! crop\\{:d}-{:d}.png'.format(x + 8, y + 8, y_idx, x_idx))

ダウンロードした画像をinput.jpg にリネームして、このpythonスクリプトと同じフォルダに置きます。

バッチファイルを作成し実行します。

python crop.py > crop.bat
crop.bat

立体的に見える牌の画像をダウンロード

画像はILLUST BOX イラストボックス https://www.illust-box.jp/sozai/37371/を使用させていただきました。

一番右の白牌を切り抜きます。

四隅の白い部分を透過

切り抜いた白牌をgimpで開き [アルファチャンネルの追加] 、四隅を削除(透過)します。

白牌の上に各麻雀牌の画像を重ね合わせ

重ね合わせには、ImageMagickのcompositeを使用します。

mkdir ..\compo
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-1.png base.png ..\compo\1-1.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-2.png base.png ..\compo\1-2.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-3.png base.png ..\compo\1-3.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-4.png base.png ..\compo\1-4.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-5.png base.png ..\compo\1-5.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-6.png base.png ..\compo\1-6.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-7.png base.png ..\compo\1-7.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-8.png base.png ..\compo\1-8.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 1-9.png base.png ..\compo\1-9.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-1.png base.png ..\compo\2-1.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-2.png base.png ..\compo\2-2.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-3.png base.png ..\compo\2-3.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-4.png base.png ..\compo\2-4.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-5.png base.png ..\compo\2-5.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-6.png base.png ..\compo\2-6.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-7.png base.png ..\compo\2-7.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-8.png base.png ..\compo\2-8.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 2-9.png base.png ..\compo\2-9.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-1.png base.png ..\compo\3-1.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-2.png base.png ..\compo\3-2.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-3.png base.png ..\compo\3-3.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-4.png base.png ..\compo\3-4.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-5.png base.png ..\compo\3-5.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-6.png base.png ..\compo\3-6.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-7.png base.png ..\compo\3-7.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-8.png base.png ..\compo\3-8.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 3-9.png base.png ..\compo\3-9.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 4-1.png base.png ..\compo\4-1.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 4-2.png base.png ..\compo\4-2.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 4-3.png base.png ..\compo\4-3.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 4-4.png base.png ..\compo\4-4.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 4-5.png base.png ..\compo\4-5.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 4-6.png base.png ..\compo\4-6.png
magick.exe composite -gravity northwest -geometry +11+13 -compose over 4-7.png base.png ..\compo\4-7.png

ソースコード

主な処理の説明です。

TileType:麻雀牌の種類を表すクラス

各麻雀牌にユニークなIDを割り当てます。

Tile:麻雀牌を表すクラス

位置(行, 列)、何段目か、表示位置(XY座標)、選択中、表示中、イメージID (PySimpleGUI のDrawImageの戻り値)等の情報を持ちます。

比較メソッド

    def __lt__(self, other):
        self_val = (self.__layer * 10000) + (self.__pos[0] * 100) + self.__pos[1]
        other_val = (other.__layer * 10000) + (other.__pos[0] * 100) + other.__pos[1]
        return (self_val < other_val)

sort 用の比較メソッドです。layer(何段目)、__pos[0](行)、__pos[1](列)で昇順になるように比較します。

位置(段目、行、列)設定

    def setLayerPos(self, layer, pos):
        self.__layer = layer
        self.__pos = pos
        row, col = self.__pos

        if row >= 0:
            self.__x = 50 + int((self.IMAGE_W - 1) * (col / 2)) - (5 * layer)
            self.__y = 50 + int((self.IMAGE_H - 1) * (row / 2)) - (5 * layer)
        else:
            self.__x = self.IMAGE_W * -2
            self.__y = self.IMAGE_H * -2

layer(段目)、row(行)、col(列)をメンバ変数にセットし、表示位置(XY座標)を計算で求めます。

クリックされたかチェック

    def is_hit(self, click_x, click_y):
        if self.visible:
            if (self.__x <= click_x) and (click_x <= (self.__x + self.IMAGE_W)):
                if (self.__y <= click_y) and (click_y <= (self.__y + self.IMAGE_H)):
                    return True
        return False

クリック座標(click_x, click_y)が自分自身の表示範囲内かどうか判断します。

Board:16行x24列のエリアを管理するクラス

麻雀牌を置く位置、各位置に置く牌の数(初期値)、各位置に置いている牌の数(ゲーム経過とともに減少)等の情報を持ちます。

指定した位置に牌を置けるか判断

    def can_stack(self, row, col):
        ret = (-1, -1, -1)
        max_stack_tiles = self.__max_stack_tiles[row][col]
        if max_stack_tiles >= 1:
            layer = self.__num_stack_tiles[row][col]
            if layer < max_stack_tiles:
                ret = (layer, row, col)

        return ret

引数で指定された位置(row, col)に牌を置けるか判断し、置ける場合はその位置(layer, row, col) 、置けない場合は(-1, -1, -1) を返します。

引数で中央(7, 11)が指定された場合は (-1, -1, -1)を返します(中央に関しては呼び出し元のstack_tile()で処理します)

本メソッドはアプリ起動時または[NewGame]ボタンを押下時に呼び出されます。

指定した位置に牌を置く(牌の数を+1する)

    def stack_tile(self, row, col):
        (ret_layer, ret_row, ret_col) = self.can_stack(row, col)
        if ret_row != -1:
            self.__num_stack_tiles[row][col] += 1
            return (ret_layer, ret_row, ret_col)

        if row == self.TOP_ROW and col == self.TOP_COL:
            if self.__num_stack_tiles[row][col] <= 0:
                self.__num_stack_tiles[row][col] = 1
                return (4, row, col)

        return (-1, -1, -1)

引数で指定された位置に牌を置ける場合は牌の数を+1します。

中央(7, 11)の牌は、数は1にしますが5段目なので戻り値の layer(0~4)は4 を返しています。

牌が左端にあるかチェック

    def is_left_edge(self, row, col):
        left_col = col - 2
        if left_col < 0:
            return True

        num_left_tiles = []
        #  +-----+
        #  |row-1|
        #  |col-2|+-----+
        #  |     ||row  |
        #  +-----+|col  |
        #         |     |
        #         +-----+
        if (row >= 1):
            num_left_tiles.append(self.__num_stack_tiles[row - 1][left_col])

        #  +-----++-----+
        #  |row  ||row  |
        #  |col-2||col  |
        #  |     ||     |
        #  +-----++-----+
        num_left_tiles.append(self.__num_stack_tiles[row][left_col])

        #         +-----+
        #         |row  |
        #  +-----+|col  |
        #  |row+1||     |
        #  |col-2|+-----+
        #  |     |
        #  +-----+
        if ((row + 1) < self.NUM_ROWS):
            num_left_tiles.append(self.__num_stack_tiles[row + 1][left_col])

        is_edge = True
        for left_num in num_left_tiles:
            if left_num >= self.__num_stack_tiles[row][col]:
                is_edge = False
                break

        return is_edge

左端にあるかどうかは、自分自身の左に牌があるかどうかで判断します。牌の半個分ずれている位置もチェックします。左に牌があったとしても自分自身の段目より低ければ自分自身が左端にあると判断します。右端チェック(is_right_edge)も同様です。このチェックは牌が選択可能かどうかチェックするメソッド(can_select)から呼び出されます。

牌が選択可能かチェック

    def can_select(self, tile):
        row, col = tile.pos
        max_tiles = self.__max_stack_tiles[row][col]
        num_tiles = self.__num_stack_tiles[row][col]

        if num_tiles <= 0:
            return False

        if row == self.TOP_ROW and col == self.TOP_COL:
            return True

        if tile.layer == 3:
            if self.__num_stack_tiles[self.TOP_ROW][self.TOP_COL] <= 0:
                return True
            else:
                return False

        if (tile.layer + 1) < num_tiles:
            # It's not at the top.
            return False

        if self.is_left_edge(row, col):
            return True

        if self.is_right_edge(row, col):
            return True

        return False

牌が選択可能かチェックします。自分自身の上に牌が無く、左端または右端の牌であれば選択可能です。

牌を取り除く(牌の数を -1する)

    def pick_up_tile(self, tile):
        if not tile is None:
            row, col = tile.pos
            if self.__num_stack_tiles[row][col] >= 1:
                self.__num_stack_tiles[row][col] -= 1
            tile.visible = False

牌の数を -1 して、表示中フラグをFalseにします。

パターンに従って牌を置く

    def make_game(self):
        key_list = list(tile_imgs.keys()) + list(tile_imgs.keys())
        random.shuffle(key_list)

        # タイルを置く順番.  Order of stacking tiles.
        pattern_list = []
        pattern_list.append([(14, 20), (0, 0), (0, 2), (14, 18), (2, 4), (12, 10),
                           # ...省略...
                            (10, 14), (8, 14), (10, 10), (10, 8)])

        pattern_list.append([(0, 4), (14, 12), (2, 6), (12, 10), (4, 10),
                           # ...省略...
                            (10, 12), (12, 16), (10, 14), (0, 0), (7, 11)])

        pattern_list.append([(6, 10), (8, 8), (4, 12), (4, 8), (8, 12),
                           # ...省略...
                            (10, 8), (8, 18), (6, 4), (8, 4), (7, 11)])

        self.pattern_idx += 1
        if self.pattern_idx >= len(pattern_list):
            self.pattern_idx = 0

        tile_list = []
        for pos_idx in range(0, len(pattern_list[self.pattern_idx]), 2):
            tile_id = key_list[int(pos_idx / 2)]
            for pos in [pattern_list[self.pattern_idx][pos_idx], pattern_list[self.pattern_idx][pos_idx + 1]]:
                row, col = pos
                (ret_layer, ret_row, ret_col) = self.stack_tile(row, col)
                if ret_row != -1:
                    tile = Tile(tile_id)
                    tile.setLayerPos(ret_layer, (ret_row, ret_col))
                    tile_list.append(tile)

        tile_list.sort()
        return tile_list

牌を完全にランダムに配置するとほぼ解けないため、パターンを用意しそのパターンに従ってランダムに配置します。パターンは3個用意しておき、[New Game]ボタンが押下するたびに、パターンを切り替えます。

配置した牌は、段目、行、列で昇順にソートしておきます。

MahjongSolitaire:メインクラス

ゲーム開始

    def new_game(self):
        self.draw.Erase()
        self.complete = False

        self.board.clear()
        self.tile_list = self.board.make_game()

        for tile in self.tile_list:
            tile.img_id = self.draw.DrawImage(data=tile_imgs[tile.tile_id], location=tile.xy)

        self.select_img_id = self.draw.DrawImage(data=select_img, location=(-100, -100))

DrawImageで麻雀牌を表示します。画像が重なる場合は、先に表示した方が下(後に表示した方が上)になります。

また、選択状態を表す牌の上に表示する半透明の黄色い画像は範囲外(-100, -100)に表示しておきます。

クリック押下時の処理

    def onclick(self, click_x, click_y):
        if self.enable_mouse_click == False:
            return

        if self.complete:
            return

        self.enable_mouse_click = False

        selected_tile = None
        for tile in self.tile_list:
            if tile.selected:
                selected_tile = tile

        is_hit = False
        hit_tile = None
        for tile in reversed(self.tile_list):
            if tile.is_hit(click_x, click_y):
                is_hit = True
                if tile.selected:
                    tile.selected = False
                    selected_tile = None
                else:
                    if self.board.can_select(tile):
                        hit_tile = tile

                        if selected_tile is None:
                            tile.selected = True
                            selected_tile = tile

                        else:
                            if selected_tile == hit_tile:
                                # Tile of the same type
                                self.board.pick_up_tile(hit_tile)
                                hit_x, hit_y = hit_tile.xy
                                self.draw.RelocateFigure(hit_tile.img_id, hit_x, hit_y)

                                self.board.pick_up_tile(selected_tile)
                                sel_x, sel_y = selected_tile.xy
                                self.draw.RelocateFigure(selected_tile.img_id, sel_x, sel_y)

                                selected_tile.selected = False
                                selected_tile = None
                break

        if not selected_tile is None and is_hit == False:
            tile.selected = False
            selected_tile = None

        if not selected_tile is None:
            sel_x, sel_y = selected_tile.xy
        else:
            sel_x, sel_y = (-100, -100)

        self.draw.RelocateFigure(self.select_img_id, sel_x, sel_y)
        self.draw.ParentForm.refresh()

        if self.board.num_tiles() == 0:
            sg.popup('complete !')
            self.complete = True

ダブルクリックを避けるため、 enable_mouse_click がFalseの時は何もせずに処理を抜けます。Trueの時は処理開始し、すぐにenable_mouse_click をFalseにします。enable_mouse_clickはタイマーイベントにより一定時間後にTrueになります。

選択中の牌があるかどうかチェックします。

牌のリストを逆順にチェックし、クリック位置に牌があるかどうかチェックします。逆順にチェックするのは、牌が重なっている場合、後に表示した方が上に表示されているためです。

クリック位置に牌があり、選択中の牌が無い場合はクリックした牌を選択状態にします。

選択中の牌をクリックした場合は選択状態を解除します。

選択中の牌とクリックした牌が同じ種類であれば取り除きます。

全ての牌を取り除いたら「complete」をポップアップで表示します。

メインループ

    def main_loop(self):
        while True: # Event Loop
            event, values = self.window.read(timeout=300, timeout_key='-timeout-')
            if event == sg.WIN_CLOSED:
                break

            if event == 'New Game':
                self.new_game()

            elif event in '-timeout-':
                self.enable_mouse_click = True

            elif event == 'Graph':
                click_x, click_y = values['Graph']
                self.onclick(click_x, click_y)

        self.window.close()

メインループです。「New Game」ボタンが押下されたら牌の配置を初期化します。0.3秒毎のタイマーイベントで、enable_mouse_click を Trueにします。マウスクリックされたら onclickを呼び出します。

まとめ

今回は、PySimpleGUIを使って、麻雀ソリティアを作成しました。

コメント

タイトルとURLをコピーしました