Compare commits
3 Commits
2eecc94cfd
...
3257d40722
| Author | SHA1 | Date | |
|---|---|---|---|
| 3257d40722 | |||
| f9d100f918 | |||
| 0fc20da274 |
@@ -131,7 +131,15 @@ De sterkere speler kan zo winnende zetten vinden die de zwakkere mist. Wie sterk
|
||||
|
||||
---
|
||||
|
||||
## 7. Snelle Bediening
|
||||
## 7. Blunder-modus
|
||||
|
||||
Normaal speelt de AI altijd de beste zet die hij kan vinden. Maar dat kan frustrerend zijn voor jongere of minder ervaren spelers die nooit winnen. De **blunder-modus** geeft de AI een instelbare kans (bijvoorbeeld 20%) om een willekeurige zet te doen in plaats van diep na te denken. Als er een blunder gebeurt, slaat de AI zijn slimme analyse over en laat hij een schijfje in een willekeurige open kolom vallen. De rest van de tijd speelt hij gewoon op volle kracht — maar af en toe maakt hij een domme fout die een oplettende speler kan afstraffen.
|
||||
|
||||
Blunders gaan nooit boven een directe winst of blokkade. Als de AI nu kan winnen, of als de tegenstander op het punt staat te winnen, maakt de AI altijd de juiste zet. Blunders vervangen alleen de diepe zoektocht op beurten waar er geen directe dreiging is.
|
||||
|
||||
---
|
||||
|
||||
## 8. Snelle Bediening
|
||||
|
||||
De ESP32-C3 heeft maar één kern. Als de AI nadenkt, kan hij de bediening een paar seconden blokkeren.
|
||||
Twee trucs zorgen ervoor dat het spel soepel blijft:
|
||||
|
||||
@@ -112,7 +112,13 @@ This three-phase approach makes the AI both fast (instant reactions to obvious m
|
||||
|
||||
In demo mode, two AI players play against each other. To make the games interesting (rather than always ending in a draw), each player is randomly assigned a different search depth. One player might look 5 moves ahead while the other only looks 3 moves ahead. The stronger player can find winning setups that the weaker one misses, leading to exciting games with real winners. Who gets the advantage is randomized each game.
|
||||
|
||||
## 7. Responsive Controls
|
||||
## 7. Blunder Mode
|
||||
|
||||
Normally, the AI always plays the best move it can find. But that can be frustrating for younger or casual players who never get to win. **Blunder mode** gives the AI a configurable chance (for example 20%) to make a random move instead of running the deep minimax search. When a blunder happens, the AI simply drops a disc in a random open column. It still plays normally the rest of the time, so the game feels real - but every now and then the AI makes a silly mistake that a sharp player can punish.
|
||||
|
||||
Blunders never override an instant win or block. If the AI can win right now, or if the opponent is about to win, the AI always makes the correct move. Blunders only replace the deep search on turns where there is no immediate threat.
|
||||
|
||||
## 8. Responsive Controls
|
||||
|
||||
The ESP32-C3 is a single-core processor. When the AI is thinking, it could block all input for several seconds. Two techniques keep the game responsive:
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ When idle (no input for the configured timeout), the board enters demo mode wher
|
||||
|
||||
The ESP32 creates a WiFi access point:
|
||||
|
||||
- **Network:** `Connect4-Config`
|
||||
- **Network:** Configured via `WIFI_SSID` build flag (default: `Connect4`)
|
||||
- **Password:** Configured via `WIFI_PASSWORD` build flag (default: `youlose4`)
|
||||
- **Admin page:** Connect to the network and open `http://192.168.4.1`
|
||||
|
||||
@@ -64,8 +64,7 @@ The ESP32 creates a WiFi access point:
|
||||
| **Base AI Ply** | Search depth for the AI (1-10). Higher = stronger. |
|
||||
| **Brightness** | LED brightness (0-255). |
|
||||
| **Idle Timeout** | Seconds of inactivity before demo mode starts. |
|
||||
| **Blunders** | Reserved for future use. |
|
||||
| **Evolution** | Progressive difficulty: AI gets stronger as game goes on.|
|
||||
| **Blunders** | AI randomly picks a bad move at the configured chance %. |
|
||||
|
||||
Settings are saved to flash (NVS) and persist across reboots.
|
||||
|
||||
@@ -99,19 +98,21 @@ pio device monitor
|
||||
|
||||
All configurable parameters are defined as `-D` flags in `platformio.ini`:
|
||||
|
||||
| Flag | Default | Description |
|
||||
| :--------------------- | :------ | :--------------------------------------------- |
|
||||
| `LED_PIN` | `4` | GPIO pin for NeoPixel data line |
|
||||
| `ENC_A` | `0` | GPIO pin for encoder CLK |
|
||||
| `ENC_B` | `1` | GPIO pin for encoder DT |
|
||||
| `ENC_SW` | `2` | GPIO pin for encoder button |
|
||||
| `SENSITIVITY` | `4` | Encoder steps per detent (higher = less sensitive) |
|
||||
| `SHOW_BORDER` | `1` | Show blue border frame (0 = off, 1 = on) |
|
||||
| `DEFAULT_LOOK_AHEAD` | `8` | Default AI search depth (plies) |
|
||||
| `DEFAULT_BRIGHTNESS` | `25` | Default LED brightness (0-255) |
|
||||
| `DEFAULT_IDLE_TIMEOUT` | `45` | Seconds before demo mode activates |
|
||||
| `MAX_GAME_LOG` | `5` | Number of games stored in the game log |
|
||||
| `WIFI_PASSWORD` | `youlose4` | Password for the WiFi access point |
|
||||
| Flag | Default | Description |
|
||||
| :--------------------- | :--------- | :------------------------------------------------- |
|
||||
| `LED_PIN` | `4` | GPIO pin for NeoPixel data line |
|
||||
| `ENC_A` | `0` | GPIO pin for encoder CLK |
|
||||
| `ENC_B` | `1` | GPIO pin for encoder DT |
|
||||
| `ENC_SW` | `2` | GPIO pin for encoder button |
|
||||
| `SENSITIVITY` | `4` | Encoder steps per detent (higher = less sensitive) |
|
||||
| `SHOW_BORDER` | `1` | Show blue border frame (0 = off, 1 = on) |
|
||||
| `DEFAULT_LOOK_AHEAD` | `8` | Default AI search depth (plies) |
|
||||
| `DEFAULT_BRIGHTNESS` | `25` | Default LED brightness (0-255) |
|
||||
| `DEFAULT_IDLE_TIMEOUT` | `45` | Seconds before demo mode activates |
|
||||
| `DEMO_RESET_PAUSE` | `30000` | Milliseconds before finished game enters demo |
|
||||
| `MAX_GAME_LOG` | `5` | Number of games stored in the game log |
|
||||
| `WIFI_SSID` | `Connect4` | SSID for the WiFi access point |
|
||||
| `WIFI_PASSWORD` | `youlose4` | Password for the WiFi access point |
|
||||
|
||||
## Project Structure
|
||||
|
||||
|
||||
+1
-3
@@ -12,14 +12,12 @@ build_flags =
|
||||
-D ENC_SW=2
|
||||
-D SENSITIVITY=4
|
||||
-D SHOW_BORDER=0
|
||||
-D BRIGHTNESS=25
|
||||
-D IDLE_TIMEOUT=45000
|
||||
-D DEMO_RESET_PAUSE=20000
|
||||
-D DEBOUNCE_DELAY=50
|
||||
-D DEFAULT_LOOK_AHEAD=8
|
||||
-D DEFAULT_BRIGHTNESS=25
|
||||
-D DEFAULT_IDLE_TIMEOUT=45
|
||||
-D MAX_GAME_LOG=100
|
||||
-D WIFI_SSID=\"Connect4\"
|
||||
-D WIFI_PASSWORD=\"youlose4\"
|
||||
lib_deps =
|
||||
fastled/FastLED @ 3.9.12
|
||||
|
||||
+80
-49
@@ -13,16 +13,48 @@
|
||||
#define SENSITIVITY 4
|
||||
#endif
|
||||
|
||||
#ifndef LED_PIN
|
||||
#define LED_PIN 4
|
||||
#endif
|
||||
|
||||
#ifndef ENC_A
|
||||
#define ENC_A 0
|
||||
#endif
|
||||
|
||||
#ifndef ENC_B
|
||||
#define ENC_B 1
|
||||
#endif
|
||||
|
||||
#ifndef ENC_SW
|
||||
#define ENC_SW 2
|
||||
#endif
|
||||
|
||||
#define NUM_LEDS 64
|
||||
|
||||
#ifndef MAX_GAME_LOG
|
||||
#define MAX_GAME_LOG 5
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_LOOK_AHEAD
|
||||
#define DEFAULT_LOOK_AHEAD 8
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_BRIGHTNESS
|
||||
#define DEFAULT_BRIGHTNESS 25
|
||||
#endif
|
||||
|
||||
#ifndef DEFAULT_IDLE_TIMEOUT
|
||||
#define DEFAULT_IDLE_TIMEOUT 60
|
||||
#endif
|
||||
|
||||
#ifndef DEMO_RESET_PAUSE
|
||||
#define DEMO_RESET_PAUSE 30000
|
||||
#endif
|
||||
|
||||
#ifndef WIFI_SSID
|
||||
#define WIFI_SSID "Connect4"
|
||||
#endif
|
||||
|
||||
const int COLS = 7;
|
||||
const int ROWS = 6;
|
||||
const int colOrder[] = {3, 2, 4, 1, 5, 0, 6};
|
||||
@@ -49,11 +81,11 @@ uint8_t demoPly[2] = {4, 4};
|
||||
bool abortAi = false;
|
||||
bool lastButtonState = HIGH;
|
||||
|
||||
uint8_t currentLookAhead = 6;
|
||||
uint8_t currentBrightness = 30;
|
||||
uint32_t currentIdleTimeoutMs = 60000;
|
||||
uint8_t currentLookAhead = DEFAULT_LOOK_AHEAD;
|
||||
uint8_t currentBrightness = DEFAULT_BRIGHTNESS;
|
||||
uint32_t currentIdleTimeoutMs = DEFAULT_IDLE_TIMEOUT * 1000;
|
||||
bool blunderEnabled = false;
|
||||
bool progressiveDifficulty = false;
|
||||
uint8_t blunderChance = 20;
|
||||
|
||||
uint8_t aiBrightness = 0;
|
||||
bool aiFadeUp = true;
|
||||
@@ -84,7 +116,6 @@ void renderBoard();
|
||||
void showMenu();
|
||||
int getFirstEmptyRow(int col);
|
||||
bool isBoardFull();
|
||||
int getDynamicPly();
|
||||
int8_t scanBoard();
|
||||
bool checkGameEnd();
|
||||
void updateThinkingVisuals(int8_t pColor, int8_t column);
|
||||
@@ -217,13 +248,6 @@ bool isBoardFull() {
|
||||
return true;
|
||||
}
|
||||
|
||||
int getDynamicPly() {
|
||||
if (!progressiveDifficulty && gameState != DEMO) return currentLookAhead;
|
||||
int count = 0;
|
||||
for (int c = 0; c < COLS; c++) for (int r = 0; r < ROWS; r++) if (board[c][r] != 0) count++;
|
||||
return constrain(currentLookAhead + (count / 7), 1, 10);
|
||||
}
|
||||
|
||||
int8_t scanBoard() {
|
||||
memset(winMask, 0, sizeof(winMask));
|
||||
auto check = [&](int c, int r, int dc, int dr) {
|
||||
@@ -243,21 +267,14 @@ int8_t scanBoard() {
|
||||
|
||||
bool checkGameEnd() {
|
||||
winnerPlayer = scanBoard();
|
||||
if (winnerPlayer != 0) {
|
||||
if (gameState != DEMO) logGame(winnerPlayer);
|
||||
gameState = FINISHED_WIN;
|
||||
demoResetTimer = millis();
|
||||
lastActivityTime = millis();
|
||||
return true;
|
||||
}
|
||||
if (isBoardFull()) {
|
||||
if (gameState != DEMO) logGame(0);
|
||||
gameState = FINISHED_DRAW;
|
||||
demoResetTimer = millis();
|
||||
lastActivityTime = millis();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
bool won = winnerPlayer != 0;
|
||||
bool draw = !won && isBoardFull();
|
||||
if (!won && !draw) return false;
|
||||
if (gameState != DEMO) logGame(won ? winnerPlayer : 0);
|
||||
gameState = won ? FINISHED_WIN : FINISHED_DRAW;
|
||||
demoResetTimer = millis();
|
||||
lastActivityTime = millis();
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Animation ---
|
||||
@@ -337,27 +354,41 @@ void performAiMove(int8_t aiP) {
|
||||
int huP = (aiP == 1) ? 2 : 1;
|
||||
int bestScore = -30000; int bestCol = 3;
|
||||
int originalPly = currentLookAhead;
|
||||
currentLookAhead = (gameState == DEMO) ? demoPly[aiP - 1] : getDynamicPly();
|
||||
if (gameState == DEMO) currentLookAhead = demoPly[aiP - 1];
|
||||
|
||||
for (int c = 0; c < COLS; c++) {
|
||||
// Phase 1: always take an instant win or block an opponent's win
|
||||
bool found = false;
|
||||
for (int c = 0; c < COLS && !found; c++) {
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = aiP; if (scanBoard() == aiP) { board[c][r]=0; bestCol=c; goto finalizeMove; }
|
||||
board[c][r] = huP; if (scanBoard() == huP) { board[c][r]=0; bestCol=c; goto finalizeMove; }
|
||||
board[c][r] = aiP; if (scanBoard() == aiP) { board[c][r]=0; bestCol=c; found=true; break; }
|
||||
board[c][r] = huP; if (scanBoard() == huP) { board[c][r]=0; bestCol=c; found=true; break; }
|
||||
board[c][r] = 0;
|
||||
}
|
||||
}
|
||||
for (int c : colOrder) {
|
||||
if (abortAi) goto finalizeMove;
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = aiP;
|
||||
int score = minimax(currentLookAhead, -30000, 30000, false, aiP, huP, c);
|
||||
board[c][r] = 0;
|
||||
if (score > bestScore) { bestScore = score; bestCol = c; }
|
||||
|
||||
// Phase 2: blunder — pick a random column instead of deep search
|
||||
if (!found && blunderEnabled && gameState != DEMO && (random(100) < blunderChance)) {
|
||||
int validCols[COLS], count = 0;
|
||||
for (int c = 0; c < COLS; c++) if (getFirstEmptyRow(c) != -1) validCols[count++] = c;
|
||||
bestCol = validCols[random(count)];
|
||||
found = true;
|
||||
}
|
||||
|
||||
// Phase 3: deep minimax search
|
||||
if (!found) {
|
||||
for (int c : colOrder) {
|
||||
if (abortAi) break;
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
board[c][r] = aiP;
|
||||
int score = minimax(currentLookAhead, -30000, 30000, false, aiP, huP, c);
|
||||
board[c][r] = 0;
|
||||
if (score > bestScore) { bestScore = score; bestCol = c; }
|
||||
}
|
||||
}
|
||||
}
|
||||
finalizeMove:
|
||||
|
||||
currentLookAhead = originalPly;
|
||||
if (!abortAi) { moveDiscToCol(activeCol, bestCol, aiP, 80); if (!abortAi) { delay(100); animateDrop(bestCol, aiP); } }
|
||||
}
|
||||
@@ -377,8 +408,8 @@ void handleRoot() {
|
||||
html += "Base AI Ply:<input type='number' name='ply' value='" + String(currentLookAhead) + "'>";
|
||||
html += "Brightness:<input type='number' name='br' value='" + String(currentBrightness) + "'>";
|
||||
html += "Idle Timeout (s):<input type='number' name='idle' value='" + String(currentIdleTimeoutMs / 1000) + "'>";
|
||||
html += "Blunders: <input type='checkbox' name='blunder' " + String(blunderEnabled ? "checked" : "") + "><br>";
|
||||
html += "Evolution: <input type='checkbox' name='evolve' " + String(progressiveDifficulty ? "checked" : "") + "><br><br>";
|
||||
html += "Blunders: <input type='checkbox' name='blunder' " + String(blunderEnabled ? "checked" : "") + ">";
|
||||
html += " Chance (%):<input type='number' name='blunderPct' min='1' max='100' value='" + String(blunderChance) + "'><br><br>";
|
||||
html += "<input type='submit' value='Save Settings' style='background:#28a745;color:white;'>";
|
||||
html += "</form></div>";
|
||||
html += "<div class='card' style='margin-top:15px;text-align:left;'><h3 style='text-align:center;'>Game Log</h3>";
|
||||
@@ -405,7 +436,7 @@ void handleSave() {
|
||||
if (server.hasArg("br")) { currentBrightness = server.arg("br").toInt(); FastLED.setBrightness(currentBrightness); prefs.putUChar("br", currentBrightness); }
|
||||
if (server.hasArg("idle")) { currentIdleTimeoutMs = server.arg("idle").toInt() * 1000; prefs.putUInt("idle", currentIdleTimeoutMs / 1000); }
|
||||
blunderEnabled = server.hasArg("blunder"); prefs.putBool("blunder", blunderEnabled);
|
||||
progressiveDifficulty = server.hasArg("evolve"); prefs.putBool("evolve", progressiveDifficulty);
|
||||
if (server.hasArg("blunderPct")) { blunderChance = constrain(server.arg("blunderPct").toInt(), 1, 100); prefs.putUChar("blPct", blunderChance); }
|
||||
server.sendHeader("Location", "/"); server.send(303);
|
||||
}
|
||||
|
||||
@@ -485,7 +516,7 @@ void handleFinished() {
|
||||
}
|
||||
FastLED.show();
|
||||
}
|
||||
if (millis() - demoResetTimer > 30000) {
|
||||
if (millis() - demoResetTimer > DEMO_RESET_PAUSE) {
|
||||
resetBoard();
|
||||
randomizeDemoPlies();
|
||||
gameState = DEMO;
|
||||
@@ -498,16 +529,16 @@ void handleFinished() {
|
||||
|
||||
void setup() {
|
||||
prefs.begin("c4-game", false);
|
||||
currentLookAhead = prefs.getUChar("ply", 8);
|
||||
currentBrightness = prefs.getUChar("br", 25);
|
||||
currentIdleTimeoutMs = prefs.getUInt("idle", 60) * 1000;
|
||||
currentLookAhead = prefs.getUChar("ply", DEFAULT_LOOK_AHEAD);
|
||||
currentBrightness = prefs.getUChar("br", DEFAULT_BRIGHTNESS);
|
||||
currentIdleTimeoutMs = prefs.getUInt("idle", DEFAULT_IDLE_TIMEOUT) * 1000;
|
||||
blunderEnabled = prefs.getBool("blunder", false);
|
||||
progressiveDifficulty = prefs.getBool("evolve", false);
|
||||
blunderChance = prefs.getUChar("blPct", 20);
|
||||
loadGameLog();
|
||||
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
|
||||
FastLED.setBrightness(currentBrightness);
|
||||
pinMode(ENC_SW, INPUT_PULLUP);
|
||||
WiFi.softAP("Connect4-Config", WIFI_PASSWORD);
|
||||
WiFi.softAP(WIFI_SSID, WIFI_PASSWORD);
|
||||
server.on("/", handleRoot);
|
||||
server.on("/save", HTTP_POST, handleSave);
|
||||
server.begin();
|
||||
|
||||
Reference in New Issue
Block a user