[Python] コンソールで遊べる簡単なゲームを作る - ○×ゲーム

# はじめに

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

# 完成品

`|>` の行が入力.

```text:コンソール画面
+-------+
| _ _ _ |
| _ _ _ |
| _ _ _ |
+-------+
O の手番です
どこに書き込むかを入力してください; x y: 左上は 0 0.
ゲームを終了する場合、quit
|> 1 1
+-------+
| _ _ _ |
| _ O _ |
| _ _ _ |
+-------+
X の手番です
どこに書き込むかを入力してください; x y: 左上は 0 0.
ゲームを終了する場合、quit
|> 0 2
+-------+
| _ _ _ |
| _ O _ |
| X _ _ |
+-------+
O の手番です
どこに書き込むかを入力してください; x y: 左上は 0 0.
ゲームを終了する場合、quit
|> 2 2
+-------+
| _ _ _ |
| _ O _ |
| X _ O |
+-------+
X の手番です
どこに書き込むかを入力してください; x y: 左上は 0 0.
ゲームを終了する場合、quit
|> 2 0
+-------+
| _ _ X |
| _ O _ |
| X _ O |
+-------+
O の手番です
どこに書き込むかを入力してください; x y: 左上は 0 0.
ゲームを終了する場合、quit
|> 0 0
+-------+
| O _ X |
| _ O _ |
| X _ O |
+-------+
決着!
O の勝ち!
```

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

```python:noughts-and-crosses.py
class Board:
  grid: list[int] # 盤面

  BLANK  = 0
  NOUGHT = 1
  CROSS  = 2

  def __init__(self):
    # 大きさは 3x3 固定
    #        ,-------- 0 列目
    #        | ,------ 1 列目
    #        | | ,---- 2 列目
    #        v v v
    # grid [ a b c d e f g h i ]
    #        ----- ***** -----
    #        row 0 row 1 row 2
    self.grid = [Board.BLANK] * 9

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

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

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

  def show(self):
    print('+-------+')
    for y in range(3):
      print('| ' + ' '.join(
            '_' if n == Board.BLANK else 'O' if n == Board.NOUGHT else 'X'
            for n in self.grid[y * 3:(y + 1) * 3]
        ) + ' |')
    print('+-------+')

  @staticmethod
  def on_board(x, y) -> bool:
    '''
    盤上のマスか？
    '''
    return 0 <= x < 3 and 0 <= y < 3

  def put(self, x, y, type) -> bool:
    '''
    座標 (x, y) に type を置く.
    Returns:
      type が勝利した場合 True. さもなくば False.
    '''
    # 決着が付いたかどうかを返すため、不正な入力は例外で処理する
    if type != Board.NOUGHT and type != Board.CROSS:
      raise ValueError('type must be Board.NOUGHT, or Board.CROSS')
    if not self.on_board(x, y):
      raise ValueError('both of x, y must be in range [0..3)')
    if self.at(x, y) != Board.BLANK:
      raise ValueError('(x, y) is already occupied')
    self.grid[Board.index(x, y)] = type
    # 縦
    if all(self.at(x, ty) == type for ty in range(3)):
      return True
    # 横
    if all(self.at(tx, y) == type for tx in range(3)):
      return True
    # 斜め ＼
    if x == y and all(self.at(t, t) == type for t in range(3)):
      return True
    # 斜め ／
    if x + y == 2 and all(self.at(t, 2-t) == type for t in range(3)):
      return True
    return False

  def fulfilled(self):
    return not Board.BLANK in self.grid

class Game:
  board: Board
  move_to: int # 手番を持つプレイヤー
  winner: int

  def __init__(self):
    self.board = Board()
    self.move_to = Board.NOUGHT # 最初は O の手番
    self.winner = Board.BLANK

  def put(self, x, y) -> bool:
    '''
    座標 (x, y) に type を置く.
    Returns:
      記号を書けた場合 True. さもなくば False.
    '''
    # Board.put は不正な入力に対して例外で処理するため、
    # Game.put で不正な入力をはじく.
    if not Board.on_board(x, y):
      return False
    if self.board.at(x, y) != Board.BLANK:
      return False
    # マークを書く
    if self.board.put(x, y, self.move_to):
      # 決着
      self.winner = self.move_to
    # 手番を移す
    self.move_to = 3 - self.move_to
    return True
  
  def game_over(self):
    return self.winner != Board.BLANK or self.board.fulfilled()

  def show(self):
    self.board.show()

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

  Returns:
    処理を終了する場合 True
  '''
  game.show()
  if game.move_to == Board.NOUGHT:
    print('O の手番です')
  else:
    print('X の手番です')
  # 行、列の順で入力させるべきかも？
  print('どこに書き込むかを入力してください; x y: 左上は 0 0.')
  print('ゲームを終了する場合、quit')
  user_input = input('|> ').split()
  if user_input == ['quit']:
    return True
  if len(user_input) != 2:
    print('座標はふたつの数字をスペース区切りで入力してください; e.g. 1 2')
    return False

  try:
    x, y = map(int, user_input)
  except ValueError:
    print('数字を入力してください')
    return False

  if not game.put(x, y):
    print(f'座標 ({x}, {y}) に書くことはできません')

  if game.game_over():
    game.show()
    print('決着!')
    if game.winner == Board.NOUGHT:
      print('O の勝ち!')
    elif game.winner == Board.CROSS:
      print('X の勝ち!')
    else: # game.winner == Board.BLANK
      print('引き分け!')
    return True
  return False

# メインの処理
game = Game()
while(True):
  if process_message(game):
    break
```
</details>

# 説明

Python の勉強となったポイントは以前の記事([lights out](https://qiita.com/takayoshi1968/items/d494b3987842c2d5fd28)、[bulls and cows](https://qiita.com/takayoshi1968/items/45bfe94fac8f055aef26)) と重複する場合書かない.

## メッセージループ

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

* print で入力形式などを表示する
* input で入力を受け取る
* 入力に応じた処理

のみっつで、盤面に対する操作やクリア判定は Board/Game クラスに任せている.

## Board クラス

手番の管理を Game クラスに任せて、こちらは盤面の記憶などをしている.
分ける意味は薄かったかも.

### 内部表現

二次元リストで問題はないのだが、何となく一次元リストを使って grid: list[int] としている.
中身はコンストラクタのコメントに書いてある通り、0 行目の情報、1 行目の情報、... となっている.
BLANK = 0 で空きマス、NOUGHT = 1 が ○、CROSS = 2 が × である.
二次元的な x,y を使ってアクセスするには index(self, x, y) を使うようにしている.
x,y が盤面の内側であることを確かめるために on_board(self, x, y) を用意している.
現在どちらの手番であるかとゲーム終了判定は Game クラスで行っている.

### 記号の書き込みと勝利判定

`put(self, x, y, type)` ではマスに記号を書き込んでいる.
コメントに書いた通り `Board.put` は決着が付いたかどうかを返すため、入力の妥当性判定は Game クラスに任せている.
`Board.put` は不正な入力に対して例外を上げているが、`Game` クラスを信用してそもそもチェックしない実装もあるだろうか.

入力に問題ないことが分かったならば記号を書き込み(`self.grid[Board.index(x, y)] = type`)、勝敗判定を行っている.
縦横斜めのいずれかで並んだ場合は勝敗が決したので `True`、そうでない場合は `False` を返している.
斜め ＼ の判定にある `x == y` は、`(x,y)` がこのライン上にあるかどうかを判定している. これを行わず and の右側の判定だけでも全く問題はない.
斜め ／ の `x + y == 2` も同様.

```python:noughts-and-crosses.py(書き込みと勝利判定)
self.grid[Board.index(x, y)] = type
# 縦
if all(self.at(x, ty) == type for ty in range(3)):
  return True
# 横
if all(self.at(tx, y) == type for tx in range(3)):
  return True
# 斜め ＼
if x == y and all(self.at(t, t) == type for t in range(3)):
  return True
# 斜め ／
if x + y == 2 and all(self.at(t, 2-t) == type for t in range(3)):
  return True
return False
```

### 空きマスが残っているかの判定

`fulfilled(self)` では9マスすべてに書き込まれているかを判定している.
「すべてのマスが空きマスでない」ということは「`grid` が `BLANK` を含まない(`not Board.BLANK in self.grid`)」ということである.

### 盤面の描画

`show(self)` では盤面の状態を画面に表示する.

`grid` の形式より、y 行目は `[self.grid[y * 3:(y + 1) * 3]]` で取得できる.
空きマスが `'_'`、○ が `'O'` (大文字のオー)、× は `'X'` (大文字のエックス) で表示している.

```python:noughts-and-crosses.py(抜粋)
def show(self):
  print('+-------+')
  for y in range(3):
    print('| ' + ' '.join(
          '_' if n == Board.BLANK else 'O' if n == Board.NOUGHT else 'X'
          for n in self.grid[y * 3:(y + 1) * 3]
      ) + ' |')
  print('+-------+')
```

## Game クラス

内部で `Board` を持ち、手番の管理を行っている.

### 記号の書き込みと手番の移動

`put(self, x, y)` では入力のチェック、決着した場合は勝者の記憶、そうでない場合は手番の移動を行っている.

手番の移動は、`NOUGHT`/`CROSS` が `1/2` であることを利用して

```python:noughts-and-crosses(抜粋)
self.move_to = 3 - self.move_to
```

のように書いているが、定義の仕方によらない

```python:別案
self.move_to = Board.NOUGHT if self.move_to == Board.CROSS else Board.CROSS
```

や

```python:別案
match self.move_to:
  case Board.NOUGHT:
    self.move_to = Board.CROSS
  case Board.CROSS:
    self.move_to = Board.NOUGHT
self.move_to = Board.NOUGHT if self.move_to == Board.CROSS else Board.CROSS
```

と書いてもよい.

ふたつの値を交互に取る方法は他に以下のようなものがある:

```python:ふたつの値を交互に取る方法
print('0/1 を交互に取る方法 1')
val = 0
print(f'{val=}')
val ^= 1
print(f'{val=}')
val ^= 1
print(f'{val=}')
val ^= 1
print(f'{val=}')

print('0/1 を交互に取る方法 2')
val = 0
print(f'{val=}')
val = (val+1) % 2
print(f'{val=}')
val = (val+1) % 2
print(f'{val=}')
val = (val+1) % 2
print(f'{val=}')

print('1/-1 を交互に取る方法')
val = 1
print(f'{val=}')
val *= -1
print(f'{val=}')
val *= -1
print(f'{val=}')
val *= -1
print(f'{val=}')
```

### Python の勉強ポイント

この部分を書いて勉強になった点のまとめ

#### デバッグ用文字列フォーマット

○×ゲームを書いて学んだことではないのだが、上の「ふたつの値を交互に取る方法」にあるように `f'{val=}'` のように `=` を書いた場合デバッグ用のフォーマットになる.
`f'val={val}'` などと書く場合コピペで片方だけ書き換え忘れたりすると悲しいので大変助かる機能だと感じた.

```python:e.g.
val = 42
print(f'{val=}')# val=42

# 式や空白の有無も反映されるみたい
a = 2
b = 3
print(f'{a+b=}')# a+b=5
print(f'{a +b=}')# a +b=5
print(f'{a+ b=}')# a+ b=5
print(f'{a + b=}')# a + b=5
print(f'{a + b = }')# a + b = 5
```