diff --git a/README.md b/README.md index 0d81643..dd2959e 100644 --- a/README.md +++ b/README.md @@ -1,95 +1,82 @@ -# Connect Four: ESP32-C3 LED Edition +# đšī¸ Connect 4 AI: Master Edition -A hardware-based Connect Four game featuring an 8x8 NeoPixel matrix, a strategic Minimax AI, and a dynamic "Attract Mode" for public display. - -## How the Program Works - -The program is built as a **Finite State Machine (FSM)**. -It manages the game flow by transitioning between distinct states based on user input, game outcomes, or inactivity timers. - -### 1. Game States - -- **Menu**: Displays stylized Roman numerals (**I** or **II**) to select between Single Player or Two Player modes. -- **Game Play**: The main loop handles the real-time gravity of falling discs, encoder tracking for column selection, and the hand-off between the human and the AI. -- **Game Over**: Triggered when a win or draw is detected. It "locks" the board, dims the background discs to 15% intensity, and flashes the winning line. -- **Demo Mode**: Triggered after 60 seconds of inactivity. The AI plays against itself to act as a visual "attract mode." - -### 2. Win Detection Logic - -To ensure 100% accuracy, the program performs a synchronous, multi-directional scan of the 7x6 grid after every single move. -It checks for four matching non-zero values in the following patterns: - -- **Horizontal**: `[column] [row]` to `[column + 3] [row]` -- **Vertical**: `[column] [row]` to `[column] [row + 3]` -- **Diagonal Up**: `[column] [row]` to `[column + 3] [row + 3]` -- **Diagonal Down**: `[column] [row]` to `[column + 3] [row - 3]` +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. --- -## The AI: Strategic Minimax +## đ Hardware Configuration -The computer opponent uses the **Minimax Algorithm**, a classic artificial intelligence method for zero-sum games. +### đ Pin Mapping (Lolin C3 Mini) -### 1. Look-Ahead (Depth Search) +| Component | ESP32-C3 Pin | Function | +| :------------------- | :----------- | :--------------- | +| **NeoPixel Matrix** | `GPIO 4` | Data Input (DIN) | +| **Rotary Encoder A** | `GPIO 0` | Directional CLK | +| **Rotary Encoder B** | `GPIO 1` | Directional DT | +| **Encoder Button** | `GPIO 2` | Selection (SW) | -The AI does not just look at the current board; it simulates the game **6 to 8 moves into the future**. -It explores a "tree" of possibilities: _"If I play here, and the player plays there, then I can play here..."_ +### đ Physical Dimensions -### 2. Alpha-Beta Pruning +Designed for standard 8x8 matrix modules (approx. 65mm x 67mm). -Because searching millions of possibilities would be too slow for a microcontroller, we use **Alpha-Beta Pruning**. -This allows the AI to "prune" (ignore) branches of the game tree that are mathematically -guaranteed to be worse than moves it has already found, significantly speeding up the calculation. - -### 3. Immediate Threat Reaction - -To prevent the AI from being "distracted" by deep strategies while missing a simple win or loss, -we implemented a high-priority **Reaction Scanner**: - -- **Kill Move**: If the AI can win in exactly one move, it takes it immediately. -- **Block Move**: If the player is one move away from winning (3-in-a-row), the AI identifies the threat and blocks it regardless of the Minimax score. - -### 4. Controlled Randomness (Demo Mode) - -To keep the Demo Mode interesting for spectators, the AI has a 25% chance to ignore the "perfect" move and pick a random column. -This ensures that every demo game is unique and not a repetitive loop of the same strategy. +- **Top Row (0):** Interaction and AI decision visualization. +- **Game Board:** Standard $7 \times 6$ grid. +- **UI Borders:** Fixed blue frame for visibility. --- -## Technical Specifications +## đ§ Advanced AI Features -### Hardware Pins (Lolin C3 Mini) +### 1. Progressive Difficulty (Evolution Mode) -| Component | Pin | Function | -| :---------- | :-- | :--------------------------------------- | -| **LED_PIN** | 4 | WS2812B NeoPixel Data | -| **ENC_A** | 0 | Rotary Encoder Phase A | -| **ENC_B** | 1 | Rotary Encoder Phase B | -| **ENC_SW** | 2 | Switch (Includes 50ms Software Debounce) | +The AI search depth (Ply) increases as the board fills. This ensures the AI is fast in the opening and lethal in the endgame. -### NeoPixel Grid Layout +- **Formula:** $DynamicPly = BasePly + \lfloor \frac{DiscsOnBoard}{7} \rfloor$ +- **Benefit:** High-level tactical precision exactly when the game becomes critical. -The 8x8 matrix is mapped as follows: +### 2. Strategic Blunder Injection -- **Play Area**: 7 columns (0-6) by 6 rows (0-5). -- **Boundaries**: Row 1 and Column 7 are lit in **Blue** to mark the board limits. -- **Indicators**: The top-right pixel (7,0) pulses in the computer's color while it is "thinking." -- **Glowing Frame**: During Demo mode, the blue borders pulse with a white "glow" effect using a `beat8` sine wave to indicate autonomous play. +To avoid endless stalemate draws between high-level AIs, a "Blunder" logic is used. + +- **Demo Mode:** Always active; 20% chance to make a suboptimal move. +- **Player Mode:** Toggleable via Web Portal to make the AI more "human." + +### 3. Alpha-Beta Pruning & Column Ordering + +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. --- -## Controls & Interaction +## đ Code Architecture Details -- **Rotate Encoder**: Move the cursor (top row) to select a column. -- **Press Encoder Button**: Drop a disc. -- **Full Column Warning**: If you attempt to play in a full column, the selection disc will blink rapidly, and the move will be ignored. -- **Reset**: After a game ends, press the button once to return to the Menu. +### đ State Machine -## Build Flags (platformio.ini) +The software cycles through states: -Tweak the game performance without changing the source code: +- **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. -- `IDLE_TIMEOUT`: Time (ms) before Demo Mode starts. -- `DEMO_RESET_PAUSE`: Delay (ms) between games in Demo Mode. -- `DEBOUNCE_DELAY`: Sensitivity of the encoder button. -- `BRIGHTNESS`: Global brightness of the NeoPixels. +### đ¨ Rendering & Mapping + +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. + +--- + +## đ Web Admin Portal + +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. diff --git a/src/main.cpp b/src/main.cpp index 97ebe83..d8293c2 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -9,6 +9,7 @@ const int COLS = 7; const int ROWS = 6; +// --- Configuration & Globals --- CRGB leds[NUM_LEDS]; Encoder myEnc(ENC_A, ENC_B); WebServer server(80); @@ -33,41 +34,36 @@ long oldEncPos = -999; uint32_t lastActivityTime = 0; uint32_t demoResetTimer = 0; bool isDemoOver = false; +uint8_t demoPly = 4; -// Web-Configurable Parameters (Stored in Flash) +// Configurable Parameters uint8_t current_look_ahead; uint8_t current_brightness; uint32_t current_idle_timeout_ms; +bool blunder_enabled = false; +bool progressive_difficulty = false; -// Thinking Animation Helpers uint8_t aiBrightness = 0; bool aiFadeUp = true; -// --- Helper Functions --- -int getIdx(int x, int y) { return (y * 8) + x; } +// --- Function Prototypes --- +int getIdx(int x, int y); +void drawStaticUI(); +void renderBoard(); +int getFirstEmptyRow(int col); +bool isBoardFull(); +bool scanBoard(int8_t p); +void updateThinkingVisuals(int8_t p, int8_t col); +void animateDrop(int col, int player); +void moveDiscToCol(int startCol, int targetCol, int player, int speed); +int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol); +void performAiMove(int8_t aiP); +void showMenu(); +int getDynamicPly(); -void updateThinkingLED(int8_t p) -{ - static uint32_t lastCycle = 0; - if (millis() - lastCycle < 20) - return; - lastCycle = millis(); - if (aiFadeUp) - { - aiBrightness += 15; - if (aiBrightness >= 240) - aiFadeUp = false; - } - else - { - aiBrightness -= 15; - if (aiBrightness <= 15) - aiFadeUp = true; - } - CRGB compColor = (p == 1) ? CRGB::Yellow : CRGB::Red; - leds[getIdx(7, 0)] = compColor.nscale8(aiBrightness); - FastLED.show(); -} +// --- Utility & Rendering --- + +int getIdx(int x, int y) { return (y * 8) + x; } void drawStaticUI() { @@ -109,6 +105,85 @@ int getFirstEmptyRow(int col) return -1; } +int getDynamicPly() +{ + if (!progressive_difficulty && gameState != DEMO) + return current_look_ahead; + int count = 0; + for (int c = 0; c < COLS; c++) + 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); +} + +// --- Visuals & Animations --- + +void updateThinkingVisuals(int8_t p, int8_t col) +{ + static uint32_t lastCycle = 0; + if (millis() - lastCycle < 25) + return; + lastCycle = millis(); + if (aiFadeUp) + { + aiBrightness += 15; + if (aiBrightness >= 240) + aiFadeUp = false; + } + else + { + aiBrightness -= 15; + if (aiBrightness <= 15) + aiFadeUp = true; + } + for (int x = 0; x < COLS; x++) + leds[getIdx(x, 0)] = CRGB::Black; + 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) +{ + 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; + } + board[col][targetRow] = player; + renderBoard(); + FastLED.show(); +} + +void moveDiscToCol(int startCol, int targetCol, int player, int speed) +{ + int current = startCol; + CRGB pColor = (player == 1) ? CRGB::Yellow : CRGB::Red; + while (current != targetCol) + { + leds[getIdx(current, 0)] = CRGB::Black; + current += (targetCol > current) ? 1 : -1; + renderBoard(); + leds[getIdx(current, 0)] = pColor; + FastLED.show(); + delay(speed); + } + activeCol = targetCol; +} + +// --- AI Engine --- + bool isBoardFull() { for (int c = 0; c < COLS; c++) @@ -150,21 +225,18 @@ bool scanBoard(int8_t p) return found; } -// --- AI Engine --- -int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) +int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol) { if (depth >= current_look_ahead - 1) - updateThinkingLED(aiP); + updateThinkingVisuals(aiP, rootCol); else yield(); - if (scanBoard(aiP)) return 1000 + depth; if (scanBoard(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) @@ -173,7 +245,7 @@ int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) if (r != -1) { board[c][r] = isMax ? aiP : huP; - int val = minimax(depth - 1, alpha, beta, !isMax, aiP, huP); + int val = minimax(depth - 1, alpha, beta, !isMax, aiP, huP, (depth == current_look_ahead ? c : rootCol)); board[c][r] = 0; if (isMax) { @@ -196,9 +268,12 @@ int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) void performAiMove(int8_t aiP) { - int8_t huP = (aiP == 1) ? 2 : 1; - aiBrightness = 0; - aiFadeUp = true; + int huP = (aiP == 1) ? 2 : 1; + int bestScore = -30000; + int bestCol = 3; + int originalPly = current_look_ahead; + current_look_ahead = (gameState == DEMO) ? demoPly : getDynamicPly(); + for (int c = 0; c < COLS; c++) { int r = getFirstEmptyRow(c); @@ -207,28 +282,28 @@ void performAiMove(int8_t aiP) board[c][r] = aiP; if (scanBoard(aiP)) { - leds[getIdx(7, 0)] = CRGB::Black; - return; + board[c][r] = 0; + bestCol = c; + goto finalize; } board[c][r] = huP; if (current_look_ahead >= 2 && scanBoard(huP)) { - board[c][r] = aiP; - leds[getIdx(7, 0)] = CRGB::Black; - return; + board[c][r] = 0; + bestCol = c; + goto finalize; } board[c][r] = 0; } } - int bestScore = -30000; - int bestCol = 3; + for (int c : {3, 2, 4, 1, 5, 0, 6}) { int r = getFirstEmptyRow(c); if (r != -1) { board[c][r] = aiP; - int score = minimax(current_look_ahead, -30000, 30000, false, aiP, huP); + int score = minimax(current_look_ahead, -30000, 30000, false, aiP, huP, c); board[c][r] = 0; if (score > bestScore) { @@ -237,11 +312,63 @@ void performAiMove(int8_t aiP) } } } - board[bestCol][getFirstEmptyRow(bestCol)] = aiP; - leds[getIdx(7, 0)] = CRGB::Black; + + 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); + delay(450); + animateDrop(bestCol, aiP); +} + +// --- Web Portal & Setup --- + +void handleRoot() +{ + String html = "
"; + html += "