#include #include #include #include #include #include #ifndef SHOW_BORDER #define SHOW_BORDER 1 #endif #define NUM_LEDS 64 const int COLS = 7; const int ROWS = 6; CRGB leds[NUM_LEDS]; Encoder myEnc(ENC_A, ENC_B); WebServer server(80); Preferences prefs; int8_t board[COLS][ROWS]; bool winMask[NUM_LEDS]; enum State { MENU, PLAYING, FINISHED_WIN, FINISHED_DRAW, DEMO }; State gameState = MENU; int8_t menuMode = 0; int8_t currentPlayer = 1; int8_t winnerPlayer = 0; int8_t activeCol = 3; long oldEncPos = -999; uint32_t lastActivityTime = 0; uint32_t demoResetTimer = 0; bool isDemoOver = false; uint8_t demoPly = 4; bool abortAi = false; uint8_t current_look_ahead; uint8_t current_brightness; uint32_t current_idle_timeout_ms; bool blunder_enabled = false; bool progressive_difficulty = false; uint8_t aiBrightness = 0; bool aiFadeUp = true; // --- Function Prototypes --- int getIdx(int x, int y); void drawStaticUI(); void renderBoard(); int getFirstEmptyRow(int col); bool isBoardFull(); int8_t scanBoard(); void updateThinkingVisuals(int8_t playerColor, int8_t column); 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 aiPlayer, int8_t humanPlayer, int8_t rootCol); void performAiMove(int8_t aiPlayer); void showMenu(); int getDynamicPly(); int getIdx(int x, int y) { return (y * 8) + x; } void drawStaticUI() { FastLED.clear(); #if SHOW_BORDER == 1 CRGB borderColor = CRGB::Blue; if (gameState == DEMO || gameState >= 2) { uint8_t glow = beat8(15); borderColor = blend(CRGB::Blue, CRGB::White, glow / 4); } for (int x = 0; x < 7; x++) leds[getIdx(x, 1)] = borderColor; for (int y = 1; y < 8; y++) leds[getIdx(7, y)] = borderColor; #endif } void renderBoard() { drawStaticUI(); for (int column = 0; column < COLS; column++) { for (int row = 0; row < ROWS; row++) { if (board[column][row] == 1) leds[getIdx(column, 7 - row)] = CRGB::Yellow; if (board[column][row] == 2) leds[getIdx(column, 7 - row)] = CRGB::Red; } } } int getFirstEmptyRow(int col) { for (int row = 0; row < ROWS; row++) { if (board[col][row] == 0) return row; } return -1; } bool isBoardFull() { for (int column = 0; column < COLS; column++) { if (board[column][ROWS - 1] == 0) return false; } return true; } int getDynamicPly() { if (!progressive_difficulty && gameState != DEMO) return current_look_ahead; int occupiedCount = 0; for (int column = 0; column < COLS; column++) for (int row = 0; row < ROWS; row++) if (board[column][row] != 0) occupiedCount++; return constrain(current_look_ahead + (occupiedCount / 7), 1, 10); } void updateThinkingVisuals(int8_t playerColor, int8_t column) { 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 = (playerColor == 1) ? CRGB::Yellow : CRGB::Red; leds[getIdx(column, 0)] = aiColor.nscale8(aiBrightness); FastLED.show(); } void animateDrop(int col, int player) { int targetRow = getFirstEmptyRow(col); if (targetRow == -1) return; for (int row = 5; row >= targetRow; row--) { renderBoard(); leds[getIdx(col, 7 - row)] = (player == 1) ? CRGB::Yellow : CRGB::Red; FastLED.show(); delay(max(20, 80 - (5 - row) * 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 && !abortAi) { leds[getIdx(current, 0)] = CRGB::Black; current += (targetCol > current) ? 1 : -1; renderBoard(); leds[getIdx(current, 0)] = pColor; FastLED.show(); delay(speed); if (digitalRead(ENC_SW) == LOW) abortAi = true; } activeCol = targetCol; } int8_t scanBoard() { memset(winMask, 0, sizeof(winMask)); auto checkMatch = [&](int col, int row, int dCol, int dRow) { int8_t pAtPos = board[col][row]; if (pAtPos != 0 && board[col + dCol][row + dRow] == pAtPos && board[col + 2 * dCol][row + 2 * dRow] == pAtPos && board[col + 3 * dCol][row + 3 * dRow] == pAtPos) { for (int i = 0; i < 4; i++) winMask[getIdx(col + i * dCol, 7 - (row + i * dRow))] = true; return pAtPos; } return (int8_t)0; }; for (int r = 0; r < 6; r++) for (int c = 0; c < 4; c++) { int8_t res = checkMatch(c, r, 1, 0); if (res) return res; } for (int r = 0; r < 3; r++) for (int c = 0; c < 7; c++) { int8_t res = checkMatch(c, r, 0, 1); if (res) return res; } for (int r = 0; r < 3; r++) for (int c = 0; c < 4; c++) { int8_t res = checkMatch(c, r, 1, 1); if (res) return res; } for (int r = 3; r < 6; r++) for (int c = 0; c < 4; c++) { int8_t res = checkMatch(c, r, 1, -1); if (res) return res; } return 0; } int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiPlayer, int8_t humanPlayer, int8_t rootCol) { if (depth % 2 == 0) { if (digitalRead(ENC_SW) == LOW) { abortAi = true; return 0; } } if (depth >= current_look_ahead - 1) updateThinkingVisuals(aiPlayer, rootCol); else yield(); if (abortAi) return 0; int8_t winner = scanBoard(); if (winner == aiPlayer) return 1000 + depth; // Win sooner is better if (winner == humanPlayer) return -1000 - depth; // Lose later is better if (depth == 0 || isBoardFull()) return 0; int colOrder[] = {3, 2, 4, 1, 5, 0, 6}; int bestScore = isMax ? -10000 : 10000; for (int column : colOrder) { if (abortAi) return 0; int row = getFirstEmptyRow(column); if (row != -1) { board[column][row] = isMax ? aiPlayer : humanPlayer; int score = minimax(depth - 1, alpha, beta, !isMax, aiPlayer, humanPlayer, (depth == current_look_ahead ? column : rootCol)); board[column][row] = 0; if (isMax) { bestScore = max(bestScore, score); alpha = max(alpha, bestScore); } else { bestScore = min(bestScore, score); beta = min(beta, bestScore); } if (beta <= alpha) break; } } return bestScore; } void performAiMove(int8_t aiPlayer) { abortAi = false; int humanPlayer = (aiPlayer == 1) ? 2 : 1; int bestScore = -30000; int bestCol = 3; int originalPly = current_look_ahead; current_look_ahead = (gameState == DEMO) ? demoPly : getDynamicPly(); // PHASE 1: Immediate Win Check (OFFENSE) for (int column = 0; column < COLS; column++) { int row = getFirstEmptyRow(column); if (row != -1) { board[column][row] = aiPlayer; if (scanBoard() == aiPlayer) { board[column][row] = 0; bestCol = column; goto finalizeMove; // TAKE THE WIN IMMEDIATELY } board[column][row] = 0; } } // PHASE 2: Immediate Block Check (DEFENSE) for (int column = 0; column < COLS; column++) { int row = getFirstEmptyRow(column); if (row != -1) { board[column][row] = humanPlayer; if (scanBoard() == humanPlayer) { board[column][row] = 0; bestCol = column; goto finalizeMove; // MUST BLOCK } board[column][row] = 0; } } // PHASE 3: Minimax Look-ahead for (int column : {3, 2, 4, 1, 5, 0, 6}) { if (abortAi) goto finalizeMove; int row = getFirstEmptyRow(column); if (row != -1) { board[column][row] = aiPlayer; int score = minimax(current_look_ahead, -30000, 30000, false, aiPlayer, humanPlayer, column); board[column][row] = 0; if (score > bestScore) { bestScore = score; bestCol = column; } } } if ((gameState == DEMO || blunder_enabled) && random(100) < 20 && !abortAi) { int randomColumn = random(0, 7); if (getFirstEmptyRow(randomColumn) != -1) bestCol = randomColumn; } finalizeMove: current_look_ahead = originalPly; if (!abortAi) { moveDiscToCol(activeCol, bestCol, aiPlayer, 100); delay(450); animateDrop(bestCol, aiPlayer); } } void handleRoot() { String html = "

Connect 4 Admin

"; html += "Base AI Ply:Brightness:Idle Timeout (s):"; html += "Blunders:
Evolution:

"; server.send(200, "text/html", html); } void handleSave() { if (server.hasArg("ply")) { current_look_ahead = server.arg("ply").toInt(); prefs.putUChar("ply", current_look_ahead); } if (server.hasArg("br")) { current_brightness = server.arg("br").toInt(); FastLED.setBrightness(current_brightness); prefs.putUChar("br", current_brightness); } if (server.hasArg("idle")) { current_idle_timeout_ms = server.arg("idle").toInt() * 1000; prefs.putUInt("idle", current_idle_timeout_ms / 1000); } blunder_enabled = server.hasArg("blunder"); prefs.putBool("blunder", blunder_enabled); progressive_difficulty = server.hasArg("evolve"); prefs.putBool("evolve", progressive_difficulty); server.sendHeader("Location", "/"); server.send(303); } void showMenu() { isDemoOver = false; FastLED.clear(); #if SHOW_BORDER == 1 for (int x = 0; x < 7; x++) leds[getIdx(x, 1)] = CRGB::Blue; for (int y = 1; y < 8; y++) leds[getIdx(7, y)] = CRGB::Blue; #endif if (menuMode < 2) { CRGB pCol = (menuMode == 1) ? CRGB::Red : CRGB::Yellow; for (int y = 3; y <= 6; y++) leds[getIdx(3, y)] = pCol; leds[getIdx(2, 3)] = pCol; leds[getIdx(4, 3)] = pCol; leds[getIdx(2, 6)] = pCol; leds[getIdx(4, 6)] = pCol; } else { for (int y = 3; y <= 6; y++) { leds[getIdx(2, y)] = CRGB::Yellow; leds[getIdx(4, y)] = CRGB::Red; } leds[getIdx(1, 3)] = CRGB::Yellow; leds[getIdx(3, 3)] = CRGB::Yellow; leds[getIdx(1, 6)] = CRGB::Yellow; leds[getIdx(3, 6)] = CRGB::Yellow; leds[getIdx(3, 3)] = CRGB::Red; leds[getIdx(5, 3)] = CRGB::Red; leds[getIdx(3, 6)] = CRGB::Red; leds[getIdx(5, 6)] = CRGB::Red; } FastLED.show(); } void setup() { Serial.begin(115200); prefs.begin("c4-game", false); current_look_ahead = prefs.getUChar("ply", 8); current_brightness = prefs.getUChar("br", 25); current_idle_timeout_ms = prefs.getUInt("idle", 60) * 1000; blunder_enabled = prefs.getBool("blunder", false); progressive_difficulty = prefs.getBool("evolve", false); FastLED.addLeds(leds, NUM_LEDS); FastLED.setBrightness(current_brightness); pinMode(ENC_SW, INPUT_PULLUP); WiFi.softAP("Connect4-Config", WIFI_PASSWORD); server.on("/", handleRoot); server.on("/save", HTTP_POST, handleSave); server.begin(); lastActivityTime = millis(); showMenu(); } void loop() { server.handleClient(); long newPos = myEnc.read() / SENSITIVITY; bool pressed = (digitalRead(ENC_SW) == LOW); if (newPos != oldEncPos || (pressed && (millis() - lastActivityTime > 500))) { if (gameState >= 2 || gameState == DEMO) { abortAi = true; memset(board, 0, sizeof(board)); winnerPlayer = 0; demoResetTimer = 0; for (int i = 0; i < 10; i++) { fadeToBlackBy(leds, NUM_LEDS, 40); FastLED.show(); delay(20); } gameState = MENU; showMenu(); oldEncPos = newPos; lastActivityTime = millis(); delay(300); return; } 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) { menuMode = (newPos % 3 + 3) % 3; oldEncPos = newPos; showMenu(); } if (pressed) { memset(board, 0, sizeof(board)); gameState = PLAYING; if (menuMode == 1) { performAiMove(1); currentPlayer = 2; } else { currentPlayer = 1; } delay(300); } } else if (gameState == PLAYING) { if (newPos != oldEncPos) { activeCol = (newPos % 7 + 7) % 7; oldEncPos = newPos; lastActivityTime = millis(); } renderBoard(); leds[getIdx(activeCol, 0)] = (currentPlayer == 1) ? CRGB::Yellow : CRGB::Red; FastLED.show(); if (pressed) { lastActivityTime = millis(); int row = getFirstEmptyRow(activeCol); if (row != -1) { animateDrop(activeCol, 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); lastActivityTime = millis(); if (!abortAi) { winnerPlayer = scanBoard(); if (winnerPlayer != 0) { gameState = FINISHED_WIN; demoResetTimer = millis(); } else if (isBoardFull()) { gameState = FINISHED_DRAW; demoResetTimer = millis(); } } } else { currentPlayer = (currentPlayer == 1) ? 2 : 1; } } delay(300); } } } else if (gameState == DEMO) { renderBoard(); FastLED.show(); delay(600); performAiMove(currentPlayer); if (!abortAi) { winnerPlayer = scanBoard(); if (winnerPlayer != 0) { gameState = FINISHED_WIN; demoResetTimer = millis(); } else if (isBoardFull()) { gameState = FINISHED_DRAW; demoResetTimer = millis(); } else { currentPlayer = (currentPlayer == 1) ? 2 : 1; } } } else { static uint32_t lastFlash = 0; static bool toggle = true; if (millis() - lastFlash > 300) { lastFlash = millis(); toggle = !toggle; renderBoard(); for (int i = 0; i < NUM_LEDS; i++) { #if SHOW_BORDER == 1 if (leds[i] == CRGB::Blue) continue; #endif if (gameState == FINISHED_WIN) { if (winMask[i]) leds[i] = toggle ? (winnerPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black; else leds[i].nscale8(60); } else if (gameState == FINISHED_DRAW) { if (!toggle) leds[i] = CRGB::Black; } } FastLED.show(); } if (millis() - demoResetTimer > 15000) { memset(board, 0, sizeof(board)); gameState = DEMO; demoResetTimer = 0; demoPly = random(3, 7); } } }