Upload 18 files
Browse files- .gitignore +3 -0
- LICENSE +21 -0
- README.md +157 -0
- STRUCTURE.md +24 -0
- TECHSTACK.md +11 -0
- main.py +10 -0
- requirements.txt +10 -0
- src/__init__.py +0 -0
- src/board_ui.py +222 -0
- src/engine.py +120 -0
- src/game_state.py +28 -0
- src/gui.py +498 -0
- src/mirror.py +95 -0
- src/overlay.py +137 -0
- src/vision.py +182 -0
- tests/test_engine.py +31 -0
- tests/test_engine_manual.py +37 -0
- tests/test_mirror.py +56 -0
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
stockfish.exe/*.exe
|
| 2 |
+
*.exe
|
| 3 |
+
__pycache__/
|
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 callmerem
|
| 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.
|
README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CheckerChesser
|
| 2 |
+
|
| 3 |
+
CheckerChesser is a powerful chess utility application that combines a local chess analysis board with real-time screen monitoring capabilities. It is designed to assist players in analyzing games, finding best moves, and testing positions.
|
| 4 |
+
|
| 5 |
+
<img width="1354" height="680" alt="image" src="https://github.com/user-attachments/assets/dc902cf0-a31c-4abf-8f85-9dda475eaac8" />
|
| 6 |
+
<img width="1323" height="719" alt="image" src="https://github.com/user-attachments/assets/1dc5c833-bf2b-4562-a5f7-8630c72fefab" />
|
| 7 |
+
<img width="1347" height="675" alt="image" src="https://github.com/user-attachments/assets/4005f25a-b1ef-42a6-a732-cad55ea27a32" />
|
| 8 |
+
|
| 9 |
+
<img width="587" height="187" alt="image" src="https://github.com/user-attachments/assets/85ca3a62-fdd3-4e7d-ad34-058520587279" />
|
| 10 |
+
<img width="1366" height="681" alt="image" src="https://github.com/user-attachments/assets/3935066d-b436-486a-8c40-813bf5b3bed0" />
|
| 11 |
+
<img width="1363" height="681" alt="image" src="https://github.com/user-attachments/assets/07a27629-3aef-4d9b-a4fc-6e16633dfb7b" />
|
| 12 |
+
|
| 13 |
+
<img width="1366" height="715" alt="image" src="https://github.com/user-attachments/assets/e82defd9-f2fc-4f9b-9172-2b107105dec2" /><br><br>
|
| 14 |
+
|
| 15 |
+
_So yeah, it was so good I got banned_
|
| 16 |
+
<img width="863" height="115" alt="image" src="https://github.com/user-attachments/assets/2803a361-f8dd-4160-97a8-4f4d70756967" /><br><br>
|
| 17 |
+
|
| 18 |
+
**_All that grind for nothing lol!_**
|
| 19 |
+
|
| 20 |
+
<img width="331" height="126" alt="image" src="https://github.com/user-attachments/assets/e4b20a49-4d3b-4424-b24e-5fdafece9185" />
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
## Core Features
|
| 26 |
+
|
| 27 |
+
### 1. Local Analysis Board
|
| 28 |
+
- **Full Chess Engine Integration**: Powered by **Stockfish**, one of the strongest chess engines in the world.
|
| 29 |
+
- **Interactive Board**: Move pieces, flip the board, and play against the AI.
|
| 30 |
+
- **Dual Player Mode**: Play locally against a friend or test moves for both sides.
|
| 31 |
+
- **Analysis Visualization**: Visualize top engine moves with colored arrows indicating strength and threat.
|
| 32 |
+
|
| 33 |
+
### 2. Board Editor
|
| 34 |
+
- **Custom Scenario Setup**: Manually place any piece on the board to recreate specific game states.
|
| 35 |
+
- **Automation Control**: The engine is strictly disabled during editing to prevent interference.
|
| 36 |
+
- **Piece Palette**: Intuitive drag-and-drop style interface (click-to-place) for all piece types.
|
| 37 |
+
|
| 38 |
+
### 3. Screen Analysis (Vision)
|
| 39 |
+
- **Real-time Monitoring**: Define a region on your screen to monitor a live chess board (e.g., from a website or video).
|
| 40 |
+
- **Auto-Calibration**: The application automatically detects the board structure from a standard starting position.
|
| 41 |
+
- **Live suggestions**: As moves are played on the screen, the app updates the internal board and suggests the best response instantly.
|
| 42 |
+
|
| 43 |
+
### 4. Turn Swapping & Customization
|
| 44 |
+
- **First Move Control**: Choose whether White or Black moves first (useful for variants or playing as Black from the start).
|
| 45 |
+
- **Play As**: Switch sides to play as Black or White against the engine.
|
| 46 |
+
|
| 47 |
+
---
|
| 48 |
+
|
| 49 |
+
## Technical Architecture & Machine Learning
|
| 50 |
+
|
| 51 |
+
### Is it based on Machine Learning?
|
| 52 |
+
**Yes and No.**
|
| 53 |
+
|
| 54 |
+
1. **Chess Intelligence (Yes)**: The application utilizes **Stockfish**, which employs **NNUE (Efficiently Updatable Neural Network)** technology. This is a form of machine learning where a neural network attempts to evaluate positions, providing superior performance over classical hand-crafted evaluation functions.
|
| 55 |
+
2. **Vision System (No)**: The screen analysis feature uses **Computer Vision** techniques (specifically Template Matching and Mean Squared Error comparisons), not Deep Learning. It requires a clear view of a 2D chess board to "calibrate" and match pieces against a known template.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Installation
|
| 60 |
+
|
| 61 |
+
1. **Prerequisites**:
|
| 62 |
+
- Python 3.8+
|
| 63 |
+
- Stockfish Engine (Executable must be placed in the project folder or specified in config).
|
| 64 |
+
|
| 65 |
+
2. **Dependencies**:
|
| 66 |
+
Install the required Python packages:
|
| 67 |
+
```bash
|
| 68 |
+
pip install -r requirements.txt
|
| 69 |
+
```
|
| 70 |
+
*(Requires: customtkinter, python-chess, mss, opencv-python, numpy)*
|
| 71 |
+
|
| 72 |
+
3. **Setup (Stockfish Guide)**:
|
| 73 |
+
This application requires the **Stockfish Chess Engine** to function.
|
| 74 |
+
|
| 75 |
+
**Step-by-Step Guide:**
|
| 76 |
+
1. **Download**: Visit the official Stockfish website: [https://stockfishchess.org/download/](https://stockfishchess.org/download/)
|
| 77 |
+
2. **Select Version**: Download the version suitable for your system (usually "Windows (AVX2)" or "Windows (x64-modern)").
|
| 78 |
+
3. **Extract**: Unzip the downloaded folder. Inside, you will find an executable file (e.g., `stockfish-windows-x86-64-avx2.exe`).
|
| 79 |
+
4. **Rename**: Rename this file to simply `stockfish.exe`.
|
| 80 |
+
5. **Place File**: Move `stockfish.exe` directly into the `CheckerChesser` project folder.
|
| 81 |
+
|
| 82 |
+
**Correct Folder Structure:**
|
| 83 |
+
```text
|
| 84 |
+
CheckerChesser/
|
| 85 |
+
├── src/
|
| 86 |
+
│ ├── __pycache__/
|
| 87 |
+
│ ├── __init__.py
|
| 88 |
+
│ ├── board_ui.py
|
| 89 |
+
│ ├── engine.py
|
| 90 |
+
│ ├── game_state.py
|
| 91 |
+
│ ├── gui.py
|
| 92 |
+
│ ├── mirror.py
|
| 93 |
+
│ ├── overlay.py
|
| 94 |
+
│ └── vision.py
|
| 95 |
+
├── tests/
|
| 96 |
+
├── main.py
|
| 97 |
+
├── requirements.txt
|
| 98 |
+
├── stockfish.exe/ <-- PLACE HERE (folder named btw)
|
| 99 |
+
├── LICENSE
|
| 100 |
+
└── README.md
|
| 101 |
+
```
|
| 102 |
+
|
| 103 |
+
---
|
| 104 |
+
|
| 105 |
+
## Usage Guide
|
| 106 |
+
|
| 107 |
+
### Starting the App
|
| 108 |
+
Run the main script:
|
| 109 |
+
```bash
|
| 110 |
+
python main.py
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
### Modes
|
| 114 |
+
|
| 115 |
+
#### Local Game
|
| 116 |
+
- **Play**: Drag and drop or click squares to move pieces.
|
| 117 |
+
- **Analysis Mode**: Toggle `Analysis Mode` switch to see Best Move arrows overlaid on the board.
|
| 118 |
+
- **Two Player**: Toggle `Two Player Mode` to control both sides manually.
|
| 119 |
+
- **Flip Board**: Click `⟳ Flip Board` to rotate the view.
|
| 120 |
+
|
| 121 |
+
#### Board Editor
|
| 122 |
+
1. Toggle `Edit Mode` switch to **ON**.
|
| 123 |
+
2. A palette of pieces (White/Black) and a Trash Bin will appear.
|
| 124 |
+
3. **Left Click** a piece in the palette to select it.
|
| 125 |
+
4. **Left Click** any square on the board to place that piece.
|
| 126 |
+
5. Select the **Trash Bin** and click a square to clear it.
|
| 127 |
+
6. Toggle `Edit Mode` **OFF** to resume play.
|
| 128 |
+
> **Note**: AI logic is paused while Edit Mode is active.
|
| 129 |
+
|
| 130 |
+
#### Screen Analysis
|
| 131 |
+
1. Click `Screen Analysis` in the sidebar.
|
| 132 |
+
2. An overlay window will appear. Drag to select the area of the chess board you want to monitor.
|
| 133 |
+
3. **Important**: Ensure the board on screen is at the **Starting Position** when you start monitoring. The tool needs this to calibrate piece textures.
|
| 134 |
+
4. Once calibrated, the app will track moves and display the best engine move in real-time.
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Troubleshooting
|
| 139 |
+
|
| 140 |
+
- **Engine Not Found**: Ensure `stockfish.exe` is in the folder.
|
| 141 |
+
- **Vision Not Working**: Make sure the board on screen is not obstructed and matches standard 2D chess pieces. Glare or unusual piece sets may confuse the template matcher.
|
| 142 |
+
- **Performance**: High `Best Moves` count or deep analysis may use significant CPU resources. Lower the number of moves if the app lags.
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
## Citation
|
| 147 |
+
|
| 148 |
+
```bibtex
|
| 149 |
+
@misc{CheckerChesser,
|
| 150 |
+
author = {algorembrant},
|
| 151 |
+
title = {CheckerChesser},
|
| 152 |
+
year = {2026},
|
| 153 |
+
publisher = {GitHub},
|
| 154 |
+
journal = {GitHub repository},
|
| 155 |
+
howpublished = {\url{https://github.com/algorembrant/CheckerChesser}},
|
| 156 |
+
}
|
| 157 |
+
```
|
STRUCTURE.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Project Structure
|
| 2 |
+
|
| 3 |
+
```text
|
| 4 |
+
CheckerChesser/
|
| 5 |
+
├── src/
|
| 6 |
+
│ ├── __init__.py
|
| 7 |
+
│ ├── board_ui.py
|
| 8 |
+
│ ├── engine.py
|
| 9 |
+
│ ├── game_state.py
|
| 10 |
+
│ ├── gui.py
|
| 11 |
+
│ ├── mirror.py
|
| 12 |
+
│ ├── overlay.py
|
| 13 |
+
│ └── vision.py
|
| 14 |
+
├── tests/
|
| 15 |
+
│ ├── test_engine.py
|
| 16 |
+
│ ├── test_engine_manual.py
|
| 17 |
+
│ └── test_mirror.py
|
| 18 |
+
├── .gitignore
|
| 19 |
+
├── LICENSE
|
| 20 |
+
├── main.py
|
| 21 |
+
├── README.md
|
| 22 |
+
├── requirements.txt
|
| 23 |
+
└── TECHSTACK.md
|
| 24 |
+
```
|
TECHSTACK.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Techstack
|
| 2 |
+
|
| 3 |
+
Audit of **CheckerChesser** project files (excluding environment and cache):
|
| 4 |
+
|
| 5 |
+
| File Type | Count | Size (KB) |
|
| 6 |
+
| :--- | :--- | :--- |
|
| 7 |
+
| Python (.py) | 12 | 55.9 |
|
| 8 |
+
| (no extension) | 2 | 1.1 |
|
| 9 |
+
| Markdown (.md) | 1 | 7.1 |
|
| 10 |
+
| Plain Text (.txt) | 1 | 0.1 |
|
| 11 |
+
| **Total** | **16** | **64.2** |
|
main.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import customtkinter as ctk
|
| 2 |
+
from src.gui import ChessApp
|
| 3 |
+
|
| 4 |
+
if __name__ == "__main__":
|
| 5 |
+
ctk.set_appearance_mode("Dark")
|
| 6 |
+
ctk.set_default_color_theme("blue")
|
| 7 |
+
|
| 8 |
+
app = ChessApp()
|
| 9 |
+
app.mainloop()
|
| 10 |
+
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
customtkinter
|
| 2 |
+
python-chess
|
| 3 |
+
opencv-python
|
| 4 |
+
pillow
|
| 5 |
+
mss
|
| 6 |
+
numpy
|
| 7 |
+
paramiko
|
| 8 |
+
pyglet
|
| 9 |
+
pywin32
|
| 10 |
+
pyautogui
|
src/__init__.py
ADDED
|
File without changes
|
src/board_ui.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import customtkinter as ctk
|
| 2 |
+
import chess
|
| 3 |
+
|
| 4 |
+
class BoardUI(ctk.CTkFrame):
|
| 5 |
+
def __init__(self, master, game_state, **kwargs):
|
| 6 |
+
super().__init__(master, **kwargs)
|
| 7 |
+
self.game_state = game_state
|
| 8 |
+
self.canvas = ctk.CTkCanvas(self, bg="#302E2B", highlightthickness=0)
|
| 9 |
+
self.canvas.pack(fill="both", expand=True)
|
| 10 |
+
|
| 11 |
+
self.square_size = 50 # Will be recalculated on resize
|
| 12 |
+
self.selected_square = None
|
| 13 |
+
self.pieces = {}
|
| 14 |
+
self.flipped = False # Board orientation
|
| 15 |
+
self.edit_mode = False
|
| 16 |
+
self.selected_edit_piece = None # Piece to place in edit mode (None = Delete)
|
| 17 |
+
self.last_analysis_moves = [] # cache for redrawing arrows
|
| 18 |
+
|
| 19 |
+
self.canvas.bind("<Button-1>", self.on_click)
|
| 20 |
+
self.canvas.bind("<Configure>", self.on_resize)
|
| 21 |
+
|
| 22 |
+
def on_resize(self, event):
|
| 23 |
+
# Calculate square size based on available space (use smaller dimension)
|
| 24 |
+
size = min(event.width, event.height)
|
| 25 |
+
self.square_size = size // 8
|
| 26 |
+
self.draw_board()
|
| 27 |
+
|
| 28 |
+
def get_visual_coords(self, file, rank):
|
| 29 |
+
"""Convert chess file/rank to visual coordinates accounting for flip."""
|
| 30 |
+
if self.flipped:
|
| 31 |
+
visual_file = 7 - file
|
| 32 |
+
visual_rank = rank # When flipped, rank 0 (row 1) is at top
|
| 33 |
+
else:
|
| 34 |
+
visual_file = file
|
| 35 |
+
visual_rank = 7 - rank # Normal: rank 7 (row 8) is at top
|
| 36 |
+
return visual_file, visual_rank
|
| 37 |
+
|
| 38 |
+
def get_chess_square_from_visual(self, visual_file, visual_rank):
|
| 39 |
+
"""Convert visual coordinates to chess square."""
|
| 40 |
+
if self.flipped:
|
| 41 |
+
file = 7 - visual_file
|
| 42 |
+
rank = visual_rank
|
| 43 |
+
else:
|
| 44 |
+
file = visual_file
|
| 45 |
+
rank = 7 - visual_rank
|
| 46 |
+
return chess.square(file, rank)
|
| 47 |
+
|
| 48 |
+
def draw_board(self):
|
| 49 |
+
self.canvas.delete("all")
|
| 50 |
+
colors = ["#EBECD0", "#779556"] # Light, Dark squares
|
| 51 |
+
|
| 52 |
+
# Center the board if window is not square
|
| 53 |
+
canvas_w = self.canvas.winfo_width()
|
| 54 |
+
canvas_h = self.canvas.winfo_height()
|
| 55 |
+
board_size = self.square_size * 8
|
| 56 |
+
offset_x = (canvas_w - board_size) // 2
|
| 57 |
+
offset_y = (canvas_h - board_size) // 2
|
| 58 |
+
|
| 59 |
+
self.offset_x = max(0, offset_x)
|
| 60 |
+
self.offset_y = max(0, offset_y)
|
| 61 |
+
|
| 62 |
+
for visual_rank in range(8):
|
| 63 |
+
for visual_file in range(8):
|
| 64 |
+
color = colors[(visual_rank + visual_file) % 2]
|
| 65 |
+
x1 = self.offset_x + visual_file * self.square_size
|
| 66 |
+
y1 = self.offset_y + visual_rank * self.square_size
|
| 67 |
+
x2 = x1 + self.square_size
|
| 68 |
+
y2 = y1 + self.square_size
|
| 69 |
+
|
| 70 |
+
self.canvas.create_rectangle(x1, y1, x2, y2, fill=color, outline="")
|
| 71 |
+
|
| 72 |
+
# Get chess square for this visual position
|
| 73 |
+
chess_square = self.get_chess_square_from_visual(visual_file, visual_rank)
|
| 74 |
+
piece = self.game_state.board.piece_at(chess_square)
|
| 75 |
+
|
| 76 |
+
if piece:
|
| 77 |
+
self.draw_piece(x1, y1, piece)
|
| 78 |
+
|
| 79 |
+
# Highlight King if in Check
|
| 80 |
+
if self.game_state.board.is_check():
|
| 81 |
+
self.highlight_king_check()
|
| 82 |
+
|
| 83 |
+
if self.selected_square is not None:
|
| 84 |
+
self.highlight_square(self.selected_square)
|
| 85 |
+
|
| 86 |
+
# Redraw arrows if they exist
|
| 87 |
+
if self.last_analysis_moves:
|
| 88 |
+
self.display_analysis(self.last_analysis_moves, cache=False)
|
| 89 |
+
|
| 90 |
+
def draw_piece(self, x, y, piece):
|
| 91 |
+
symbol = piece.unicode_symbol()
|
| 92 |
+
font_size = int(self.square_size * 0.7)
|
| 93 |
+
fill_color = "#1a1a1a" if piece.color == chess.BLACK else "#ffffff"
|
| 94 |
+
|
| 95 |
+
# Add shadow for better visibility
|
| 96 |
+
shadow_offset = max(1, int(self.square_size * 0.02))
|
| 97 |
+
self.canvas.create_text(
|
| 98 |
+
x + self.square_size/2 + shadow_offset,
|
| 99 |
+
y + self.square_size/2 + shadow_offset,
|
| 100 |
+
text=symbol,
|
| 101 |
+
font=("Segoe UI Symbol", font_size, "bold"),
|
| 102 |
+
fill="gray30"
|
| 103 |
+
)
|
| 104 |
+
self.canvas.create_text(
|
| 105 |
+
x + self.square_size/2,
|
| 106 |
+
y + self.square_size/2,
|
| 107 |
+
text=symbol,
|
| 108 |
+
font=("Segoe UI Symbol", font_size, "bold"),
|
| 109 |
+
fill=fill_color
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
def highlight_king_check(self):
|
| 113 |
+
"""Highlight the King's square in red if in check."""
|
| 114 |
+
king_square = self.game_state.board.king(self.game_state.board.turn)
|
| 115 |
+
if king_square is not None:
|
| 116 |
+
file = chess.square_file(king_square)
|
| 117 |
+
rank = chess.square_rank(king_square)
|
| 118 |
+
visual_file, visual_rank = self.get_visual_coords(file, rank)
|
| 119 |
+
|
| 120 |
+
x1 = self.offset_x + visual_file * self.square_size
|
| 121 |
+
y1 = self.offset_y + visual_rank * self.square_size
|
| 122 |
+
x2 = x1 + self.square_size
|
| 123 |
+
y2 = y1 + self.square_size
|
| 124 |
+
|
| 125 |
+
# Create a red glow/border
|
| 126 |
+
self.canvas.create_oval(x1+2, y1+2, x2-2, y2-2, outline="#FF0000", width=4, tag="check")
|
| 127 |
+
self.canvas.create_rectangle(x1, y1, x2, y2, fill="red", stipple="gray25", outline="")
|
| 128 |
+
|
| 129 |
+
def highlight_square(self, square):
|
| 130 |
+
file = chess.square_file(square)
|
| 131 |
+
rank = chess.square_rank(square)
|
| 132 |
+
visual_file, visual_rank = self.get_visual_coords(file, rank)
|
| 133 |
+
|
| 134 |
+
x1 = self.offset_x + visual_file * self.square_size
|
| 135 |
+
y1 = self.offset_y + visual_rank * self.square_size
|
| 136 |
+
x2 = x1 + self.square_size
|
| 137 |
+
y2 = y1 + self.square_size
|
| 138 |
+
|
| 139 |
+
self.canvas.create_rectangle(x1, y1, x2, y2, fill="yellow", stipple="gray50", outline="gold", width=2)
|
| 140 |
+
|
| 141 |
+
def on_click(self, event):
|
| 142 |
+
# Adjust for offset
|
| 143 |
+
adj_x = event.x - getattr(self, 'offset_x', 0)
|
| 144 |
+
adj_y = event.y - getattr(self, 'offset_y', 0)
|
| 145 |
+
|
| 146 |
+
if adj_x < 0 or adj_y < 0:
|
| 147 |
+
return
|
| 148 |
+
|
| 149 |
+
visual_file = int(adj_x // self.square_size)
|
| 150 |
+
visual_rank = int(adj_y // self.square_size)
|
| 151 |
+
|
| 152 |
+
if visual_file < 0 or visual_file > 7 or visual_rank < 0 or visual_rank > 7:
|
| 153 |
+
return
|
| 154 |
+
|
| 155 |
+
chess_square = self.get_chess_square_from_visual(visual_file, visual_rank)
|
| 156 |
+
|
| 157 |
+
if self.edit_mode:
|
| 158 |
+
# Edit Mode Logic
|
| 159 |
+
self.game_state.set_piece(chess_square, self.selected_edit_piece)
|
| 160 |
+
self.draw_board()
|
| 161 |
+
return
|
| 162 |
+
|
| 163 |
+
# Normal Play Logic
|
| 164 |
+
if self.selected_square is None:
|
| 165 |
+
piece = self.game_state.board.piece_at(chess_square)
|
| 166 |
+
if piece and piece.color == self.game_state.board.turn:
|
| 167 |
+
self.selected_square = chess_square
|
| 168 |
+
self.draw_board()
|
| 169 |
+
else:
|
| 170 |
+
# Try to move
|
| 171 |
+
move = chess.Move(self.selected_square, chess_square)
|
| 172 |
+
# Check for promotion
|
| 173 |
+
piece_at_start = self.game_state.board.piece_at(self.selected_square)
|
| 174 |
+
if piece_at_start and piece_at_start.piece_type == chess.PAWN:
|
| 175 |
+
if (self.game_state.board.turn == chess.WHITE and chess.square_rank(chess_square) == 7) or \
|
| 176 |
+
(self.game_state.board.turn == chess.BLACK and chess.square_rank(chess_square) == 0):
|
| 177 |
+
move = chess.Move(self.selected_square, chess_square, promotion=chess.QUEEN)
|
| 178 |
+
|
| 179 |
+
if move in self.game_state.board.legal_moves:
|
| 180 |
+
self.game_state.board.push(move)
|
| 181 |
+
self.selected_square = None
|
| 182 |
+
self.draw_board()
|
| 183 |
+
self.master.event_generate("<<MoveMade>>")
|
| 184 |
+
else:
|
| 185 |
+
piece = self.game_state.board.piece_at(chess_square)
|
| 186 |
+
if piece and piece.color == self.game_state.board.turn:
|
| 187 |
+
self.selected_square = chess_square
|
| 188 |
+
self.draw_board()
|
| 189 |
+
else:
|
| 190 |
+
self.selected_square = None
|
| 191 |
+
self.draw_board()
|
| 192 |
+
|
| 193 |
+
def draw_arrow(self, start_sq, end_sq, color="#00FF00", width=4):
|
| 194 |
+
# Get visual coordinates for arrow
|
| 195 |
+
start_file = chess.square_file(start_sq)
|
| 196 |
+
start_rank = chess.square_rank(start_sq)
|
| 197 |
+
end_file = chess.square_file(end_sq)
|
| 198 |
+
end_rank = chess.square_rank(end_sq)
|
| 199 |
+
|
| 200 |
+
start_vf, start_vr = self.get_visual_coords(start_file, start_rank)
|
| 201 |
+
end_vf, end_vr = self.get_visual_coords(end_file, end_rank)
|
| 202 |
+
|
| 203 |
+
x1 = self.offset_x + start_vf * self.square_size + self.square_size // 2
|
| 204 |
+
y1 = self.offset_y + start_vr * self.square_size + self.square_size // 2
|
| 205 |
+
x2 = self.offset_x + end_vf * self.square_size + self.square_size // 2
|
| 206 |
+
y2 = self.offset_y + end_vr * self.square_size + self.square_size // 2
|
| 207 |
+
|
| 208 |
+
self.canvas.create_line(x1, y1, x2, y2, fill=color, width=width, arrow="last", arrowshape=(16, 20, 6), tag="arrow")
|
| 209 |
+
|
| 210 |
+
def display_analysis(self, top_moves, cache=True):
|
| 211 |
+
if cache:
|
| 212 |
+
self.last_analysis_moves = top_moves
|
| 213 |
+
|
| 214 |
+
self.canvas.delete("arrow")
|
| 215 |
+
|
| 216 |
+
colors = ["#00FF00", "#00FFFF", "#FFFF00"]
|
| 217 |
+
|
| 218 |
+
for i, move_data in enumerate(top_moves):
|
| 219 |
+
if i >= len(colors):
|
| 220 |
+
break
|
| 221 |
+
move = move_data["move"]
|
| 222 |
+
self.draw_arrow(move.from_square, move.to_square, color=colors[i], width=6 - i)
|
src/engine.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import chess
|
| 2 |
+
import chess.engine
|
| 3 |
+
import os
|
| 4 |
+
import threading
|
| 5 |
+
|
| 6 |
+
class EngineHandler:
|
| 7 |
+
def __init__(self, engine_path="stockfish.exe"):
|
| 8 |
+
self.engine_path = engine_path
|
| 9 |
+
self.engine = None
|
| 10 |
+
self.lock = threading.Lock() # Prevent concurrent engine access
|
| 11 |
+
|
| 12 |
+
def initialize_engine(self):
|
| 13 |
+
# 1. Check if path exists
|
| 14 |
+
if not os.path.exists(self.engine_path):
|
| 15 |
+
return False, f"Engine not found at {self.engine_path}. Please place 'stockfish.exe' in the project folder."
|
| 16 |
+
|
| 17 |
+
# 2. If it is a directory, search for an executable inside
|
| 18 |
+
final_path = self.engine_path
|
| 19 |
+
if os.path.isdir(self.engine_path):
|
| 20 |
+
found_exe = None
|
| 21 |
+
for root, dirs, files in os.walk(self.engine_path):
|
| 22 |
+
for file in files:
|
| 23 |
+
if "stockfish" in file.lower() and file.lower().endswith(".exe"):
|
| 24 |
+
found_exe = os.path.join(root, file)
|
| 25 |
+
break
|
| 26 |
+
if found_exe:
|
| 27 |
+
break
|
| 28 |
+
|
| 29 |
+
if found_exe:
|
| 30 |
+
final_path = found_exe
|
| 31 |
+
else:
|
| 32 |
+
return False, f"Directory found at {self.engine_path}, but no 'stockfish' executable was found inside."
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
self.engine = chess.engine.SimpleEngine.popen_uci(final_path)
|
| 36 |
+
return True, f"Engine initialized successfully ({os.path.basename(final_path)})."
|
| 37 |
+
except PermissionError:
|
| 38 |
+
return False, f"Permission denied accessing {self.engine_path}. Try running as Administrator or check file properties."
|
| 39 |
+
except Exception as e:
|
| 40 |
+
return False, f"Failed to initialize engine: {e}"
|
| 41 |
+
|
| 42 |
+
def get_best_move(self, fen, time_limit=1.0):
|
| 43 |
+
if not self.engine:
|
| 44 |
+
return None
|
| 45 |
+
|
| 46 |
+
with self.lock:
|
| 47 |
+
try:
|
| 48 |
+
board = chess.Board(fen)
|
| 49 |
+
result = self.engine.play(board, chess.engine.Limit(time=time_limit))
|
| 50 |
+
return result.move
|
| 51 |
+
except Exception as e:
|
| 52 |
+
import traceback
|
| 53 |
+
traceback.print_exc()
|
| 54 |
+
print(f"Error getting best move: {e!r}")
|
| 55 |
+
# Try to reinitialize engine if it died
|
| 56 |
+
self._try_reinit()
|
| 57 |
+
return None
|
| 58 |
+
|
| 59 |
+
def _try_reinit(self):
|
| 60 |
+
"""Attempt to reinitialize the engine if it crashed."""
|
| 61 |
+
try:
|
| 62 |
+
if self.engine:
|
| 63 |
+
try:
|
| 64 |
+
self.engine.quit()
|
| 65 |
+
except:
|
| 66 |
+
pass
|
| 67 |
+
self.engine = None
|
| 68 |
+
self.initialize_engine()
|
| 69 |
+
except:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
def get_top_moves(self, fen, limit=3, time_limit=1.0):
|
| 73 |
+
if not self.engine:
|
| 74 |
+
return []
|
| 75 |
+
|
| 76 |
+
with self.lock:
|
| 77 |
+
try:
|
| 78 |
+
board = chess.Board(fen)
|
| 79 |
+
info = self.engine.analyse(board, chess.engine.Limit(time=time_limit), multipv=limit)
|
| 80 |
+
|
| 81 |
+
if isinstance(info, dict):
|
| 82 |
+
info = [info]
|
| 83 |
+
|
| 84 |
+
top_moves = []
|
| 85 |
+
for i, line in enumerate(info):
|
| 86 |
+
if "pv" in line:
|
| 87 |
+
move = line["pv"][0]
|
| 88 |
+
score = line["score"].relative.score(mate_score=10000)
|
| 89 |
+
top_moves.append({
|
| 90 |
+
"rank": i + 1,
|
| 91 |
+
"move": move,
|
| 92 |
+
"score": score,
|
| 93 |
+
"pv": line["pv"]
|
| 94 |
+
})
|
| 95 |
+
return top_moves
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"Error analyzing: {e}")
|
| 99 |
+
self._try_reinit()
|
| 100 |
+
return []
|
| 101 |
+
|
| 102 |
+
def quit(self):
|
| 103 |
+
if self.engine:
|
| 104 |
+
try:
|
| 105 |
+
self.engine.quit()
|
| 106 |
+
except:
|
| 107 |
+
pass
|
| 108 |
+
|
| 109 |
+
def get_evaluation(self, fen):
|
| 110 |
+
if not self.engine:
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
with self.lock:
|
| 114 |
+
try:
|
| 115 |
+
board = chess.Board(fen)
|
| 116 |
+
info = self.engine.analyse(board, chess.engine.Limit(depth=15))
|
| 117 |
+
return info["score"].relative.score(mate_score=10000)
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f"Error in evaluation: {e}")
|
| 120 |
+
return None
|
src/game_state.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import chess
|
| 2 |
+
|
| 3 |
+
class GameState:
|
| 4 |
+
def __init__(self):
|
| 5 |
+
self.board = chess.Board()
|
| 6 |
+
|
| 7 |
+
def reset(self):
|
| 8 |
+
self.board.reset()
|
| 9 |
+
|
| 10 |
+
def make_move(self, uci_move):
|
| 11 |
+
try:
|
| 12 |
+
move = chess.Move.from_uci(uci_move)
|
| 13 |
+
if move in self.board.legal_moves:
|
| 14 |
+
self.board.push(move)
|
| 15 |
+
return True
|
| 16 |
+
return False
|
| 17 |
+
except:
|
| 18 |
+
return False
|
| 19 |
+
|
| 20 |
+
def set_piece(self, square, piece):
|
| 21 |
+
"""Set a piece on the board directly."""
|
| 22 |
+
self.board.set_piece_at(square, piece)
|
| 23 |
+
|
| 24 |
+
def get_fen(self):
|
| 25 |
+
return self.board.fen()
|
| 26 |
+
|
| 27 |
+
def is_game_over(self):
|
| 28 |
+
return self.board.is_game_over()
|
src/gui.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import customtkinter as ctk
|
| 2 |
+
import threading
|
| 3 |
+
import chess
|
| 4 |
+
import time
|
| 5 |
+
from src.game_state import GameState
|
| 6 |
+
from src.engine import EngineHandler
|
| 7 |
+
from src.board_ui import BoardUI
|
| 8 |
+
from src.overlay import SelectionOverlay, ProjectionOverlay
|
| 9 |
+
from src.vision import VisionHandler
|
| 10 |
+
from src.mirror import MirrorHandler
|
| 11 |
+
|
| 12 |
+
class ChessApp(ctk.CTk):
|
| 13 |
+
def __init__(self):
|
| 14 |
+
super().__init__()
|
| 15 |
+
|
| 16 |
+
self.title("CheckerChesser")
|
| 17 |
+
self.geometry("900x700")
|
| 18 |
+
self.minsize(600, 500) # Minimum window size
|
| 19 |
+
|
| 20 |
+
# Initialize Logic
|
| 21 |
+
self.game_state = GameState()
|
| 22 |
+
self.engine = EngineHandler()
|
| 23 |
+
self.vision = VisionHandler()
|
| 24 |
+
self.mirror = MirrorHandler()
|
| 25 |
+
|
| 26 |
+
# Screen Mirroring State
|
| 27 |
+
self.mirroring = False
|
| 28 |
+
self.mirror_region = None
|
| 29 |
+
self.projection_overlay = None
|
| 30 |
+
|
| 31 |
+
self.grid_rowconfigure(0, weight=1)
|
| 32 |
+
self.grid_columnconfigure(0, weight=0) # Sidebar
|
| 33 |
+
self.grid_columnconfigure(1, weight=1) # Main Content
|
| 34 |
+
|
| 35 |
+
# Sidebar
|
| 36 |
+
self.sidebar = ctk.CTkFrame(self, width=200, corner_radius=0)
|
| 37 |
+
self.sidebar.grid(row=0, column=0, sticky="nsew")
|
| 38 |
+
|
| 39 |
+
self.logo_label = ctk.CTkLabel(self.sidebar, text="CheckerChesser", font=ctk.CTkFont(size=20, weight="bold"))
|
| 40 |
+
self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10))
|
| 41 |
+
|
| 42 |
+
self.new_game_btn = ctk.CTkButton(self.sidebar, text="New Local Game", command=self.start_local_game)
|
| 43 |
+
self.new_game_btn.grid(row=1, column=0, padx=20, pady=10)
|
| 44 |
+
|
| 45 |
+
self.mirror_btn = ctk.CTkButton(self.sidebar, text="Screen Mirroring", command=self.start_screen_mirroring)
|
| 46 |
+
self.mirror_btn.grid(row=2, column=0, padx=20, pady=10)
|
| 47 |
+
|
| 48 |
+
self.stop_btn = ctk.CTkButton(self.sidebar, text="Stop Mirroring", command=self.stop_mirroring, fg_color="red", hover_color="darkred")
|
| 49 |
+
self.stop_btn.grid(row=3, column=0, padx=20, pady=10)
|
| 50 |
+
self.stop_btn.grid_remove() # Hidden by default
|
| 51 |
+
|
| 52 |
+
self.status_label = ctk.CTkLabel(self.sidebar, text="Status: Idle", anchor="w")
|
| 53 |
+
self.status_label.grid(row=4, column=0, padx=20, pady=(20, 0), sticky="ew")
|
| 54 |
+
|
| 55 |
+
# Content Area
|
| 56 |
+
self.content_frame = ctk.CTkFrame(self, corner_radius=0, fg_color="transparent")
|
| 57 |
+
self.content_frame.grid(row=0, column=1, sticky="nsew")
|
| 58 |
+
self.content_frame.grid_rowconfigure(0, weight=1)
|
| 59 |
+
self.content_frame.grid_columnconfigure(0, weight=1)
|
| 60 |
+
|
| 61 |
+
# Init Engine
|
| 62 |
+
# Defer execution to ensure mainloop is running before thread tries to callback
|
| 63 |
+
self.after(200, self.init_engine_thread)
|
| 64 |
+
|
| 65 |
+
# Sidebar Toggle State
|
| 66 |
+
self.sidebar_visible = True
|
| 67 |
+
|
| 68 |
+
# Sidebar Toggle Button (Floating)
|
| 69 |
+
self.sidebar_toggle_btn = ctk.CTkButton(self, text="☰", width=30, height=30,
|
| 70 |
+
command=self.toggle_sidebar,
|
| 71 |
+
fg_color="gray20", hover_color="gray30")
|
| 72 |
+
self.sidebar_toggle_btn.place(x=10, y=10)
|
| 73 |
+
|
| 74 |
+
# Current Board View
|
| 75 |
+
self.board_ui = None
|
| 76 |
+
self.start_local_game()
|
| 77 |
+
|
| 78 |
+
def toggle_sidebar(self):
|
| 79 |
+
if self.sidebar_visible:
|
| 80 |
+
self.sidebar.grid_remove()
|
| 81 |
+
self.sidebar_visible = False
|
| 82 |
+
self.sidebar_toggle_btn.configure(fg_color="transparent") # Blend in more when closed? Or keep visible?
|
| 83 |
+
# When closed, content takes full width.
|
| 84 |
+
# self.grid_columnconfigure(0, weight=0) is already set for sidebar column.
|
| 85 |
+
# But the sidebar frame is gone, so column 0 collapses.
|
| 86 |
+
else:
|
| 87 |
+
self.sidebar.grid()
|
| 88 |
+
self.sidebar_visible = True
|
| 89 |
+
self.sidebar_toggle_btn.configure(fg_color="gray20")
|
| 90 |
+
|
| 91 |
+
def init_engine_thread(self):
|
| 92 |
+
def _init():
|
| 93 |
+
success, msg = self.engine.initialize_engine()
|
| 94 |
+
self.after(0, lambda: self.status_label.configure(text="Engine: Ready" if success else "Engine: Not Found"))
|
| 95 |
+
if not success:
|
| 96 |
+
print(msg)
|
| 97 |
+
threading.Thread(target=_init, daemon=True).start()
|
| 98 |
+
|
| 99 |
+
def start_local_game(self):
|
| 100 |
+
self.stop_mirroring() # Stop any active mirroring
|
| 101 |
+
|
| 102 |
+
# Clear content
|
| 103 |
+
for widget in self.content_frame.winfo_children():
|
| 104 |
+
widget.destroy()
|
| 105 |
+
|
| 106 |
+
self.game_state.reset()
|
| 107 |
+
|
| 108 |
+
# Controls Frame (horizontal layout for controls)
|
| 109 |
+
self.controls_frame = ctk.CTkFrame(self.content_frame, fg_color="transparent")
|
| 110 |
+
self.controls_frame.grid(row=1, column=0, pady=10, sticky="ew")
|
| 111 |
+
|
| 112 |
+
# Left controls
|
| 113 |
+
left_frame = ctk.CTkFrame(self.controls_frame, fg_color="transparent")
|
| 114 |
+
left_frame.pack(side="left", padx=20)
|
| 115 |
+
|
| 116 |
+
# Play As selector
|
| 117 |
+
self.play_as_var = ctk.StringVar(value="White")
|
| 118 |
+
play_as_label = ctk.CTkLabel(left_frame, text="Play as:")
|
| 119 |
+
play_as_label.pack(side="left", padx=(0, 5))
|
| 120 |
+
self.play_as_menu = ctk.CTkOptionMenu(left_frame, variable=self.play_as_var,
|
| 121 |
+
values=["White", "Black"],
|
| 122 |
+
command=self.on_play_as_change,
|
| 123 |
+
width=80)
|
| 124 |
+
self.play_as_menu.pack(side="left", padx=5)
|
| 125 |
+
|
| 126 |
+
# First Move selector
|
| 127 |
+
self.first_move_var = ctk.StringVar(value="White")
|
| 128 |
+
first_move_label = ctk.CTkLabel(left_frame, text="First Move:")
|
| 129 |
+
first_move_label.pack(side="left", padx=(10, 5))
|
| 130 |
+
self.first_move_menu = ctk.CTkOptionMenu(left_frame, variable=self.first_move_var,
|
| 131 |
+
values=["White", "Black"],
|
| 132 |
+
command=self.on_first_move_change,
|
| 133 |
+
width=80)
|
| 134 |
+
self.first_move_menu.pack(side="left", padx=5)
|
| 135 |
+
|
| 136 |
+
# Flip Board button
|
| 137 |
+
self.flip_btn = ctk.CTkButton(left_frame, text="⟳ Flip Board", command=self.flip_board, width=100)
|
| 138 |
+
self.flip_btn.pack(side="left", padx=10)
|
| 139 |
+
|
| 140 |
+
# Right controls
|
| 141 |
+
right_frame = ctk.CTkFrame(self.controls_frame, fg_color="transparent")
|
| 142 |
+
right_frame.pack(side="right", padx=20)
|
| 143 |
+
|
| 144 |
+
# Best Moves count
|
| 145 |
+
moves_label = ctk.CTkLabel(right_frame, text="Show Best Moves:")
|
| 146 |
+
moves_label.pack(side="left", padx=(0, 5))
|
| 147 |
+
self.best_moves_var = ctk.StringVar(value="3")
|
| 148 |
+
self.best_moves_menu = ctk.CTkOptionMenu(right_frame, variable=self.best_moves_var,
|
| 149 |
+
values=["1", "2", "3"],
|
| 150 |
+
command=self.on_best_moves_change,
|
| 151 |
+
width=60)
|
| 152 |
+
self.best_moves_menu.pack(side="left", padx=5)
|
| 153 |
+
|
| 154 |
+
# Board UI
|
| 155 |
+
self.board_ui = BoardUI(self.content_frame, self.game_state)
|
| 156 |
+
self.board_ui.grid(row=0, column=0, padx=20, pady=20, sticky="nsew")
|
| 157 |
+
|
| 158 |
+
# Toggles Frame
|
| 159 |
+
toggles_frame = ctk.CTkFrame(self.content_frame, fg_color="transparent")
|
| 160 |
+
toggles_frame.grid(row=2, column=0, pady=5)
|
| 161 |
+
|
| 162 |
+
# Analysis Toggle
|
| 163 |
+
self.analysis_var = ctk.BooleanVar(value=False)
|
| 164 |
+
self.analysis_switch = ctk.CTkSwitch(toggles_frame, text="Analysis Mode",
|
| 165 |
+
variable=self.analysis_var, command=self.toggle_analysis)
|
| 166 |
+
self.analysis_switch.pack(side="left", padx=15)
|
| 167 |
+
|
| 168 |
+
# Two Player Mode Toggle
|
| 169 |
+
self.two_player_var = ctk.BooleanVar(value=False)
|
| 170 |
+
self.two_player_switch = ctk.CTkSwitch(toggles_frame, text="Two Player Mode",
|
| 171 |
+
variable=self.two_player_var, command=self.toggle_two_player)
|
| 172 |
+
self.two_player_switch.pack(side="left", padx=15)
|
| 173 |
+
|
| 174 |
+
# Force Move Button (Initially hidden or shown based on mode? Best to just have it handy)
|
| 175 |
+
self.force_move_btn = ctk.CTkButton(left_frame, text="⚡ Force Move", command=self.force_ai_move, width=100, fg_color="orange", hover_color="darkorange")
|
| 176 |
+
self.force_move_btn.pack(side="left", padx=10)
|
| 177 |
+
|
| 178 |
+
# Edit Mode Toggle
|
| 179 |
+
self.edit_mode_var = ctk.BooleanVar(value=False)
|
| 180 |
+
self.edit_mode_switch = ctk.CTkSwitch(toggles_frame, text="Edit Mode",
|
| 181 |
+
variable=self.edit_mode_var, command=self.toggle_edit_mode)
|
| 182 |
+
self.edit_mode_switch.pack(side="left", padx=15)
|
| 183 |
+
|
| 184 |
+
# Piece Palette (Hidden by default)
|
| 185 |
+
self.palette_frame = ctk.CTkFrame(self.content_frame, fg_color="transparent")
|
| 186 |
+
self.palette_frame.grid(row=3, column=0, pady=5)
|
| 187 |
+
self.palette_frame.grid_remove()
|
| 188 |
+
|
| 189 |
+
self.init_palette()
|
| 190 |
+
|
| 191 |
+
# Score display label
|
| 192 |
+
self.score_label = ctk.CTkLabel(self.content_frame, text="", font=("Arial", 12))
|
| 193 |
+
self.score_label.grid(row=4, column=0, pady=5)
|
| 194 |
+
|
| 195 |
+
# Bind move event
|
| 196 |
+
self.bind("<<MoveMade>>", self.on_move_made)
|
| 197 |
+
self.status_label.configure(text="Mode: vs AI (White)")
|
| 198 |
+
|
| 199 |
+
def start_screen_mirroring(self):
|
| 200 |
+
self.status_label.configure(text="Select Region to Mirror to...")
|
| 201 |
+
self.withdraw() # Hide main win
|
| 202 |
+
|
| 203 |
+
# Function called when region is selected
|
| 204 |
+
def on_selection(region):
|
| 205 |
+
self.deiconify() # Show main win
|
| 206 |
+
self.begin_mirroring(region)
|
| 207 |
+
|
| 208 |
+
SelectionOverlay(self, on_selection)
|
| 209 |
+
|
| 210 |
+
def begin_mirroring(self, region):
|
| 211 |
+
"""
|
| 212 |
+
Start mirroring moves to the selected region.
|
| 213 |
+
"""
|
| 214 |
+
self.mirror_region = region
|
| 215 |
+
self.mirroring = True
|
| 216 |
+
|
| 217 |
+
self.stop_btn.grid() # Show stop button
|
| 218 |
+
|
| 219 |
+
# Clear content and show mirroring UI
|
| 220 |
+
# We can actually KEEP the board UI for mirroring, so the user can play!
|
| 221 |
+
# The original analysis mode cleared everything. Mirroring implies playing locally.
|
| 222 |
+
|
| 223 |
+
# Just update status
|
| 224 |
+
self.status_label.configure(text="Mode: Screen Mirroring Active")
|
| 225 |
+
self.stop_btn.grid()
|
| 226 |
+
|
| 227 |
+
# Optional: Show overlay on the target region to confirm?
|
| 228 |
+
self.projection_overlay = ProjectionOverlay(region)
|
| 229 |
+
# Maybe draw a box or something? For now just keep it simple.
|
| 230 |
+
|
| 231 |
+
def stop_mirroring(self):
|
| 232 |
+
self.mirroring = False
|
| 233 |
+
self.mirror_region = None
|
| 234 |
+
|
| 235 |
+
if self.projection_overlay:
|
| 236 |
+
self.projection_overlay.destroy()
|
| 237 |
+
self.projection_overlay = None
|
| 238 |
+
|
| 239 |
+
self.stop_btn.grid_remove()
|
| 240 |
+
self.status_label.configure(text="Mirroring Stopped")
|
| 241 |
+
|
| 242 |
+
# Restore normal status text if game is ongoing
|
| 243 |
+
if not self.game_state.is_game_over():
|
| 244 |
+
turn_str = "White" if self.game_state.board.turn == chess.WHITE else "Black"
|
| 245 |
+
self.status_label.configure(text=f"Your Turn ({turn_str})")
|
| 246 |
+
|
| 247 |
+
def toggle_analysis(self):
|
| 248 |
+
if self.analysis_var.get():
|
| 249 |
+
self.update_analysis()
|
| 250 |
+
else:
|
| 251 |
+
self.board_ui.canvas.delete("arrow")
|
| 252 |
+
|
| 253 |
+
def toggle_two_player(self):
|
| 254 |
+
if self.two_player_var.get():
|
| 255 |
+
self.status_label.configure(text="Mode: Two Player")
|
| 256 |
+
turn = "White" if self.game_state.board.turn == chess.WHITE else "Black"
|
| 257 |
+
self.status_label.configure(text=f"{turn}'s Turn")
|
| 258 |
+
# Clear AI thinking text if any
|
| 259 |
+
if "Thinking" in self.status_label.cget("text"):
|
| 260 |
+
self.status_label.configure(text=f"{turn}'s Turn")
|
| 261 |
+
else:
|
| 262 |
+
self.status_label.configure(text="Mode: vs AI (White)")
|
| 263 |
+
if self.game_state.board.turn == chess.WHITE:
|
| 264 |
+
self.status_label.configure(text="Your Turn (White)")
|
| 265 |
+
|
| 266 |
+
def flip_board(self):
|
| 267 |
+
"""Flip the board orientation."""
|
| 268 |
+
if hasattr(self, 'board_ui') and self.board_ui:
|
| 269 |
+
self.board_ui.flipped = not getattr(self.board_ui, 'flipped', False)
|
| 270 |
+
self.board_ui.draw_board()
|
| 271 |
+
|
| 272 |
+
def on_play_as_change(self, value):
|
| 273 |
+
"""Handle play as color change."""
|
| 274 |
+
if self.edit_mode_var.get():
|
| 275 |
+
return # Do not reset if editing
|
| 276 |
+
self.reset_game()
|
| 277 |
+
|
| 278 |
+
def init_palette(self):
|
| 279 |
+
"""Initialize the piece palette for editing."""
|
| 280 |
+
pieces = [
|
| 281 |
+
(chess.PAWN, chess.WHITE, "♙"), (chess.KNIGHT, chess.WHITE, "♘"), (chess.BISHOP, chess.WHITE, "♗"),
|
| 282 |
+
(chess.ROOK, chess.WHITE, "♖"), (chess.QUEEN, chess.WHITE, "♕"), (chess.KING, chess.WHITE, "♔"),
|
| 283 |
+
(chess.PAWN, chess.BLACK, "♟"), (chess.KNIGHT, chess.BLACK, "♞"), (chess.BISHOP, chess.BLACK, "♝"),
|
| 284 |
+
(chess.ROOK, chess.BLACK, "♜"), (chess.QUEEN, chess.BLACK, "♛"), (chess.KING, chess.BLACK, "♚")
|
| 285 |
+
]
|
| 286 |
+
|
| 287 |
+
# White pieces row
|
| 288 |
+
for i, (pt, color, symbol) in enumerate(pieces[:6]):
|
| 289 |
+
btn = ctk.CTkButton(self.palette_frame, text=symbol, width=40, font=("Segoe UI Symbol", 24),
|
| 290 |
+
command=lambda p=chess.Piece(pt, color): self.select_palette_piece(p))
|
| 291 |
+
btn.grid(row=0, column=i, padx=2, pady=2)
|
| 292 |
+
|
| 293 |
+
# Black pieces row
|
| 294 |
+
for i, (pt, color, symbol) in enumerate(pieces[6:]):
|
| 295 |
+
btn = ctk.CTkButton(self.palette_frame, text=symbol, width=40, font=("Segoe UI Symbol", 24),
|
| 296 |
+
command=lambda p=chess.Piece(pt, color): self.select_palette_piece(p))
|
| 297 |
+
btn.grid(row=1, column=i, padx=2, pady=2)
|
| 298 |
+
|
| 299 |
+
# Trash / Delete
|
| 300 |
+
trash_btn = ctk.CTkButton(self.palette_frame, text="🗑", width=40, font=("Segoe UI Symbol", 20), fg_color="red", hover_color="darkred",
|
| 301 |
+
command=lambda: self.select_palette_piece(None))
|
| 302 |
+
trash_btn.grid(row=0, column=6, rowspan=2, padx=5, sticky="ns")
|
| 303 |
+
|
| 304 |
+
def select_palette_piece(self, piece):
|
| 305 |
+
self.board_ui.selected_edit_piece = piece
|
| 306 |
+
|
| 307 |
+
def toggle_edit_mode(self):
|
| 308 |
+
is_edit = self.edit_mode_var.get()
|
| 309 |
+
self.board_ui.edit_mode = is_edit
|
| 310 |
+
self.board_ui.selected_square = None # Clear selection
|
| 311 |
+
self.board_ui.draw_board()
|
| 312 |
+
|
| 313 |
+
if is_edit:
|
| 314 |
+
self.palette_frame.grid()
|
| 315 |
+
self.status_label.configure(text="Edit Mode: Select piece to place")
|
| 316 |
+
else:
|
| 317 |
+
self.palette_frame.grid_remove()
|
| 318 |
+
self.status_label.configure(text="Edit Mode Disabled")
|
| 319 |
+
# Resume game logic state
|
| 320 |
+
turn_str = "White" if self.game_state.board.turn == chess.WHITE else "Black"
|
| 321 |
+
self.status_label.configure(text=f"Your Turn ({turn_str})")
|
| 322 |
+
|
| 323 |
+
def on_first_move_change(self, value):
|
| 324 |
+
"""Handle first move color change."""
|
| 325 |
+
if self.edit_mode_var.get():
|
| 326 |
+
return # Do not reset if editing
|
| 327 |
+
self.reset_game()
|
| 328 |
+
|
| 329 |
+
def reset_game(self):
|
| 330 |
+
"""Reset the game state based on current controls."""
|
| 331 |
+
self.game_state.reset()
|
| 332 |
+
|
| 333 |
+
# Set turn based on First Move selection
|
| 334 |
+
first_move_color = chess.BLACK if self.first_move_var.get() == "Black" else chess.WHITE
|
| 335 |
+
self.game_state.board.turn = first_move_color
|
| 336 |
+
|
| 337 |
+
play_as = self.play_as_var.get()
|
| 338 |
+
|
| 339 |
+
if play_as == "Black":
|
| 340 |
+
# Flip board so black is at bottom
|
| 341 |
+
self.board_ui.flipped = True
|
| 342 |
+
else:
|
| 343 |
+
self.board_ui.flipped = False
|
| 344 |
+
|
| 345 |
+
# Check if AI should move
|
| 346 |
+
ai_color = chess.WHITE if play_as == "Black" else chess.BLACK
|
| 347 |
+
|
| 348 |
+
if self.game_state.board.turn == ai_color and not self.two_player_var.get():
|
| 349 |
+
self.status_label.configure(text="AI Thinking...")
|
| 350 |
+
threading.Thread(target=self.make_ai_move, daemon=True).start()
|
| 351 |
+
else:
|
| 352 |
+
turn_str = "White" if self.game_state.board.turn == chess.WHITE else "Black"
|
| 353 |
+
self.status_label.configure(text=f"Your Turn ({turn_str})")
|
| 354 |
+
|
| 355 |
+
self.board_ui.draw_board()
|
| 356 |
+
|
| 357 |
+
def on_best_moves_change(self, value):
|
| 358 |
+
"""Handle best moves count change."""
|
| 359 |
+
if self.analysis_var.get():
|
| 360 |
+
self.update_analysis()
|
| 361 |
+
|
| 362 |
+
def update_analysis(self):
|
| 363 |
+
if not self.analysis_var.get():
|
| 364 |
+
return
|
| 365 |
+
|
| 366 |
+
def _analyze():
|
| 367 |
+
fen = self.game_state.get_fen()
|
| 368 |
+
limit = int(self.best_moves_var.get()) if hasattr(self, 'best_moves_var') else 3
|
| 369 |
+
top_moves = self.engine.get_top_moves(fen, limit=limit)
|
| 370 |
+
self.after(0, lambda: self.display_analysis_results(top_moves))
|
| 371 |
+
|
| 372 |
+
threading.Thread(target=_analyze, daemon=True).start()
|
| 373 |
+
|
| 374 |
+
def display_analysis_results(self, top_moves):
|
| 375 |
+
"""Display analysis results with scores."""
|
| 376 |
+
if hasattr(self, 'board_ui') and self.board_ui:
|
| 377 |
+
self.board_ui.display_analysis(top_moves)
|
| 378 |
+
|
| 379 |
+
# Update score label
|
| 380 |
+
if hasattr(self, 'score_label') and top_moves:
|
| 381 |
+
score_texts = []
|
| 382 |
+
found_mate = False
|
| 383 |
+
for i, move_data in enumerate(top_moves):
|
| 384 |
+
move = move_data["move"]
|
| 385 |
+
score = move_data["score"]
|
| 386 |
+
# Convert score to more readable format
|
| 387 |
+
if abs(score) >= 9000:
|
| 388 |
+
# Mate score
|
| 389 |
+
found_mate = True
|
| 390 |
+
mate_in = (10000 - abs(score))
|
| 391 |
+
score_str = f"M{mate_in}" if score > 0 else f"-M{mate_in}"
|
| 392 |
+
|
| 393 |
+
if i == 0: # If top move is mate
|
| 394 |
+
if score > 0:
|
| 395 |
+
self.status_label.configure(text=f"White wins in {mate_in}!")
|
| 396 |
+
else:
|
| 397 |
+
self.status_label.configure(text=f"Black wins in {mate_in}!")
|
| 398 |
+
else:
|
| 399 |
+
score_str = f"{score/100:+.2f}"
|
| 400 |
+
|
| 401 |
+
colors = ["🟢", "🔵", "🟡"]
|
| 402 |
+
score_texts.append(f"{colors[i]} {move}: {score_str}")
|
| 403 |
+
|
| 404 |
+
self.score_label.configure(text=" | ".join(score_texts))
|
| 405 |
+
|
| 406 |
+
# If no mate found in top moves, ensure status doesn't stick (unless editing/thinking)
|
| 407 |
+
if not found_mate and not self.mirroring and not self.edit_mode_var.get() and "Thinking" not in self.status_label.cget("text"):
|
| 408 |
+
# Restore turn status
|
| 409 |
+
turn = "White" if self.game_state.board.turn == chess.WHITE else "Black"
|
| 410 |
+
if self.two_player_var.get():
|
| 411 |
+
self.status_label.configure(text=f"{turn}'s Turn")
|
| 412 |
+
# Else AI turn text is handled by 'AI Thinking' or 'Your Turn'
|
| 413 |
+
|
| 414 |
+
elif hasattr(self, 'score_label'):
|
| 415 |
+
self.score_label.configure(text="")
|
| 416 |
+
|
| 417 |
+
def on_move_made(self, event=None):
|
| 418 |
+
# Check for game over
|
| 419 |
+
if self.game_state.is_game_over():
|
| 420 |
+
result = self.game_state.board.result()
|
| 421 |
+
self.status_label.configure(text=f"Game Over: {result}")
|
| 422 |
+
# Do NOT return here, we might still want to mirror the last move that caused game over?
|
| 423 |
+
# Actually if game is over, we still want to update UI.
|
| 424 |
+
# But let's act normal.
|
| 425 |
+
|
| 426 |
+
# MIRROR LOGIC
|
| 427 |
+
if self.mirroring and event:
|
| 428 |
+
# Just made a move.
|
| 429 |
+
# The event might not carry the move info directly if it's a generic binding,
|
| 430 |
+
# but we can get the last move from the board stack.
|
| 431 |
+
try:
|
| 432 |
+
if self.game_state.board.move_stack:
|
| 433 |
+
last_move = self.game_state.board.peek()
|
| 434 |
+
# If it's a move made by the player (or AI if we want to mirror AI too)
|
| 435 |
+
# For now, let's mirror ALL moves (Player and AI) so the external board stays in sync?
|
| 436 |
+
# User said "whatever i move ... it will mirroe".
|
| 437 |
+
# If AI moves on local board, user probably wants that on external board too if playing against external opponent?
|
| 438 |
+
# OR if playing vs AI locally, and mirroring to analysis board.
|
| 439 |
+
# Let's mirror everything.
|
| 440 |
+
|
| 441 |
+
# Need to know if we are 'flipped' on the external board?
|
| 442 |
+
# User selects one region. We assume it matches our orientation?
|
| 443 |
+
# User request: "make the mirror doenst matter if i am black of white... it will mirror i move on the right"
|
| 444 |
+
# This implies they want the Target Orientation to MATCH the Local Orientation.
|
| 445 |
+
# If I am flipped (Black), Target is flipped (Black).
|
| 446 |
+
is_flipped = getattr(self.board_ui, 'flipped', False)
|
| 447 |
+
|
| 448 |
+
threading.Thread(target=self.mirror.execute_move, args=(last_move, self.mirror_region, is_flipped), daemon=True).start()
|
| 449 |
+
except Exception as e:
|
| 450 |
+
print(f"Mirror error: {e}")
|
| 451 |
+
|
| 452 |
+
if self.analysis_var.get():
|
| 453 |
+
self.update_analysis()
|
| 454 |
+
|
| 455 |
+
# Check if two player mode
|
| 456 |
+
if self.two_player_var.get():
|
| 457 |
+
# Two player mode - just update turn indicator
|
| 458 |
+
turn = "White" if self.game_state.board.turn == chess.WHITE else "Black"
|
| 459 |
+
self.status_label.configure(text=f"{turn}'s Turn")
|
| 460 |
+
else:
|
| 461 |
+
# vs AI mode
|
| 462 |
+
if self.game_state.board.turn == chess.BLACK:
|
| 463 |
+
# Trigger AI move
|
| 464 |
+
if not self.edit_mode_var.get() and not self.two_player_var.get():
|
| 465 |
+
self.status_label.configure(text="AI Thinking...")
|
| 466 |
+
threading.Thread(target=self.make_ai_move, daemon=True).start()
|
| 467 |
+
else:
|
| 468 |
+
self.status_label.configure(text="Your Turn (White)")
|
| 469 |
+
|
| 470 |
+
def make_ai_move(self):
|
| 471 |
+
if self.edit_mode_var.get():
|
| 472 |
+
return
|
| 473 |
+
|
| 474 |
+
# AI plays best move
|
| 475 |
+
best_move = self.engine.get_best_move(self.game_state.get_fen())
|
| 476 |
+
if best_move:
|
| 477 |
+
self.game_state.board.push(best_move)
|
| 478 |
+
self.after(0, self.update_board_after_ai)
|
| 479 |
+
else:
|
| 480 |
+
self.after(0, lambda: self.status_label.configure(text="Engine Error"))
|
| 481 |
+
|
| 482 |
+
def update_board_after_ai(self):
|
| 483 |
+
self.board_ui.draw_board()
|
| 484 |
+
self.status_label.configure(text="Your Turn")
|
| 485 |
+
|
| 486 |
+
if self.analysis_var.get():
|
| 487 |
+
self.update_analysis()
|
| 488 |
+
|
| 489 |
+
if self.game_state.is_game_over():
|
| 490 |
+
self.status_label.configure(text=f"Game Over: {self.game_state.board.result()}")
|
| 491 |
+
|
| 492 |
+
def force_ai_move(self):
|
| 493 |
+
"""Force the AI to make a move for the current side."""
|
| 494 |
+
if self.game_state.is_game_over():
|
| 495 |
+
return
|
| 496 |
+
|
| 497 |
+
self.status_label.configure(text="Forcing AI Move...")
|
| 498 |
+
threading.Thread(target=self.make_ai_move, daemon=True).start()
|
src/mirror.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pyautogui
|
| 2 |
+
import chess
|
| 3 |
+
import time
|
| 4 |
+
import math
|
| 5 |
+
|
| 6 |
+
class MirrorHandler:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
# Configure pyautogui to be a bit safer/slower if needed
|
| 9 |
+
pyautogui.FAILSAFE = True
|
| 10 |
+
pyautogui.PAUSE = 0.1
|
| 11 |
+
|
| 12 |
+
def execute_move(self, move, region, is_flipped=False):
|
| 13 |
+
"""
|
| 14 |
+
Execute a chess move on the screen region using mouse drag.
|
| 15 |
+
|
| 16 |
+
Args:
|
| 17 |
+
move (chess.Move): The move to execute.
|
| 18 |
+
region (dict): {'left', 'top', 'width', 'height'} of the target board.
|
| 19 |
+
is_flipped (bool): If True, board is viewed from Black's perspective (rank 1 at top).
|
| 20 |
+
"""
|
| 21 |
+
if not region or not move:
|
| 22 |
+
return
|
| 23 |
+
|
| 24 |
+
start_sq = move.from_square
|
| 25 |
+
end_sq = move.to_square
|
| 26 |
+
|
| 27 |
+
# Calculate coordinates
|
| 28 |
+
start_x, start_y = self._get_square_center(start_sq, region, is_flipped)
|
| 29 |
+
end_x, end_y = self._get_square_center(end_sq, region, is_flipped)
|
| 30 |
+
|
| 31 |
+
# Save current position to restore later?
|
| 32 |
+
# Actually user said "just use my mouse", implies they expect it to take over.
|
| 33 |
+
# But maybe restoring it is nice? Let's just do the move for now.
|
| 34 |
+
|
| 35 |
+
# Perform the drag
|
| 36 |
+
# Move to start
|
| 37 |
+
pyautogui.moveTo(start_x, start_y)
|
| 38 |
+
# Drag to end
|
| 39 |
+
pyautogui.dragTo(end_x, end_y, button='left')
|
| 40 |
+
|
| 41 |
+
# Check for promotion
|
| 42 |
+
if move.promotion:
|
| 43 |
+
# Assumes auto-queen or standard promotion UI (usually Queen is closest/first)
|
| 44 |
+
# This is tricky for generic sites.
|
| 45 |
+
# Often, clicking the target square again acts as "Question" or immediate select.
|
| 46 |
+
# For now, let's just click the target square again to select Queen if it pops up.
|
| 47 |
+
time.sleep(0.1)
|
| 48 |
+
pyautogui.click()
|
| 49 |
+
|
| 50 |
+
def _get_square_center(self, square, region, is_flipped):
|
| 51 |
+
"""
|
| 52 |
+
Calculate center x, y for a given square index (0-63).
|
| 53 |
+
"""
|
| 54 |
+
file_idx = chess.square_file(square)
|
| 55 |
+
rank_idx = chess.square_rank(square)
|
| 56 |
+
|
| 57 |
+
if is_flipped:
|
| 58 |
+
# Board is black options (rank 8 at bottom? Wait.)
|
| 59 |
+
# Standard: White at bottom (Rank 0 is bottom).
|
| 60 |
+
# Flipped: Black at bottom (Rank 7 is bottom, Rank 0 is top).
|
| 61 |
+
|
| 62 |
+
# If Flipped (Black at bottom):
|
| 63 |
+
# File 0 (a) is on the Right? No, usually board just rotates 180.
|
| 64 |
+
# Visual:
|
| 65 |
+
# White bottom: a1 bottom-left.
|
| 66 |
+
# Black bottom: h8 bottom-left.
|
| 67 |
+
|
| 68 |
+
# Let's assume Standard Rotation (180 degrees):
|
| 69 |
+
# a1 (0,0) becomes top-right.
|
| 70 |
+
# h8 (7,7) becomes bottom-left.
|
| 71 |
+
|
| 72 |
+
# Let's map visual col/row (0-7 from top-left)
|
| 73 |
+
# Normal:
|
| 74 |
+
# col = file_idx
|
| 75 |
+
# row = 7 - rank_idx
|
| 76 |
+
|
| 77 |
+
# Flipped:
|
| 78 |
+
# col = 7 - file_idx
|
| 79 |
+
# row = rank_idx
|
| 80 |
+
|
| 81 |
+
col = 7 - file_idx
|
| 82 |
+
row = rank_idx
|
| 83 |
+
else:
|
| 84 |
+
# Standard (White at bottom)
|
| 85 |
+
# a1 is bottom-left
|
| 86 |
+
col = file_idx
|
| 87 |
+
row = 7 - rank_idx
|
| 88 |
+
|
| 89 |
+
sq_w = region['width'] / 8
|
| 90 |
+
sq_h = region['height'] / 8
|
| 91 |
+
|
| 92 |
+
center_x = region['left'] + (col * sq_w) + (sq_w / 2)
|
| 93 |
+
center_y = region['top'] + (row * sq_h) + (sq_h / 2)
|
| 94 |
+
|
| 95 |
+
return center_x, center_y
|
src/overlay.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import customtkinter as ctk
|
| 2 |
+
import tkinter as tk
|
| 3 |
+
import chess
|
| 4 |
+
import win32gui
|
| 5 |
+
import win32con
|
| 6 |
+
import win32api
|
| 7 |
+
|
| 8 |
+
class SelectionOverlay(ctk.CTkToplevel):
|
| 9 |
+
def __init__(self, master, on_select_callback):
|
| 10 |
+
super().__init__(master)
|
| 11 |
+
|
| 12 |
+
self.on_select_callback = on_select_callback
|
| 13 |
+
|
| 14 |
+
self.attributes("-alpha", 0.3)
|
| 15 |
+
self.attributes("-fullscreen", True)
|
| 16 |
+
self.attributes("-topmost", True)
|
| 17 |
+
self.configure(bg="black")
|
| 18 |
+
|
| 19 |
+
self.start_x = None
|
| 20 |
+
self.start_y = None
|
| 21 |
+
self.rect = None
|
| 22 |
+
|
| 23 |
+
self.canvas = ctk.CTkCanvas(self, cursor="cross", bg="grey15", highlightthickness=0)
|
| 24 |
+
self.canvas.pack(fill="both", expand=True)
|
| 25 |
+
|
| 26 |
+
self.canvas.bind("<ButtonPress-1>", self.on_press)
|
| 27 |
+
self.canvas.bind("<B1-Motion>", self.on_drag)
|
| 28 |
+
self.canvas.bind("<ButtonRelease-1>", self.on_release)
|
| 29 |
+
|
| 30 |
+
# Exit on Escape
|
| 31 |
+
self.bind("<Escape>", lambda e: self.destroy())
|
| 32 |
+
|
| 33 |
+
def on_press(self, event):
|
| 34 |
+
self.start_x = event.x
|
| 35 |
+
self.start_y = event.y
|
| 36 |
+
if self.rect:
|
| 37 |
+
self.canvas.delete(self.rect)
|
| 38 |
+
self.rect = self.canvas.create_rectangle(self.start_x, self.start_y, self.start_x, self.start_y, outline="red", width=2)
|
| 39 |
+
|
| 40 |
+
def on_drag(self, event):
|
| 41 |
+
cur_x, cur_y = (event.x, event.y)
|
| 42 |
+
self.canvas.coords(self.rect, self.start_x, self.start_y, cur_x, cur_y)
|
| 43 |
+
|
| 44 |
+
def on_release(self, event):
|
| 45 |
+
end_x, end_y = (event.x, event.y)
|
| 46 |
+
|
| 47 |
+
# Calculate region
|
| 48 |
+
left = min(self.start_x, end_x)
|
| 49 |
+
top = min(self.start_y, end_y)
|
| 50 |
+
width = abs(self.start_x - end_x)
|
| 51 |
+
height = abs(self.start_y - end_y)
|
| 52 |
+
|
| 53 |
+
if width > 50 and height > 50:
|
| 54 |
+
self.on_select_callback({'left': left, 'top': top, 'width': width, 'height': height})
|
| 55 |
+
self.destroy()
|
| 56 |
+
else:
|
| 57 |
+
# Too small, just reset
|
| 58 |
+
pass
|
| 59 |
+
|
| 60 |
+
class ProjectionOverlay(ctk.CTkToplevel):
|
| 61 |
+
def __init__(self, region):
|
| 62 |
+
"""
|
| 63 |
+
region: {'left': int, 'top': int, 'width': int, 'height': int}
|
| 64 |
+
"""
|
| 65 |
+
super().__init__()
|
| 66 |
+
|
| 67 |
+
self.region = region
|
| 68 |
+
# Position exactly over the board
|
| 69 |
+
self.geometry(f"{region['width']}x{region['height']}+{region['left']}+{region['top']}")
|
| 70 |
+
|
| 71 |
+
# Remove title bar
|
| 72 |
+
self.overrideredirect(True)
|
| 73 |
+
self.attributes("-topmost", True)
|
| 74 |
+
|
| 75 |
+
# Transparency setup for Windows
|
| 76 |
+
# We use a specific color as the transparent key.
|
| 77 |
+
self.transparent_color = "#000001" # Very nearly black
|
| 78 |
+
self.configure(bg=self.transparent_color)
|
| 79 |
+
self.wm_attributes("-transparentcolor", self.transparent_color)
|
| 80 |
+
|
| 81 |
+
# Canvas for drawing
|
| 82 |
+
self.canvas = tk.Canvas(self, bg=self.transparent_color, highlightthickness=0)
|
| 83 |
+
self.canvas.pack(fill="both", expand=True)
|
| 84 |
+
|
| 85 |
+
# Make click-through
|
| 86 |
+
self.make_click_through()
|
| 87 |
+
|
| 88 |
+
def make_click_through(self):
|
| 89 |
+
hwnd = win32gui.GetParent(self.winfo_id())
|
| 90 |
+
# If GetParent returns 0, try the window id directly (sometimes needed for Toplevels without parents)
|
| 91 |
+
if hwnd == 0:
|
| 92 |
+
hwnd = self.winfo_id()
|
| 93 |
+
|
| 94 |
+
styles = win32gui.GetWindowLong(hwnd, win32con.GWL_EXSTYLE)
|
| 95 |
+
styles = styles | win32con.WS_EX_LAYERED | win32con.WS_EX_TRANSPARENT
|
| 96 |
+
win32gui.SetWindowLong(hwnd, win32con.GWL_EXSTYLE, styles)
|
| 97 |
+
|
| 98 |
+
def draw_best_move(self, move):
|
| 99 |
+
"""
|
| 100 |
+
Draw an arrow for the best move.
|
| 101 |
+
move: python-chess Move object (e.g. e2e4)
|
| 102 |
+
"""
|
| 103 |
+
self.canvas.delete("all")
|
| 104 |
+
|
| 105 |
+
if not move:
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
# Calculate coordinates
|
| 109 |
+
# File 0-7 (a-h), Rank 0-7 (1-8)
|
| 110 |
+
# 0,0 is bottom-left (a1)
|
| 111 |
+
|
| 112 |
+
sq_w = self.region['width'] / 8
|
| 113 |
+
sq_h = self.region['height'] / 8
|
| 114 |
+
|
| 115 |
+
start_sq = move.from_square
|
| 116 |
+
end_sq = move.to_square
|
| 117 |
+
|
| 118 |
+
# Convert to x,y (top-left origin for drawing)
|
| 119 |
+
# File (x) is same
|
| 120 |
+
# Rank (y) needs flip: Rank 0 is at bottom (y moves down) -> Rank 7 is top (y=0)
|
| 121 |
+
# Rank r -> visual row (7-r)
|
| 122 |
+
|
| 123 |
+
x1 = (chess.square_file(start_sq) + 0.5) * sq_w
|
| 124 |
+
y1 = ((7 - chess.square_rank(start_sq)) + 0.5) * sq_h
|
| 125 |
+
|
| 126 |
+
x2 = (chess.square_file(end_sq) + 0.5) * sq_w
|
| 127 |
+
y2 = ((7 - chess.square_rank(end_sq)) + 0.5) * sq_h
|
| 128 |
+
|
| 129 |
+
# Draw Arrow
|
| 130 |
+
self.canvas.create_line(x1, y1, x2, y2, fill="#00ff00", width=6, arrow=tk.LAST, arrowshape=(16, 20, 6), tag="arrow")
|
| 131 |
+
|
| 132 |
+
# Draw Circle on target
|
| 133 |
+
r = min(sq_w, sq_h) * 0.2
|
| 134 |
+
self.canvas.create_oval(x2-r, y2-r, x2+r, y2+r, outline="#00ff00", width=4, tag="arrow")
|
| 135 |
+
|
| 136 |
+
def clear(self):
|
| 137 |
+
self.canvas.delete("all")
|
src/vision.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import mss
|
| 4 |
+
|
| 5 |
+
class VisionHandler:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
self.templates = {}
|
| 8 |
+
self.is_calibrated = False
|
| 9 |
+
|
| 10 |
+
def capture_screen(self, region):
|
| 11 |
+
"""
|
| 12 |
+
Capture a specific region of the screen.
|
| 13 |
+
region: {'top': int, 'left': int, 'width': int, 'height': int}
|
| 14 |
+
"""
|
| 15 |
+
with mss.mss() as sct:
|
| 16 |
+
# mss requires int for all values
|
| 17 |
+
monitor = {
|
| 18 |
+
"top": int(region["top"]),
|
| 19 |
+
"left": int(region["left"]),
|
| 20 |
+
"width": int(region["width"]),
|
| 21 |
+
"height": int(region["height"])
|
| 22 |
+
}
|
| 23 |
+
screenshot = sct.grab(monitor)
|
| 24 |
+
img = np.array(screenshot)
|
| 25 |
+
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
| 26 |
+
|
| 27 |
+
def split_board(self, board_image):
|
| 28 |
+
"""
|
| 29 |
+
Split board image into 64 squares.
|
| 30 |
+
Assumption: board_image is cropped exactly to the board edges.
|
| 31 |
+
"""
|
| 32 |
+
h, w, _ = board_image.shape
|
| 33 |
+
sq_h, sq_w = h // 8, w // 8
|
| 34 |
+
squares = []
|
| 35 |
+
# Row-major order (rank 8 down to rank 1)
|
| 36 |
+
# Standard FEN order: Rank 8 (index 0) to Rank 1 (index 7)
|
| 37 |
+
for r in range(8):
|
| 38 |
+
row_squares = []
|
| 39 |
+
for c in range(8):
|
| 40 |
+
# Add a small crop margin to avoid border noise
|
| 41 |
+
margin_h = int(sq_h * 0.1)
|
| 42 |
+
margin_w = int(sq_w * 0.1)
|
| 43 |
+
|
| 44 |
+
y1 = r * sq_h + margin_h
|
| 45 |
+
y2 = (r + 1) * sq_h - margin_h
|
| 46 |
+
x1 = c * sq_w + margin_w
|
| 47 |
+
x2 = (c + 1) * sq_w - margin_w
|
| 48 |
+
|
| 49 |
+
square = board_image[y1:y2, x1:x2]
|
| 50 |
+
row_squares.append(square)
|
| 51 |
+
squares.append(row_squares)
|
| 52 |
+
return squares
|
| 53 |
+
|
| 54 |
+
def calibrate(self, board_image):
|
| 55 |
+
"""
|
| 56 |
+
Calibrate by learning piece appearance from a starting position board image.
|
| 57 |
+
Standard Starting Position:
|
| 58 |
+
r n b q k b n r
|
| 59 |
+
p p p p p p p p
|
| 60 |
+
. . . . . . . .
|
| 61 |
+
. . . . . . . .
|
| 62 |
+
. . . . . . . .
|
| 63 |
+
. . . . . . . .
|
| 64 |
+
P P P P P P P P
|
| 65 |
+
R N B Q K B N R
|
| 66 |
+
"""
|
| 67 |
+
squares = self.split_board(board_image)
|
| 68 |
+
self.templates = {}
|
| 69 |
+
|
| 70 |
+
# Dictionary mapping piece chars to list of coordinates (row, col) in start pos
|
| 71 |
+
# We'll take the average or just the first instance as template
|
| 72 |
+
# 0-indexed rows: 0=BlackBack, 1=BlackPawn, ... 6=WhitePawn, 7=WhiteBack
|
| 73 |
+
|
| 74 |
+
piece_map = {
|
| 75 |
+
'r': [(0, 0), (0, 7)],
|
| 76 |
+
'n': [(0, 1), (0, 6)],
|
| 77 |
+
'b': [(0, 2), (0, 5)],
|
| 78 |
+
'q': [(0, 3)],
|
| 79 |
+
'k': [(0, 4)],
|
| 80 |
+
'p': [(1, i) for i in range(8)],
|
| 81 |
+
'R': [(7, 0), (7, 7)],
|
| 82 |
+
'N': [(7, 1), (7, 6)],
|
| 83 |
+
'B': [(7, 2), (7, 5)],
|
| 84 |
+
'Q': [(7, 3)],
|
| 85 |
+
'K': [(7, 4)],
|
| 86 |
+
'P': [(6, i) for i in range(8)],
|
| 87 |
+
'.': [] # Empty squares
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
# Add empty squares (rows 2, 3, 4, 5)
|
| 91 |
+
for r in range(2, 6):
|
| 92 |
+
for c in range(8):
|
| 93 |
+
piece_map['.'].append((r, c))
|
| 94 |
+
|
| 95 |
+
# Store one template per piece type (simplest approach)
|
| 96 |
+
# Better: Store all samples and use KNN, but simple template match might suffice for consistent graphics
|
| 97 |
+
|
| 98 |
+
for p_char, coords in piece_map.items():
|
| 99 |
+
if not coords:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
# Use the first coordinate as the primary template
|
| 103 |
+
r, c = coords[0]
|
| 104 |
+
template = squares[r][c]
|
| 105 |
+
|
| 106 |
+
# We convert to grayscale for simpler matching
|
| 107 |
+
gray_template = cv2.cvtColor(template, cv2.COLOR_BGR2GRAY)
|
| 108 |
+
|
| 109 |
+
# Store just one template for now
|
| 110 |
+
self.templates[p_char] = gray_template
|
| 111 |
+
|
| 112 |
+
self.is_calibrated = True
|
| 113 |
+
print("Calibration complete.")
|
| 114 |
+
|
| 115 |
+
def match_square(self, square_img):
|
| 116 |
+
if not self.templates:
|
| 117 |
+
return '.'
|
| 118 |
+
|
| 119 |
+
gray_sq = cv2.cvtColor(square_img, cv2.COLOR_BGR2GRAY)
|
| 120 |
+
|
| 121 |
+
best_score = float('inf')
|
| 122 |
+
best_piece = '.'
|
| 123 |
+
|
| 124 |
+
# Compare against all templates using MSE
|
| 125 |
+
# Note: Templates must be same size. If slightly different due to rounding, resize.
|
| 126 |
+
|
| 127 |
+
target_h, target_w = gray_sq.shape
|
| 128 |
+
|
| 129 |
+
for p_char, template in self.templates.items():
|
| 130 |
+
# Resize template to match square if needed (should be identical if grid is uniform)
|
| 131 |
+
if template.shape != gray_sq.shape:
|
| 132 |
+
template = cv2.resize(template, (target_w, target_h))
|
| 133 |
+
|
| 134 |
+
# Mean Squared Error
|
| 135 |
+
err = np.sum((gray_sq.astype("float") - template.astype("float")) ** 2)
|
| 136 |
+
err /= float(gray_sq.shape[0] * gray_sq.shape[1])
|
| 137 |
+
|
| 138 |
+
if err < best_score:
|
| 139 |
+
best_score = err
|
| 140 |
+
best_piece = p_char
|
| 141 |
+
|
| 142 |
+
return best_piece
|
| 143 |
+
|
| 144 |
+
def get_fen_from_image(self, board_image):
|
| 145 |
+
if not self.is_calibrated:
|
| 146 |
+
# Fallback or error
|
| 147 |
+
return None
|
| 148 |
+
|
| 149 |
+
squares = self.split_board(board_image)
|
| 150 |
+
fen_rows = []
|
| 151 |
+
|
| 152 |
+
for r in range(8):
|
| 153 |
+
empty_count = 0
|
| 154 |
+
row_str = ""
|
| 155 |
+
for c in range(8):
|
| 156 |
+
piece = self.match_square(squares[r][c])
|
| 157 |
+
|
| 158 |
+
if piece == '.':
|
| 159 |
+
empty_count += 1
|
| 160 |
+
else:
|
| 161 |
+
if empty_count > 0:
|
| 162 |
+
row_str += str(empty_count)
|
| 163 |
+
empty_count = 0
|
| 164 |
+
row_str += piece
|
| 165 |
+
|
| 166 |
+
if empty_count > 0:
|
| 167 |
+
row_str += str(empty_count)
|
| 168 |
+
|
| 169 |
+
fen_rows.append(row_str)
|
| 170 |
+
|
| 171 |
+
# Join with '/'
|
| 172 |
+
fen_board = "/".join(fen_rows)
|
| 173 |
+
|
| 174 |
+
# Add default game state info (w KQkq - 0 1)
|
| 175 |
+
# TODO: Detect turn based on external logic or diff?
|
| 176 |
+
# For now, we might alternate or assume it's always the user's turn to move?
|
| 177 |
+
# Actually, if we are just showing analysis, we want the engine to evaluate for the SIDE TO MOVE.
|
| 178 |
+
# But we don't know who is to move just from the static board.
|
| 179 |
+
# We can try to infer from previous state or just ask the user.
|
| 180 |
+
# For this version, let's return the board part, and handle the rest in logic.
|
| 181 |
+
|
| 182 |
+
return f"{fen_board} w KQkq - 0 1"
|
tests/test_engine.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
import os
|
| 3 |
+
import unittest
|
| 4 |
+
|
| 5 |
+
# Add src to path
|
| 6 |
+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
| 7 |
+
|
| 8 |
+
from src.engine import EngineHandler
|
| 9 |
+
from src.game_state import GameState
|
| 10 |
+
|
| 11 |
+
class TestChessLogic(unittest.TestCase):
|
| 12 |
+
def test_game_state_init(self):
|
| 13 |
+
gs = GameState()
|
| 14 |
+
self.assertEqual(gs.get_fen(), "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
| 15 |
+
|
| 16 |
+
def test_make_move(self):
|
| 17 |
+
gs = GameState()
|
| 18 |
+
# e2e4
|
| 19 |
+
success = gs.make_move("e2e4")
|
| 20 |
+
self.assertTrue(success)
|
| 21 |
+
self.assertNotEqual(gs.get_fen(), "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")
|
| 22 |
+
|
| 23 |
+
def test_engine_missing(self):
|
| 24 |
+
# Should handle missing engine gracefully
|
| 25 |
+
engine = EngineHandler("non_existent_stockfish.exe")
|
| 26 |
+
success, msg = engine.initialize_engine()
|
| 27 |
+
self.assertFalse(success)
|
| 28 |
+
self.assertIn("not found", msg)
|
| 29 |
+
|
| 30 |
+
if __name__ == '__main__':
|
| 31 |
+
unittest.main()
|
tests/test_engine_manual.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
# Add src to path
|
| 6 |
+
sys.path.append(os.path.join(os.getcwd()))
|
| 7 |
+
|
| 8 |
+
from src.engine import EngineHandler
|
| 9 |
+
|
| 10 |
+
def test_engine():
|
| 11 |
+
print("Testing EngineHandler...")
|
| 12 |
+
engine = EngineHandler()
|
| 13 |
+
success, msg = engine.initialize_engine()
|
| 14 |
+
|
| 15 |
+
if not success:
|
| 16 |
+
print(f"FAILED: {msg}")
|
| 17 |
+
return
|
| 18 |
+
|
| 19 |
+
print(f"SUCCESS: {msg}")
|
| 20 |
+
|
| 21 |
+
# Test FEN (Start Position)
|
| 22 |
+
fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
| 23 |
+
|
| 24 |
+
print("\nTesting get_top_moves(limit=3)...")
|
| 25 |
+
top_moves = engine.get_top_moves(fen, limit=3)
|
| 26 |
+
|
| 27 |
+
if len(top_moves) == 0:
|
| 28 |
+
print("FAILED: No moves returned. (Check stockfish path?)")
|
| 29 |
+
else:
|
| 30 |
+
print(f"SUCCESS: Returned {len(top_moves)} moves.")
|
| 31 |
+
for m in top_moves:
|
| 32 |
+
print(f"Rank {m['rank']}: {m['move']} (Score: {m['score']})")
|
| 33 |
+
|
| 34 |
+
engine.quit()
|
| 35 |
+
|
| 36 |
+
if __name__ == "__main__":
|
| 37 |
+
test_engine()
|
tests/test_mirror.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import chess
|
| 3 |
+
from src.mirror import MirrorHandler
|
| 4 |
+
|
| 5 |
+
class TestMirrorLogic(unittest.TestCase):
|
| 6 |
+
def setUp(self):
|
| 7 |
+
self.mirror = MirrorHandler()
|
| 8 |
+
self.region = {'left': 100, 'top': 100, 'width': 800, 'height': 800}
|
| 9 |
+
|
| 10 |
+
def test_coordinate_mapping_standard(self):
|
| 11 |
+
# Test a1 (start square)
|
| 12 |
+
# Standard: a1 is bottom-left (0,0) in cartesian, but for screen pixels:
|
| 13 |
+
# col 0, row 7 (since y increases downwards)
|
| 14 |
+
# region top=100. height=800. square height=100.
|
| 15 |
+
# row 7 y range: 100 + 700 = 800 to 900. Center = 850.
|
| 16 |
+
# col 0 x range: 100 + 0 = 100 to 200. Center = 150.
|
| 17 |
+
|
| 18 |
+
sq = chess.A1
|
| 19 |
+
x, y = self.mirror._get_square_center(sq, self.region, is_flipped=False)
|
| 20 |
+
self.assertEqual(x, 150)
|
| 21 |
+
self.assertEqual(y, 850)
|
| 22 |
+
|
| 23 |
+
# Test h8 (top-right)
|
| 24 |
+
# col 7, row 0
|
| 25 |
+
# x: 100 + 700 + 50 = 850
|
| 26 |
+
# y: 100 + 0 + 50 = 150
|
| 27 |
+
sq = chess.H8
|
| 28 |
+
x, y = self.mirror._get_square_center(sq, self.region, is_flipped=False)
|
| 29 |
+
self.assertEqual(x, 850)
|
| 30 |
+
self.assertEqual(y, 150)
|
| 31 |
+
|
| 32 |
+
def test_coordinate_mapping_flipped(self):
|
| 33 |
+
# Flipped: Black at bottom.
|
| 34 |
+
# a1 (0,0) becomes top-right?
|
| 35 |
+
# Let's re-verify logic:
|
| 36 |
+
# Flipped: col = 7 - file_idx, row = rank_idx
|
| 37 |
+
|
| 38 |
+
# a1: file 0, rank 0
|
| 39 |
+
# col = 7, row = 0
|
| 40 |
+
# x: 850, y: 150 (Top-Right) -> Correct for rotated 180.
|
| 41 |
+
|
| 42 |
+
sq = chess.A1
|
| 43 |
+
x, y = self.mirror._get_square_center(sq, self.region, is_flipped=True)
|
| 44 |
+
self.assertEqual(x, 850)
|
| 45 |
+
self.assertEqual(y, 150)
|
| 46 |
+
|
| 47 |
+
# h8: file 7, rank 7
|
| 48 |
+
# col = 0, row = 7
|
| 49 |
+
# x: 150, y: 850 (Bottom-Left)
|
| 50 |
+
sq = chess.H8
|
| 51 |
+
x, y = self.mirror._get_square_center(sq, self.region, is_flipped=True)
|
| 52 |
+
self.assertEqual(x, 150)
|
| 53 |
+
self.assertEqual(y, 850)
|
| 54 |
+
|
| 55 |
+
if __name__ == '__main__':
|
| 56 |
+
unittest.main()
|