Skip to content

Commit 93caa5c

Browse files
committed
promoted piece selection from webUI
1 parent 8244237 commit 93caa5c

9 files changed

Lines changed: 217 additions & 7 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Features that differentiate this fork from the original Concept-Bytes project:
2323
- **Draw/Resign**: Lift both kings (non playing color first) off the board and hold them lifted for 2 seconds to end the game.
2424
- **Castling**: Castling is now possible, just move the king 2 squares towards the side you want to castle and it will show you where to move the rook.
2525
- **En passant**: En passant captures are now possible. Also correctly sets en passant square in the FEN (so Stockfish can take en passant too)
26+
- **Promotion**: The promoted piece can now be picked from the WebUI. If the WebUI is not open, Queen is automatically picked.
2627
- **Bot**: You can now pick the bot starting side and difficulty
2728
- **Turns**: Doesn't allow white to play infinite moves in a row, enforces turns
2829
- **Calibration**: Automatically orders GPIOs, shift-register outputs and LED index mapping. You won't need to care about pin order or LED strip layout. In simple terms: it can rotate/flip the board. Also makes it easier to throubleshoot magnet detection issues by printing info in the serial monitor console.

data/board.html.gz

349 Bytes
Binary file not shown.

data/css/styles.css.gz

104 Bytes
Binary file not shown.

src/chess_game.cpp

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,44 @@ void ChessGame::applyMove(int fromRow, int fromCol, int toRow, int toCol, char p
123123
}
124124

125125
if (chessEngine->isPawnPromotion(piece, toRow)) {
126-
promotion = promotion != ' ' && promotion != '\0' ? (ChessUtils::isWhitePiece(piece) ? toupper(promotion) : tolower(promotion)) : (ChessUtils::isWhitePiece(piece) ? 'Q' : 'q');
126+
if (!replaying) boardDriver->promotionAnimation(toCol);
127+
// If promotion piece is already specified (from bot, lichess, replay), use it
128+
if (promotion != ' ' && promotion != '\0') {
129+
promotion = ChessUtils::isWhitePiece(piece) ? toupper(promotion) : tolower(promotion);
130+
} else if (!replaying && !isRemoteMove && wifiManager->isWebClientConnected()) {
131+
// Acquire LED mutex so any queued animation (blink/capture) finishes first, then show Yellow LED on the promotion square while waiting
132+
boardDriver->acquireLEDs();
133+
boardDriver->clearAllLEDs(false);
134+
boardDriver->setSquareLED(toRow, toCol, LedColors::Yellow);
135+
boardDriver->showLEDs();
136+
boardDriver->releaseLEDs();
137+
// Wait for user to choose promotion piece
138+
wifiManager->startPromotionWait(ChessUtils::getPieceColor(piece));
139+
unsigned long promotionStart = millis();
140+
const unsigned long PROMOTION_TIMEOUT_MS = 60000; // 60 second timeout
141+
while (wifiManager->isPromotionPending() && wifiManager->getPromotionChoice() == ' ') {
142+
if (millis() - promotionStart >= PROMOTION_TIMEOUT_MS) {
143+
Serial.println("Promotion timeout - defaulting to queen");
144+
break;
145+
}
146+
delay(100);
147+
}
148+
149+
promotion = wifiManager->getPromotionChoice();
150+
wifiManager->clearPromotion();
151+
boardDriver->clearAllLEDs();
152+
153+
// If timed out (no choice received), default to queen
154+
if (promotion == ' ')
155+
promotion = ChessUtils::isWhitePiece(piece) ? 'Q' : 'q';
156+
else
157+
promotion = ChessUtils::isWhitePiece(piece) ? toupper(promotion) : tolower(promotion);
158+
} else {
159+
// No web client, default to queen
160+
promotion = ChessUtils::isWhitePiece(piece) ? 'Q' : 'q';
161+
}
127162
board[toRow][toCol] = promotion;
128163
Serial.printf("Pawn promoted to %c\n", promotion);
129-
if (!replaying) boardDriver->promotionAnimation(toCol);
130164
}
131165

132166
if (moveHistory && moveHistory->isRecording())

src/chess_lichess.cpp

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,10 +143,13 @@ void ChessLichess::update() {
143143

144144
if ((currentTurn == myColor) && tryPlayerMove(myColor, fromRow, fromCol, toRow, toCol)) {
145145
// Player's turn - handle physical move
146-
if (chessEngine->isPawnPromotion(board[fromRow][fromCol], toRow))
147-
promotion = 'q'; // Default promotion to queen, can be enhanced to allow player choice later
148-
// Process locally FIRST - show animations immediately
149-
applyMove(fromRow, fromCol, toRow, toCol, promotion);
146+
// Check if this will be a promotion BEFORE applyMove modifies the board
147+
bool isPromotion = chessEngine->isPawnPromotion(board[fromRow][fromCol], toRow);
148+
// Promotion is handled inside applyMove: if web client is connected, it waits for user choice, otherwise it defaults to queen.
149+
applyMove(fromRow, fromCol, toRow, toCol);
150+
// After applyMove, retrieve the actual promotion piece used
151+
if (isPromotion)
152+
promotion = tolower(board[toRow][toCol]);
150153
updateGameStatus();
151154
wifiManager->updateBoardState(ChessUtils::boardToFEN(board, currentTurn, chessEngine), ChessUtils::evaluatePosition(board));
152155
// Then send move to Lichess (blocking)

src/web/board.html

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ <h3>
3737
</div>
3838
</div>
3939

40+
<!-- Promotion overlay -->
41+
<div id="promotionOverlay" class="settings-overlay">
42+
<div class="promotion-popup">
43+
<h3>Promote Pawn</h3>
44+
<div class="promotion-pieces" id="promotionPieces">
45+
<!-- Pieces injected dynamically based on color -->
46+
</div>
47+
</div>
48+
</div>
49+
4050
<!-- Settings popup overlay -->
4151
<div id="settingsPopup" class="settings-overlay">
4252
<div class="settings-popup">
@@ -1402,6 +1412,50 @@ <h3>
14021412
text.style.color = textColor;
14031413
}
14041414

1415+
// ==========================================
1416+
// Promotion overlay
1417+
// ==========================================
1418+
1419+
let promotionPending = false;
1420+
let promotionCooldownUntil = 0;
1421+
1422+
function showPromotionOverlay(color) {
1423+
const pieces = (color === 'w') ? [{ code: 'q', piece: 'wQ' }, { code: 'r', piece: 'wR' }, { code: 'b', piece: 'wB' }, { code: 'n', piece: 'wN' }] : [{ code: 'q', piece: 'bQ' }, { code: 'r', piece: 'bR' }, { code: 'b', piece: 'bB' }, { code: 'n', piece: 'bN' }];
1424+
1425+
const container = $('#promotionPieces');
1426+
container.empty();
1427+
pieces.forEach(p => {
1428+
const btn = $('<button class="promotion-piece-btn" data-piece="' + p.code + '"></button>');
1429+
btn.append('<img src="' + pieceTheme(p.piece) + '" alt="' + p.piece + '">');
1430+
btn.on('click', function () {
1431+
selectPromotion(p.code);
1432+
});
1433+
container.append(btn);
1434+
});
1435+
1436+
promotionPending = true;
1437+
$('#promotionOverlay').addClass('visible');
1438+
}
1439+
1440+
function hidePromotionOverlay() {
1441+
promotionPending = false;
1442+
$('#promotionOverlay').removeClass('visible');
1443+
}
1444+
1445+
function selectPromotion(piece) {
1446+
hidePromotionOverlay();
1447+
promotionCooldownUntil = Date.now() + 2000;
1448+
fetch('/promotion', {
1449+
method: 'POST',
1450+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1451+
body: 'piece=' + encodeURIComponent(piece)
1452+
}).then(r => {
1453+
if (!r.ok) console.log('Failed to send promotion choice');
1454+
}).catch(e => {
1455+
console.log('Promotion request error:', e);
1456+
});
1457+
}
1458+
14051459
// Fetch board state from server
14061460
function fetchBoardState() {
14071461
if (editMode || reviewMode) return;
@@ -1479,6 +1533,13 @@ <h3>
14791533
if (data.evaluation !== undefined && isLive) {
14801534
updateEvaluationBar(data.evaluation);
14811535
}
1536+
// Show promotion overlay if server is waiting for a promotion choice
1537+
if (data.promotion && !promotionPending && Date.now() > promotionCooldownUntil) {
1538+
showPromotionOverlay(data.promotion.color);
1539+
} else if (!data.promotion && promotionPending) {
1540+
// Server no longer waiting (e.g. timed out) - hide overlay
1541+
hidePromotionOverlay();
1542+
}
14821543
})
14831544
.catch(error => {
14841545
console.log('Fetch failed:', error);

src/web/css/styles.css

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1425,4 +1425,47 @@ input[type="submit"]:hover,
14251425

14261426
.delete-confirm-btn.no:hover {
14271427
background-color: #777;
1428+
}
1429+
1430+
/* Promotion popup */
1431+
.promotion-popup {
1432+
background-color: #353434;
1433+
border: 2px solid #ec8703;
1434+
border-radius: 12px;
1435+
padding: 20px;
1436+
text-align: center;
1437+
}
1438+
1439+
.promotion-popup h3 {
1440+
margin-top: 0;
1441+
margin-bottom: 16px;
1442+
color: #fff;
1443+
}
1444+
1445+
.promotion-pieces {
1446+
display: flex;
1447+
gap: 8px;
1448+
justify-content: center;
1449+
}
1450+
1451+
.promotion-piece-btn {
1452+
width: 72px;
1453+
height: 72px;
1454+
background: none;
1455+
border: 2px solid transparent;
1456+
border-radius: 8px;
1457+
cursor: pointer;
1458+
padding: 4px;
1459+
transition: border-color 0.15s, background-color 0.15s;
1460+
}
1461+
1462+
.promotion-piece-btn:hover {
1463+
border-color: #ec8703;
1464+
background-color: rgba(236, 135, 3, 0.15);
1465+
}
1466+
1467+
.promotion-piece-btn img {
1468+
width: 100%;
1469+
height: 100%;
1470+
object-fit: contain;
14281471
}

src/wifi_manager_esp32.cpp

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010

1111
static const char* INITIAL_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1";
1212

13-
WiFiManagerESP32::WiFiManagerESP32(BoardDriver* bd, MoveHistory* mh) : boardDriver(bd), moveHistory(mh), server(AP_PORT), wifiSSID(SECRET_SSID), wifiPassword(SECRET_PASS), gameMode("0"), lichessToken(""), botConfig(), scanAllChannels(WIFI_SCAN_ALL_CHANNELS), currentFen(INITIAL_FEN), hasPendingEdit(false), hasPendingResign(false), hasPendingDraw(false), pendingResignColor('?'), hasPendingWiFi(false), boardEvaluation(0.0f), otaUpdater(bd), autoOtaEnabled(false) {}
13+
WiFiManagerESP32::WiFiManagerESP32(BoardDriver* bd, MoveHistory* mh) : boardDriver(bd), moveHistory(mh), server(AP_PORT), wifiSSID(SECRET_SSID), wifiPassword(SECRET_PASS), gameMode("0"), lichessToken(""), botConfig(), scanAllChannels(WIFI_SCAN_ALL_CHANNELS), currentFen(INITIAL_FEN), hasPendingEdit(false), hasPendingResign(false), hasPendingDraw(false), pendingResignColor('?'), promotion{}, lastBoardPollTime(0), hasPendingWiFi(false), boardEvaluation(0.0f), otaUpdater(bd), autoOtaEnabled(false) {
14+
promotion.reset();
15+
}
1416

1517
void WiFiManagerESP32::begin() {
1618
Serial.println("=== Starting OpenChess WiFi Manager (ESP32) ===");
@@ -68,6 +70,7 @@ void WiFiManagerESP32::begin() {
6870
// Set up web server routes with async handlers
6971
server.on("/board-update", HTTP_GET, [this](AsyncWebServerRequest* request) { request->send(200, "application/json", this->getBoardUpdateJSON()); });
7072
server.on("/board-update", HTTP_POST, [this](AsyncWebServerRequest* request) { this->handleBoardEditSuccess(request); });
73+
server.on("/promotion", HTTP_POST, [this](AsyncWebServerRequest* request) { this->handlePromotion(request); });
7174
server.on("/resign", HTTP_POST, [this](AsyncWebServerRequest* request) { this->handleResign(request); });
7275
server.on("/draw", HTTP_POST, [this](AsyncWebServerRequest* request) { this->handleDraw(request); });
7376
server.on("/wifi", HTTP_GET, [this](AsyncWebServerRequest* request) { request->send(200, "application/json", this->getWiFiInfoJSON()); });
@@ -111,9 +114,14 @@ void WiFiManagerESP32::begin() {
111114
}
112115

113116
String WiFiManagerESP32::getBoardUpdateJSON() {
117+
this->lastBoardPollTime = millis();
114118
JsonDocument doc;
115119
doc["fen"] = currentFen;
116120
doc["evaluation"] = serialized(String(boardEvaluation, 2));
121+
if (promotion.pending) {
122+
JsonObject promo = doc["promotion"].to<JsonObject>();
123+
promo["color"] = String(promotion.color);
124+
}
117125
String output;
118126
serializeJson(doc, output);
119127
return output;
@@ -438,6 +446,42 @@ void WiFiManagerESP32::clearPendingDraw() {
438446
hasPendingDraw = false;
439447
}
440448

449+
void WiFiManagerESP32::handlePromotion(AsyncWebServerRequest* request) {
450+
if (!promotion.pending) {
451+
request->send(400, "text/plain", "No promotion pending");
452+
return;
453+
}
454+
if (request->hasArg("piece")) {
455+
String piece = request->arg("piece");
456+
piece.toLowerCase();
457+
if (piece == "q" || piece == "r" || piece == "b" || piece == "n") {
458+
promotion.choice = piece.charAt(0);
459+
Serial.printf("Promotion choice received from web: %c\n", (char)promotion.choice);
460+
request->send(200, "text/plain", "OK");
461+
} else {
462+
request->send(400, "text/plain", "Invalid piece (use 'q', 'r', 'b', or 'n')");
463+
}
464+
} else {
465+
request->send(400, "text/plain", "Missing 'piece' parameter");
466+
}
467+
}
468+
469+
void WiFiManagerESP32::startPromotionWait(char color) {
470+
promotion.color = color;
471+
promotion.choice = ' ';
472+
promotion.pending = true;
473+
Serial.printf("Promotion wait started for %s\n", color == 'w' ? "White" : "Black");
474+
}
475+
476+
void WiFiManagerESP32::clearPromotion() {
477+
promotion.reset();
478+
}
479+
480+
bool WiFiManagerESP32::isWebClientConnected() const {
481+
// Consider web client connected if it polled within the last 2 seconds
482+
return lastBoardPollTime > 0 && (millis() - lastBoardPollTime < 2000);
483+
}
484+
441485
void WiFiManagerESP32::checkPendingWiFi() {
442486
if (!hasPendingWiFi)
443487
return;

src/wifi_manager_esp32.h

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,22 @@ class WiFiManagerESP32 {
5959
volatile bool hasPendingDraw;
6060
char pendingResignColor; // 'w' or 'b' — the side resigning
6161

62+
// Promotion state for web-based piece selection
63+
struct PromotionState {
64+
volatile bool pending; // True while waiting for web client to choose a piece
65+
volatile char choice; // Piece chosen by web client ('q','r','b','n') or ' ' if none yet
66+
char color; // 'w' or 'b' — color of the promoting pawn
67+
void reset() {
68+
pending = false;
69+
choice = ' ';
70+
color = ' ';
71+
}
72+
};
73+
PromotionState promotion;
74+
75+
// Web client heartbeat (tracks whether board.html is actively polling)
76+
unsigned long lastBoardPollTime; // millis() of last /board-update GET request
77+
6278
// Deferred WiFi reconnection (set by web handler, processed in main loop)
6379
String pendingWiFiSSID;
6480
String pendingWiFiPassword;
@@ -70,6 +86,7 @@ class WiFiManagerESP32 {
7086
String getLichessInfoJSON();
7187
String getBoardSettingsJSON();
7288
void handleBoardEditSuccess(AsyncWebServerRequest* request);
89+
void handlePromotion(AsyncWebServerRequest* request);
7390
void handleConnectWiFi(AsyncWebServerRequest* request);
7491
void handleGameSelection(AsyncWebServerRequest* request);
7592
void handleSaveLichessToken(AsyncWebServerRequest* request);
@@ -131,6 +148,13 @@ class WiFiManagerESP32 {
131148
bool getPendingDraw();
132149
void clearPendingResign();
133150
void clearPendingDraw();
151+
// Promotion management (from web interface)
152+
void startPromotionWait(char color);
153+
bool isPromotionPending() const { return promotion.pending; }
154+
char getPromotionChoice() const { return promotion.choice; }
155+
void clearPromotion();
156+
// Web client connection check
157+
bool isWebClientConnected() const;
134158
// WiFi connection management
135159
bool connectToWiFi(const String& ssid, const String& password, bool fromWeb = false);
136160
// Call from main loop to process deferred WiFi reconnection

0 commit comments

Comments
 (0)