├── requirements.txt ├── README.md ├── LICENSE ├── gomoku.py ├── .gitignore └── play_on_desktop.py /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | PyQt5 3 | scipy 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gomoku Desktop Debugger 2 | 3 | ## Introduction 4 | 5 | Life is not easy, especially for SUSTech CS juniors taking CS303 (Artificial Intelligence). 6 | 7 | This GUI utility is created to ease the debug process of the Gomoku AI project. You can easily integrate it with your coursework and play interactively with the AI written by yourself. 8 | 9 | ## Features 10 | 11 | - AI vs. Human 12 | - AI vs. AI 13 | - Human vs. Human 14 | - Start from a given composition 15 | 16 | Currently this project is feature-incomplete and probably bug-rich. 17 | 18 | ## Installation 19 | 20 | This program depends on Python 3.5+ and PyQt5. You can run the following commands to download the source code and install dependencies. 21 | 22 | ``` sh 23 | git clone https://github.com/ziqin/Gomoku-Debugger.git 24 | cd Gomoku-Debugger 25 | pip install -r requirements.txt 26 | ``` 27 | 28 | Note that you may need to replace `pip` with `pip3`. 29 | 30 | ## Usage 31 | 32 | 1. Replace the content of `gomoku.py` with your coursework 33 | 2. Run `play_on_desktop.py` 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ziqin WANG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /gomoku.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from typing import Tuple 3 | 4 | 5 | class Color: 6 | BLACK = -1 7 | WHITE = 1 8 | NONE = 0 9 | 10 | 11 | class AI: 12 | np.random.seed(0) 13 | 14 | def __init__(self, chessboard_size, color, time_out): 15 | self.chessboard_size = chessboard_size 16 | self.color = color # you are white or black 17 | self.time_out = time_out # the algorithm's running time must not exceed the time limit 18 | self.candidate_list = [] # append your decision into candidate_list 19 | self.last_chessboard = np.zeros((chessboard_size, chessboard_size)) 20 | 21 | def go(self, chessboard: np.ndarray) -> None: 22 | """ 23 | :param chessboard: current chessboard 24 | :return: None 25 | """ 26 | self.candidate_list.clear() 27 | drop_pos = self.play(chessboard) 28 | assert chessboard[drop_pos] == Color.NONE 29 | self.candidate_list.append(drop_pos) 30 | self.last_chessboard = chessboard 31 | 32 | def play(self, chessboard: np.ndarray) -> Tuple[int,int]: 33 | """ 34 | :param chessboard: the chessboard presented to the AI 35 | :return: the coordinate where the AI will drop 36 | """ 37 | # randomly select an empty position 38 | xs, ys = np.where(chessboard == Color.NONE) 39 | indexes = list(zip(xs, ys)) 40 | return indexes[np.random.randint(len(indexes))] 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | # PyCharm 127 | .idea/ -------------------------------------------------------------------------------- /play_on_desktop.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | import logging 4 | import numpy as np 5 | import sys 6 | from enum import IntEnum 7 | from gomoku import AI 8 | from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal 9 | from PyQt5.QtWidgets import (QApplication, QWidget, QPushButton, QLabel, QMessageBox, 10 | QGridLayout, QLayout, QHBoxLayout, QVBoxLayout) 11 | from typing import Optional, Tuple 12 | from scipy.signal import correlate2d as corr 13 | 14 | 15 | class Color(IntEnum): 16 | BLACK = -1 17 | WHITE = 1 18 | 19 | 20 | class ChessPiece(QPushButton): 21 | num = 0 22 | 23 | def __init__(self, row: int, col: int, size=32): 24 | super().__init__() 25 | self.coordinate = (row, col) 26 | self.color = None 27 | self.setFixedWidth(size) 28 | self.setFixedHeight(size) 29 | 30 | def drop(self, color: Color): 31 | assert self.color is None 32 | assert color in {Color.BLACK, Color.WHITE} 33 | self.color = color 34 | ChessPiece.num += 1 35 | self.setText(str(ChessPiece.num)) 36 | self.setStyleSheet(f'''background-color: {color.name.lower()}; 37 | border-radius: {self.width()/2}px; 38 | border-style: solid; 39 | border-width: 2px; 40 | border-color: gray; 41 | color: {'white' if color == Color.BLACK else 'black'} 42 | ''') 43 | logging.info(f'{color.name} {self.coordinate}') 44 | QCoreApplication.processEvents() # refresh ui immediately 45 | 46 | def clear(self): 47 | self.color = None 48 | self.setStyleSheet('') 49 | 50 | 51 | class ChessBoard(QWidget): 52 | dropped = pyqtSignal(int, int) 53 | 54 | def __init__(self, data: np.ndarray, parent=None): 55 | super(QWidget, self).__init__(parent=parent) 56 | self.data = data 57 | row, col = data.shape 58 | self.pieces = np.empty(data.shape, dtype=object) 59 | layout = QGridLayout() 60 | for c in range(col): 61 | layout.addWidget(QLabel('%3d' % c), 0, c+1) 62 | for r in range(row): 63 | layout.addWidget(QLabel('%3d' % r), r+1, 0) 64 | for c in range(col): 65 | this = self.pieces[r, c] = ChessPiece(r, c) 66 | this.clicked.connect(lambda: self._on_click(self.sender())) 67 | layout.addWidget(this, r+1, c+1) 68 | self.setLayout(layout) 69 | layout.setSizeConstraint(QLayout.SetFixedSize) 70 | 71 | def place(self, coordinate: Tuple[int, int], color: Color): 72 | self.pieces[coordinate].drop(color) 73 | 74 | def _on_click(self, piece: ChessPiece): 75 | if piece.color is None: 76 | self.dropped.emit(*piece.coordinate) 77 | else: 78 | QMessageBox.warning(self, 'Invalid Position', 'This position has been occupied!') 79 | 80 | 81 | class Player(QObject): 82 | dropped = pyqtSignal(int, int) 83 | 84 | def __init__(self, color: Color, chessboard_data: np.ndarray = None): 85 | super().__init__() 86 | self.chessboard = None 87 | self.color = color 88 | self.chessboard = chessboard_data 89 | 90 | def set_chessboard(self, board: ChessBoard): 91 | self.chessboard = board.data 92 | 93 | def play(self, color: Color): 94 | """ 95 | Process if and only if color == self.color 96 | Emit `dropped` signal when finished 97 | """ 98 | raise NotImplementedError 99 | 100 | 101 | class AIPlayer(Player): 102 | def __init__(self, color: Color, ai, chessboard_data: np.ndarray = None): 103 | super().__init__(color, chessboard_data) 104 | self.ai = ai 105 | 106 | def play(self, color: Color): 107 | if color != self.color: 108 | return 109 | self.ai.go(self.chessboard) 110 | self.dropped.emit(*self.ai.candidate_list[-1]) 111 | 112 | 113 | class HumanPlayer(Player): 114 | def __init__(self, color: Color, board: ChessBoard = None, chessboard_data: np.ndarray = None): 115 | super().__init__(color, chessboard_data) 116 | self.board = board 117 | self.choice = None 118 | 119 | def set_chessboard(self, board: ChessBoard): 120 | super().set_chessboard(board) 121 | self.board = board 122 | 123 | def play(self, color: Color): 124 | if color != self.color: 125 | return 126 | self.board.dropped.connect(self.__receive) 127 | self.board.setEnabled(True) 128 | 129 | def __receive(self, row, col): 130 | self.board.setEnabled(False) 131 | self.board.dropped.disconnect(self.__receive) 132 | self.dropped.emit(row, col) 133 | 134 | 135 | class HumanPresetPlayer(Player): 136 | def __init__(self, color: Color, board: ChessBoard = None, chessboard_data: np.ndarray = None): 137 | super().__init__(color, chessboard_data) 138 | self.board = board 139 | self.choice = None 140 | 141 | def set_chessboard(self, board: ChessBoard): 142 | super().set_chessboard(board) 143 | self.board = board 144 | 145 | def play(self, color: Color): 146 | if color != self.color: 147 | return 148 | self.board.dropped.connect(self.__receive) 149 | self.board.setEnabled(True) 150 | 151 | def __receive(self, row, col): 152 | self.board.dropped.disconnect(self.__receive) 153 | self.dropped.emit(row, col) 154 | 155 | 156 | class MainWindow(QWidget): 157 | drop = pyqtSignal(Color) 158 | 159 | def __init__(self, size=15, parent=None): 160 | super().__init__(parent=parent) 161 | self.chessboard_data = np.zeros((size, size), dtype=np.int8) 162 | self.chessboard_panel = ChessBoard(self.chessboard_data) 163 | 164 | self.preset_btn = QPushButton('Preset') 165 | self.start_btn = QPushButton('Start') 166 | self.stop_btn = QPushButton('Stop') 167 | self.preset_btn.clicked.connect(self.preset) 168 | self.start_btn.clicked.connect(self.start) 169 | self.stop_btn.clicked.connect(self.stop) 170 | 171 | self.setWindowTitle('Gomoku Desktop') 172 | self.chessboard_panel.setEnabled(False) 173 | layout = QVBoxLayout() 174 | layout.addWidget(self.chessboard_panel) 175 | buttons_layout = QHBoxLayout() 176 | buttons_layout.addWidget(self.preset_btn) 177 | buttons_layout.addWidget(self.start_btn) 178 | buttons_layout.addWidget(self.stop_btn) 179 | layout.addLayout(buttons_layout) 180 | self.setLayout(layout) 181 | layout.setSizeConstraint(QLayout.SetFixedSize) 182 | 183 | self.preset_players = [] 184 | self.players = {} 185 | self.current_color = Color.BLACK 186 | 187 | self._is_presetting = False 188 | self._is_playing = False 189 | self.preset_btn.setEnabled(True) 190 | self.start_btn.setEnabled(True) 191 | self.stop_btn.setEnabled(False) 192 | 193 | def set_player(self, color: Color, player: Player): 194 | player.set_chessboard(self.chessboard_panel) 195 | self.players[color] = player 196 | 197 | @property 198 | def is_presetting(self): 199 | return self._is_presetting 200 | 201 | @is_presetting.setter 202 | def is_presetting(self, val): 203 | self._is_presetting = val 204 | self.preset_btn.setDisabled(val) 205 | self.start_btn.setEnabled(val) 206 | if val: 207 | for player in self.preset_players: 208 | self.drop.connect(player.play) 209 | player.dropped.connect(self.receive) 210 | logging.debug('Preset connected') 211 | else: 212 | for player in self.preset_players: 213 | self.drop.disconnect(player.play) 214 | player.dropped.disconnect(self.receive) 215 | logging.debug('Preset disconnected') 216 | 217 | @property 218 | def is_playing(self): 219 | return self._is_playing 220 | 221 | @is_playing.setter 222 | def is_playing(self, val): 223 | self._is_playing = val 224 | self.preset_btn.setDisabled(val) 225 | self.start_btn.setDisabled(val) 226 | self.stop_btn.setEnabled(val) 227 | if val: 228 | for player in self.players.values(): 229 | self.drop.connect(player.play) 230 | player.dropped.connect(self.receive) 231 | logging.debug('Play connected') 232 | else: 233 | for player in self.players.values(): 234 | self.drop.disconnect(player.play) 235 | player.dropped.disconnect(self.receive) 236 | logging.debug('Play disconnected') 237 | 238 | def preset(self): 239 | self.preset_players = [ 240 | HumanPresetPlayer(Color.BLACK, self.chessboard_panel, self.chessboard_data), 241 | HumanPresetPlayer(Color.WHITE, self.chessboard_panel, self.chessboard_data) 242 | ] 243 | self.is_presetting = True 244 | self.next(self.is_presetting) 245 | logging.info('Presetting composition') 246 | 247 | def start(self): 248 | if self.is_presetting: 249 | self.is_presetting = False 250 | self.is_playing = True 251 | logging.info('Game starts') 252 | self.judge() 253 | self.next(self.is_playing) 254 | 255 | def receive(self, row: int, col: int): 256 | self.chessboard_panel.place((row, col), self.current_color) 257 | self.chessboard_data[row, col] = self.current_color 258 | if self.is_playing: 259 | self.judge() 260 | self.current_color = Color(-self.current_color) 261 | self.next(self.is_presetting or self.is_playing) 262 | 263 | def stop(self): 264 | logging.info('Game stops') 265 | self.is_playing = False 266 | self.chessboard_panel.setEnabled(False) 267 | 268 | def next(self, ok: bool): 269 | if ok: 270 | self.drop.emit(self.current_color) 271 | 272 | def judge(self): 273 | winner = self._check_winner() 274 | if winner: 275 | QMessageBox.information(self, 'Game Finished', f'Winner: {winner.name}') 276 | self.is_playing = False 277 | elif np.count_nonzero(self.chessboard_data == 0) == 0: # no empty position 278 | QMessageBox.information(self, 'Game Finished', 'Draw') 279 | self.is_playing = False 280 | 281 | def _check_winner(self) -> Optional[Color]: 282 | """:return: color of the winner. `None` if unfinished or for a draw""" 283 | patterns = [ 284 | np.ones(5, dtype=np.int8).reshape(1, 5), 285 | np.ones(5, dtype=np.int8).reshape(5, 1), 286 | np.eye(5, dtype=np.int8), 287 | np.fliplr(np.eye(5, dtype=np.int8)) 288 | ] 289 | black = (self.chessboard_data == Color.BLACK).astype(np.int8) 290 | white = (self.chessboard_data == Color.WHITE).astype(np.int8) 291 | black_win = max([np.max(corr(black, p, mode='same')) for p in patterns]) == 5 292 | white_win = max([np.max(corr(white, p, mode='same')) for p in patterns]) == 5 293 | if black_win == white_win: # draw 294 | return None 295 | elif black_win: 296 | return Color.BLACK 297 | else: # white_win 298 | return Color.WHITE 299 | 300 | 301 | def main(): 302 | logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s %(levelname)s] %(message)s') 303 | app = QApplication(sys.argv) 304 | chessboard_length = 15 305 | win = MainWindow(chessboard_length) 306 | win.set_player(Color.BLACK, HumanPlayer(Color.BLACK)) 307 | win.set_player(Color.WHITE, AIPlayer(Color.WHITE, AI(chessboard_length, Color.WHITE, -1))) 308 | win.show() 309 | sys.exit(app.exec()) 310 | 311 | 312 | if __name__ == '__main__': 313 | main() 314 | --------------------------------------------------------------------------------