「伝説のゲーム「2048」をPythonで自作してAIに解かせる(2) 」をリファクタリングさせていただいた

## いきさつ

@masa_ramen さんの「[伝説のゲーム「2048」をPythonで自作してAIに解かせる(2) ~Pythonで「2048」を自作する~](https://qiita.com/masa_ramen/items/ac31db0d20ad88cff09a)」を拝見したところ、大量のif文に圧倒されました。
4x4の盤面を辞書データで作り、盤面の状態のすべての組合せをif文で書いて移動処理しているようでした。
3行以上同じ処理が書いてあるとまとめてしまいたくなるのがプログラマのサガ、ということでリファクタリングしてみることにしました。

## リファクタリング

以下のようなことを考えてリファクタリングしました。
試行錯誤しながらリファクタリングしている状況なので、他にも良いアイデアなどありましたらコメントいただけたると有難いです。

* 盤面データを文字の辞書データから数値の2次元リストにする
  * 2重forループで移動処理を簡潔に書けそう
  * 数値のまま合算処理および得点計算できそう
  * 2次元リストなら回転処理も簡潔に書けそう
* 盤面あるいはゲームをクラスにする
  * deepcopy処理はコンストラクタでコピーするだけで済みそう
  * テストしやすいプロパティおよびメソッドにしよう
* 辞書データを活用してif文を削減する
  * キー操作の判定if文は、キーと処理メソッドの辞書にできそう
* 意味不明な変数名を無くす
  * flagでは何のフラグなのかわからないので「何」の方を変数名にする
* マジックナンバー(意味不明な数値、文字列)を無くす
  * 数値は意味の分かる変数名に代入してから使う

## ソースコード

```py:game2048.py
import random

SIZE = 4
DIGITS = 4


class Game:

    def __init__(self, board=None, verbose=True):
        self.board = [row[:] for row in board or [[0] * SIZE] * SIZE]
        self.verbose = verbose
        self.score = 0

    def show(self):
        separator = ' -' + '-' * (DIGITS + len(' | ')) * SIZE
        print(separator)
        for row in self.board:
            print(' | ' + ' | '.join(f'{tile:{DIGITS}}' for tile in row) + ' |')
        print(separator)

    def scoring(self, tile):
        self.score += tile * 2
        if self.verbose:
            print(f'{tile}+{tile}={tile*2}')

    def move_left(self):
        moved = False
        for row in self.board:
            for left in range(SIZE - 1):
                for right in range(left + 1, SIZE):
                    if row[right] == 0:
                        continue
                    if row[left] == 0:
                        row[left] = row[right]
                        row[right] = 0
                        moved = True
                        continue
                    if row[left] == row[right]:
                        self.scoring(row[right])
                        row[left] += row[right]
                        row[right] = 0
                        moved = True
                        break
                    if row[left] != row[right]:
                        break
        return moved

    def rotate_left(self):
        self.board = [list(row) for row in zip(*self.board)][::-1]

    def rotate_right(self):
        self.board = [list(row)[::-1] for row in zip(*self.board)]

    def rotate_turn(self):
        self.board = [row[::-1] for row in self.board][::-1]

    def flick_left(self):
        moved = self.move_left()
        return moved

    def flick_right(self):
        self.rotate_turn()
        moved = self.move_left()
        self.rotate_turn()
        return moved

    def flick_up(self):
        self.rotate_left()
        moved = self.move_left()
        self.rotate_right()
        return moved

    def flick_down(self):
        self.rotate_right()
        moved = self.move_left()
        self.rotate_left()
        return moved

    def playable(self):
        return any(flick(Game(self.board, verbose=False))
                   for flick in (Game.flick_left, Game.flick_right,
                                 Game.flick_up, Game.flick_down))

    def put_tile(self):
        zeros = [(y, x)
                 for y in range(SIZE)
                 for x in range(SIZE)
                 if self.board[y][x] == 0]
        y, x = random.choice(zeros)
        self.board[y][x] = random.choice((2, 4))


def play():
    game = Game()
    game.put_tile()
    game.put_tile()
    game.show()
    key_flick = {'r': game.flick_right,
                 'l': game.flick_left,
                 'u': game.flick_up,
                 'd': game.flick_down}
    try:
        count = 0
        while game.playable():
            key = input(f'input {count}th move>>> ')
            if key not in key_flick:
                print('err')
            elif key_flick[key]():
                game.put_tile()
                game.show()
                print(f'score = {game.score}')
                count += 1
    except (KeyboardInterrupt, EOFError):
        print()
    print('#######################')
    print('Game Over')
    print(f'Final Score = {game.score}')
    print('#######################')


if __name__ == '__main__':
    play()
```

## ソースコード解説

```py
class Game:
```

最初、Boardクラスを作ったのですが、盤面と得点を保持したGameクラスが必要と思い直しました。
doctestを利用したテストが簡単に書けるようにもなりました。

```py
def __init__(self, board=None, verbose=True):
```

Gameインスタンス初期化メソッド

* board引数
数値を移動できるかどうかを判断するための盤面のコピーを作る際に、現状の盤面データを渡します。
新規ゲームの場合は引数を省略して、後ほど盤面を作ります。

* verbose引数
元のプログラムでは各関数のend引数で得点計算・表示をするかどうかを判断するために使っていました。
このインスタンスでは、詳細表示するか判断するためのverbose変数を用意しました。デフォルト値はTrueで表示あり、引数でFalseを与えると表示なしです。

```py
self.board = [row[:] for row in board or [[0] * SIZE] * SIZE]
```

リスト内包表記を使って、各行(`row`)の全要素スライス(`[:]`)を作ってboardをコピーした2次元リストを作り出しています。
`board or [[0] * SIZE] * SIZE]` は、「boardがあればboard、なければ `[[0] * SIZE] * SIZE]`」という意味です。
`[[0] * SIZE] * SIZE]` はリストを共有したリストが作られ、通常の処理では意図しない動作をひきおこすので、普段はこんな書き方をしてはいけません。ここでは `row[:]` でリストのスライスを作ってコピー処理していますので、これで問題ありません。

```py
    def move_left(self):
        moved = False
        for row in self.board:
            for left in range(SIZE - 1):
                for right in range(left + 1, SIZE):
                    if row[right] == 0:
                        continue
                    if row[left] == 0:
                        row[left] = row[right]
                        row[right] = 0
                        moved = True
                        continue
                    if row[left] == row[right]:
                        self.scoring(row[right])
                        row[left] += row[right]
                        row[right] = 0
                        moved = True
                        break
                    if row[left] != row[right]:
                        break
        return moved
```

膨大なif文がこれだけになりました。
復帰値として移動したかどうかの真偽値を返しています。移動できなかったときは新しいタイルを出現させないための判断に使えます。

```py
    def rotate_left(self):
        self.board = [list(row) for row in zip(*self.board)][::-1]

    def rotate_right(self):
        self.board = [list(row)[::-1] for row in zip(*self.board)]

    def rotate_turn(self):
        self.board = [row[::-1] for row in self.board][::-1]
```

2次元リストの回転処理です。
zip関数の転置、行の反転、列の反転を組み合わせて実現しています。

```py
    def playable(self):
        return any(flick(Game(self.board, verbose=False))
                   for flick in (Game.flick_left, Game.flick_right,
                                 Game.flick_up, Game.flick_down))
```

どの方向にも動かせなくなった時がゲームオーバーですので、その判定をしています。
`Game.flick_left, Game.flick_right, Game.flick_up, Game.flick_down` は、インスタンスに紐づいていないメソッドで、引数にインスタンスを渡すことでメソッドを呼び出せます。
`Game(self.board, verbose=False)` で現状の盤面をコピーしたGameインスタンスを作って、`flick(...)` で各メソッドを呼び出し、`any(...)` でいずれかの方向に移動できたらプレイ可能と判断しています。

```py
    def put_tile(self):
        zeros = [(y, x)
                 for y in range(SIZE)
                 for x in range(SIZE)
                 if self.board[y][x] == 0]
        y, x = random.choice(zeros)
        self.board[y][x] = random.choice((2, 4))
```

盤面の値が0の場所を探してrandom.choiceし、2か4をrandom.choiceした値にしています。

```py
def play():
    game = Game()
    game.put_tile()
    game.put_tile()
    game.show()
```

新しいゲームを用意し、最初に2つのタイルを置き、表示。

```py
    key_flick = {'r': game.flick_right,
                 'l': game.flick_left,
                 'u': game.flick_up,
                 'd': game.flick_down}
```

キー操作に対応するメソッドを辞書にしています。こちらはインスタンスに紐づいたメソッドで、引数なしでメソッドを呼び出せます。

```py
            elif key_flick[key]():
```

`key_flick[key]` でキーに応じたメソッドを取り出し、`()` でメソッドを呼び出しています。

## テスト

doctestを利用したテストを実施しました。

### テストコード

```py:game2048_test.py
from self2048 import Game

def flick_left_test():
    """
    >>> game = Game()
    >>> game.flick_left()
    False
    >>> game.board
    [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
    >>> game.score
    0
    >>> game = Game([[2,0,0,0],[2,4,0,0],[2,4,2,0],[2,4,2,4]])
    >>> game.flick_left()
    False
    >>> game.board
    [[2, 0, 0, 0], [2, 4, 0, 0], [2, 4, 2, 0], [2, 4, 2, 4]]
    >>> game.score
    0
    >>> game = Game([[0,0,0,0],[0,0,0,2],[0,0,2,0],[0,0,2,2]])
    >>> game.flick_left()
    2+2=4
    True
    >>> game.board
    [[0, 0, 0, 0], [2, 0, 0, 0], [2, 0, 0, 0], [4, 0, 0, 0]]
    >>> game.score
    4
    >>> game = Game([[0,2,0,0],[0,2,0,2],[0,2,2,0],[0,2,2,2]])
    >>> game.flick_left()
    2+2=4
    2+2=4
    2+2=4
    True
    >>> game.board
    [[2, 0, 0, 0], [4, 0, 0, 0], [4, 0, 0, 0], [4, 2, 0, 0]]
    >>> game.score
    12
    >>> game = Game([[2,0,0,0],[2,0,0,2],[2,0,2,0],[2,0,2,2]])
    >>> game.flick_left()
    2+2=4
    2+2=4
    2+2=4
    True
    >>> game.board
    [[2, 0, 0, 0], [4, 0, 0, 0], [4, 0, 0, 0], [4, 2, 0, 0]]
    >>> game.score
    12
    >>> game = Game([[2,2,0,0],[2,2,0,2],[2,2,2,0],[2,2,2,2]])
    >>> game.flick_left()
    2+2=4
    2+2=4
    2+2=4
    2+2=4
    2+2=4
    True
    >>> game.board
    [[4, 0, 0, 0], [4, 2, 0, 0], [4, 2, 0, 0], [4, 4, 0, 0]]
    >>> game.score
    20
    >>> game = Game([[4,4,4,4],[4,4,4,2],[4,4,2,4],[4,4,2,2]])
    >>> game.flick_left()
    4+4=8
    4+4=8
    4+4=8
    4+4=8
    4+4=8
    2+2=4
    True
    >>> game.board
    [[8, 8, 0, 0], [8, 4, 2, 0], [8, 2, 4, 0], [8, 4, 0, 0]]
    >>> game.score
    44
    >>> game = Game([[4,2,4,4],[4,2,4,2],[4,2,2,4],[4,2,2,2]])
    >>> game.flick_left()
    4+4=8
    2+2=4
    2+2=4
    True
    >>> game.board
    [[4, 2, 8, 0], [4, 2, 4, 2], [4, 4, 4, 0], [4, 4, 2, 0]]
    >>> game.score
    16
    >>> game = Game([[2,4,4,4],[2,4,4,2],[2,4,2,4],[2,4,2,2]])
    >>> game.flick_left()
    4+4=8
    4+4=8
    2+2=4
    True
    >>> game.board
    [[2, 8, 4, 0], [2, 8, 2, 0], [2, 4, 2, 4], [2, 4, 4, 0]]
    >>> game.score
    20
    >>> game = Game([[2,2,4,4],[2,2,4,2],[2,2,2,4],[2,2,2,2]])
    >>> game.flick_left()
    2+2=4
    4+4=8
    2+2=4
    2+2=4
    2+2=4
    2+2=4
    True
    >>> game.board
    [[4, 8, 0, 0], [4, 4, 2, 0], [4, 2, 4, 0], [4, 4, 0, 0]]
    >>> game.score
    28
    """

def rotate_test():
    """
    >>> game = Game([[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]])
    >>> game.rotate_left()
    >>> game.board
    [[4, 8, 12, 16], [3, 7, 11, 15], [2, 6, 10, 14], [1, 5, 9, 13]]
    >>> game = Game([[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]])
    >>> game.rotate_right()
    >>> game.board
    [[13, 9, 5, 1], [14, 10, 6, 2], [15, 11, 7, 3], [16, 12, 8, 4]]
    >>> game = Game([[1,2,3,4],[5,6,7,8],[9,10,11,12],[13,14,15,16]])
    >>> game.rotate_turn()
    >>> game.board
    [[16, 15, 14, 13], [12, 11, 10, 9], [8, 7, 6, 5], [4, 3, 2, 1]]
    """

if __name__ == '__main__':
    import doctest
    doctest.testmod()
```

### テスト結果

```console:テスト実行結果
$ python game2048_test.py -v
Trying:
    game = Game()
Expecting nothing
ok
Trying:
    game.flick_left()
Expecting:
    False
ok
Trying:
    game.board
Expecting:
    [[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]]
ok
Trying:
    game.score
Expecting:
    0
ok
（途中省略）
1 items had no tests:
    __main__
2 items passed all tests:
  40 tests in __main__.flick_left_test
   9 tests in __main__.rotate_test
49 tests in 3 items.
49 passed and 0 failed.
Test passed.
```