diff --git a/README.md b/README.md index dd2959e..df3d6e0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/main.cpp b/src/main.cpp index d8293c2..afa9984 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -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 = ""; + String html = "" + ""; html += "

Connect 4 Admin

"; html += "Base AI Ply (1-10):"; html += "Brightness (5-255):"; @@ -419,10 +428,6 @@ void setup() FastLED.addLeds(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)