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

# はじめに

Python の勉強のため簡単なゲームを作りましたので備忘録としてまとめておきます.
Bulls and Cows を最短で解くプログラムを期待してこられた方がいらしたら申し訳ありませんが、単純に遊べるだけのものです.
とりとめのない個人的な記事ではありますが、python を勉強するどなたかの参考になりましたならば幸いです.
環境は Python 3.13.1、Windows 11、コマンドプロンプトです.

# 完成品

`|>` の行が入力.

```text:コンソール画面(開始時)
No. Guess   | Bulls Cows
4 つの数字を推測し空白区切りで入力してください; e.g. 1 5 0 4
bulls: 値も位置も正しい数字の数
cows : 値は正しいが位置が異なる数字の数
ゲームを終了する場合、quit
|> 
```
```text:コンソール画面(クリア時)
4 つの数字を推測し空白区切りで入力してください; e.g. 1 5 0 4
bulls: 値も位置も正しい数字の数
cows : 値は正しいが位置が異なる数字の数
ゲームを終了する場合、quit
|> 0 4 9 8
No. Guess   | Bulls Cows
 1. 0 1 2 3 |     1    0
 2. 0 3 1 2 |     1    0
 3. 0 4 5 6 |     2    0
 4. 0 4 6 5 |     2    0
 5. 0 4 7 8 |     3    0
 6. 0 4 7 9 |     2    1
 7. 0 4 9 7 |     3    0
 8. 0 4 9 8 |     4    0
ゲームクリア!
```

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

```python:bulls-and-cows.py
from random import shuffle
from collections import namedtuple

Result = namedtuple('Result', ['bulls', 'cows'])

class Game:
  secret: list[int]
  history: list[tuple[list[int],Result]]

  def __init__(self):
    nums = list(range(10))
    shuffle(nums)
    self.secret = nums[0:4]
    self.history = []

  def answer(self, guess) -> bool:
    if len(guess) != len(set(guess)):
      # 重複があるので受け付けない
      return False
    bulls = 0
    cows = 0
    for i,(s,g) in enumerate(zip(self.secret, guess)):
      if s == g:
        bulls += 1
      elif g in self.secret:
        cows += 1
    result = Result(bulls, cows)
    self.history.append((guess,result))
    return True

  def show(self):
    print('No. Guess   | Bulls Cows')
    for i,h in enumerate(self.history):
      guess,result = h
      bulls,cows = result
      guess_str = ' '.join(str(g) for g in guess)
      print(f'{i+1:>2}. {guess_str} |     {bulls}    {cows}')

  def game_over(self):
    return self.history and self.history[-1][1].bulls == 4

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

  Returns:
    処理を終了する場合 True
  '''
  game.show()
  print('4 つの数字を推測し空白区切りで入力してください; e.g. 1 5 0 4')
  print('bulls: 値も位置も正しい数字の数')
  print('cows : 値は正しいが位置が異なる数字の数')
  print('ゲームを終了する場合、quit')
  # 内部で input() を使っているのにメッセージループを名乗れるかは知らない.
  user_input = input('|> ').split()
  if user_input == ['quit']:
    return True
  if len(user_input) != 4:
    print('4 つの数字をスペース区切りで入力してください; e.g. 7 0 8 3 ')
    return False

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

  if not game.answer(guess):
    print(f'推測に重複があります')

  if game.game_over():
    game.show()
    print('ゲームクリア!')
    return True
  return False

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

# 説明

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

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

のみっつで、入力された数字の記憶やクリア判定は Game クラスに任せている.

毎回ルールの説明を書いているので、回答の履歴を毎回描画している(`game.show()`).

## `Game` クラス

### 内部表現

プレイヤーが当てることになる秘密の数列 `secret: list[int]` と、
プレイヤーが行った推測の履歴 `history: list[tuple[list[int], Result]]` を持っている.
`history` をもう少し説明的に書けば、`list[(推測した四つの数字), (Bulls, Cows)]`.

### 回答とクリア判定

`answer(self, guess)` では回答を受け取り、形式に問題がなければ `(bulls, cows)` を計算して `history` に保存する.
`game_over(self)` では最後の回答で `bulls` が 4 であるかを調べることによってクリア判定を行っている.

### 履歴の描画

`show(self)` ではプレイヤーが行った推測の履歴を表形式で表示している.

### Python の勉強ポイント

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

#### リストのシャッフル

`__init__(self)` では `secret` を作成するために配列をシャッフルしている.
`random.shuffle` を使うことで、シーケンスをばらばらにできる.

```python:bulls-and-cows.py
from random import shuffle

[中略]

def __init__(self):
  nums = list(range(10))
  shuffle(nums)
  self.secret = nums[0:4]
  self.history = []
```

#### リストの直積

`answer(self, guess)` では `(bulls,cows)` を計算するために、`self.secret` と `guess` を同時にループしている.
Python に組み込まれている `zip` 関数を使うと実現できる.

```python:zip 関数の例
list0 = [ 0, 2, 4, 6 ]
list1 = [ 1, 3, 5, 7 ]
zipped = list(zip(list0, list1))
print(zipped) # [(0, 1), (2, 3), (4, 5), (6, 7)]
```

今回は `self.secret` と `guess` どちらも長さ 4 の数列であることが分かっているが、zip 関数が返す要素数はもっとも短いリストに合わせられる.

```python:zip 関数の例
list0 = [ 0, 2, 4, 6, 8, 10 ] # 長さ 6
list1 = [ 1, 3, 5, 7 ] # 長さ 4
zipped = list(zip(list0, list1)) # 長さ min(6, 4) = 4
print(zipped) # [(0, 1), (2, 3), (4, 5), (6, 7)]
```

#### 名前付きタプル

推測の結果を単に `result = (bulls,cows)` のようなタプルにすると、分割代入(`bulls,cows = result`) のようにしない場合 `bulls = result[0]` のような仕方でアクセスすることになる.
これを、`bulls = result.bulls` のようにする場合クラスを使うか、今回のように名前付きタプルを使うかとなる(今回のコードでは分割代入しかしていないので名前付きタプルの恩恵はないけれど).
`collections.namedtuple` 

```python:sample.py
from collections import namedtuple

# Result という名前で、0 番目の要素の名前が bulls、1 番目の要素のが cows であるような名前付きタプルを作成する.
# 第一引数の 'Sample' は str や repr などで文字列として扱うときに使われる.
# 例として変数名の方を T としているが、分かりやすさのために名前を揃えた方がよい.
T = namedtuple('Sample', ['x', 'y', 'z'])

nt = T(1, 2, 3) # T はクラスのコンストラクタと同じようにして扱う
print(nt) # Sample(x=1, y=2, z=3)
```

中身はタプルなので、`==` で比較する場合などではクラスで定義する場合と少し異なるが、省スペースで定義できるため使い所を考えれば便利なのだと思う.

#### リストが特定の値を含んでいるかを判定する

`answer(self, guess)` では、`cows` を求めるため `self.secret` が `guess` の要素を含んでいるかを `g in self.secret` で判定している.

```python:bulls-and-cows.py
def answer(self, guess) -> bool:
  if len(guess) != len(set(guess)):
    # 重複があるので受け付けない
    return False
  bulls = 0
  cows = 0
  for i,(s,g) in enumerate(zip(self.secret, guess)):
    if s == g:
      bulls += 1
    elif g in self.secret:
      cows += 1
  result = Result(bulls, cows)
  self.history.append((guess,result))
  return True
```

`g in self.secret` は `any(s == g for s in self.secret)` と書き換えられる.
Java などで `list.contains(value)` のような書き方に慣れていると面食らう構文だが、こういうのが python らしい書き方なのだろう.