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

# はじめに

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

# 完成品

`|> ` の行が入力.

```text:コンソール画面(開始時)
     0  1  2  3  4
   +---------------+
 0 | X  X  X  X  X |
 1 | X  X  X  X  X |
 2 | X  X  X  X  X |
 3 | X  X  X  X  X |
 4 | X  X  X  X  X |
   +---------------+
すべてのライトを点灯状態 (X) から消灯状態 (_) にしてください
どこを中心に反転するかを入力してください; x y: 左上は 0 0.
ゲームを終了する場合、quit
|> 1 1
     0  1  2  3  4
   +---------------+
 0 | X  _  X  X  X |
 1 | _  _  _  X  X |
 2 | X  _  X  X  X |
 3 | X  X  X  X  X |
 4 | X  X  X  X  X |
   +---------------+
すべてのライトを点灯状態 (X) から消灯状態 (_) にしてください
どこを中心に反転するかを入力してください; x y: 左上は 0 0.
ゲームを終了する場合、quit
|> quit
```

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

```python:lights-out.py
class Board:
  grid: list[bool] # 盤面
  width: int
  height: int

  DARK  = False
  LIGHT = True

  def __init__(self, width, height):
    # 大きさが 3x4 であるとき:
    #        ,-------- 0 列目
    #        | ,------ 1 列目
    #        | | ,---- 2 列目
    #        | | | ,-- 3 列目
    #        v v v v
    # grid [ a b c d e f g h i j k l ]
    #        ------- ******* -------
    #        row 0   row 1   row 2
    self.grid = [Board.LIGHT] * (width * height)
    self.width = width
    self.height = height

  def index(self, x, y):
    return x + y * self.width

  def show(self):
    print('    ' + ' '.join(
          str(n).rjust(2)
          for n in range(self.width)
      ))
    print('   +' + ''.join('-' for _ in range(self.width*3)) + '+')
    for y in range(self.height):
      print(str(y).rjust(2) + ' | ' + ' '.join(
            '_ ' if n == Board.DARK else 'X '
            for n in self.grid[y * self.width:(y + 1) * self.width]
        ) + '|')

    print('   +' + ''.join('-' for _ in range(self.width*3)) + '+')

  def on_board(self, x, y) -> bool:
    '''
    盤上のマスか？
    '''
    return 0 <= x < self.width and 0 <= y < self.height

  def flip(self, x, y) -> bool:
    '''
    座標 (x, y) を中心に反転する.
    Returns:
      (x, y) を反転できた場合 True. さもなくば False.
    '''
    if not self.on_board(x, y):
      return False
    self.grid[self.index(x, y)] ^= True
    for nx,ny in [(x+1,y), (x-1,y), (x,y+1), (x,y-1)]:
      if self.on_board(nx, ny):
        self.grid[self.index(nx, ny)] ^= True
    return True

  def game_over(self):
    return not any(self.grid)

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

  Returns:
    処理を終了する場合 True
  '''
  board.show()
  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 board.flip(x, y):
    print(f'座標 ({x}, {y}) で反転することはできません')

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

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

# 説明

## メッセージループ

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

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

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

### Python の勉強ポイント

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

#### 標準入力は `input()` 関数で受け取れる.

`input('|> ')` とすることで、`|> ` を標準出力に書き込み、その行に入力させることができる.
今回のコードでは `input('|> ').split()` としており、入力を空白文字で分割している.

#### 文字列配列から整数配列への変換

`x, y = map(int, user_input)` とすることで、`user_input: list[str]` を `list[int]` に変換している.
いきなり分割代入を使わず丁寧に書くと以下のような感じ:

```python:alt
user_input: list[str] = input('|> ').split()
# map 関数のジェネリクス風なイメージ:
# converted: list[U] = map(f: Callable[[T],U], list[T])
# Java 風に書けば user_input.stream().map(v -> Integer.parse(v)).collect(toList())
input_ints: list[int] = map(int, user_input)
x,y = input_ints
```

## `Board` クラス

### 盤面の内部表現

二次元リストで問題はないのだが、何となく一次元リストを使って `grid: list[int]` としている.
中身はコンストラクタのコメントに書いてある通り、0 行目の情報、1 行目の情報、... となっている.
`DARK = False` で消灯、`LIGHT = True` が点灯である.
二次元的な `x,y` を使ってアクセスするには `index(self, x, y)` を使うようにしている.
`x,y` が盤面の内側であることを確かめるために `on_board(self, x, y)` を用意している.
ゲームクリアの判定は `game_over(self)` で行っている.
判定方法としては、`grid` に含まれるすべてが `DARK = False` であればクリアなので、`LIGHT = True` であるものがひとつも存在しなければよい.
これを python で書くと `not any(self.grid)` となる.

### 盤面の操作

`flip(self, x, y)` は `x,y` を中心とする十字の範囲を反転するメソッドである.
`x,y` が盤外でないことを `if not self.on_board(x, y)` で確かめたのち、`True` と XOR を取ることで論理値を反転する.

### 盤面の描画

`show(self)` で盤面を描画している.
一行目は x 座標を書き、二行目は盤の上端 (`+---------------+`).
その後、一列目に y 座標を書いてから盤面の状態を描画する.
そして最後に盤の下端 (`+---------------+`) を書いておしまい.

```text:コンソール
     0  1  2  3  4
   +---------------+
 0 | X  _  X  X  X |
 1 | _  _  _  X  X |
 2 | X  _  X  X  X |
 3 | X  X  X  X  X |
 4 | X  X  X  X  X |
   +---------------+
```

### Python の勉強ポイント

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

#### 同じ値を持つリストの作成

`__init__(self, width, height)` で使用している `self.grid = [Board.LIGHT] * (width * height)` のように、
`{長さ 1 の配列} * {要素数}` とすることで指定した長さで、同じ値を持つリストを作成できる.
`{長さ 1 の配列}` でなくても `{リスト} * n` とすることで、`{リスト}` を `n` 回繰り返したリストを作成できる.

```python:リストのスカラー倍
ls = [1,2] * 3
print(ls) # [1, 2, 1, 2, 1, 2]
```

今回のようにリストの中身がイミュータブルな値であれば問題ないけれど、以下のようなケースでは注意しなければならない.

```python:リストのスカラー倍(注意を要する例)
ls = [[]] * 3
print(ls) # [[], [], []]
ls[0].append(5)
print(ls) # [[5], [5], [5]]

# # イメージとしては以下のような感じで、ls の中身がすべて同一のインスタンスであることによる.
# # i.e. list * int ではシャローコピーが行われる.
# ls = []
# inner = []
# ls.append(inner)
# ls.append(inner)
# ls.append(inner)
# inner.append(5)
# print(ls) # [[5], [5], [5]]
```

#### タプルのリストを用いたループ

`flip(self, x, y)` では `x,y` に隣接するマスについて処理するため  `for` 文を用いて以下のように書いた.

```python:lights-out.py
for nx,ny in [(x+1,y), (x-1,y), (x,y+1), (x,y-1)]:
  if self.on_board(nx, ny):
    self.grid[self.index(nx, ny)] ^= True
```

for 文の変数もこのように分割代入して使用することができる.

#### 文字列の右寄せ

`show(self)` では数字を右寄せにしている(`Board` の大きさが可変なので、10 列以上あるケースとかのため(杞憂)).
実装では `str(n).rjust(2)` としているが、`f'{n:>2}'` でも同じことができる.
これは「`n` を文字列としたのち、右寄せした(最小)幅 2 の文字列とする」の意.

#### リストをひとつの文字列に変換する

`show(self)` には `grid: list[int]` を文字列にする処理が入っている.

```python:lights-out.py
print(str(y).rjust(2) + ' | ' + ' '.join(
      '_ ' if n == Board.DARK else 'X '
      for n in self.grid[y * self.width:(y + 1) * self.width]
  ) + '|')
```

無理やりひとつの文に押し込めているので、分解して考える:

```python:lights-out.py
column_legend = str(y).rjust(2)
left_barline = ' | '
row = self.grid[y * self.width:(y + 1) * self.width]
lights = ' '.join(
      '_ ' if n == Board.DARK else 'X '
      for n in row
  )
right_barline = '|'
print(column_legend + left_barline + lights + right_barline)
```

文字列の結合 (変数 `lights`) には `str.join(iterable)` を使っている.
これは `' '` を区切り文字として、`iterable` をひとつの文字列にするメソッドである.
Java 風に書けば、`row.stream().map(n -> n == Board.DARK? "_ " : "X ").collect(joining(" "))`