cli-othello 0.1.0__tar.gz → 1.0.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-othello
3
- Version: 0.1.0
3
+ Version: 1.0.2
4
4
  Summary: Play Othello (Reversi) in your terminal against a 5-level AI
5
5
  Author-email: Lapius7 <20071209ryo@gmail.com>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cli-othello"
7
- version = "0.1.0"
7
+ version = "1.0.2"
8
8
  description = "Play Othello (Reversi) in your terminal against a 5-level AI"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,3 +1,3 @@
1
1
  """cli-othello: play Othello in your terminal."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "1.0.2"
@@ -0,0 +1,301 @@
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
+ PLAYER_NAMES = {BLACK: "黒", WHITE: "白"}
11
+
12
+ # Each cell is rendered as CELL_W columns x CELL_H rows so it reads as
13
+ # roughly square in a terminal (chars are about twice as tall as wide).
14
+ CELL_W = 5
15
+ CELL_H = 2
16
+
17
+ DISC_BLACK = "●"
18
+ DISC_WHITE = "○"
19
+ HINT_MARK = "+"
20
+
21
+ COLOR_BOARD = 1
22
+ COLOR_BLACK = 2
23
+ COLOR_WHITE = 3
24
+ COLOR_CURSOR_EMPTY = 4
25
+ COLOR_CURSOR_BLACK = 5
26
+ COLOR_CURSOR_WHITE = 6
27
+ COLOR_HINT = 7
28
+ COLOR_HEADER = 8
29
+ COLOR_STATUS = 9
30
+ COLOR_PANEL = 10
31
+ COLOR_GRID = 11
32
+
33
+
34
+ def init_colors() -> None:
35
+ curses.start_color()
36
+ curses.use_default_colors()
37
+ curses.init_pair(COLOR_BOARD, curses.COLOR_GREEN, curses.COLOR_GREEN)
38
+ # Discs fill the whole cell with their own color for maximum visibility.
39
+ curses.init_pair(COLOR_BLACK, curses.COLOR_WHITE, curses.COLOR_BLACK)
40
+ curses.init_pair(COLOR_WHITE, curses.COLOR_BLACK, curses.COLOR_WHITE)
41
+ curses.init_pair(COLOR_CURSOR_EMPTY, curses.COLOR_BLACK, curses.COLOR_YELLOW)
42
+ curses.init_pair(COLOR_CURSOR_BLACK, curses.COLOR_YELLOW, curses.COLOR_BLACK)
43
+ curses.init_pair(COLOR_CURSOR_WHITE, curses.COLOR_YELLOW, curses.COLOR_WHITE)
44
+ curses.init_pair(COLOR_HINT, curses.COLOR_YELLOW, curses.COLOR_GREEN)
45
+ curses.init_pair(COLOR_HEADER, curses.COLOR_CYAN, -1)
46
+ curses.init_pair(COLOR_STATUS, curses.COLOR_YELLOW, -1)
47
+ curses.init_pair(COLOR_PANEL, curses.COLOR_WHITE, -1)
48
+ curses.init_pair(COLOR_GRID, curses.COLOR_GREEN, -1)
49
+
50
+
51
+ def select_level(stdscr: "curses._CursesWindow") -> int:
52
+ """Let the user pick an AI difficulty level (1-5) with arrow keys."""
53
+ level = 3
54
+ curses.curs_set(0)
55
+ required_rows = 5 + MAX_LEVEL + 3
56
+ required_cols = 36
57
+ while True:
58
+ stdscr.erase()
59
+ max_y, max_x = stdscr.getmaxyx()
60
+ if max_y < required_rows or max_x < required_cols:
61
+ _draw_too_small(stdscr)
62
+ continue
63
+ stdscr.addstr(1, 2, "♟ CLI OTHELLO", curses.color_pair(COLOR_HEADER) | curses.A_BOLD)
64
+ stdscr.addstr(3, 2, "対戦するAIの強さを選んでください", curses.A_BOLD)
65
+ for i in range(MIN_LEVEL, MAX_LEVEL + 1):
66
+ label = LEVEL_LABELS[i]
67
+ if i == level:
68
+ stdscr.addstr(5 + i, 2, "> ", curses.A_BOLD)
69
+ stdscr.addstr(5 + i, 4, label, curses.A_REVERSE | curses.A_BOLD)
70
+ else:
71
+ stdscr.addstr(5 + i, 4, label, curses.A_NORMAL)
72
+ stdscr.addstr(
73
+ 5 + MAX_LEVEL + 2,
74
+ 2,
75
+ "↑/↓ で選択 Enter で決定 q で終了",
76
+ curses.color_pair(COLOR_STATUS),
77
+ )
78
+ stdscr.refresh()
79
+
80
+ key = stdscr.getch()
81
+ if key in (curses.KEY_UP, ord("k")):
82
+ level = MIN_LEVEL if level <= MIN_LEVEL else level - 1
83
+ elif key in (curses.KEY_DOWN, ord("j")):
84
+ level = MAX_LEVEL if level >= MAX_LEVEL else level + 1
85
+ elif key in (curses.KEY_ENTER, 10, 13):
86
+ return level
87
+ elif key in (ord("q"), ord("Q")):
88
+ raise SystemExit(0)
89
+
90
+
91
+ def _cell_fill_attr(cell: int, is_cursor: bool) -> int:
92
+ """Background attribute that fills the whole cell (all CELL_H rows)."""
93
+ if cell == BLACK:
94
+ return curses.color_pair(COLOR_CURSOR_BLACK if is_cursor else COLOR_BLACK) | curses.A_BOLD
95
+ if cell == WHITE:
96
+ return curses.color_pair(COLOR_CURSOR_WHITE if is_cursor else COLOR_WHITE) | curses.A_BOLD
97
+ if is_cursor:
98
+ return curses.color_pair(COLOR_CURSOR_EMPTY) | curses.A_BOLD
99
+ return curses.color_pair(COLOR_BOARD)
100
+
101
+
102
+ def _cell_center_attr(cell: int, is_cursor: bool, is_hint: bool) -> tuple[int, str]:
103
+ """Return (attribute, character) for the center row of a cell."""
104
+ fill = _cell_fill_attr(cell, is_cursor)
105
+ if cell == BLACK:
106
+ return fill, DISC_BLACK
107
+ if cell == WHITE:
108
+ return fill, DISC_WHITE
109
+ if is_hint:
110
+ attr = curses.color_pair(COLOR_CURSOR_EMPTY if is_cursor else COLOR_HINT) | curses.A_BOLD
111
+ return attr, HINT_MARK
112
+ return fill, " "
113
+
114
+
115
+ def _required_size() -> tuple[int, int]:
116
+ """Return (rows, cols) needed to draw the board and panel, plus a
117
+ 1-line/1-column margin (curses can refuse writes to the very last
118
+ row/column of the screen)."""
119
+ rows = 3 + 1 + SIZE * CELL_H + 6 + 1
120
+ cols = 4 + 3 + SIZE * CELL_W + 1
121
+ return rows, cols
122
+
123
+
124
+ def _draw_too_small(stdscr: "curses._CursesWindow") -> None:
125
+ rows, cols = _required_size()
126
+ stdscr.erase()
127
+ try:
128
+ stdscr.addstr(0, 0, "ターミナルが小さすぎます。")
129
+ stdscr.addstr(1, 0, f"必要サイズ: {cols}列 x {rows}行 以上")
130
+ stdscr.addstr(2, 0, "ウィンドウを大きくしてください。何かキーを押すと終了します。")
131
+ except curses.error:
132
+ pass
133
+ stdscr.refresh()
134
+ stdscr.getch()
135
+
136
+
137
+ def _draw_board(
138
+ stdscr: "curses._CursesWindow",
139
+ board: Board,
140
+ cursor: tuple[int, int],
141
+ legal: set[tuple[int, int]],
142
+ human_player: int,
143
+ ai_level: int,
144
+ message: str,
145
+ top: int = 0,
146
+ ) -> bool:
147
+ """Draw the board. Returns False (and shows a hint) if the terminal is
148
+ too small to fit it."""
149
+ required_rows, required_cols = _required_size()
150
+ max_y, max_x = stdscr.getmaxyx()
151
+ if max_y < required_rows or max_x < required_cols:
152
+ _draw_too_small(stdscr)
153
+ return False
154
+
155
+ stdscr.erase()
156
+ stdscr.addstr(top, 2, "♟ CLI OTHELLO", curses.color_pair(COLOR_HEADER) | curses.A_BOLD)
157
+ stdscr.addstr(
158
+ top + 1,
159
+ 2,
160
+ f"AI: {LEVEL_LABELS[ai_level]} あなた: {DISC_BLACK if human_player == BLACK else DISC_WHITE} {PLAYER_NAMES[human_player]}",
161
+ curses.color_pair(COLOR_PANEL),
162
+ )
163
+
164
+ board_top = top + 3
165
+ board_left = 4
166
+ grid_attr = curses.color_pair(COLOR_GRID)
167
+
168
+ # Column labels (A-H), centered above each cell.
169
+ header = " " * 3
170
+ for col in range(SIZE):
171
+ header += "ABCDEFGH"[col].center(CELL_W)
172
+ stdscr.addstr(board_top, board_left, header, curses.A_BOLD)
173
+
174
+ board_width = 3 + SIZE * CELL_W
175
+ for row in range(SIZE):
176
+ for line in range(CELL_H):
177
+ y = board_top + 1 + row * CELL_H + line
178
+ if line == CELL_H // 2:
179
+ row_label = f"{row + 1:>2} "
180
+ else:
181
+ row_label = " "
182
+ stdscr.addstr(y, board_left, row_label, curses.A_BOLD)
183
+ for col in range(SIZE):
184
+ cell = board.get(row, col)
185
+ is_cursor = (row, col) == cursor
186
+ is_hint = cell == EMPTY and (row, col) in legal
187
+ x = board_left + 3 + col * CELL_W
188
+
189
+ if line == CELL_H // 2:
190
+ attr, ch = _cell_center_attr(cell, is_cursor, is_hint)
191
+ text = ch.center(CELL_W)
192
+ else:
193
+ attr = _cell_fill_attr(cell, is_cursor)
194
+ text = " " * CELL_W
195
+ stdscr.addstr(y, x, text, attr)
196
+
197
+ board_bottom = board_top + 1 + SIZE * CELL_H
198
+ panel_left = board_left
199
+ panel_width = board_width
200
+
201
+ you_mark = DISC_BLACK if human_player == BLACK else DISC_WHITE
202
+ ai_mark = DISC_BLACK if human_player == WHITE else DISC_WHITE
203
+
204
+ stdscr.addstr(board_bottom + 1, panel_left, "─" * panel_width, grid_attr)
205
+ stdscr.addstr(
206
+ board_bottom + 2,
207
+ panel_left,
208
+ f"{DISC_BLACK} 黒 {board.count(BLACK):>2} {DISC_WHITE} 白 {board.count(WHITE):>2}"
209
+ f" あなた:{you_mark} AI:{ai_mark}",
210
+ curses.A_BOLD,
211
+ )
212
+ stdscr.addstr(board_bottom + 3, panel_left, "─" * panel_width, grid_attr)
213
+ stdscr.addstr(
214
+ board_bottom + 4, panel_left, message, curses.color_pair(COLOR_STATUS) | curses.A_BOLD
215
+ )
216
+ stdscr.addstr(
217
+ board_bottom + 5,
218
+ panel_left,
219
+ "矢印キー/hjkl: 移動 Enter/Space: 着手 q: 終了",
220
+ curses.color_pair(COLOR_STATUS),
221
+ )
222
+ stdscr.refresh()
223
+ return True
224
+
225
+
226
+ def play_game(stdscr: "curses._CursesWindow", human_player: int, ai_level: int) -> None:
227
+ curses.curs_set(0)
228
+ board = Board()
229
+ ai_player = opponent(human_player)
230
+ ai = OthelloAI(ai_player, ai_level)
231
+ cursor = [3, 3]
232
+ turn = BLACK
233
+ message = ""
234
+
235
+ while True:
236
+ if board.is_game_over():
237
+ break
238
+
239
+ legal = set(board.legal_moves(turn))
240
+ if not legal:
241
+ message = f"{PLAYER_NAMES[turn]}はパスしました"
242
+ turn = opponent(turn)
243
+ continue
244
+
245
+ if turn == human_player:
246
+ message = "あなたの番です"
247
+ if not _draw_board(
248
+ stdscr, board, tuple(cursor), legal, human_player, ai_level, message
249
+ ):
250
+ continue
251
+ key = stdscr.getch()
252
+ if key in (curses.KEY_UP, ord("k")):
253
+ cursor[0] = (cursor[0] - 1) % SIZE
254
+ elif key in (curses.KEY_DOWN, ord("j")):
255
+ cursor[0] = (cursor[0] + 1) % SIZE
256
+ elif key in (curses.KEY_LEFT, ord("h")):
257
+ cursor[1] = (cursor[1] - 1) % SIZE
258
+ elif key in (curses.KEY_RIGHT, ord("l")):
259
+ cursor[1] = (cursor[1] + 1) % SIZE
260
+ elif key in (curses.KEY_ENTER, 10, 13, ord(" ")):
261
+ if tuple(cursor) in legal:
262
+ board.play(cursor[0], cursor[1], turn)
263
+ turn = opponent(turn)
264
+ else:
265
+ message = "そこには置けません"
266
+ elif key in (ord("q"), ord("Q")):
267
+ return
268
+ else:
269
+ message = "AIが考えています…"
270
+ _draw_board(stdscr, board, tuple(cursor), legal, human_player, ai_level, message)
271
+ # If the terminal is too small, _draw_too_small() already
272
+ # blocked on a keypress above; proceed with the AI move
273
+ # regardless so the game keeps progressing once resized.
274
+ move = ai.choose_move(board)
275
+ if move is not None:
276
+ board.play(*move, turn)
277
+ turn = opponent(turn)
278
+
279
+ winner = board.winner()
280
+ if winner is None:
281
+ result = "引き分けです"
282
+ elif winner == human_player:
283
+ result = "あなたの勝ちです!"
284
+ else:
285
+ result = "AIの勝ちです"
286
+
287
+ while not _draw_board(
288
+ stdscr, board, tuple(cursor), set(), human_player, ai_level, result
289
+ ):
290
+ pass
291
+
292
+ max_y, max_x = stdscr.getmaxyx()
293
+ footer_y = min(SIZE + 8, max_y - 1)
294
+ try:
295
+ stdscr.addstr(footer_y, 4, "何かキーを押すと終了します", curses.color_pair(COLOR_STATUS))
296
+ except curses.error:
297
+ pass
298
+ stdscr.refresh()
299
+ stdscr.getch()
300
+
301
+
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-othello
3
- Version: 0.1.0
3
+ Version: 1.0.2
4
4
  Summary: Play Othello (Reversi) in your terminal against a 5-level AI
5
5
  Author-email: Lapius7 <20071209ryo@gmail.com>
6
6
  License: MIT
@@ -1,194 +0,0 @@
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
-
File without changes
File without changes