llmchess 1.0.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.
- llmchess-1.0.0/LICENSE +21 -0
- llmchess-1.0.0/PKG-INFO +130 -0
- llmchess-1.0.0/README.md +100 -0
- llmchess-1.0.0/chess_app/__init__.py +58 -0
- llmchess-1.0.0/chess_app/__main__.py +4 -0
- llmchess-1.0.0/chess_app/board_widget.py +385 -0
- llmchess-1.0.0/chess_app/game_controller.py +345 -0
- llmchess-1.0.0/chess_app/llm_connector.py +178 -0
- llmchess-1.0.0/chess_app/settings_dialog.py +154 -0
- llmchess-1.0.0/llmchess.egg-info/PKG-INFO +130 -0
- llmchess-1.0.0/llmchess.egg-info/SOURCES.txt +15 -0
- llmchess-1.0.0/llmchess.egg-info/dependency_links.txt +1 -0
- llmchess-1.0.0/llmchess.egg-info/entry_points.txt +2 -0
- llmchess-1.0.0/llmchess.egg-info/requires.txt +3 -0
- llmchess-1.0.0/llmchess.egg-info/top_level.txt +1 -0
- llmchess-1.0.0/pyproject.toml +49 -0
- llmchess-1.0.0/setup.cfg +4 -0
llmchess-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 oemoem12
|
|
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.
|
llmchess-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmchess
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Play international chess against AI opponents powered by local LLMs
|
|
5
|
+
Author: oemoem12
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/oemoem12/LLMChess
|
|
8
|
+
Project-URL: Repository, https://github.com/oemoem12/LLMChess
|
|
9
|
+
Project-URL: Issues, https://github.com/oemoem12/LLMChess/issues
|
|
10
|
+
Keywords: chess,ai,llm,ollama,llama.cpp,lmstudio,pyqt6,board-game
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: X11 Applications :: Qt
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Games/Entertainment :: Board Games
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: PyQt6>=6.5.0
|
|
27
|
+
Requires-Dist: python-chess>=1.10.0
|
|
28
|
+
Requires-Dist: httpx>=0.25.0
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# LLM Chess
|
|
32
|
+
|
|
33
|
+
Play international chess against AI opponents powered by local large language models (LLMs).
|
|
34
|
+
|
|
35
|
+
## Features
|
|
36
|
+
|
|
37
|
+
- **Interactive chess board** with move highlighting and last-move indicators
|
|
38
|
+
- **Pawn promotion** dialog
|
|
39
|
+
- **Undo moves** support
|
|
40
|
+
- **FEN display** and SAN move history
|
|
41
|
+
- **Dark theme** Catppuccin-style UI
|
|
42
|
+
- **Game status** detection (checkmate, stalemate, draw, etc.)
|
|
43
|
+
|
|
44
|
+
## Supported LLM Backends
|
|
45
|
+
|
|
46
|
+
Connects to any OpenAI-compatible API endpoint:
|
|
47
|
+
|
|
48
|
+
| Backend | Default URL | Notes |
|
|
49
|
+
|---------|-------------|-------|
|
|
50
|
+
| **Ollama** | `http://localhost:11434` | `ollama serve` |
|
|
51
|
+
| **llama.cpp Server** | `http://localhost:8080` | `./llama-server -m model.gguf --port 8080` |
|
|
52
|
+
| **LM Studio** | `http://localhost:1234` | Enable "Local Server" in Developer tab |
|
|
53
|
+
| **Custom** | Any URL | Configure in Settings |
|
|
54
|
+
|
|
55
|
+
## Installation
|
|
56
|
+
|
|
57
|
+
### From DEB package (Ubuntu/Debian)
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
sudo dpkg -i llmchess_1.0.0_all.deb
|
|
61
|
+
llmchess
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### From source
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# Install dependencies
|
|
68
|
+
pip install PyQt6 python-chess httpx
|
|
69
|
+
|
|
70
|
+
# Run
|
|
71
|
+
python3 main.py
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Build DEB package
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
bash build_deb.sh
|
|
78
|
+
sudo dpkg -i llmchess_1.0.0_all.deb
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
1. **Start your LLM server** (Ollama, llama.cpp, or LM Studio)
|
|
84
|
+
2. **Launch LLM Chess** (`llmchess` or from application menu)
|
|
85
|
+
3. Click **Settings** to configure your LLM connection
|
|
86
|
+
4. Select **White** (you move first) or **Black** (AI moves first)
|
|
87
|
+
5. Click a piece, then click the destination square to move
|
|
88
|
+
|
|
89
|
+
### Settings
|
|
90
|
+
|
|
91
|
+
- **Provider**: Select your backend (Ollama, llama.cpp, LM Studio, or Custom)
|
|
92
|
+
- **Base URL**: API endpoint (auto-filled for presets)
|
|
93
|
+
- **Model**: Model name to use
|
|
94
|
+
- **Temperature**: Lower = more deterministic moves (default 0.3)
|
|
95
|
+
- **Max Tokens**: Maximum response length (default 512)
|
|
96
|
+
- **Test Connection**: Verify connectivity before playing
|
|
97
|
+
|
|
98
|
+
## Project Structure
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
LLMChess/
|
|
102
|
+
├── main.py # Application entry point
|
|
103
|
+
├── requirements.txt # Python dependencies
|
|
104
|
+
├── build_deb.sh # DEB package build script
|
|
105
|
+
├── chess_app/
|
|
106
|
+
│ ├── board_widget.py # Chess board UI (PyQt6)
|
|
107
|
+
│ ├── llm_connector.py # LLM API connector
|
|
108
|
+
│ ├── game_controller.py # Main window & game controller
|
|
109
|
+
│ └── settings_dialog.py # LLM connection settings
|
|
110
|
+
└── deb_pkg/ # DEB packaging files
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## How It Works
|
|
114
|
+
|
|
115
|
+
1. Each turn, the current board state is sent to the LLM as a **FEN string** plus a list of legal moves in **UCI format**
|
|
116
|
+
2. The LLM is prompted to respond with **only one UCI move** (e.g., `e2e4`, `g1f3`)
|
|
117
|
+
3. The response is parsed to extract the UCI move and validated against legal moves
|
|
118
|
+
4. If the LLM returns an invalid or empty response, the first legal move is used as fallback
|
|
119
|
+
|
|
120
|
+
## Requirements
|
|
121
|
+
|
|
122
|
+
- Python 3.9+
|
|
123
|
+
- PyQt6
|
|
124
|
+
- python-chess
|
|
125
|
+
- httpx
|
|
126
|
+
- A running LLM server (Ollama, llama.cpp, or LM Studio)
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT License
|
llmchess-1.0.0/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# LLM Chess
|
|
2
|
+
|
|
3
|
+
Play international chess against AI opponents powered by local large language models (LLMs).
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Interactive chess board** with move highlighting and last-move indicators
|
|
8
|
+
- **Pawn promotion** dialog
|
|
9
|
+
- **Undo moves** support
|
|
10
|
+
- **FEN display** and SAN move history
|
|
11
|
+
- **Dark theme** Catppuccin-style UI
|
|
12
|
+
- **Game status** detection (checkmate, stalemate, draw, etc.)
|
|
13
|
+
|
|
14
|
+
## Supported LLM Backends
|
|
15
|
+
|
|
16
|
+
Connects to any OpenAI-compatible API endpoint:
|
|
17
|
+
|
|
18
|
+
| Backend | Default URL | Notes |
|
|
19
|
+
|---------|-------------|-------|
|
|
20
|
+
| **Ollama** | `http://localhost:11434` | `ollama serve` |
|
|
21
|
+
| **llama.cpp Server** | `http://localhost:8080` | `./llama-server -m model.gguf --port 8080` |
|
|
22
|
+
| **LM Studio** | `http://localhost:1234` | Enable "Local Server" in Developer tab |
|
|
23
|
+
| **Custom** | Any URL | Configure in Settings |
|
|
24
|
+
|
|
25
|
+
## Installation
|
|
26
|
+
|
|
27
|
+
### From DEB package (Ubuntu/Debian)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
sudo dpkg -i llmchess_1.0.0_all.deb
|
|
31
|
+
llmchess
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### From source
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Install dependencies
|
|
38
|
+
pip install PyQt6 python-chess httpx
|
|
39
|
+
|
|
40
|
+
# Run
|
|
41
|
+
python3 main.py
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Build DEB package
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
bash build_deb.sh
|
|
48
|
+
sudo dpkg -i llmchess_1.0.0_all.deb
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
1. **Start your LLM server** (Ollama, llama.cpp, or LM Studio)
|
|
54
|
+
2. **Launch LLM Chess** (`llmchess` or from application menu)
|
|
55
|
+
3. Click **Settings** to configure your LLM connection
|
|
56
|
+
4. Select **White** (you move first) or **Black** (AI moves first)
|
|
57
|
+
5. Click a piece, then click the destination square to move
|
|
58
|
+
|
|
59
|
+
### Settings
|
|
60
|
+
|
|
61
|
+
- **Provider**: Select your backend (Ollama, llama.cpp, LM Studio, or Custom)
|
|
62
|
+
- **Base URL**: API endpoint (auto-filled for presets)
|
|
63
|
+
- **Model**: Model name to use
|
|
64
|
+
- **Temperature**: Lower = more deterministic moves (default 0.3)
|
|
65
|
+
- **Max Tokens**: Maximum response length (default 512)
|
|
66
|
+
- **Test Connection**: Verify connectivity before playing
|
|
67
|
+
|
|
68
|
+
## Project Structure
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
LLMChess/
|
|
72
|
+
├── main.py # Application entry point
|
|
73
|
+
├── requirements.txt # Python dependencies
|
|
74
|
+
├── build_deb.sh # DEB package build script
|
|
75
|
+
├── chess_app/
|
|
76
|
+
│ ├── board_widget.py # Chess board UI (PyQt6)
|
|
77
|
+
│ ├── llm_connector.py # LLM API connector
|
|
78
|
+
│ ├── game_controller.py # Main window & game controller
|
|
79
|
+
│ └── settings_dialog.py # LLM connection settings
|
|
80
|
+
└── deb_pkg/ # DEB packaging files
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## How It Works
|
|
84
|
+
|
|
85
|
+
1. Each turn, the current board state is sent to the LLM as a **FEN string** plus a list of legal moves in **UCI format**
|
|
86
|
+
2. The LLM is prompted to respond with **only one UCI move** (e.g., `e2e4`, `g1f3`)
|
|
87
|
+
3. The response is parsed to extract the UCI move and validated against legal moves
|
|
88
|
+
4. If the LLM returns an invalid or empty response, the first legal move is used as fallback
|
|
89
|
+
|
|
90
|
+
## Requirements
|
|
91
|
+
|
|
92
|
+
- Python 3.9+
|
|
93
|
+
- PyQt6
|
|
94
|
+
- python-chess
|
|
95
|
+
- httpx
|
|
96
|
+
- A running LLM server (Ollama, llama.cpp, or LM Studio)
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT License
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
from PyQt6.QtWidgets import QApplication
|
|
9
|
+
|
|
10
|
+
os.environ["QT_QPA_PLATFORM"] = "xcb"
|
|
11
|
+
|
|
12
|
+
from chess_app.game_controller import MainWindow
|
|
13
|
+
|
|
14
|
+
app = QApplication(sys.argv)
|
|
15
|
+
app.setApplicationName("LLM Chess")
|
|
16
|
+
app.setOrganizationName("LLMChess")
|
|
17
|
+
app.setStyle("Fusion")
|
|
18
|
+
|
|
19
|
+
app.setStyleSheet("""
|
|
20
|
+
QMainWindow { background-color: #1E1E2E; }
|
|
21
|
+
QGroupBox {
|
|
22
|
+
color: #CDD6F4; font-weight: bold;
|
|
23
|
+
border: 1px solid #45475A; border-radius: 6px;
|
|
24
|
+
margin-top: 12px; padding-top: 14px;
|
|
25
|
+
}
|
|
26
|
+
QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px; }
|
|
27
|
+
QPushButton {
|
|
28
|
+
background-color: #585B70; color: #CDD6F4;
|
|
29
|
+
border: 1px solid #585B70; border-radius: 4px;
|
|
30
|
+
padding: 6px 16px; font-weight: bold;
|
|
31
|
+
}
|
|
32
|
+
QPushButton:hover { background-color: #6C7086; }
|
|
33
|
+
QPushButton:pressed { background-color: #45475A; }
|
|
34
|
+
QPushButton:disabled { background-color: #313244; color: #6C7086; }
|
|
35
|
+
QComboBox {
|
|
36
|
+
background-color: #313244; color: #CDD6F4;
|
|
37
|
+
border: 1px solid #45475A; border-radius: 4px; padding: 4px 8px;
|
|
38
|
+
}
|
|
39
|
+
QComboBox::drop-down { border: none; }
|
|
40
|
+
QComboBox QAbstractItemView {
|
|
41
|
+
background-color: #313244; color: #CDD6F4;
|
|
42
|
+
selection-background-color: #585B70;
|
|
43
|
+
}
|
|
44
|
+
QLabel { color: #CDD6F4; }
|
|
45
|
+
QTextEdit {
|
|
46
|
+
background-color: #313244; color: #CDD6F4;
|
|
47
|
+
border: 1px solid #45475A; border-radius: 4px;
|
|
48
|
+
}
|
|
49
|
+
QLineEdit, QSpinBox, QDoubleSpinBox {
|
|
50
|
+
background-color: #313244; color: #CDD6F4;
|
|
51
|
+
border: 1px solid #45475A; border-radius: 4px; padding: 4px;
|
|
52
|
+
}
|
|
53
|
+
QDialog { background-color: #1E1E2E; }
|
|
54
|
+
""")
|
|
55
|
+
|
|
56
|
+
window = MainWindow()
|
|
57
|
+
window.show()
|
|
58
|
+
sys.exit(app.exec())
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import chess
|
|
2
|
+
from PyQt6.QtWidgets import QWidget, QDialog, QVBoxLayout, QHBoxLayout, QPushButton
|
|
3
|
+
from PyQt6.QtCore import Qt, pyqtSignal, QRect, QSize
|
|
4
|
+
from PyQt6.QtGui import QPainter, QColor, QFont, QMouseEvent, QPen, QBrush
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
PIECE_UNICODE = {
|
|
8
|
+
chess.KING: ("\u2654", "\u265a"),
|
|
9
|
+
chess.QUEEN: ("\u2655", "\u265b"),
|
|
10
|
+
chess.ROOK: ("\u2656", "\u265c"),
|
|
11
|
+
chess.BISHOP: ("\u2657", "\u265d"),
|
|
12
|
+
chess.KNIGHT: ("\u2658", "\u265e"),
|
|
13
|
+
chess.PAWN: ("\u2659", "\u265f"),
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
LIGHT_SQUARE = QColor("#F0D9B5")
|
|
17
|
+
DARK_SQUARE = QColor("#B58863")
|
|
18
|
+
SELECTED_COLOR = QColor("#829769")
|
|
19
|
+
LEGAL_MOVE_COLOR = QColor("#82976955")
|
|
20
|
+
LEGAL_MOVE_DOT_COLOR = QColor("#829769aa")
|
|
21
|
+
LAST_MOVE_COLOR = QColor("#CDD26A")
|
|
22
|
+
CHECK_COLOR = QColor("#FF6B6B")
|
|
23
|
+
BORDER_COLOR = QColor("#4A4A4A")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PromotionDialog(QDialog):
|
|
27
|
+
def __init__(self, color: chess.Color, parent=None):
|
|
28
|
+
super().__init__(parent)
|
|
29
|
+
self.setWindowTitle("Promote Pawn")
|
|
30
|
+
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
|
|
31
|
+
self.setModal(True)
|
|
32
|
+
self.piece_type = chess.QUEEN
|
|
33
|
+
|
|
34
|
+
layout = QHBoxLayout(self)
|
|
35
|
+
layout.setSpacing(4)
|
|
36
|
+
layout.setContentsMargins(4, 4, 4, 4)
|
|
37
|
+
|
|
38
|
+
pieces = [
|
|
39
|
+
(chess.QUEEN, "Q"),
|
|
40
|
+
(chess.ROOK, "R"),
|
|
41
|
+
(chess.BISHOP, "B"),
|
|
42
|
+
(chess.KNIGHT, "N"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
for pt, label in pieces:
|
|
46
|
+
piece_char = PIECE_UNICODE[pt][0 if color == chess.WHITE else 1]
|
|
47
|
+
btn = QPushButton(piece_char)
|
|
48
|
+
btn.setFont(QFont("Segoe UI Symbol", 28))
|
|
49
|
+
btn.setFixedSize(52, 52)
|
|
50
|
+
btn.clicked.connect(lambda checked, p=pt: self._select(p))
|
|
51
|
+
layout.addWidget(btn)
|
|
52
|
+
|
|
53
|
+
def _select(self, piece_type: chess.PieceType):
|
|
54
|
+
self.piece_type = piece_type
|
|
55
|
+
self.accept()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ChessBoardWidget(QWidget):
|
|
59
|
+
move_made = pyqtSignal(str)
|
|
60
|
+
|
|
61
|
+
def __init__(self, parent=None):
|
|
62
|
+
super().__init__(parent)
|
|
63
|
+
self.board = chess.Board()
|
|
64
|
+
self._flipped = False
|
|
65
|
+
self._selected_square: int | None = None
|
|
66
|
+
self._legal_moves: list[chess.Move] = []
|
|
67
|
+
self._last_move: chess.Move | None = None
|
|
68
|
+
self._player_color = chess.WHITE
|
|
69
|
+
self._human_turn = True
|
|
70
|
+
|
|
71
|
+
self.setMinimumSize(480, 480)
|
|
72
|
+
self.setMouseTracking(False)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def square_size(self) -> int:
|
|
76
|
+
return min(self.width(), self.height()) // 8
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def board_offset_x(self) -> int:
|
|
80
|
+
return (self.width() - self.square_size * 8) // 2
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def board_offset_y(self) -> int:
|
|
84
|
+
return (self.height() - self.square_size * 8) // 2
|
|
85
|
+
|
|
86
|
+
def set_player_color(self, color: chess.Color):
|
|
87
|
+
self._player_color = color
|
|
88
|
+
self._flipped = (color == chess.BLACK)
|
|
89
|
+
self._human_turn = (self.board.turn == self._player_color)
|
|
90
|
+
self.update()
|
|
91
|
+
|
|
92
|
+
def set_position(self, fen: str):
|
|
93
|
+
self.board = chess.Board(fen)
|
|
94
|
+
self._selected_square = None
|
|
95
|
+
self._legal_moves = []
|
|
96
|
+
self._last_move = None
|
|
97
|
+
self._human_turn = (self.board.turn == self._player_color)
|
|
98
|
+
self.update()
|
|
99
|
+
|
|
100
|
+
def apply_move(self, move: chess.Move):
|
|
101
|
+
self._last_move = move
|
|
102
|
+
self.board.push(move)
|
|
103
|
+
self._selected_square = None
|
|
104
|
+
self._legal_moves = []
|
|
105
|
+
self._human_turn = (self.board.turn == self._player_color)
|
|
106
|
+
self.update()
|
|
107
|
+
|
|
108
|
+
def get_legal_moves_uci(self) -> list[str]:
|
|
109
|
+
return [move.uci() for move in self.board.legal_moves]
|
|
110
|
+
|
|
111
|
+
def paintEvent(self, event):
|
|
112
|
+
painter = QPainter(self)
|
|
113
|
+
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
114
|
+
|
|
115
|
+
sq_size = self.square_size
|
|
116
|
+
ox = self.board_offset_x
|
|
117
|
+
oy = self.board_offset_y
|
|
118
|
+
|
|
119
|
+
for row in range(8):
|
|
120
|
+
for col in range(8):
|
|
121
|
+
x = ox + col * sq_size
|
|
122
|
+
y = oy + row * sq_size
|
|
123
|
+
|
|
124
|
+
display_row = 7 - row if not self._flipped else row
|
|
125
|
+
display_col = col if not self._flipped else 7 - col
|
|
126
|
+
|
|
127
|
+
is_light = (display_row + display_col) % 2 == 0
|
|
128
|
+
color = LIGHT_SQUARE if is_light else DARK_SQUARE
|
|
129
|
+
|
|
130
|
+
painter.fillRect(QRect(x, y, sq_size, sq_size), color)
|
|
131
|
+
|
|
132
|
+
if self._selected_square is not None:
|
|
133
|
+
sel_col = chess.square_file(self._selected_square)
|
|
134
|
+
sel_row = chess.square_rank(self._selected_square)
|
|
135
|
+
|
|
136
|
+
if self._flipped:
|
|
137
|
+
sel_col = 7 - sel_col
|
|
138
|
+
else:
|
|
139
|
+
sel_row = 7 - sel_row
|
|
140
|
+
|
|
141
|
+
sx = ox + sel_col * sq_size
|
|
142
|
+
sy = oy + sel_row * sq_size
|
|
143
|
+
|
|
144
|
+
painter.fillRect(QRect(sx, sy, sq_size, sq_size), SELECTED_COLOR)
|
|
145
|
+
|
|
146
|
+
for move in self._legal_moves:
|
|
147
|
+
if move.from_square != self._selected_square:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
to_sq = move.to_square
|
|
151
|
+
to_col = chess.square_file(to_sq)
|
|
152
|
+
to_row = chess.square_rank(to_sq)
|
|
153
|
+
|
|
154
|
+
if self._flipped:
|
|
155
|
+
to_col = 7 - to_col
|
|
156
|
+
else:
|
|
157
|
+
to_row = 7 - to_row
|
|
158
|
+
|
|
159
|
+
tx = ox + to_col * sq_size
|
|
160
|
+
ty = oy + to_row * sq_size
|
|
161
|
+
|
|
162
|
+
target_piece = self.board.piece_at(to_sq)
|
|
163
|
+
if target_piece is not None:
|
|
164
|
+
painter.setPen(QPen(LEGAL_MOVE_COLOR, 3))
|
|
165
|
+
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
166
|
+
painter.drawEllipse(QRect(tx + 2, ty + 2, sq_size - 4, sq_size - 4))
|
|
167
|
+
else:
|
|
168
|
+
dot_size = sq_size // 4
|
|
169
|
+
dx = tx + (sq_size - dot_size) // 2
|
|
170
|
+
dy = ty + (sq_size - dot_size) // 2
|
|
171
|
+
painter.setPen(Qt.PenStyle.NoPen)
|
|
172
|
+
painter.setBrush(QBrush(LEGAL_MOVE_DOT_COLOR))
|
|
173
|
+
painter.drawEllipse(QRect(dx, dy, dot_size, dot_size))
|
|
174
|
+
|
|
175
|
+
if self._last_move is not None:
|
|
176
|
+
for sq in (self._last_move.from_square, self._last_move.to_square):
|
|
177
|
+
col = chess.square_file(sq)
|
|
178
|
+
row = chess.square_rank(sq)
|
|
179
|
+
|
|
180
|
+
if self._flipped:
|
|
181
|
+
col = 7 - col
|
|
182
|
+
else:
|
|
183
|
+
row = 7 - row
|
|
184
|
+
|
|
185
|
+
lx = ox + col * sq_size
|
|
186
|
+
ly = oy + row * sq_size
|
|
187
|
+
|
|
188
|
+
painter.fillRect(QRect(lx, ly, sq_size, sq_size), LAST_MOVE_COLOR)
|
|
189
|
+
|
|
190
|
+
king_sq = self.board.king(self.board.turn)
|
|
191
|
+
if king_sq is not None and self.board.is_check():
|
|
192
|
+
col = chess.square_file(king_sq)
|
|
193
|
+
row = chess.square_rank(king_sq)
|
|
194
|
+
|
|
195
|
+
if self._flipped:
|
|
196
|
+
col = 7 - col
|
|
197
|
+
else:
|
|
198
|
+
row = 7 - row
|
|
199
|
+
|
|
200
|
+
kx = ox + col * sq_size
|
|
201
|
+
ky = oy + row * sq_size
|
|
202
|
+
|
|
203
|
+
painter.fillRect(QRect(kx, ky, sq_size, sq_size), CHECK_COLOR)
|
|
204
|
+
|
|
205
|
+
piece_font = QFont("Segoe UI Symbol", int(sq_size * 0.72))
|
|
206
|
+
painter.setFont(piece_font)
|
|
207
|
+
painter.setPen(QColor("#302E2B"))
|
|
208
|
+
|
|
209
|
+
for row in range(8):
|
|
210
|
+
for col in range(8):
|
|
211
|
+
display_row = 7 - row if not self._flipped else row
|
|
212
|
+
display_col = col if not self._flipped else 7 - col
|
|
213
|
+
|
|
214
|
+
square = chess.square(display_col, display_row)
|
|
215
|
+
piece = self.board.piece_at(square)
|
|
216
|
+
if piece is None:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
x = ox + col * sq_size
|
|
220
|
+
y = oy + row * sq_size
|
|
221
|
+
|
|
222
|
+
is_white = piece.color == chess.WHITE
|
|
223
|
+
ptype = piece.piece_type
|
|
224
|
+
symbol = PIECE_UNICODE[ptype][0 if is_white else 1]
|
|
225
|
+
|
|
226
|
+
if is_white:
|
|
227
|
+
painter.setPen(QColor("#FFFFFF"))
|
|
228
|
+
shadow_offset = max(1, sq_size // 40)
|
|
229
|
+
painter.drawText(QRect(x + shadow_offset, y + shadow_offset, sq_size, sq_size),
|
|
230
|
+
Qt.AlignmentFlag.AlignCenter, symbol)
|
|
231
|
+
painter.setPen(QColor("#302E2B"))
|
|
232
|
+
else:
|
|
233
|
+
painter.setPen(QColor("#302E2B"))
|
|
234
|
+
|
|
235
|
+
painter.drawText(QRect(x, y, sq_size, sq_size),
|
|
236
|
+
Qt.AlignmentFlag.AlignCenter, symbol)
|
|
237
|
+
|
|
238
|
+
painter.setPen(QPen(BORDER_COLOR, 2))
|
|
239
|
+
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
240
|
+
painter.drawRect(QRect(ox, oy, sq_size * 8, sq_size * 8))
|
|
241
|
+
|
|
242
|
+
coord_font = QFont("Segoe UI", max(8, sq_size // 6))
|
|
243
|
+
painter.setFont(coord_font)
|
|
244
|
+
painter.setPen(QColor("#4A4A4A"))
|
|
245
|
+
|
|
246
|
+
files = "abcdefgh"
|
|
247
|
+
ranks = "12345678"
|
|
248
|
+
|
|
249
|
+
for i in range(8):
|
|
250
|
+
display_file = files[7 - i if self._flipped else i]
|
|
251
|
+
display_rank = ranks[i if self._flipped else 7 - i]
|
|
252
|
+
|
|
253
|
+
fx = ox + i * sq_size + 3
|
|
254
|
+
fy = oy + 8 * sq_size - 2
|
|
255
|
+
painter.drawText(QRect(fx, fy, sq_size, sq_size // 4),
|
|
256
|
+
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignTop,
|
|
257
|
+
display_file)
|
|
258
|
+
|
|
259
|
+
rx = ox - sq_size // 4
|
|
260
|
+
ry = oy + i * sq_size
|
|
261
|
+
painter.drawText(QRect(rx, ry, sq_size // 4, sq_size),
|
|
262
|
+
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
|
263
|
+
display_rank)
|
|
264
|
+
|
|
265
|
+
if self.board.is_game_over():
|
|
266
|
+
result = self.board.result()
|
|
267
|
+
painter.setFont(QFont("Segoe UI", max(16, sq_size // 2)))
|
|
268
|
+
painter.setPen(QPen(QColor("#FFFFFF")))
|
|
269
|
+
painter.fillRect(QRect(ox, oy + sq_size * 3, sq_size * 8, sq_size * 2),
|
|
270
|
+
QColor(0, 0, 0, 180))
|
|
271
|
+
|
|
272
|
+
outcome_text = "Game Over"
|
|
273
|
+
if result == "1-0":
|
|
274
|
+
outcome_text = "White Wins!"
|
|
275
|
+
elif result == "0-1":
|
|
276
|
+
outcome_text = "Black Wins!"
|
|
277
|
+
elif result == "1/2-1/2":
|
|
278
|
+
outcome_text = "Draw!"
|
|
279
|
+
|
|
280
|
+
painter.drawText(QRect(ox, oy + sq_size * 3, sq_size * 8, sq_size * 2),
|
|
281
|
+
Qt.AlignmentFlag.AlignCenter, outcome_text)
|
|
282
|
+
|
|
283
|
+
def mousePressEvent(self, event: QMouseEvent):
|
|
284
|
+
if not self._human_turn or self.board.is_game_over():
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
sq_size = self.square_size
|
|
288
|
+
ox = self.board_offset_x
|
|
289
|
+
oy = self.board_offset_y
|
|
290
|
+
|
|
291
|
+
col = (event.pos().x() - ox) // sq_size
|
|
292
|
+
row = (event.pos().y() - oy) // sq_size
|
|
293
|
+
|
|
294
|
+
if col < 0 or col >= 8 or row < 0 or row >= 8:
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
if self._flipped:
|
|
298
|
+
file_idx = 7 - col
|
|
299
|
+
rank_idx = row
|
|
300
|
+
else:
|
|
301
|
+
file_idx = col
|
|
302
|
+
rank_idx = 7 - row
|
|
303
|
+
|
|
304
|
+
clicked_square = chess.square(file_idx, rank_idx)
|
|
305
|
+
|
|
306
|
+
if self._selected_square is None:
|
|
307
|
+
piece = self.board.piece_at(clicked_square)
|
|
308
|
+
if piece is not None and piece.color == self._player_color:
|
|
309
|
+
self._selected_square = clicked_square
|
|
310
|
+
self._legal_moves = [
|
|
311
|
+
move for move in self.board.legal_moves
|
|
312
|
+
if move.from_square == clicked_square
|
|
313
|
+
]
|
|
314
|
+
self.update()
|
|
315
|
+
else:
|
|
316
|
+
if clicked_square == self._selected_square:
|
|
317
|
+
self._selected_square = None
|
|
318
|
+
self._legal_moves = []
|
|
319
|
+
self.update()
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
for move in self._legal_moves:
|
|
323
|
+
if move.to_square == clicked_square:
|
|
324
|
+
if move.promotion:
|
|
325
|
+
self._handle_promotion(move, clicked_square)
|
|
326
|
+
else:
|
|
327
|
+
self._execute_move(move)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
piece = self.board.piece_at(clicked_square)
|
|
331
|
+
if piece is not None and piece.color == self._player_color:
|
|
332
|
+
self._selected_square = clicked_square
|
|
333
|
+
self._legal_moves = [
|
|
334
|
+
move for move in self.board.legal_moves
|
|
335
|
+
if move.from_square == clicked_square
|
|
336
|
+
]
|
|
337
|
+
self.update()
|
|
338
|
+
else:
|
|
339
|
+
self._selected_square = None
|
|
340
|
+
self._legal_moves = []
|
|
341
|
+
self.update()
|
|
342
|
+
|
|
343
|
+
def _handle_promotion(self, move: chess.Move, to_square: chess.Square):
|
|
344
|
+
dlg = PromotionDialog(self._player_color, self)
|
|
345
|
+
sq_size = self.square_size
|
|
346
|
+
ox = self.board_offset_x
|
|
347
|
+
oy = self.board_offset_y
|
|
348
|
+
|
|
349
|
+
if self._flipped:
|
|
350
|
+
col = 7 - chess.square_file(to_square)
|
|
351
|
+
row = chess.square_rank(to_square)
|
|
352
|
+
else:
|
|
353
|
+
col = chess.square_file(to_square)
|
|
354
|
+
row = 7 - chess.square_rank(to_square)
|
|
355
|
+
|
|
356
|
+
dlg_x = self.mapToGlobal(
|
|
357
|
+
self.rect().topLeft()
|
|
358
|
+
).x() + ox + col * sq_size + sq_size
|
|
359
|
+
dlg_y = self.mapToGlobal(
|
|
360
|
+
self.rect().topLeft()
|
|
361
|
+
).y() + oy + row * sq_size
|
|
362
|
+
dlg.move(dlg_x + 10, dlg_y)
|
|
363
|
+
|
|
364
|
+
if dlg.exec() == QDialog.DialogCode.Accepted:
|
|
365
|
+
promotion_move = chess.Move(
|
|
366
|
+
move.from_square, move.to_square, promotion=dlg.piece_type
|
|
367
|
+
)
|
|
368
|
+
self._execute_move(promotion_move)
|
|
369
|
+
else:
|
|
370
|
+
self._selected_square = None
|
|
371
|
+
self._legal_moves = []
|
|
372
|
+
self.update()
|
|
373
|
+
|
|
374
|
+
def _execute_move(self, move: chess.Move):
|
|
375
|
+
self.apply_move(move)
|
|
376
|
+
self.move_made.emit(move.uci())
|
|
377
|
+
|
|
378
|
+
def LLM_play_move(self, uci_move: str):
|
|
379
|
+
try:
|
|
380
|
+
move = chess.Move.from_uci(uci_move)
|
|
381
|
+
except ValueError:
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
if move in self.board.legal_moves:
|
|
385
|
+
self.apply_move(move)
|