[fix] Demo draw state no longer flashing and adding switch to demo if player(s) abandon game.

This commit is contained in:
2026-03-07 08:28:29 +01:00
parent 73981c95c5
commit 5238fbf0f5
2 changed files with 122 additions and 107 deletions
+43 -39
View File
@@ -1,6 +1,6 @@
# 🕹️ Connect 4 AI: Master Edition
# 🕹️ Connect 4 AI: Master Edition (v2.0)
A high-performance Connect 4 implementation for ESP32-C3 and 8x8 WS2812B matrices. Features dynamic difficulty scaling, "humanized" AI movement, and a mobile-friendly web administration portal.
A high-performance, feature-rich Connect 4 implementation for the ESP32-C3. This version features a "living" AI that evolves as you play, human-like movement animations, and a robust win-detection engine.
---
@@ -15,68 +15,72 @@ A high-performance Connect 4 implementation for ESP32-C3 and 8x8 WS2812B matrice
| **Rotary Encoder B** | `GPIO 1` | Directional DT |
| **Encoder Button** | `GPIO 2` | Selection (SW) |
### 📐 Physical Dimensions
### 📐 Physical Layout
Designed for standard 8x8 matrix modules (approx. 65mm x 67mm).
The project is optimized for an 8x8 NeoPixel Matrix (65mm x 67mm).
- **Top Row (0):** Interaction and AI decision visualization.
- **Game Board:** Standard $7 \times 6$ grid.
- **UI Borders:** Fixed blue frame for visibility.
- **Row 0:** Interaction & AI Decision Visualization.
- **Row 1:** Static Blue UI border.
- **Rows 2-7:** Active $7 \times 6$ game board.
- **Status Column:** Far right column (Index 7) manages UI framing and "Glow" effects.
---
## 🧠 Advanced AI Features
## 🧠 Advanced AI & Logic Features
### 1. Progressive Difficulty (Evolution Mode)
The AI search depth (Ply) increases as the board fills. This ensures the AI is fast in the opening and lethal in the endgame.
To keep the game challenging and the CPU efficient, the AI search depth (Ply) scales as the board fills.
- **Formula:** $DynamicPly = BasePly + \lfloor \frac{DiscsOnBoard}{7} \rfloor$
- **Benefit:** High-level tactical precision exactly when the game becomes critical.
- **Benefit:** The AI is "casual" in the opening but becomes a "Grandmaster" in the endgame when tactical precision is vital.
### 2. Strategic Blunder Injection
### 2. Intelligent Win Detection & Flashing
To avoid endless stalemate draws between high-level AIs, a "Blunder" logic is used.
The win-engine has been refactored to prevent "color ghosting."
- **Demo Mode:** Always active; 20% chance to make a suboptimal move.
- **Player Mode:** Toggleable via Web Portal to make the AI more "human."
- **Winner Locking:** The `scanBoard()` function returns the specific ID of the winner (1 for Yellow, 2 for Red).
- **Flashing Accuracy:** The final animation uses this ID to ensure the winning 4-in-a-row flashes in the **correct player's color**, regardless of whose turn it was when the game ended.
### 3. Alpha-Beta Pruning & Column Ordering
### 3. Smart Watchdog (Tiered Timeout)
The engine evaluates the center column first. This triggers pruning earlier in the search tree, skipping millions of unnecessary calculations and keeping the ESP32-C3 responsive.
The game respects your "thinking time" by using a tiered idle-timeout system:
- **Menu/Finished State:** Standard timeout (e.g., 60s).
- **Playing State:** **Double Timeout** (e.g., 120s). This gives human players more time to analyze complex boards before the game auto-resets to Demo Mode.
### 4. Strategic Blunder Injection
To ensure Demo Mode doesn't end in an infinite loop of draws, a 20% "Blunder Chance" is injected. This forces the AI to occasionally make a human-like mistake, creating openings for a definitive winner.
---
## 📖 Code Architecture Details
## 📖 Code Architecture & Modules
### 🔄 State Machine
The software cycles through states:
The core loop manages five distinct states:
- **MENU:** Select mode using the rotary encoder.
- **PLAYING:** Manages player turns and the gravity-acceleration drop animation.
- **DEMO:** Auto-starts after inactivity. Randomizes Ply (3-6) and enforces blunders to ensure definitive game results.
1. **MENU:** Mode selection and board reset.
2. **PLAYING:** Active turn-based logic with gravity-accelerated drop animations.
3. **FINISHED_WIN:** Locks the winner ID and flashes the winning segment.
4. **FINISHED_DRAW:** Blinks the entire board to signify a stalemate.
5. **DEMO:** Auto-plays with randomized difficulty (Ply 3-6) and mandatory blunder logic.
### 🎨 Rendering & Mapping
### 🌐 Web Administration Portal
The `getIdx(x, y)` function maps the 2D game board to the 1D NeoPixel array. The `updateThinkingVisuals()` function provides real-time feedback of the AI's internal search process by moving a pulsing disc across the top row.
Accessible via the **"Connect4-Config"** AP at `192.168.4.1`.
- **Base Ply:** Sets the starting difficulty level.
- **Brightness:** Global LED intensity (0-255).
- **Evolution Toggle:** Turn on/off the progressive difficulty scaling.
- **Blunder Toggle:** Allow the AI to make mistakes during Human-vs-AI matches.
---
## 🌐 Web Admin Portal
## 🛠 Installation
Connect to the **"Connect4-Config"** Access Point to adjust:
- **Base Ply:** Minimum search depth.
- **Brightness:** Global LED intensity.
- **Idle Timeout:** Inactivity period before Demo Mode.
- **Toggles:** Enable/Disable Blunders and Evolution Mode.
---
## 🛠 Setup & Installation
1. Install **PlatformIO**.
2. Add dependencies: `FastLED`, `Encoder`.
3. Set your WiFi Password in `platformio.ini`: `-D WIFI_PASSWORD=\"your_pass\"`.
4. Upload to ESP32-C3.
1. **Environment:** Use VS Code with the **PlatformIO** extension.
2. **Dependencies:** `FastLED`, `Encoder`, `Preferences`.
3. **Build Flag:** Define your WiFi password in `platformio.ini`: `-D WIFI_PASSWORD=\"your_password\"`.
4. **Flash:** Upload to your ESP32-C3 and enjoy the ultimate desktop Connect 4 experience.
+79 -68
View File
@@ -29,6 +29,7 @@ State gameState = MENU;
int8_t menuMode = 0;
int8_t currentPlayer = 1;
int8_t winnerPlayer = 0; // Tracks who actually won for the flashing effect
int8_t activeCol = 3;
long oldEncPos = -999;
uint32_t lastActivityTime = 0;
@@ -36,7 +37,6 @@ uint32_t demoResetTimer = 0;
bool isDemoOver = false;
uint8_t demoPly = 4;
// Configurable Parameters
uint8_t current_look_ahead;
uint8_t current_brightness;
uint32_t current_idle_timeout_ms;
@@ -52,7 +52,7 @@ void drawStaticUI();
void renderBoard();
int getFirstEmptyRow(int col);
bool isBoardFull();
bool scanBoard(int8_t p);
int8_t scanBoard(); // Changed to return the winner ID
void updateThinkingVisuals(int8_t p, int8_t col);
void animateDrop(int col, int player);
void moveDiscToCol(int startCol, int targetCol, int player, int speed);
@@ -69,7 +69,7 @@ void drawStaticUI()
{
FastLED.clear();
CRGB borderColor = CRGB::Blue;
if (gameState == DEMO || (gameState >= 2 && isDemoOver))
if (gameState == DEMO || gameState >= 2)
{
uint8_t glow = beat8(15);
borderColor = blend(CRGB::Blue, CRGB::White, glow / 4);
@@ -114,8 +114,7 @@ int getDynamicPly()
for (int r = 0; r < ROWS; r++)
if (board[c][r] != 0)
count++;
int evolution = count / 7;
return constrain(current_look_ahead + evolution, 1, 10);
return constrain(current_look_ahead + (count / 7), 1, 10);
}
// --- Visuals & Animations ---
@@ -143,7 +142,6 @@ void updateThinkingVisuals(int8_t p, int8_t col)
CRGB aiColor = (p == 1) ? CRGB::Yellow : CRGB::Red;
leds[getIdx(col, 0)] = aiColor.nscale8(aiBrightness);
FastLED.show();
activeCol = col;
}
void animateDrop(int col, int player)
@@ -151,15 +149,12 @@ void animateDrop(int col, int player)
int targetRow = getFirstEmptyRow(col);
if (targetRow == -1)
return;
int currentDelay = 80;
for (int r = 5; r >= targetRow; r--)
{
renderBoard();
leds[getIdx(col, 7 - r)] = (player == 1) ? CRGB::Yellow : CRGB::Red;
FastLED.show();
delay(currentDelay);
if (currentDelay > 20)
currentDelay -= 15;
delay(max(20, 80 - (5 - r) * 15));
}
board[col][targetRow] = player;
renderBoard();
@@ -192,37 +187,49 @@ bool isBoardFull()
return true;
}
bool scanBoard(int8_t p)
int8_t scanBoard()
{
memset(winMask, 0, sizeof(winMask));
bool found = false;
auto check = [&](int c, int r, int dc, int dr)
{
if (board[c][r] == p && board[c + dc][r + dr] == p && board[c + 2 * dc][r + 2 * dr] == p && board[c + 3 * dc][r + 3 * dr] == p)
int8_t p = board[c][r];
if (p != 0 && board[c + dc][r + dr] == p && board[c + 2 * dc][r + 2 * dr] == p && board[c + 3 * dc][r + 3 * dr] == p)
{
for (int i = 0; i < 4; i++)
winMask[getIdx(c + i * dc, 7 - (r + i * dr))] = true;
return true;
return p;
}
return false;
return (int8_t)0;
};
for (int r = 0; r < 6; r++)
for (int c = 0; c < 4; c++)
if (check(c, r, 1, 0))
found = true;
{
int8_t res = check(c, r, 1, 0);
if (res)
return res;
}
for (int r = 0; r < 3; r++)
for (int c = 0; c < 7; c++)
if (check(c, r, 0, 1))
found = true;
{
int8_t res = check(c, r, 0, 1);
if (res)
return res;
}
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++)
if (check(c, r, 1, 1))
found = true;
{
int8_t res = check(c, r, 1, 1);
if (res)
return res;
}
for (int r = 3; r < 6; r++)
for (int c = 0; c < 4; c++)
if (check(c, r, 1, -1))
found = true;
return found;
{
int8_t res = check(c, r, 1, -1);
if (res)
return res;
}
return 0;
}
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol)
@@ -231,12 +238,16 @@ int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP,
updateThinkingVisuals(aiP, rootCol);
else
yield();
if (scanBoard(aiP))
// Check for wins within minimax
int8_t win = scanBoard();
if (win == aiP)
return 1000 + depth;
if (scanBoard(huP))
if (win == huP)
return -1000 - depth;
if (depth == 0 || isBoardFull())
return 0;
int order[] = {3, 2, 4, 1, 5, 0, 6};
int best = isMax ? -2000 : 2000;
for (int c : order)
@@ -249,14 +260,12 @@ int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP,
board[c][r] = 0;
if (isMax)
{
if (val > best)
best = val;
best = max(best, val);
alpha = max(alpha, best);
}
else
{
if (val < best)
best = val;
best = min(best, val);
beta = min(beta, best);
}
if (beta <= alpha)
@@ -280,14 +289,14 @@ void performAiMove(int8_t aiP)
if (r != -1)
{
board[c][r] = aiP;
if (scanBoard(aiP))
if (scanBoard() == aiP)
{
board[c][r] = 0;
bestCol = c;
goto finalize;
}
board[c][r] = huP;
if (current_look_ahead >= 2 && scanBoard(huP))
if (current_look_ahead >= 2 && scanBoard() == huP)
{
board[c][r] = 0;
bestCol = c;
@@ -296,7 +305,6 @@ void performAiMove(int8_t aiP)
board[c][r] = 0;
}
}
for (int c : {3, 2, 4, 1, 5, 0, 6})
{
int r = getFirstEmptyRow(c);
@@ -312,14 +320,12 @@ void performAiMove(int8_t aiP)
}
}
}
if ((gameState == DEMO || blunder_enabled) && random(100) < 20)
{
int rCol = random(0, 7);
if (getFirstEmptyRow(rCol) != -1)
bestCol = rCol;
}
finalize:
current_look_ahead = originalPly;
moveDiscToCol(activeCol, bestCol, aiP, 100);
@@ -327,11 +333,14 @@ finalize:
animateDrop(bestCol, aiP);
}
// --- Web Portal & Setup ---
// --- Web Portal ---
void handleRoot()
{
String html = "<html><head><meta name='viewport' content='width=device-width, initial-scale=1'><style>body{font-family:sans-serif;background:#121212;color:white;text-align:center;} .card{background:#222;padding:25px;border-radius:15px;display:inline-block;margin-top:20px;} input{width:100%;padding:10px;margin:10px 0;border-radius:5px;border:none;}</style></head><body>";
String html = "<html><head><meta name='viewport' content='width=device-width, initial-scale=1'>"
"<style>body{font-family:sans-serif;background:#121212;color:white;text-align:center;}"
" .card{background:#222;padding:25px;border-radius:15px;display:inline-block;margin-top:20px;}"
" input{width:100%;padding:10px;margin:10px 0;border-radius:5px;border:none;}</style></head><body>";
html += "<h1>Connect 4 Admin</h1><div class='card'><form action='/save' method='POST'>";
html += "Base AI Ply (1-10):<input type='number' name='ply' value='" + String(current_look_ahead) + "'>";
html += "Brightness (5-255):<input type='number' name='br' value='" + String(current_brightness) + "'>";
@@ -419,10 +428,6 @@ void setup()
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(current_brightness);
pinMode(ENC_SW, INPUT_PULLUP);
WiFi.disconnect(true);
WiFi.mode(WIFI_AP);
delay(100);
WiFi.softAP("Connect4-Config", WIFI_PASSWORD);
server.on("/", handleRoot);
server.on("/save", HTTP_POST, handleSave);
@@ -439,7 +444,7 @@ void loop()
if (newPos != oldEncPos || (pressed && (millis() - lastActivityTime > 500)))
{
if (gameState == DEMO || isDemoOver)
if (gameState >= 2 || gameState == DEMO)
{
for (int i = 0; i < 10; i++)
{
@@ -447,7 +452,7 @@ void loop()
FastLED.show();
delay(30);
}
delay(1000);
delay(500);
gameState = MENU;
memset(board, 0, sizeof(board));
showMenu();
@@ -458,6 +463,16 @@ void loop()
lastActivityTime = millis();
}
uint32_t activeLimit = (gameState == PLAYING) ? (current_idle_timeout_ms * 2) : current_idle_timeout_ms;
if (gameState != DEMO && (gameState < 2) && (millis() - lastActivityTime > activeLimit))
{
gameState = DEMO;
memset(board, 0, sizeof(board));
currentPlayer = 1;
demoPly = random(3, 7);
return;
}
if (gameState == MENU)
{
if (newPos != oldEncPos)
@@ -481,13 +496,6 @@ void loop()
}
delay(300);
}
if (millis() - lastActivityTime > current_idle_timeout_ms)
{
gameState = DEMO;
memset(board, 0, sizeof(board));
currentPlayer = 1;
demoPly = random(3, 7);
}
}
else if (gameState == PLAYING)
{
@@ -505,23 +513,34 @@ void loop()
if (row != -1)
{
animateDrop(activeCol, currentPlayer);
if (scanBoard(currentPlayer))
winnerPlayer = scanBoard();
if (winnerPlayer != 0)
{
gameState = FINISHED_WIN;
demoResetTimer = millis();
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
demoResetTimer = millis();
}
else
{
if (menuMode < 2)
{
int8_t aiP = (menuMode == 0) ? 2 : 1;
performAiMove(aiP);
if (scanBoard(aiP))
winnerPlayer = scanBoard();
if (winnerPlayer != 0)
{
currentPlayer = aiP;
gameState = FINISHED_WIN;
demoResetTimer = millis();
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
demoResetTimer = millis();
}
}
else
{
@@ -538,16 +557,15 @@ void loop()
FastLED.show();
delay(600);
performAiMove(currentPlayer);
if (scanBoard(currentPlayer))
winnerPlayer = scanBoard();
if (winnerPlayer != 0)
{
gameState = FINISHED_WIN;
isDemoOver = true;
demoResetTimer = millis();
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
isDemoOver = true;
demoResetTimer = millis();
}
else
@@ -556,14 +574,7 @@ void loop()
}
}
else
{
if (!isDemoOver && (millis() - lastActivityTime > current_idle_timeout_ms))
{
memset(board, 0, sizeof(board));
gameState = DEMO;
currentPlayer = 1;
return;
}
{ // FINISHED state
static uint32_t lastFlash = 0;
static bool toggle = true;
if (millis() - lastFlash > 300)
@@ -578,9 +589,9 @@ void loop()
if (gameState == FINISHED_WIN)
{
if (winMask[i])
leds[i] = toggle ? (currentPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black;
leds[i] = toggle ? (winnerPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black;
else
leds[i].nscale8(40);
leds[i].nscale8(60);
}
else if (gameState == FINISHED_DRAW)
{
@@ -590,11 +601,11 @@ void loop()
}
FastLED.show();
}
if (isDemoOver && (millis() - demoResetTimer > 15000))
if (millis() - demoResetTimer > 15000)
{
memset(board, 0, sizeof(board));
gameState = DEMO;
isDemoOver = false;
demoResetTimer = 0;
demoPly = random(3, 7);
}
if (pressed)