cli-othello 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cli_othello-0.1.0/PKG-INFO +66 -0
- cli_othello-0.1.0/README.md +54 -0
- cli_othello-0.1.0/pyproject.toml +25 -0
- cli_othello-0.1.0/setup.cfg +4 -0
- cli_othello-0.1.0/src/cli_othello/__init__.py +3 -0
- cli_othello-0.1.0/src/cli_othello/ai.py +157 -0
- cli_othello-0.1.0/src/cli_othello/board.py +103 -0
- cli_othello-0.1.0/src/cli_othello/cli.py +44 -0
- cli_othello-0.1.0/src/cli_othello/ui.py +194 -0
- cli_othello-0.1.0/src/cli_othello.egg-info/PKG-INFO +66 -0
- cli_othello-0.1.0/src/cli_othello.egg-info/SOURCES.txt +13 -0
- cli_othello-0.1.0/src/cli_othello.egg-info/dependency_links.txt +1 -0
- cli_othello-0.1.0/src/cli_othello.egg-info/entry_points.txt +2 -0
- cli_othello-0.1.0/src/cli_othello.egg-info/requires.txt +3 -0
- cli_othello-0.1.0/src/cli_othello.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-othello
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Play Othello (Reversi) in your terminal against a 5-level AI
|
|
5
|
+
Author-email: Lapius7 <20071209ryo@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Lapius7/cli-othello
|
|
8
|
+
Project-URL: Repository, https://github.com/Lapius7/cli-othello
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: windows-curses; platform_system == "Windows"
|
|
12
|
+
|
|
13
|
+
# cli-othello
|
|
14
|
+
|
|
15
|
+
ターミナルでオセロ(リバーシ)を楽しめるCLIパッケージです。
|
|
16
|
+
5段階の強さから選べるAIと対局できます。
|
|
17
|
+
|
|
18
|
+
## インストール
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install cli-othello
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 使い方
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
othello
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
起動すると対局前にAIの強さ(Lv.1〜Lv.5)を選ぶ画面が表示されます。
|
|
31
|
+
強さを指定して直接対局を始めることもできます。
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
othello --level 3
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 操作方法
|
|
38
|
+
|
|
39
|
+
| キー | 動作 |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| ↑ / ↓ / ← / → (または h/j/k/l) | カーソル移動 |
|
|
42
|
+
| Enter / Space | カーソル位置に着手 |
|
|
43
|
+
| q | 終了 |
|
|
44
|
+
|
|
45
|
+
盤面では、自分が置ける場所が緑色の `·` でハイライトされます。
|
|
46
|
+
|
|
47
|
+
## AIの強さ
|
|
48
|
+
|
|
49
|
+
| レベル | 特徴 |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Lv.1 入門 | ランダム性が高く、ほぼ気の向くままに着手 |
|
|
52
|
+
| Lv.2 初級 | 浅い探索+盤面の位置評価を考慮 |
|
|
53
|
+
| Lv.3 中級 | 角・辺の重要度や着手可能数(機動力)も評価 |
|
|
54
|
+
| Lv.4 上級 | より深く先読みしつつ盤面を評価 |
|
|
55
|
+
| Lv.5 最強 | 深い探索(最大6手先)で最も精度高く着手を選択 |
|
|
56
|
+
|
|
57
|
+
内部的にはミニマックス法とアルファベータ枝刈りを用い、レベルごとに探索の深さと評価関数の精度を変化させています。
|
|
58
|
+
|
|
59
|
+
## 必要環境
|
|
60
|
+
|
|
61
|
+
- Python 3.9以上
|
|
62
|
+
- Windowsの場合は `windows-curses` が自動的に依存関係としてインストールされます
|
|
63
|
+
|
|
64
|
+
## ライセンス
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# cli-othello
|
|
2
|
+
|
|
3
|
+
ターミナルでオセロ(リバーシ)を楽しめるCLIパッケージです。
|
|
4
|
+
5段階の強さから選べるAIと対局できます。
|
|
5
|
+
|
|
6
|
+
## インストール
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install cli-othello
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## 使い方
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
othello
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
起動すると対局前にAIの強さ(Lv.1〜Lv.5)を選ぶ画面が表示されます。
|
|
19
|
+
強さを指定して直接対局を始めることもできます。
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
othello --level 3
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### 操作方法
|
|
26
|
+
|
|
27
|
+
| キー | 動作 |
|
|
28
|
+
| --- | --- |
|
|
29
|
+
| ↑ / ↓ / ← / → (または h/j/k/l) | カーソル移動 |
|
|
30
|
+
| Enter / Space | カーソル位置に着手 |
|
|
31
|
+
| q | 終了 |
|
|
32
|
+
|
|
33
|
+
盤面では、自分が置ける場所が緑色の `·` でハイライトされます。
|
|
34
|
+
|
|
35
|
+
## AIの強さ
|
|
36
|
+
|
|
37
|
+
| レベル | 特徴 |
|
|
38
|
+
| --- | --- |
|
|
39
|
+
| Lv.1 入門 | ランダム性が高く、ほぼ気の向くままに着手 |
|
|
40
|
+
| Lv.2 初級 | 浅い探索+盤面の位置評価を考慮 |
|
|
41
|
+
| Lv.3 中級 | 角・辺の重要度や着手可能数(機動力)も評価 |
|
|
42
|
+
| Lv.4 上級 | より深く先読みしつつ盤面を評価 |
|
|
43
|
+
| Lv.5 最強 | 深い探索(最大6手先)で最も精度高く着手を選択 |
|
|
44
|
+
|
|
45
|
+
内部的にはミニマックス法とアルファベータ枝刈りを用い、レベルごとに探索の深さと評価関数の精度を変化させています。
|
|
46
|
+
|
|
47
|
+
## 必要環境
|
|
48
|
+
|
|
49
|
+
- Python 3.9以上
|
|
50
|
+
- Windowsの場合は `windows-curses` が自動的に依存関係としてインストールされます
|
|
51
|
+
|
|
52
|
+
## ライセンス
|
|
53
|
+
|
|
54
|
+
MIT
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "cli-othello"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Play Othello (Reversi) in your terminal against a 5-level AI"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Lapius7", email = "20071209ryo@gmail.com" }]
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dependencies = [
|
|
14
|
+
"windows-curses; platform_system=='Windows'",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.scripts]
|
|
18
|
+
othello = "cli_othello.cli:main"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://github.com/Lapius7/cli-othello"
|
|
22
|
+
Repository = "https://github.com/Lapius7/cli-othello"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""5-level Othello AI using minimax with alpha-beta pruning.
|
|
2
|
+
|
|
3
|
+
Each level scales both the search depth and the sophistication of the
|
|
4
|
+
position evaluation, so higher levels look further ahead and judge
|
|
5
|
+
positions more accurately (corners, edges, mobility, stability).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import random
|
|
11
|
+
|
|
12
|
+
from .board import BLACK, EMPTY, SIZE, WHITE, Board, opponent
|
|
13
|
+
|
|
14
|
+
# Static positional weights, used by the higher levels.
|
|
15
|
+
_POSITION_WEIGHTS = [
|
|
16
|
+
120, -20, 20, 5, 5, 20, -20, 120,
|
|
17
|
+
-20, -40, -5, -5, -5, -5, -40, -20,
|
|
18
|
+
20, -5, 15, 3, 3, 15, -5, 20,
|
|
19
|
+
5, -5, 3, 3, 3, 3, -5, 5,
|
|
20
|
+
5, -5, 3, 3, 3, 3, -5, 5,
|
|
21
|
+
20, -5, 15, 3, 3, 15, -5, 20,
|
|
22
|
+
-20, -40, -5, -5, -5, -5, -40, -20,
|
|
23
|
+
120, -20, 20, 5, 5, 20, -20, 120,
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_CORNERS = [(0, 0), (0, SIZE - 1), (SIZE - 1, 0), (SIZE - 1, SIZE - 1)]
|
|
27
|
+
|
|
28
|
+
# level -> (search depth, randomness, use_positional_weights, use_mobility)
|
|
29
|
+
_LEVEL_CONFIG = {
|
|
30
|
+
1: dict(depth=1, randomness=0.35, weights=False, mobility=False),
|
|
31
|
+
2: dict(depth=2, randomness=0.15, weights=True, mobility=False),
|
|
32
|
+
3: dict(depth=2, randomness=0.0, weights=True, mobility=True),
|
|
33
|
+
4: dict(depth=3, randomness=0.0, weights=True, mobility=True),
|
|
34
|
+
5: dict(depth=4, randomness=0.0, weights=True, mobility=True),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Endgame solver: once few enough empty cells remain, search exhaustively
|
|
38
|
+
# to the end of the game instead of stopping at the configured depth.
|
|
39
|
+
_ENDGAME_EMPTY_THRESHOLD = {3: 8, 4: 9, 5: 10}
|
|
40
|
+
|
|
41
|
+
def _order_moves(moves: list[tuple[int, int]]) -> list[tuple[int, int]]:
|
|
42
|
+
"""Sort moves by static positional weight (best-first) to help alpha-beta
|
|
43
|
+
pruning cut off more branches earlier."""
|
|
44
|
+
return sorted(
|
|
45
|
+
moves, key=lambda m: _POSITION_WEIGHTS[m[0] * SIZE + m[1]], reverse=True
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
MIN_LEVEL = 1
|
|
50
|
+
MAX_LEVEL = 5
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OthelloAI:
|
|
54
|
+
"""Picks moves for `player` at the given difficulty level (1-5)."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, player: int, level: int) -> None:
|
|
57
|
+
if level not in _LEVEL_CONFIG:
|
|
58
|
+
raise ValueError(f"level must be between {MIN_LEVEL} and {MAX_LEVEL}")
|
|
59
|
+
self.player = player
|
|
60
|
+
self.level = level
|
|
61
|
+
self.config = _LEVEL_CONFIG[level]
|
|
62
|
+
|
|
63
|
+
def choose_move(self, board: Board) -> tuple[int, int] | None:
|
|
64
|
+
moves = board.legal_moves(self.player)
|
|
65
|
+
if not moves:
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
if random.random() < self.config["randomness"]:
|
|
69
|
+
return random.choice(moves)
|
|
70
|
+
|
|
71
|
+
depth = self.config["depth"]
|
|
72
|
+
empty_cells = board.cells.count(EMPTY)
|
|
73
|
+
endgame_threshold = _ENDGAME_EMPTY_THRESHOLD.get(self.level)
|
|
74
|
+
if endgame_threshold is not None and empty_cells <= endgame_threshold:
|
|
75
|
+
depth = empty_cells
|
|
76
|
+
|
|
77
|
+
moves = _order_moves(moves)
|
|
78
|
+
best_score = None
|
|
79
|
+
best_moves: list[tuple[int, int]] = []
|
|
80
|
+
for move in moves:
|
|
81
|
+
child = board.clone()
|
|
82
|
+
child.play(*move, self.player)
|
|
83
|
+
score = self._minimax(
|
|
84
|
+
child, depth - 1, opponent(self.player), -float("inf"), float("inf")
|
|
85
|
+
)
|
|
86
|
+
if best_score is None or score > best_score:
|
|
87
|
+
best_score = score
|
|
88
|
+
best_moves = [move]
|
|
89
|
+
elif score == best_score:
|
|
90
|
+
best_moves.append(move)
|
|
91
|
+
return random.choice(best_moves)
|
|
92
|
+
|
|
93
|
+
def _minimax(
|
|
94
|
+
self, board: Board, depth: int, turn: int, alpha: float, beta: float
|
|
95
|
+
) -> float:
|
|
96
|
+
if depth <= 0 or board.is_game_over():
|
|
97
|
+
return self._evaluate(board)
|
|
98
|
+
|
|
99
|
+
moves = board.legal_moves(turn)
|
|
100
|
+
if not moves:
|
|
101
|
+
return self._minimax(board, depth - 1, opponent(turn), alpha, beta)
|
|
102
|
+
|
|
103
|
+
moves = _order_moves(moves)
|
|
104
|
+
maximizing = turn == self.player
|
|
105
|
+
best = -float("inf") if maximizing else float("inf")
|
|
106
|
+
for move in moves:
|
|
107
|
+
child = board.clone()
|
|
108
|
+
child.play(*move, turn)
|
|
109
|
+
score = self._minimax(child, depth - 1, opponent(turn), alpha, beta)
|
|
110
|
+
if maximizing:
|
|
111
|
+
best = max(best, score)
|
|
112
|
+
alpha = max(alpha, best)
|
|
113
|
+
else:
|
|
114
|
+
best = min(best, score)
|
|
115
|
+
beta = min(beta, best)
|
|
116
|
+
if alpha >= beta:
|
|
117
|
+
break
|
|
118
|
+
return best
|
|
119
|
+
|
|
120
|
+
def _evaluate(self, board: Board) -> float:
|
|
121
|
+
me, foe = self.player, opponent(self.player)
|
|
122
|
+
|
|
123
|
+
if board.is_game_over():
|
|
124
|
+
diff = board.count(me) - board.count(foe)
|
|
125
|
+
return diff * 10000
|
|
126
|
+
|
|
127
|
+
if not self.config["weights"]:
|
|
128
|
+
return board.count(me) - board.count(foe)
|
|
129
|
+
|
|
130
|
+
score = 0.0
|
|
131
|
+
for row in range(SIZE):
|
|
132
|
+
for col in range(SIZE):
|
|
133
|
+
cell = board.get(row, col)
|
|
134
|
+
if cell == me:
|
|
135
|
+
score += _POSITION_WEIGHTS[row * SIZE + col]
|
|
136
|
+
elif cell == foe:
|
|
137
|
+
score -= _POSITION_WEIGHTS[row * SIZE + col]
|
|
138
|
+
|
|
139
|
+
if self.config["mobility"]:
|
|
140
|
+
my_moves = len(board.legal_moves(me))
|
|
141
|
+
foe_moves = len(board.legal_moves(foe))
|
|
142
|
+
score += (my_moves - foe_moves) * 8
|
|
143
|
+
|
|
144
|
+
my_corners = sum(1 for r, c in _CORNERS if board.get(r, c) == me)
|
|
145
|
+
foe_corners = sum(1 for r, c in _CORNERS if board.get(r, c) == foe)
|
|
146
|
+
score += (my_corners - foe_corners) * 25
|
|
147
|
+
|
|
148
|
+
return score
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
LEVEL_LABELS = {
|
|
152
|
+
1: "Lv.1 入門",
|
|
153
|
+
2: "Lv.2 初級",
|
|
154
|
+
3: "Lv.3 中級",
|
|
155
|
+
4: "Lv.4 上級",
|
|
156
|
+
5: "Lv.5 最強",
|
|
157
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Othello board representation and rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
SIZE = 8
|
|
6
|
+
EMPTY = 0
|
|
7
|
+
BLACK = 1
|
|
8
|
+
WHITE = 2
|
|
9
|
+
|
|
10
|
+
DIRECTIONS = [
|
|
11
|
+
(-1, -1), (-1, 0), (-1, 1),
|
|
12
|
+
(0, -1), (0, 1),
|
|
13
|
+
(1, -1), (1, 0), (1, 1),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def opponent(player: int) -> int:
|
|
18
|
+
return WHITE if player == BLACK else BLACK
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Board:
|
|
22
|
+
"""8x8 Othello board. Cells are stored as a flat list of length 64."""
|
|
23
|
+
|
|
24
|
+
def __init__(self) -> None:
|
|
25
|
+
self.cells = [EMPTY] * (SIZE * SIZE)
|
|
26
|
+
mid = SIZE // 2
|
|
27
|
+
self.cells[(mid - 1) * SIZE + (mid - 1)] = WHITE
|
|
28
|
+
self.cells[(mid - 1) * SIZE + mid] = BLACK
|
|
29
|
+
self.cells[mid * SIZE + (mid - 1)] = BLACK
|
|
30
|
+
self.cells[mid * SIZE + mid] = WHITE
|
|
31
|
+
|
|
32
|
+
def clone(self) -> "Board":
|
|
33
|
+
new = Board.__new__(Board)
|
|
34
|
+
new.cells = list(self.cells)
|
|
35
|
+
return new
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def in_bounds(row: int, col: int) -> bool:
|
|
39
|
+
return 0 <= row < SIZE and 0 <= col < SIZE
|
|
40
|
+
|
|
41
|
+
def get(self, row: int, col: int) -> int:
|
|
42
|
+
return self.cells[row * SIZE + col]
|
|
43
|
+
|
|
44
|
+
def set(self, row: int, col: int, value: int) -> None:
|
|
45
|
+
self.cells[row * SIZE + col] = value
|
|
46
|
+
|
|
47
|
+
def _flips_for_move(self, row: int, col: int, player: int) -> list[tuple[int, int]]:
|
|
48
|
+
if self.get(row, col) != EMPTY:
|
|
49
|
+
return []
|
|
50
|
+
rival = opponent(player)
|
|
51
|
+
flips: list[tuple[int, int]] = []
|
|
52
|
+
for dr, dc in DIRECTIONS:
|
|
53
|
+
line: list[tuple[int, int]] = []
|
|
54
|
+
r, c = row + dr, col + dc
|
|
55
|
+
while self.in_bounds(r, c) and self.get(r, c) == rival:
|
|
56
|
+
line.append((r, c))
|
|
57
|
+
r, c = r + dr, c + dc
|
|
58
|
+
if line and self.in_bounds(r, c) and self.get(r, c) == player:
|
|
59
|
+
flips.extend(line)
|
|
60
|
+
return flips
|
|
61
|
+
|
|
62
|
+
def legal_moves(self, player: int) -> list[tuple[int, int]]:
|
|
63
|
+
moves = []
|
|
64
|
+
for row in range(SIZE):
|
|
65
|
+
for col in range(SIZE):
|
|
66
|
+
if self._flips_for_move(row, col, player):
|
|
67
|
+
moves.append((row, col))
|
|
68
|
+
return moves
|
|
69
|
+
|
|
70
|
+
def has_legal_move(self, player: int) -> bool:
|
|
71
|
+
for row in range(SIZE):
|
|
72
|
+
for col in range(SIZE):
|
|
73
|
+
if self._flips_for_move(row, col, player):
|
|
74
|
+
return True
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def play(self, row: int, col: int, player: int) -> bool:
|
|
78
|
+
flips = self._flips_for_move(row, col, player)
|
|
79
|
+
if not flips:
|
|
80
|
+
return False
|
|
81
|
+
self.set(row, col, player)
|
|
82
|
+
for r, c in flips:
|
|
83
|
+
self.set(r, c, player)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
def count(self, player: int) -> int:
|
|
87
|
+
return self.cells.count(player)
|
|
88
|
+
|
|
89
|
+
def is_full(self) -> bool:
|
|
90
|
+
return EMPTY not in self.cells
|
|
91
|
+
|
|
92
|
+
def is_game_over(self) -> bool:
|
|
93
|
+
if self.is_full():
|
|
94
|
+
return True
|
|
95
|
+
return not self.has_legal_move(BLACK) and not self.has_legal_move(WHITE)
|
|
96
|
+
|
|
97
|
+
def winner(self) -> int | None:
|
|
98
|
+
black, white = self.count(BLACK), self.count(WHITE)
|
|
99
|
+
if black > white:
|
|
100
|
+
return BLACK
|
|
101
|
+
if white > black:
|
|
102
|
+
return WHITE
|
|
103
|
+
return None
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Command-line entry point for cli-othello."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import curses
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .ai import MAX_LEVEL, MIN_LEVEL
|
|
10
|
+
from .ui import init_colors, play_game, select_level
|
|
11
|
+
from .board import BLACK
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(stdscr: "curses._CursesWindow", level: int | None) -> None:
|
|
15
|
+
init_colors()
|
|
16
|
+
chosen_level = level if level is not None else select_level(stdscr)
|
|
17
|
+
play_game(stdscr, human_player=BLACK, ai_level=chosen_level)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="othello",
|
|
23
|
+
description="Play Othello (Reversi) in your terminal against a 5-level AI.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"-l",
|
|
27
|
+
"--level",
|
|
28
|
+
type=int,
|
|
29
|
+
choices=range(MIN_LEVEL, MAX_LEVEL + 1),
|
|
30
|
+
help="AIの強さを指定して直接対局を開始する (1-5)。省略すると選択画面が表示されます。",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"-V", "--version", action="version", version=f"cli-othello {__version__}"
|
|
34
|
+
)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
curses.wrapper(_run, args.level)
|
|
39
|
+
except KeyboardInterrupt:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
if __name__ == "__main__":
|
|
44
|
+
main()
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""curses-based terminal UI for cli-othello."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import curses
|
|
6
|
+
|
|
7
|
+
from .ai import LEVEL_LABELS, MAX_LEVEL, MIN_LEVEL, OthelloAI
|
|
8
|
+
from .board import BLACK, EMPTY, SIZE, WHITE, Board, opponent
|
|
9
|
+
|
|
10
|
+
DISC_CHARS = {EMPTY: " ", BLACK: "●", WHITE: "○"}
|
|
11
|
+
PLAYER_NAMES = {BLACK: "黒", WHITE: "白"}
|
|
12
|
+
|
|
13
|
+
COLOR_BOARD = 1
|
|
14
|
+
COLOR_BLACK = 2
|
|
15
|
+
COLOR_WHITE = 3
|
|
16
|
+
COLOR_CURSOR = 4
|
|
17
|
+
COLOR_HINT = 5
|
|
18
|
+
COLOR_HEADER = 6
|
|
19
|
+
COLOR_STATUS = 7
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def init_colors() -> None:
|
|
23
|
+
curses.start_color()
|
|
24
|
+
curses.use_default_colors()
|
|
25
|
+
curses.init_pair(COLOR_BOARD, curses.COLOR_WHITE, curses.COLOR_GREEN)
|
|
26
|
+
curses.init_pair(COLOR_BLACK, curses.COLOR_BLACK, curses.COLOR_GREEN)
|
|
27
|
+
curses.init_pair(COLOR_WHITE, curses.COLOR_WHITE, curses.COLOR_GREEN)
|
|
28
|
+
curses.init_pair(COLOR_CURSOR, curses.COLOR_BLACK, curses.COLOR_YELLOW)
|
|
29
|
+
curses.init_pair(COLOR_HINT, curses.COLOR_GREEN, curses.COLOR_BLACK)
|
|
30
|
+
curses.init_pair(COLOR_HEADER, curses.COLOR_CYAN, -1)
|
|
31
|
+
curses.init_pair(COLOR_STATUS, curses.COLOR_YELLOW, -1)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def select_level(stdscr: "curses._CursesWindow") -> int:
|
|
35
|
+
"""Let the user pick an AI difficulty level (1-5) with arrow keys."""
|
|
36
|
+
level = 3
|
|
37
|
+
curses.curs_set(0)
|
|
38
|
+
while True:
|
|
39
|
+
stdscr.erase()
|
|
40
|
+
stdscr.addstr(1, 2, "CLI OTHELLO", curses.color_pair(COLOR_HEADER) | curses.A_BOLD)
|
|
41
|
+
stdscr.addstr(3, 2, "対戦するAIの強さを選んでください", curses.color_pair(COLOR_HEADER))
|
|
42
|
+
for i in range(MIN_LEVEL, MAX_LEVEL + 1):
|
|
43
|
+
label = LEVEL_LABELS[i]
|
|
44
|
+
attr = curses.A_REVERSE | curses.A_BOLD if i == level else curses.A_NORMAL
|
|
45
|
+
stdscr.addstr(5 + i, 4, f"{label}", attr)
|
|
46
|
+
stdscr.addstr(
|
|
47
|
+
5 + MAX_LEVEL + 2,
|
|
48
|
+
2,
|
|
49
|
+
"↑/↓ で選択、Enter で決定、q で終了",
|
|
50
|
+
curses.color_pair(COLOR_STATUS),
|
|
51
|
+
)
|
|
52
|
+
stdscr.refresh()
|
|
53
|
+
|
|
54
|
+
key = stdscr.getch()
|
|
55
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
56
|
+
level = MIN_LEVEL if level <= MIN_LEVEL else level - 1
|
|
57
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
58
|
+
level = MAX_LEVEL if level >= MAX_LEVEL else level + 1
|
|
59
|
+
elif key in (curses.KEY_ENTER, 10, 13):
|
|
60
|
+
return level
|
|
61
|
+
elif key in (ord("q"), ord("Q")):
|
|
62
|
+
raise SystemExit(0)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _draw_board(
|
|
66
|
+
stdscr: "curses._CursesWindow",
|
|
67
|
+
board: Board,
|
|
68
|
+
cursor: tuple[int, int],
|
|
69
|
+
legal: set[tuple[int, int]],
|
|
70
|
+
human_player: int,
|
|
71
|
+
ai_level: int,
|
|
72
|
+
message: str,
|
|
73
|
+
top: int = 0,
|
|
74
|
+
) -> None:
|
|
75
|
+
stdscr.erase()
|
|
76
|
+
stdscr.addstr(top, 2, "CLI OTHELLO", curses.color_pair(COLOR_HEADER) | curses.A_BOLD)
|
|
77
|
+
stdscr.addstr(
|
|
78
|
+
top,
|
|
79
|
+
16,
|
|
80
|
+
f"AI: {LEVEL_LABELS[ai_level]} あなた: {DISC_CHARS[human_player]}{PLAYER_NAMES[human_player]}",
|
|
81
|
+
curses.color_pair(COLOR_STATUS),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
board_top = top + 2
|
|
85
|
+
board_left = 4
|
|
86
|
+
|
|
87
|
+
stdscr.addstr(board_top, board_left + 2, " ".join("ABCDEFGH"))
|
|
88
|
+
for row in range(SIZE):
|
|
89
|
+
stdscr.addstr(board_top + 1 + row, board_left, f"{row + 1} ")
|
|
90
|
+
for col in range(SIZE):
|
|
91
|
+
cell = board.get(row, col)
|
|
92
|
+
char = DISC_CHARS[cell]
|
|
93
|
+
x = board_left + 2 + col * 3
|
|
94
|
+
y = board_top + 1 + row
|
|
95
|
+
|
|
96
|
+
if (row, col) == cursor:
|
|
97
|
+
attr = curses.color_pair(COLOR_CURSOR) | curses.A_BOLD
|
|
98
|
+
elif cell == BLACK:
|
|
99
|
+
attr = curses.color_pair(COLOR_BLACK) | curses.A_BOLD
|
|
100
|
+
elif cell == WHITE:
|
|
101
|
+
attr = curses.color_pair(COLOR_WHITE) | curses.A_BOLD
|
|
102
|
+
else:
|
|
103
|
+
attr = curses.color_pair(COLOR_BOARD)
|
|
104
|
+
|
|
105
|
+
display = char
|
|
106
|
+
if cell == EMPTY and (row, col) in legal:
|
|
107
|
+
display = "·"
|
|
108
|
+
if (row, col) != cursor:
|
|
109
|
+
attr = curses.color_pair(COLOR_HINT)
|
|
110
|
+
|
|
111
|
+
stdscr.addstr(y, x, f" {display} ", attr)
|
|
112
|
+
|
|
113
|
+
score_y = board_top + SIZE + 2
|
|
114
|
+
stdscr.addstr(
|
|
115
|
+
score_y,
|
|
116
|
+
board_left,
|
|
117
|
+
f"● 黒: {board.count(BLACK)} ○ 白: {board.count(WHITE)}",
|
|
118
|
+
curses.A_BOLD,
|
|
119
|
+
)
|
|
120
|
+
stdscr.addstr(score_y + 2, board_left, message, curses.color_pair(COLOR_STATUS))
|
|
121
|
+
stdscr.addstr(
|
|
122
|
+
score_y + 3,
|
|
123
|
+
board_left,
|
|
124
|
+
"矢印キー: 移動 Enter/Space: 着手 q: 終了",
|
|
125
|
+
curses.color_pair(COLOR_STATUS),
|
|
126
|
+
)
|
|
127
|
+
stdscr.refresh()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def play_game(stdscr: "curses._CursesWindow", human_player: int, ai_level: int) -> None:
|
|
131
|
+
curses.curs_set(0)
|
|
132
|
+
board = Board()
|
|
133
|
+
ai_player = opponent(human_player)
|
|
134
|
+
ai = OthelloAI(ai_player, ai_level)
|
|
135
|
+
cursor = [3, 3]
|
|
136
|
+
turn = BLACK
|
|
137
|
+
message = ""
|
|
138
|
+
|
|
139
|
+
while True:
|
|
140
|
+
if board.is_game_over():
|
|
141
|
+
break
|
|
142
|
+
|
|
143
|
+
legal = set(board.legal_moves(turn))
|
|
144
|
+
if not legal:
|
|
145
|
+
message = f"{PLAYER_NAMES[turn]}はパスしました"
|
|
146
|
+
turn = opponent(turn)
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
if turn == human_player:
|
|
150
|
+
message = "あなたの番です"
|
|
151
|
+
_draw_board(stdscr, board, tuple(cursor), legal, human_player, ai_level, message)
|
|
152
|
+
key = stdscr.getch()
|
|
153
|
+
if key in (curses.KEY_UP, ord("k")):
|
|
154
|
+
cursor[0] = (cursor[0] - 1) % SIZE
|
|
155
|
+
elif key in (curses.KEY_DOWN, ord("j")):
|
|
156
|
+
cursor[0] = (cursor[0] + 1) % SIZE
|
|
157
|
+
elif key in (curses.KEY_LEFT, ord("h")):
|
|
158
|
+
cursor[1] = (cursor[1] - 1) % SIZE
|
|
159
|
+
elif key in (curses.KEY_RIGHT, ord("l")):
|
|
160
|
+
cursor[1] = (cursor[1] + 1) % SIZE
|
|
161
|
+
elif key in (curses.KEY_ENTER, 10, 13, ord(" ")):
|
|
162
|
+
if tuple(cursor) in legal:
|
|
163
|
+
board.play(cursor[0], cursor[1], turn)
|
|
164
|
+
turn = opponent(turn)
|
|
165
|
+
else:
|
|
166
|
+
message = "そこには置けません"
|
|
167
|
+
elif key in (ord("q"), ord("Q")):
|
|
168
|
+
return
|
|
169
|
+
else:
|
|
170
|
+
message = "AIが考えています…"
|
|
171
|
+
_draw_board(stdscr, board, tuple(cursor), legal, human_player, ai_level, message)
|
|
172
|
+
move = ai.choose_move(board)
|
|
173
|
+
if move is not None:
|
|
174
|
+
board.play(*move, turn)
|
|
175
|
+
turn = opponent(turn)
|
|
176
|
+
|
|
177
|
+
winner = board.winner()
|
|
178
|
+
if winner is None:
|
|
179
|
+
result = "引き分けです"
|
|
180
|
+
elif winner == human_player:
|
|
181
|
+
result = "あなたの勝ちです!"
|
|
182
|
+
else:
|
|
183
|
+
result = "AIの勝ちです"
|
|
184
|
+
_draw_board(stdscr, board, tuple(cursor), set(), human_player, ai_level, result)
|
|
185
|
+
stdscr.addstr(
|
|
186
|
+
SIZE + 8,
|
|
187
|
+
4,
|
|
188
|
+
"何かキーを押すと終了します",
|
|
189
|
+
curses.color_pair(COLOR_STATUS),
|
|
190
|
+
)
|
|
191
|
+
stdscr.refresh()
|
|
192
|
+
stdscr.getch()
|
|
193
|
+
|
|
194
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cli-othello
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Play Othello (Reversi) in your terminal against a 5-level AI
|
|
5
|
+
Author-email: Lapius7 <20071209ryo@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Lapius7/cli-othello
|
|
8
|
+
Project-URL: Repository, https://github.com/Lapius7/cli-othello
|
|
9
|
+
Requires-Python: >=3.9
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: windows-curses; platform_system == "Windows"
|
|
12
|
+
|
|
13
|
+
# cli-othello
|
|
14
|
+
|
|
15
|
+
ターミナルでオセロ(リバーシ)を楽しめるCLIパッケージです。
|
|
16
|
+
5段階の強さから選べるAIと対局できます。
|
|
17
|
+
|
|
18
|
+
## インストール
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install cli-othello
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 使い方
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
othello
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
起動すると対局前にAIの強さ(Lv.1〜Lv.5)を選ぶ画面が表示されます。
|
|
31
|
+
強さを指定して直接対局を始めることもできます。
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
othello --level 3
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 操作方法
|
|
38
|
+
|
|
39
|
+
| キー | 動作 |
|
|
40
|
+
| --- | --- |
|
|
41
|
+
| ↑ / ↓ / ← / → (または h/j/k/l) | カーソル移動 |
|
|
42
|
+
| Enter / Space | カーソル位置に着手 |
|
|
43
|
+
| q | 終了 |
|
|
44
|
+
|
|
45
|
+
盤面では、自分が置ける場所が緑色の `·` でハイライトされます。
|
|
46
|
+
|
|
47
|
+
## AIの強さ
|
|
48
|
+
|
|
49
|
+
| レベル | 特徴 |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| Lv.1 入門 | ランダム性が高く、ほぼ気の向くままに着手 |
|
|
52
|
+
| Lv.2 初級 | 浅い探索+盤面の位置評価を考慮 |
|
|
53
|
+
| Lv.3 中級 | 角・辺の重要度や着手可能数(機動力)も評価 |
|
|
54
|
+
| Lv.4 上級 | より深く先読みしつつ盤面を評価 |
|
|
55
|
+
| Lv.5 最強 | 深い探索(最大6手先)で最も精度高く着手を選択 |
|
|
56
|
+
|
|
57
|
+
内部的にはミニマックス法とアルファベータ枝刈りを用い、レベルごとに探索の深さと評価関数の精度を変化させています。
|
|
58
|
+
|
|
59
|
+
## 必要環境
|
|
60
|
+
|
|
61
|
+
- Python 3.9以上
|
|
62
|
+
- Windowsの場合は `windows-curses` が自動的に依存関係としてインストールされます
|
|
63
|
+
|
|
64
|
+
## ライセンス
|
|
65
|
+
|
|
66
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/cli_othello/__init__.py
|
|
4
|
+
src/cli_othello/ai.py
|
|
5
|
+
src/cli_othello/board.py
|
|
6
|
+
src/cli_othello/cli.py
|
|
7
|
+
src/cli_othello/ui.py
|
|
8
|
+
src/cli_othello.egg-info/PKG-INFO
|
|
9
|
+
src/cli_othello.egg-info/SOURCES.txt
|
|
10
|
+
src/cli_othello.egg-info/dependency_links.txt
|
|
11
|
+
src/cli_othello.egg-info/entry_points.txt
|
|
12
|
+
src/cli_othello.egg-info/requires.txt
|
|
13
|
+
src/cli_othello.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cli_othello
|