[Python] コンソールで遊べる簡単なゲームを作る - スライドパズル

# はじめに

Python の勉強のため簡単なゲームを作りましたので備忘録としてまとめておきます.
とりとめのない個人的な記事ではありますが、python を勉強するどなたかの参考になりましたならば幸いです.
環境は Python 3.13.1、Windows 11、コマンドプロンプトです.

スライドパズルを実装する中で私が python として学んだことは過去に書いた記事と重複しているので特筆しません.
よろしければ過去記事もご覧ください:

* [lights out](https://qiita.com/takayoshi1968/items/d494b3987842c2d5fd28)
* [bulls and cows](https://qiita.com/takayoshi1968/items/45bfe94fac8f055aef26)
* [○×ゲーム](https://qiita.com/takayoshi1968/items/45bfe94fac8f055aef26)

# 完成品

`|>` の行が入力.
入力のしやすさを考え空きマスを移動させる形なので、操作感はパズドラに近い.

```コンソール
+-----------+
| 0| 1| 2| 3|
| 4| 5| 7| X|
| 8| 9| 6|11|
|12|13|10|14|
+-----------+
空白マス(X) をどの方向に動かすかを入力してください(up, down, left, right)
ゲームを終了する場合、quit
|> left
+-----------+
| 0| 1| 2| 3|
| 4| 5| X| 7|
| 8| 9| 6|11|
|12|13|10|14|
+-----------+
空白マス(X) をどの方向に動かすかを入力してください(up, down, left, right)
ゲームを終了する場合、quit
|> down
+-----------+
| 0| 1| 2| 3|
| 4| 5| 6| 7|
| 8| 9| X|11|
|12|13|10|14|
+-----------+
空白マス(X) をどの方向に動かすかを入力してください(up, down, left, right)
ゲームを終了する場合、quit
|> down
+-----------+
| 0| 1| 2| 3|
| 4| 5| 6| 7|
| 8| 9|10|11|
|12|13| X|14|
+-----------+
空白マス(X) をどの方向に動かすかを入力してください(up, down, left, right)
ゲームを終了する場合、quit
|> right
+-----------+
| 0| 1| 2| 3|
| 4| 5| 6| 7|
| 8| 9|10|11|
|12|13|14| X|
+-----------+
ゲームクリア！
```

<details>
<summary>ソースコード</summary>

```
from random import randrange

class Board:
  numbers: list[int] # 盤面
  blank: int # 空白マスの位置(インデックス)

  # [[ 定数 ]]
  # 空白マスを表現する値
  BLANK = -1
  # 移動方向
  LEFT  = (-1,  0)
  RIGHT = ( 1,  0)
  UP    = ( 0, -1)
  DOWN  = ( 0,  1)

  def __init__(self):
    # 大きさは 4x4 固定とする
    #           ,-------- 0 列目
    #           | ,------ 1 列目
    #           | | ,---- 2 列目
    #           | | | ,-- 3 列目
    #           v v v v
    # numbers [ a b c d e f g h i j k l m n o p q]
    #           ------- ********* ------- *******
    #           row 0   row 1     row 2   row 3
    self.numbers = list(range(16))
    self.blank = 15 # 右下
    self.numbers[self.blank] = Board.BLANK

  def shuffle(self):
    directions = [Board.LEFT, Board.RIGHT, Board.UP, Board.DOWN]
    count = 0
    # 単純に numbers をシャッフルすると解けない盤面が出来上がる可能性があるため、
    # 適当に動かすことでばらばらにする.
    # 解けるスライドパズルであるということは偶置換であることと同値らしいのだが、
    # スライドパズルではなく Python の練習が目的なので踏み込まない.
    # 30 回も動かせば十分ばらばらになるはず.
    # シャッフルの完了時に解けているのは望ましくないので、
    # count >= 30 であっても self.is_solved() であれば動かす.
    while count < 30 or self.is_solved():
      if(self.move(directions[randrange(4)])):
        count += 1 # 実際に空白マスが移動した場合だけカウントする

  @staticmethod
  def index(x, y):
    return x + y * 4

  @staticmethod
  def coordinate(index):
    y = index // 4
    x = index - 4 * y
    return (x, y)

  def at(self, x, y):
    return self.numbers[Board.index(x, y)]

  def get_blank_position(self):
    return Board.coordinate(self.blank)

  def show(self):
    print('+-----------+')
    for y in range(4):
      print('|' + '|'.join(
            ' X' if n == Board.BLANK else str(n).rjust(2)
            for n in self.numbers[y * 4:(y + 1) * 4]
        ) + '|')
    print('+-----------+')

  def move(self, direction) -> bool:
    '''
    空白マス(X) を移動させる.
    Args:
      direction: (x: int, y: int) なる単位ベクトル.
    Returns:
      移動に成功したか？
    Examples:
      >> board.move(Board.UP)
      >> board.move(Board.DOWN)
    '''
    def put(x, y, val):
      index = Board.index(x, y)
      self.numbers[index] = val

    vx, vy = direction
    if abs(vx) + abs(vy) != 1:
      raise ValueError('direction must be a unit vector')
    bx, by = self.get_blank_position()
    # nx, ny: 移動後の空白マスの位置
    nx = bx + vx
    ny = by + vy
    if nx < 0 or 4 <= nx or ny < 0 or 4 <= ny:
      # 空白マスが範囲外に出た
      return False
    # 実際に移動させる; i.e. (bx, by) と (nx, ny) を交換する.
    put(bx, by, self.at(nx, ny))
    put(nx, ny, Board.BLANK)
    self.blank = Board.index(nx, ny)
    return True

  def is_solved(self):
    if self.blank != 15:
      # 空白マスが右下にないので未解決
      return False
    for i in range(15):
      if self.numbers[i] != i:
        # 数字 i の位置が異なるので未解決
        return False
    # ここまでのチェックを通り抜けたならば解けている
    return True

def process_message(board):
  '''
  メッセージループの処理

  Returns:
    処理を終了する場合 True
  '''
  board.show()
  print('空白マス(X) をどの方向に動かすかを入力してください(up, down, left, right)')
  print('ゲームを終了する場合、quit')
  # 内部で input() を使っているのにメッセージループを名乗れるかは知らない.
  # コマンドの表現には dict を使うのもよいが、大した数ではないので書き下してしまう.
  match input('|> '):
    case 'up':
      board.move(Board.UP)
    case 'down':
      board.move(Board.DOWN)
    case 'left':
      board.move(Board.LEFT)
    case 'right':
      board.move(Board.RIGHT)
    case 'quit':
      return True
    case _:
      print('入力を読み取れませんでした')
      return False
  if board.is_solved():
    board.show()
    print('ゲームクリア！')    
    return True
  return False

# メインの処理
board = Board()
board.shuffle()
while(True):
  if process_message(board):
    break
```
</details>

# 説明

## メッセージループ
メインの処理はコードの一番下に描かれている関数 process_message と、それを呼び出す無限ループである.
process_message で行っていることを大まかに分ければ

print で入力形式などを表示する
input で入力を受け取る
入力に応じた処理
のみっつで、盤面の管理やクリア判定は Board クラスに任せている.

## `Board` クラス

### 内部表現

空きマスがどこにあるかを `blank` として持っている.
盤面は二次元リストで問題はないのだが、何となく一次元リストを使って `numbers: list[int]` としている.
中身はコンストラクタのコメントに書いてある通り、0 行目の情報、1 行目の情報、... となっている.
二次元的な x,y を使ってアクセスするには `index(self, x, y)` を使うようにしている.
ゲームクリアの判定は `is_solved(self)` で行っている.
判定方法としては、空きマスが右下にあり、`numbers` が 0 から 14 まで昇順で持っていればよい.
`BLANK = 15` としていればここで空きマスの位置を確かめる必要もなくせる.

### 空きマスの移動

`move(self, direction)` で空きマスを方向 `direction` に移動させる.
ここで、`direction` は `Board.LEFT` などとして定義している単位ベクトルである.
空きマスの移動は、その方向にある数字と空きマスの交換である.
中で補助関数 `put` を用意したり `get_blank_position` を使ったりしているが、`index`/`coordinate` の呼び出しが無駄に増えているので、すべて `index` 形式で考える方が簡潔だったかもしれない.