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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """cli-othello: play Othello in your terminal."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,2 @@
1
+ [console_scripts]
2
+ othello = cli_othello.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [:platform_system == "Windows"]
3
+ windows-curses
@@ -0,0 +1 @@
1
+ cli_othello