Python(Kivy)で作るオセロアプリ(iOSアプリ)

# はじめに
Pythonでマルチタップアプリを開発するためのオープンソースライブラリkivyを使ってオセロアプリを作成してみました。
また、最終的にiOSのSimulatorで(xcode)でビルドしました。
# 環境
python: 3.7.7
kivy: 1.11.1
xcode: 11.7
# 作成物
完成形
![Sep-06-2020 20-30-30.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/a5c14098-6adb-4f49-a261-4ca9ecd6843f.gif)

# ソースコード解説
今回作成したオセロアプリを開発した順番を追いながら説明します。

## 1. オセロ盤面と初期石を配置
下記の状態まで作成
<img width="300" alt="スクリーンショット 2020-09-06 20.44.30.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/9ab0cea1-55d9-90ce-113a-6e2c22864a4a.png">

```python
class OthelloApp(App):
    title = 'オセロ'
    def build(self):
        return OthelloGrid()


OthelloApp().run()
```
APPクラスの中でOthelloGridクラスをreturnしている。
今回のアプリの処理はこのOthelloGridクラスの中で行うようにしている。

```python
class OthelloGrid(Widget):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.num = 8
        self.tile = [[' ' for x in range(self.num)] for x in range(self.num)]
        self.turn = 'W'
        self.grid = GridLayout(cols=self.num, spacing=[3,3], size=(Window.width, Window.height))

        for x in range(self.num):
            for y in range(self.num):
                if x == 3 and y == 3 or x == 4 and y == 4:
                    self.grid.add_widget(WhiteStone())
                    self.tile[x][y] = 'W'
                elif x == 4 and y == 3 or x == 3 and y == 4:
                    self.grid.add_widget(BlackStone())
                    self.tile[x][y] = 'B'
                else:
                    self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y]))
        self.add_widget(self.grid)
```
`self.num`はオセロ盤面の縦、横のマスの数である。
`self.tile`は盤面の状態を記憶しておくためのリストで白が置かれている時`'W'`、黒が置かれている時`'B'`、何も置かれていない時`' '`の値をとる。
`self.turn`は現在のターンが黒か白かを記憶しているもので、初期は白ターンから始まるようにしている。
実際に画面に描画する盤面は`self.grid(GridLayout)`で定義しており、既に石が置かれているマスには白の石の場合`WhiteStoneクラス`、黒の石の場合`BlackStoneクラス`をadd_widgetしており、まだ石が置かれていないマスには`PutButtonクラス`をadd_widgetしています。

```python
class WhiteStone(Label):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.bind(pos=self.update)
        self.bind(size=self.update)
        self.update()
    def update(self, *args):
        self.canvas.clear()
        self.canvas.add(Color(0.451,0.3059,0.1882,1))
        self.canvas.add(Rectangle(pos=self.pos, size=self.size))
        self.canvas.add(Color(1,1,1,1))
        self.canvas.add(Ellipse(pos=self.pos, size=self.size))

class BlackStone(Label):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.bind(pos=self.update)
        self.bind(size=self.update)
        self.update()
    def update(self, *args):
        self.canvas.clear()
        self.canvas.add(Color(0.451,0.3059,0.1882,1))
        self.canvas.add(Rectangle(pos=self.pos, size=self.size))
        self.canvas.add(Color(0,0,0,1))
        self.canvas.add(Ellipse(pos=self.pos, size=self.size))

class PutButton(Button):
    def __init__(self, tile_id, **kwargs):
        super().__init__(**kwargs)
        self.tile_id = tile_id
```
`WhiteStone`、`BlackStone`クラスはLabelクラスを継承しており、単純にマス`Rectangle(pos=self.pos, size=self.size)`の上に楕円の石`Ellipse(pos=self.pos, size=self.size)`を描画しているだけです。

`PutButton`クラスはButtonクラスを継承しており、まだ押した時の処理はありません。
`tile_id`として、`grid`上のどの位置のマスかをインスタンス自身が記憶できるようにしています。

## 2.マスをタップした時に石を置く
下記の状態まで作成
![Sep-06-2020 21-29-16.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/0ea73da1-44b0-f2c5-5309-8e9a67ef04b0.gif)

下記のように`PutButton`クラスに`on_press`ファンクションを作成し、マスをタップされた時の処理を追加する。

```python:PutButtonクラス
def on_press(self):
    put_x = self.tile_id[0]
    put_y = self.tile_id[1]
    turn = self.parent.parent.turn
    
    self.parent.parent.tile[put_x][put_y] = turn
    self.parent.parent.put_stone()
```
`put_x`、`put_y`にタップされたマスの番号を代入し、`turn`に現在のターンを代入する。
親のクラス(`OthelloGrid`)の`tile`のタップされたマスの場所に`turn`の値を代入し、`put_stone`ファンクションを呼び出す。
`put_stone`は`OthelloGrid`に下記のように作成したファンクションで、`tile`の中身から盤面を再作成するファンクションである。

```python:OthelloGridクラス
def put_stone(self):
    self.clear_widgets()
    self.grid = GridLayout(cols=self.num, spacing=[3,3], size=(Window.width, Window.height))
    next_turn = 'W' if self.turn == 'B' else 'B'

    for x in range(self.num):
        for y in range(self.num):
            if self.tile[x][y] == 'W':
                self.grid.add_widget(WhiteStone())
            elif self.tile[x][y] == 'B':
                self.grid.add_widget(BlackStone())
            else:
                self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y]))
```
## 3.挟んだ石をひっくり返す(ひっくり返せないマスをタップした場合は何もしない)
下記の状態まで作成
![Sep-06-2020 21-50-38.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/b7458cb3-a6c0-8021-9a03-a470fb76f8f6.gif)

下記のように`OthelloGrid`クラスにマスの座標と現在のターンを起点にひっくり返すことができる石があるかをチェックするファンクション`can_reverse_check`と`reverse_list`を追加する。

```python:OthelloGridクラス
def can_reverse_check(self, check_x, check_y, turn):
    check =[]
    # 左上確認
    check += self.reverse_list(check_x, check_y, -1, -1, turn)
    # 上確認
    check += self.reverse_list(check_x, check_y, -1, 0, turn)
    # 右上確認
    check += self.reverse_list(check_x, check_y, -1, 1, turn)
    # 右確認
    check += self.reverse_list(check_x, check_y, 0, 1, turn)
    # 右下確認
    check += self.reverse_list(check_x, check_y, 1, 1, turn)
    # 下確認
    check += self.reverse_list(check_x, check_y, 1, 0, turn)
    # 左下確認
    check += self.reverse_list(check_x, check_y, 1, -1, turn)
    # 左確認
    check += self.reverse_list(check_x, check_y, 0, -1, turn)
    return check

def reverse_list(self, check_x, check_y, dx, dy, turn):
    tmp = []
    while True:
        check_x += dx
        check_y += dy
        if check_x < 0 or check_x > 7:
            tmp = []
            break
        if check_y < 0 or check_y > 7:
            tmp = []
            break

        if self.tile[check_x][check_y] == turn:
            break
        elif self.tile[check_x][check_y] == ' ':
            tmp = []
            break
        else:
            tmp.append((check_x, check_y))
    return tmp
```
`can_reverse_check`では石を置こうとしているマスにからそれぞれの方向に対して、ひっくり返すことができる石があるかをチェックするファンクションである`reverse_list`を呼んでいる。
戻り値として、ひっくり返すことができる石の座標のリストが返ってくるようにしている。

下記のように、この`can_reverse_check`を`PutButton`クラスがタップされた時に(`on_press`内で)呼び出し、戻り値のリストの中身があった場合`tile`の値を更新して、盤面を作り直す(`put_stone`を呼び出す)。
リストの中身が無かった場合は何もしない。

```python:PutButtonクラス
def on_press(self):
    put_x = self.tile_id[0]
    put_y = self.tile_id[1]
    check =[]
    turn = self.parent.parent.turn
    
    check += self.parent.parent.can_reverse_check(self.tile_id[0], self.tile_id[1], turn)
    if check:
        self.parent.parent.tile[put_x][put_y] = turn
        for x, y in check:
            self.parent.parent.tile[x][y] = turn
        self.parent.parent.put_stone()
```

## 4.パス機能とゲーム終了時の処理の追加
石を置く場所がなくなった時のパス機能
![Sep-06-2020 22-25-46.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/14a78e30-6c59-83a7-7e38-967893fa493a.gif)

ゲーム終了時の勝敗判定
![Sep-06-2020 22-29-12.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/54cc992c-7d44-d31a-3bf3-98adc8ad9dbf.gif)

`OthelloGrid`クラスの`put_stone`を下記のように拡張する

```python:OthelloGridクラス
def put_stone(self):
    pass_flag = True
    finish_flag = True
    check = []
    self.clear_widgets()
    self.grid = GridLayout(cols=self.num, spacing=[3,3], size=(Window.width, Window.height))
    next_turn = 'W' if self.turn == 'B' else 'B'

    for x in range(self.num):
        for y in range(self.num):
            if self.tile[x][y] == 'W':
                self.grid.add_widget(WhiteStone())
            elif self.tile[x][y] == 'B':
                self.grid.add_widget(BlackStone())
            else:
                self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y]))
    
    for x in range(self.num):
        for y in range(self.num):
            if self.tile[x][y] == ' ':
                finish_flag = False
                check += self.can_reverse_check(x, y, next_turn)
            if check:
                pass_flag = False
                break

    if finish_flag:
        content = Button(text=self.judge_winner())
        popup = Popup(title='Game set!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3))
        content.bind(on_press=popup.dismiss)
        popup.open()
    else:    
        if pass_flag:
            skip_turn_text = 'White Turn' if self.turn == 'B' else 'Black Turn'
            content = Button(text='OK')
            popup = Popup(title=skip_turn_text+' Skip!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3))
            content.bind(on_press=popup.dismiss)
            popup.open()
        else:
            self.turn = next_turn

        self.add_widget(self.grid)
```
`pass_flag`と`finish_flag`を用意し、パスするかゲームを終了するかの判定に用いる。
`tile`の中の何も置かれていない全てのマス(値が`' '`のマス)に対して、次のターンのプレイヤーがそのマスに石を置いた時にひっくり返す石があるかを確認し、もしなければ次のターンをスキップする。
その際にPopupでスキップしたことを画面に表示するようにする。

もし、`tile`の中の何も置かれていないマスがなければゲーム終了とみなし、下記の`judge_winner`ファンクションでどちらが勝ったかを判別して、Popupで画面に表示する。

```python:OthelloGridクラス
def judge_winner(self):
    white = 0
    black = 0
    for x in range(self.num):
        for y in range(self.num):
            if self.tile[x][y] == 'W':
                white += 1
            elif self.tile[x][y] == 'B':
                black += 1
    print(white)
    print(black)
    return 'White Win!' if white >= black else 'Black Win!'
```
ここまでで、オセロの処理としては一通り終わりとなります。

## ソースコード全体
他にもResetButtonやターンを表示するラベルなども追加していますが、その辺りは下記のソースコード全体でご確認ください。
(gitにもあげています。https://github.com/fu-yuta/kivy-project/tree/master/Othello)

```python:main.py
from kivy.app import App
from kivy.uix.widget import Widget
from kivy.core.window import Window
from kivy.uix.gridlayout import GridLayout
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.popup import Popup
from kivy.graphics import Color, Ellipse, Rectangle

class OthelloGrid(Widget):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.num = 8
        self.tile = [[' ' for x in range(self.num)] for x in range(self.num)]
        self.turn = 'W'
        self.grid = GridLayout(cols=self.num, spacing=[3,3], size_hint_y=7)

        for x in range(self.num):
            for y in range(self.num):
                if x == 3 and y == 3 or x == 4 and y == 4:
                    self.grid.add_widget(WhiteStone())
                    self.tile[x][y] = 'W'
                elif x == 4 and y == 3 or x == 3 and y == 4:
                    self.grid.add_widget(BlackStone())
                    self.tile[x][y] = 'B'
                else:
                    self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y]))

        self.creat_view('White Turn')
    
    def put_stone(self):
        self.grid = GridLayout(cols=self.num, spacing=[3,3], size_hint_y=7)
        pass_flag = True
        finish_flag = True
        check = []
        next_turn = 'W' if self.turn == 'B' else 'B'

        for x in range(self.num):
            for y in range(self.num):
                if self.tile[x][y] == 'W':
                    self.grid.add_widget(WhiteStone())
                elif self.tile[x][y] == 'B':
                    self.grid.add_widget(BlackStone())
                else:
                    self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y]))
        
        for x in range(self.num):
            for y in range(self.num):
                if self.tile[x][y] == ' ':
                    finish_flag = False
                    check += self.can_reverse_check(x, y, next_turn)
                if check:
                    pass_flag = False
                    break

        if finish_flag:
            content = Button(text=self.judge_winner())
            popup = Popup(title='Game set!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3))
            content.bind(on_press=popup.dismiss)
            popup.open()
            self.restart_game()
        else:    
            if pass_flag:
                skip_turn_text = 'White Turn' if self.turn == 'B' else 'Black Turn'
                content = Button(text='OK')
                popup = Popup(title=skip_turn_text+' Skip!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3))
                content.bind(on_press=popup.dismiss)
                popup.open()
            else:
                self.turn = next_turn

            turn_text = 'Black Turn' if self.turn == 'B' else 'White Turn'
            self.creat_view(turn_text)
    
    def can_reverse_check(self, check_x, check_y, turn):
        check =[]
        # 左上確認
        check += self.reverse_list(check_x, check_y, -1, -1, turn)
        # 上確認
        check += self.reverse_list(check_x, check_y, -1, 0, turn)
        # 右上確認
        check += self.reverse_list(check_x, check_y, -1, 1, turn)
        # 右確認
        check += self.reverse_list(check_x, check_y, 0, 1, turn)
        # 右下確認
        check += self.reverse_list(check_x, check_y, 1, 1, turn)
        # 下確認
        check += self.reverse_list(check_x, check_y, 1, 0, turn)
        # 左下確認
        check += self.reverse_list(check_x, check_y, 1, -1, turn)
        # 左確認
        check += self.reverse_list(check_x, check_y, 0, -1, turn)
        return check

    def reverse_list(self, check_x, check_y, dx, dy, turn):
        tmp = []
        while True:
            check_x += dx
            check_y += dy
            if check_x < 0 or check_x > 7:
                tmp = []
                break
            if check_y < 0 or check_y > 7:
                tmp = []
                break

            if self.tile[check_x][check_y] == turn:
                break
            elif self.tile[check_x][check_y] == ' ':
                tmp = []
                break
            else:
                tmp.append((check_x, check_y))
        return tmp

    def judge_winner(self):
        white = 0
        black = 0
        for x in range(self.num):
            for y in range(self.num):
                if self.tile[x][y] == 'W':
                    white += 1
                elif self.tile[x][y] == 'B':
                    black += 1
        print(white)
        print(black)
        return 'White Win!' if white >= black else 'Black Win!'

    def restart_game(self):
        print("restart game")
        self.tile = [[' ' for x in range(self.num)] for x in range(self.num)]
        self.turn = 'W'
        self.grid = GridLayout(cols=self.num, spacing=[3,3], size_hint_y=7)

        for x in range(self.num):
            for y in range(self.num):
                if x == 3 and y == 3 or x == 4 and y == 4:
                    self.grid.add_widget(WhiteStone())
                    self.tile[x][y] = 'W'
                elif x == 4 and y == 3 or x == 3 and y == 4:
                    self.grid.add_widget(BlackStone())
                    self.tile[x][y] = 'B'
                else:
                    self.grid.add_widget(PutButton(background_color=(0.451,0.3059,0.1882,1), background_normal='', tile_id=[x, y]))

        self.creat_view('White Turn')

    def creat_view(self, turn_text):
        self.clear_widgets()
        self.turn_label = Label(text=turn_text, width=Window.width , size_hint_y=1, font_size='30sp')
        self.restart_button = RestartButton(text='Restart')
        self.layout = BoxLayout(orientation='vertical', spacing=10, size=(Window.width, Window.height))
        self.layout.add_widget(self.turn_label)
        self.layout.add_widget(self.grid)
        self.layout.add_widget(self.restart_button)
        self.add_widget(self.layout)


class WhiteStone(Label):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.bind(pos=self.update)
        self.bind(size=self.update)
        self.update()
    def update(self, *args):
        self.canvas.clear()
        self.canvas.add(Color(0.451,0.3059,0.1882,1))
        self.canvas.add(Rectangle(pos=self.pos, size=self.size))
        self.canvas.add(Color(1,1,1,1))
        self.canvas.add(Ellipse(pos=self.pos, size=self.size))

class BlackStone(Label):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.bind(pos=self.update)
        self.bind(size=self.update)
        self.update()
    def update(self, *args):
        self.canvas.clear()
        self.canvas.add(Color(0.451,0.3059,0.1882,1))
        self.canvas.add(Rectangle(pos=self.pos, size=self.size))
        self.canvas.add(Color(0,0,0,1))
        self.canvas.add(Ellipse(pos=self.pos, size=self.size))

class PutButton(Button):
    def __init__(self, tile_id,  **kwargs):
        super().__init__(**kwargs)
        self.tile_id = tile_id

    def on_press(self):
        print(self.tile_id)
        put_x = self.tile_id[0]
        put_y = self.tile_id[1]
        check =[]
        turn = self.parent.parent.parent.turn
        
        check += self.parent.parent.parent.can_reverse_check(self.tile_id[0], self.tile_id[1], turn)
        if check:
            self.parent.parent.parent.tile[put_x][put_y] = turn
            for x, y in check:
                self.parent.parent.parent.tile[x][y] = turn
            self.parent.parent.parent.put_stone()

class RestartButton(Button):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)

    def on_press(self):
        content = Button(text='OK')
        popup = Popup(title='Restart Game!', content=content, auto_dismiss=False, size_hint=(None, None), size=(Window.width, Window.height/3))
        content.bind(on_press=popup.dismiss)
        popup.open()
        self.parent.parent.restart_game()

class OthelloApp(App):
    title = 'オセロ'
    def build(self):
        return OthelloGrid()


OthelloApp().run()
```

# iOS Simulaterでのビルド
下記の記事を参考にさせていただきました。
https://qiita.com/sobassy/items/b06e76cf23046a78ba05

Xcodeのコマンドラインツールが入っていない場合は下記コマンドを実行してください。

```
xcode-select --install
```

依存関係をインストールする

```
brew install autoconf automake libtool pkg-config
brew link libtool
```

Cythonをインストールする

```
pip install cython
```

[kivy-ios](https://github.com/kivy/kivy-ios)をgit cloneする。

```
git clone https://github.com/kivy/kivy-ios.git
cd kivy-ios
```

iOS用のkivyをビルドするために下記コマンドを実行する(完了までに数十分ほどかかるかも)

```
python toolchain.py build kivy
```

上記が完了したらkivyプログラムをXcode用にビルドする。

```
python toolchain.py create [Xcodeのプロジェクト名（任意の名前）] [kivyプログラムのフォルダ名]
```
この時、kivyプログラムのファイルの名前は`main.py`にしておかなければならない。
Xcodeのプロジェクト名を`App`とした場合、`App-ios`というディレクトリが作成されており、その中に`App.xcodeproj`が作成されている。
このプロジェクトをXcodeで開く。

```
open App.xcodeproj
```
XcodeでSimulatorを指定してビルドすれば、アプリが立ち上がるハズである。
もし、kivyプログラムを更新した場合には、下記コマンドでXcodeのプロジェクトも更新しなければならない。

```
python toolchain.py update App
```

# 終わりに
pythonのライブラリの1つであるkivyを使って、オセロアプリを作成し、iOSのSimulaterでビルドしてみました。
今回は、main.pyの中で全ての処理を書いていましたがkivyにはkv言語というもので、wighetなどを分けて書くことができるので、そちらへの移植も今後考えていきたい。
また、オセロのAIを組み込んでプレイヤー対CPUの対戦機能も今後追加していきたい。

# 参考
https://qiita.com/sobassy/items/b06e76cf23046a78ba05
https://github.com/PrestaMath/reverse_tile