[fix] AI killer instinct, border toggle, and animation timing.

This commit is contained in:
2026-03-09 10:15:49 +01:00
parent da63f05ac3
commit 3e29a2e4da
4 changed files with 267 additions and 269 deletions
+46 -49
View File
@@ -1,72 +1,69 @@
# 📑 Technical Specification: Connect 4 AI Logic # 🕹️ Connect 4 AI: How the Brain Works
## 1. Board Representation: The 2D Grid Model To create a competitive Connect 4 experience on a small microcontroller, the game uses a mix of mathematical strategy and "shortcuts" to play like a human master.
The game state is maintained in a 2D array of signed integers: `int8_t board[COLS][ROWS]`. This structure mirrors the physical dimensions of a standard Connect 4 rack.
### Data Structure
- **Dimensions:** 7 columns (X) by 6 rows (Y).
- **Mapping:** \* `0`: Null/Empty slot.
- `1`: Player 1 (Yellow / Human).
- `2`: Player 2 (Red / AI).
- **Hardware Translation:** To drive the 8x8 NeoPixel matrix, the 2D coordinates are flattened into a 1D index using the specific mapping:
$$Index = (y \times 8) + x$$
_Note: The 8th column ($x=7$) is ignored by the game logic and reserved for UI borders._
--- ---
## 2. Positional Evaluation ## 1. The Virtual Board
Since Connect 4 has a state-space complexity of approximately $4.5 \times 10^{12}$, the ESP32 cannot calculate every possible outcome to the end of the game from the first move. Instead, it uses a **Heuristic Evaluation Function** to score board positions. The computer sees the board as a grid of numbers. It uses a **7-column by 6-row** map where:
### Scoring Heuristics - **0** = Empty space
- **1** = Yellow (Human Player)
- **2** = Red (Computer AI)
1. **Terminal Victory:** Any move that results in a 4-in-a-row is valued at $+1000$ (for AI) or $-1000$ (for Human). Every time a disc is dropped, a "Scan" function checks every possible direction (horizontal, vertical, and diagonal) to see if anyone has reached four in a row.
2. **Temporal Weighting:** To ensure the AI chooses the _fastest_ path to victory and the _longest_ path to defeat, the score is adjusted by the search depth:
- **AI Win:** $1000 + depth$
- **Human Win:** $-1000 - depth$
3. **Column Geometry:** The AI inherently values central columns higher than edges. This is not explicitly hardcoded in the score but emerges from the search logic: a disc in column 3 can be part of horizontal, vertical, and diagonal win lines in both directions, making it mathematically more valuable.
--- ---
## 3. Determining the Value of a "Half-Move" ## 2. Thinking Ahead (The "What If?" Engine)
A "half-move" is a single disc placement by one player. Its value is determined via the **Minimax Algorithm** with **Alpha-Beta Pruning**. The AI doesn't just look at the current board; it plays out thousands of "What if?" scenarios in its head.
### The Recursive Search Process ### The Minimax Strategy
The AI simulates a move (a "branch") and then recursively simulates the opponent's best possible responses. The value of a move is the "minimized" maximum score possible from that branch. The AI uses a strategy called **Minimax**. It assumes that you will play your absolute best move, and it tries to find the move that leaves you with the worst possible outcome. It "maximizes" its own advantage while "minimizing" yours.
### Optimization: Alpha-Beta Pruning ### Alpha-Beta Pruning (The Shortcut)
To prevent the ESP32-C3 from timing out, the engine "prunes" branches that are mathematically guaranteed to be worse than previously explored paths. Calculating every possible move in Connect 4 would take hours. To make the AI fast, it uses **Pruning**. If the AI starts calculating a move and realizes its definitely worse than a move it already found, it "prunes" (deletes) that entire branch of thought and moves on. This allows it to ignore up to 90% of useless moves.
- **Alpha ($\alpha$):** The best score the AI (Maximizer) can guarantee.
- **Beta ($\beta$):** The best score the Human (Minimizer) can guarantee.
- **The Cut-off:** If at any point $\beta \leq \alpha$, the branch is abandoned.
### 4. Dynamic Move Ordering
The efficiency of the value determination is heavily reliant on **Move Ordering**. By evaluating the most promising columns first (starting from the center), the AI finds a high "Alpha" value quickly.
- **Search Order:** `3 -> 2 -> 4 -> 1 -> 5 -> 0 -> 6`
This ordering allows the Alpha-Beta pruning to discard up to 90% of the possible moves in the outer columns without calculating them, significantly reducing the "Thinking" time on the microcontroller.
--- ---
## 5. Summary of Logic Execution ## 3. Scoring System (The AIs Instincts)
1. **Generate** all valid moves for the current board state. Since the computer can't always see to the very end of the game, it uses a scoring system to guess which positions are strongest:
2. **Order** moves starting from the center column.
3. **Execute** Minimax recursion for each move up to the current **Ply**. - **Speed Matters:** The AI is rewarded more for a win that happens soon than a win that takes a long time. This gives it a "killer instinct" to end the game as quickly as possible.
4. **Prune** branches that cannot mathematically improve the current best option. - **The Center is King:** The AI is programmed to prefer the middle column. Mathematically, the center column is involved in the most possible winning combinations, so the AI fights to control it early in the game.
5. **Return** the move with the highest heuristic value.
--- ---
## References ## 4. Being Responsive (Interrupt Handling)
- [Information on how to analyze Connect-four](https://www.google.com/search?q=https://en.wikipedia.org/wiki/Connect_Four%23Mathematical_solution) Your game runs on an **ESP32-C3**, which is a single-tasking processor. Normally, if the AI spends 2 seconds thinking, the buttons would feel "broken" or "frozen" until it finishes.
- [How does minimax work](https://en.wikipedia.org/wiki/Minimax)
- [What is aplpha-beta pruning](ttps://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning) We solve this with two clever tricks:
1. **Mid-Thought Checks:** Every few milliseconds of calculation, the AI "pauses" for a microsecond to see if you have pressed the button.
2. **Instant Exit:** If it detects you pressed the button while it was thinking, it abandons all calculations immediately and jumps back to the main menu.
---
## 5. Summary of an AI Move
When it is the computer's turn, it follows these steps in a split second:
1. **Check for Lethal:** Can I win right now? If yes, take it.
2. **Check for Danger:** Can the human win on their next move? If yes, block it.
3. **Search:** Look at the middle columns first, then the edges.
4. **Prune:** Throw away bad moves immediately to save time.
5. **Act:** Choose the move that leads to the quickest victory.
---
## 📚 References & Further Reading
- [The Mathematical Solution to Connect 4](https://en.wikipedia.org/wiki/Connect_Four#Mathematical_solution)
- [How the Minimax Algorithm Works](https://en.wikipedia.org/wiki/Minimax)
- [Understanding Alpha-Beta Pruning](https://en.wikipedia.org/wiki/Alpha%E2%80%93beta_pruning)
+26 -62
View File
@@ -1,86 +1,50 @@
# 🕹️ Connect 4 AI: Master Edition (v2.0) # 🕹️ Connect 4 AI: Grandmaster Edition (v2.5)
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. A high-performance Connect 4 implementation for the ESP32-C3 (RISC-V). This version features a "Killer Instinct" AI, human-like animations, and a real-time interrupt system.
---
## 🛠 Hardware Configuration ## 🛠 Hardware Configuration
### 🔌 Pin Mapping (Lolin C3 Mini) ### 🔌 Pin Mapping (Lolin C3 Mini)
| Component | ESP32-C3 Pin | Function | | Component | ESP32-C3 Pin | Function |
| :------------------- | :----------- | :--------------- | | :------------------- | :----------- | :------------------- |
| **NeoPixel Matrix** | `GPIO 4` | Data Input (DIN) | | **NeoPixel Matrix** | `GPIO 4` | Data Input (DIN) |
| **Rotary Encoder A** | `GPIO 0` | Directional CLK | | **Rotary Encoder A** | `GPIO 0` | Directional CLK |
| **Rotary Encoder B** | `GPIO 1` | Directional DT | | **Rotary Encoder B** | `GPIO 1` | Directional DT |
| **Encoder Button** | `GPIO 2` | Selection (SW) | | **Encoder Button** | `GPIO 2` | Selection/Abort (SW) |
### 📐 Physical Layout ### 📐 Physical Layout
The project is optimized for an 8x8 NeoPixel Matrix (65mm x 67mm). - **Game Board:** 7 Columns x 6 Rows.
- **Top Row (Row 0):** Interaction row (Selection & AI Thinking pulse).
- **Row 0:** Interaction & AI Decision Visualization. - **UI Border:** Row 1 and Column 7 (Blue frame, toggleable via `SHOW_BORDER`).
- **Row 1:** Static Blue UI border. - **Coordinate Formula:** $Index = (y \times 8) + x$
- **Rows 2-7:** Active $7 \times 6$ game board.
- **Status Column:** Far right column (Index 7) manages UI framing and "Glow" effects.
--- ---
## 🧠 Advanced AI & Logic Features ## 🧠 Advanced AI Features
### 1. Progressive Difficulty (Evolution Mode) ### 1. Offense-Priority Strategy
To keep the game challenging and the CPU efficient, the AI search depth (Ply) scales as the board fills. The AI follows a strict 3-phase move evaluation:
- **Formula:** $DynamicPly = BasePly + \lfloor \frac{DiscsOnBoard}{7} \rfloor$ 1. **Lethal:** If the AI can connect four this turn, it takes the win immediately.
- **Benefit:** The AI is "casual" in the opening but becomes a "Grandmaster" in the endgame when tactical precision is vital. 2. **Defensive:** If the human player has a lethal move, the AI blocks it.
3. **Strategic:** If no immediate wins exist, it runs a deep Minimax search.
### 2. Intelligent Win Detection & Flashing ### 2. High-Priority Interrupts
The win-engine has been refactored to prevent "color ghosting." The AI's single-core RISC-V processor is kept responsive via an "Abort Flag." Pressing the button or turning the encoder during an AI calculation (Demo or Playing) immediately kills the recursion and returns the user to the Menu.
- **Winner Locking:** The `scanBoard()` function returns the specific ID of the winner (1 for Yellow, 2 for Red). ### 3. Evolution Mode
- **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. Smart Watchdog (Tiered Timeout) AI difficulty scales dynamically: $DynamicPly = BasePly + \lfloor \frac{DiscsOnBoard}{7} \rfloor$.
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 & Modules ## 🛠 Installation & Build
### 🔄 State Machine 1. **Environment:** VS Code with PlatformIO.
The core loop manages five distinct states:
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.
### 🌐 Web Administration Portal
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.
---
## 🛠 Installation
1. **Environment:** Use VS Code with the **PlatformIO** extension.
2. **Dependencies:** `FastLED`, `Encoder`, `Preferences`. 2. **Dependencies:** `FastLED`, `Encoder`, `Preferences`.
3. **Build Flag:** Define your WiFi password in `platformio.ini`: `-D WIFI_PASSWORD=\"your_password\"`. 3. **Build Flags:** - `-D SHOW_BORDER=1` (Enables blue frame)
4. **Flash:** Upload to your ESP32-C3 and enjoy the ultimate desktop Connect 4 experience. - `-D SHOW_BORDER=0` (Full-screen board mode)
+1
View File
@@ -11,6 +11,7 @@ build_flags =
-D ENC_B=1 -D ENC_B=1
-D ENC_SW=2 -D ENC_SW=2
-D SENSITIVITY=4 -D SENSITIVITY=4
-D SHOW_BORDER=0
-D BRIGHTNESS=25 -D BRIGHTNESS=25
-D IDLE_TIMEOUT=45000 -D IDLE_TIMEOUT=45000
-D DEMO_RESET_PAUSE=20000 -D DEMO_RESET_PAUSE=20000
+194 -158
View File
@@ -5,11 +5,14 @@
#include <WebServer.h> #include <WebServer.h>
#include <Preferences.h> #include <Preferences.h>
#ifndef SHOW_BORDER
#define SHOW_BORDER 1
#endif
#define NUM_LEDS 64 #define NUM_LEDS 64
const int COLS = 7; const int COLS = 7;
const int ROWS = 6; const int ROWS = 6;
// --- Configuration & Globals ---
CRGB leds[NUM_LEDS]; CRGB leds[NUM_LEDS];
Encoder myEnc(ENC_A, ENC_B); Encoder myEnc(ENC_A, ENC_B);
WebServer server(80); WebServer server(80);
@@ -29,13 +32,14 @@ State gameState = MENU;
int8_t menuMode = 0; int8_t menuMode = 0;
int8_t currentPlayer = 1; int8_t currentPlayer = 1;
int8_t winnerPlayer = 0; // Tracks who actually won for the flashing effect int8_t winnerPlayer = 0;
int8_t activeCol = 3; int8_t activeCol = 3;
long oldEncPos = -999; long oldEncPos = -999;
uint32_t lastActivityTime = 0; uint32_t lastActivityTime = 0;
uint32_t demoResetTimer = 0; uint32_t demoResetTimer = 0;
bool isDemoOver = false; bool isDemoOver = false;
uint8_t demoPly = 4; uint8_t demoPly = 4;
bool abortAi = false;
uint8_t current_look_ahead; uint8_t current_look_ahead;
uint8_t current_brightness; uint8_t current_brightness;
@@ -52,22 +56,21 @@ void drawStaticUI();
void renderBoard(); void renderBoard();
int getFirstEmptyRow(int col); int getFirstEmptyRow(int col);
bool isBoardFull(); bool isBoardFull();
int8_t scanBoard(); // Changed to return the winner ID int8_t scanBoard();
void updateThinkingVisuals(int8_t p, int8_t col); void updateThinkingVisuals(int8_t playerColor, int8_t column);
void animateDrop(int col, int player); void animateDrop(int col, int player);
void moveDiscToCol(int startCol, int targetCol, int player, int speed); 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); int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiPlayer, int8_t humanPlayer, int8_t rootCol);
void performAiMove(int8_t aiP); void performAiMove(int8_t aiPlayer);
void showMenu(); void showMenu();
int getDynamicPly(); int getDynamicPly();
// --- Utility & Rendering ---
int getIdx(int x, int y) { return (y * 8) + x; } int getIdx(int x, int y) { return (y * 8) + x; }
void drawStaticUI() void drawStaticUI()
{ {
FastLED.clear(); FastLED.clear();
#if SHOW_BORDER == 1
CRGB borderColor = CRGB::Blue; CRGB borderColor = CRGB::Blue;
if (gameState == DEMO || gameState >= 2) if (gameState == DEMO || gameState >= 2)
{ {
@@ -78,48 +81,57 @@ void drawStaticUI()
leds[getIdx(x, 1)] = borderColor; leds[getIdx(x, 1)] = borderColor;
for (int y = 1; y < 8; y++) for (int y = 1; y < 8; y++)
leds[getIdx(7, y)] = borderColor; leds[getIdx(7, y)] = borderColor;
#endif
} }
void renderBoard() void renderBoard()
{ {
drawStaticUI(); drawStaticUI();
for (int c = 0; c < COLS; c++) for (int column = 0; column < COLS; column++)
{ {
for (int r = 0; r < ROWS; r++) for (int row = 0; row < ROWS; row++)
{ {
if (board[c][r] == 1) if (board[column][row] == 1)
leds[getIdx(c, 7 - r)] = CRGB::Yellow; leds[getIdx(column, 7 - row)] = CRGB::Yellow;
if (board[c][r] == 2) if (board[column][row] == 2)
leds[getIdx(c, 7 - r)] = CRGB::Red; leds[getIdx(column, 7 - row)] = CRGB::Red;
} }
} }
} }
int getFirstEmptyRow(int col) int getFirstEmptyRow(int col)
{ {
for (int r = 0; r < ROWS; r++) for (int row = 0; row < ROWS; row++)
{ {
if (board[col][r] == 0) if (board[col][row] == 0)
return r; return row;
} }
return -1; return -1;
} }
bool isBoardFull()
{
for (int column = 0; column < COLS; column++)
{
if (board[column][ROWS - 1] == 0)
return false;
}
return true;
}
int getDynamicPly() int getDynamicPly()
{ {
if (!progressive_difficulty && gameState != DEMO) if (!progressive_difficulty && gameState != DEMO)
return current_look_ahead; return current_look_ahead;
int count = 0; int occupiedCount = 0;
for (int c = 0; c < COLS; c++) for (int column = 0; column < COLS; column++)
for (int r = 0; r < ROWS; r++) for (int row = 0; row < ROWS; row++)
if (board[c][r] != 0) if (board[column][row] != 0)
count++; occupiedCount++;
return constrain(current_look_ahead + (count / 7), 1, 10); return constrain(current_look_ahead + (occupiedCount / 7), 1, 10);
} }
// --- Visuals & Animations --- void updateThinkingVisuals(int8_t playerColor, int8_t column)
void updateThinkingVisuals(int8_t p, int8_t col)
{ {
static uint32_t lastCycle = 0; static uint32_t lastCycle = 0;
if (millis() - lastCycle < 25) if (millis() - lastCycle < 25)
@@ -139,8 +151,8 @@ void updateThinkingVisuals(int8_t p, int8_t col)
} }
for (int x = 0; x < COLS; x++) for (int x = 0; x < COLS; x++)
leds[getIdx(x, 0)] = CRGB::Black; leds[getIdx(x, 0)] = CRGB::Black;
CRGB aiColor = (p == 1) ? CRGB::Yellow : CRGB::Red; CRGB aiColor = (playerColor == 1) ? CRGB::Yellow : CRGB::Red;
leds[getIdx(col, 0)] = aiColor.nscale8(aiBrightness); leds[getIdx(column, 0)] = aiColor.nscale8(aiBrightness);
FastLED.show(); FastLED.show();
} }
@@ -149,12 +161,12 @@ void animateDrop(int col, int player)
int targetRow = getFirstEmptyRow(col); int targetRow = getFirstEmptyRow(col);
if (targetRow == -1) if (targetRow == -1)
return; return;
for (int r = 5; r >= targetRow; r--) for (int row = 5; row >= targetRow; row--)
{ {
renderBoard(); renderBoard();
leds[getIdx(col, 7 - r)] = (player == 1) ? CRGB::Yellow : CRGB::Red; leds[getIdx(col, 7 - row)] = (player == 1) ? CRGB::Yellow : CRGB::Red;
FastLED.show(); FastLED.show();
delay(max(20, 80 - (5 - r) * 15)); delay(max(20, 80 - (5 - row) * 15));
} }
board[col][targetRow] = player; board[col][targetRow] = player;
renderBoard(); renderBoard();
@@ -165,7 +177,7 @@ void moveDiscToCol(int startCol, int targetCol, int player, int speed)
{ {
int current = startCol; int current = startCol;
CRGB pColor = (player == 1) ? CRGB::Yellow : CRGB::Red; CRGB pColor = (player == 1) ? CRGB::Yellow : CRGB::Red;
while (current != targetCol) while (current != targetCol && !abortAi)
{ {
leds[getIdx(current, 0)] = CRGB::Black; leds[getIdx(current, 0)] = CRGB::Black;
current += (targetCol > current) ? 1 : -1; current += (targetCol > current) ? 1 : -1;
@@ -173,59 +185,52 @@ void moveDiscToCol(int startCol, int targetCol, int player, int speed)
leds[getIdx(current, 0)] = pColor; leds[getIdx(current, 0)] = pColor;
FastLED.show(); FastLED.show();
delay(speed); delay(speed);
if (digitalRead(ENC_SW) == LOW)
abortAi = true;
} }
activeCol = targetCol; activeCol = targetCol;
} }
// --- AI Engine ---
bool isBoardFull()
{
for (int c = 0; c < COLS; c++)
if (board[c][5] == 0)
return false;
return true;
}
int8_t scanBoard() int8_t scanBoard()
{ {
memset(winMask, 0, sizeof(winMask)); memset(winMask, 0, sizeof(winMask));
auto check = [&](int column, int row, int columnOffset, int rowOffset) auto checkMatch = [&](int col, int row, int dCol, int dRow)
{ {
int8_t postion = board[column][row]; int8_t pAtPos = board[col][row];
if (postion != 0 && board[column + columnOffset][row + rowOffset] == postion && board[column + 2 * columnOffset][row + 2 * rowOffset] == postion && board[column + 3 * columnOffset][row + 3 * rowOffset] == postion) 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 index = 0; index < 4; index++) for (int i = 0; i < 4; i++)
winMask[getIdx(column + index * columnOffset, 7 - (row + index * rowOffset))] = true; winMask[getIdx(col + i * dCol, 7 - (row + i * dRow))] = true;
return postion; return pAtPos;
} }
return (int8_t)0; return (int8_t)0;
}; };
for (int row = 0; row < 6; row++) for (int r = 0; r < 6; r++)
for (int column = 0; column < 4; column++) for (int c = 0; c < 4; c++)
{ {
int8_t res = check(column, row, 1, 0); int8_t res = checkMatch(c, r, 1, 0);
if (res) if (res)
return res; return res;
} }
for (int row = 0; row < 3; row++) for (int r = 0; r < 3; r++)
for (int column = 0; column < 7; column++) for (int c = 0; c < 7; c++)
{ {
int8_t res = check(column, row, 0, 1); int8_t res = checkMatch(c, r, 0, 1);
if (res) if (res)
return res; return res;
} }
for (int row = 0; row < 3; row++) for (int r = 0; r < 3; r++)
for (int column = 0; column < 4; column++) for (int c = 0; c < 4; c++)
{ {
int8_t res = check(column, row, 1, 1); int8_t res = checkMatch(c, r, 1, 1);
if (res) if (res)
return res; return res;
} }
for (int row = 3; row < 6; row++) for (int r = 3; r < 6; r++)
for (int column = 0; column < 4; column++) for (int c = 0; c < 4; c++)
{ {
int8_t res = check(column, row, 1, -1); int8_t res = checkMatch(c, r, 1, -1);
if (res) if (res)
return res; return res;
} }
@@ -234,55 +239,69 @@ int8_t scanBoard()
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiPlayer, int8_t humanPlayer, int8_t rootCol) 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) if (depth >= current_look_ahead - 1)
updateThinkingVisuals(aiPlayer, rootCol); updateThinkingVisuals(aiPlayer, rootCol);
else else
yield(); yield();
if (abortAi)
return 0;
// Check for wins within minimax int8_t winner = scanBoard();
int8_t win = scanBoard(); if (winner == aiPlayer)
if (win == aiPlayer) return 1000 + depth; // Win sooner is better
return 1000 + depth; if (winner == humanPlayer)
if (win == humanPlayer) return -1000 - depth; // Lose later is better
return -1000 - depth;
if (depth == 0 || isBoardFull()) if (depth == 0 || isBoardFull())
return 0; return 0;
int order[] = {3, 2, 4, 1, 5, 0, 6}; int colOrder[] = {3, 2, 4, 1, 5, 0, 6};
int best = isMax ? -2000 : 2000; int bestScore = isMax ? -10000 : 10000;
for (int column : order)
for (int column : colOrder)
{ {
if (abortAi)
return 0;
int row = getFirstEmptyRow(column); int row = getFirstEmptyRow(column);
if (row != -1) if (row != -1)
{ {
board[column][row] = isMax ? aiPlayer : humanPlayer; board[column][row] = isMax ? aiPlayer : humanPlayer;
int val = minimax(depth - 1, alpha, beta, !isMax, aiPlayer, humanPlayer, (depth == current_look_ahead ? column : rootCol)); int score = minimax(depth - 1, alpha, beta, !isMax, aiPlayer, humanPlayer, (depth == current_look_ahead ? column : rootCol));
board[column][row] = 0; board[column][row] = 0;
if (isMax) if (isMax)
{ {
best = max(best, val); bestScore = max(bestScore, score);
alpha = max(alpha, best); alpha = max(alpha, bestScore);
} }
else else
{ {
best = min(best, val); bestScore = min(bestScore, score);
beta = min(beta, best); beta = min(beta, bestScore);
} }
if (beta <= alpha) if (beta <= alpha)
break; break;
} }
} }
return best; return bestScore;
} }
void performAiMove(int8_t aiPlayer) void performAiMove(int8_t aiPlayer)
{ {
abortAi = false;
int humanPlayer = (aiPlayer == 1) ? 2 : 1; int humanPlayer = (aiPlayer == 1) ? 2 : 1;
int bestScore = -30000; int bestScore = -30000;
int bestCol = 3; int bestCol = 3;
int originalPly = current_look_ahead; int originalPly = current_look_ahead;
current_look_ahead = (gameState == DEMO) ? demoPly : getDynamicPly(); current_look_ahead = (gameState == DEMO) ? demoPly : getDynamicPly();
// PHASE 1: Immediate Win Check (OFFENSE)
for (int column = 0; column < COLS; column++) for (int column = 0; column < COLS; column++)
{ {
int row = getFirstEmptyRow(column); int row = getFirstEmptyRow(column);
@@ -293,20 +312,34 @@ void performAiMove(int8_t aiPlayer)
{ {
board[column][row] = 0; board[column][row] = 0;
bestCol = column; bestCol = column;
goto finalize; goto finalizeMove; // TAKE THE WIN IMMEDIATELY
}
board[column][row] = humanPlayer;
if (current_look_ahead >= 2 && scanBoard() == humanPlayer)
{
board[column][row] = 0;
bestCol = column;
goto finalize;
} }
board[column][row] = 0; 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}) for (int column : {3, 2, 4, 1, 5, 0, 6})
{ {
if (abortAi)
goto finalizeMove;
int row = getFirstEmptyRow(column); int row = getFirstEmptyRow(column);
if (row != -1) if (row != -1)
{ {
@@ -320,34 +353,29 @@ void performAiMove(int8_t aiPlayer)
} }
} }
} }
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, aiPlayer, 100);
delay(450);
animateDrop(bestCol, aiPlayer);
}
// --- Web Portal --- 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() void handleRoot()
{ {
String html = "<html><head><meta name='viewport' content='width=device-width, initial-scale=1'>" 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><h1>Connect 4 Admin</h1><div class='card'><form action='/save' method='POST'>";
"<style>body{font-family:sans-serif;background:#121212;color:white;text-align:center;}" html += "Base AI Ply:<input type='number' name='ply' value='" + String(current_look_ahead) + "'>Brightness:<input type='number' name='br' value='" + String(current_brightness) + "'>Idle Timeout (s):<input type='number' name='idle' value='" + String(current_idle_timeout_ms / 1000) + "'>";
" .card{background:#222;padding:25px;border-radius:15px;display:inline-block;margin-top:20px;}" html += "Blunders: <input type='checkbox' name='blunder' " + String(blunder_enabled ? "checked" : "") + "><br>Evolution: <input type='checkbox' name='evolve' " + String(progressive_difficulty ? "checked" : "") + "><br><br><input type='submit' value='Save' style='background:#28a745;color:white;'></form></div></body></html>";
" 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) + "'>";
html += "Idle Timeout (Sec):<input type='number' name='idle' value='" + String(current_idle_timeout_ms / 1000) + "'>";
html += "Enable Blunders: <input type='checkbox' name='blunder' " + String(blunder_enabled ? "checked" : "") + "><br>";
html += "Evolution Mode: <input type='checkbox' name='evolve' " + String(progressive_difficulty ? "checked" : "") + "><br><br>";
html += "<input type='submit' value='Save Settings' style='background:#28a745;color:white;font-weight:bold;'></form></div></body></html>";
server.send(200, "text/html", html); server.send(200, "text/html", html);
} }
@@ -366,9 +394,8 @@ void handleSave()
} }
if (server.hasArg("idle")) if (server.hasArg("idle"))
{ {
uint32_t s = server.arg("idle").toInt(); current_idle_timeout_ms = server.arg("idle").toInt() * 1000;
current_idle_timeout_ms = s * 1000; prefs.putUInt("idle", current_idle_timeout_ms / 1000);
prefs.putUInt("idle", s);
} }
blunder_enabled = server.hasArg("blunder"); blunder_enabled = server.hasArg("blunder");
prefs.putBool("blunder", blunder_enabled); prefs.putBool("blunder", blunder_enabled);
@@ -382,19 +409,21 @@ void showMenu()
{ {
isDemoOver = false; isDemoOver = false;
FastLED.clear(); FastLED.clear();
#if SHOW_BORDER == 1
for (int x = 0; x < 7; x++) for (int x = 0; x < 7; x++)
leds[getIdx(x, 1)] = CRGB::Blue; leds[getIdx(x, 1)] = CRGB::Blue;
for (int y = 1; y < 8; y++) for (int y = 1; y < 8; y++)
leds[getIdx(7, y)] = CRGB::Blue; leds[getIdx(7, y)] = CRGB::Blue;
#endif
if (menuMode < 2) if (menuMode < 2)
{ {
CRGB p1Col = (menuMode == 1) ? CRGB::Red : CRGB::Yellow; CRGB pCol = (menuMode == 1) ? CRGB::Red : CRGB::Yellow;
for (int y = 3; y <= 6; y++) for (int y = 3; y <= 6; y++)
leds[getIdx(3, y)] = p1Col; leds[getIdx(3, y)] = pCol;
leds[getIdx(2, 3)] = p1Col; leds[getIdx(2, 3)] = pCol;
leds[getIdx(4, 3)] = p1Col; leds[getIdx(4, 3)] = pCol;
leds[getIdx(2, 6)] = p1Col; leds[getIdx(2, 6)] = pCol;
leds[getIdx(4, 6)] = p1Col; leds[getIdx(4, 6)] = pCol;
} }
else else
{ {
@@ -424,7 +453,6 @@ void setup()
current_idle_timeout_ms = prefs.getUInt("idle", 60) * 1000; current_idle_timeout_ms = prefs.getUInt("idle", 60) * 1000;
blunder_enabled = prefs.getBool("blunder", false); blunder_enabled = prefs.getBool("blunder", false);
progressive_difficulty = prefs.getBool("evolve", false); progressive_difficulty = prefs.getBool("evolve", false);
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(current_brightness); FastLED.setBrightness(current_brightness);
pinMode(ENC_SW, INPUT_PULLUP); pinMode(ENC_SW, INPUT_PULLUP);
@@ -446,18 +474,21 @@ void loop()
{ {
if (gameState >= 2 || gameState == DEMO) if (gameState >= 2 || gameState == DEMO)
{ {
for (int index = 0; index < 10; index++) abortAi = true;
{
fadeToBlackBy(leds, NUM_LEDS, 32);
FastLED.show();
delay(30);
}
delay(500);
gameState = MENU;
memset(board, 0, sizeof(board)); 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(); showMenu();
lastActivityTime = millis();
oldEncPos = newPos; oldEncPos = newPos;
lastActivityTime = millis();
delay(300);
return; return;
} }
lastActivityTime = millis(); lastActivityTime = millis();
@@ -503,12 +534,14 @@ void loop()
{ {
activeCol = (newPos % 7 + 7) % 7; activeCol = (newPos % 7 + 7) % 7;
oldEncPos = newPos; oldEncPos = newPos;
lastActivityTime = millis();
} }
renderBoard(); renderBoard();
leds[getIdx(activeCol, 0)] = (currentPlayer == 1) ? CRGB::Yellow : CRGB::Red; leds[getIdx(activeCol, 0)] = (currentPlayer == 1) ? CRGB::Yellow : CRGB::Red;
FastLED.show(); FastLED.show();
if (pressed) if (pressed)
{ {
lastActivityTime = millis();
int row = getFirstEmptyRow(activeCol); int row = getFirstEmptyRow(activeCol);
if (row != -1) if (row != -1)
{ {
@@ -528,18 +561,22 @@ void loop()
{ {
if (menuMode < 2) if (menuMode < 2)
{ {
int8_t aiPlayer = (menuMode == 0) ? 2 : 1; int8_t aiP = (menuMode == 0) ? 2 : 1;
performAiMove(aiPlayer); performAiMove(aiP);
winnerPlayer = scanBoard(); lastActivityTime = millis();
if (winnerPlayer != 0) if (!abortAi)
{ {
gameState = FINISHED_WIN; winnerPlayer = scanBoard();
demoResetTimer = millis(); if (winnerPlayer != 0)
} {
else if (isBoardFull()) gameState = FINISHED_WIN;
{ demoResetTimer = millis();
gameState = FINISHED_DRAW; }
demoResetTimer = millis(); else if (isBoardFull())
{
gameState = FINISHED_DRAW;
demoResetTimer = millis();
}
} }
} }
else else
@@ -557,24 +594,27 @@ void loop()
FastLED.show(); FastLED.show();
delay(600); delay(600);
performAiMove(currentPlayer); performAiMove(currentPlayer);
winnerPlayer = scanBoard(); if (!abortAi)
if (winnerPlayer != 0)
{ {
gameState = FINISHED_WIN; winnerPlayer = scanBoard();
demoResetTimer = millis(); if (winnerPlayer != 0)
} {
else if (isBoardFull()) gameState = FINISHED_WIN;
{ demoResetTimer = millis();
gameState = FINISHED_DRAW; }
demoResetTimer = millis(); else if (isBoardFull())
} {
else gameState = FINISHED_DRAW;
{ demoResetTimer = millis();
currentPlayer = (currentPlayer == 1) ? 2 : 1; }
else
{
currentPlayer = (currentPlayer == 1) ? 2 : 1;
}
} }
} }
else else
{ // FINISHED state {
static uint32_t lastFlash = 0; static uint32_t lastFlash = 0;
static bool toggle = true; static bool toggle = true;
if (millis() - lastFlash > 300) if (millis() - lastFlash > 300)
@@ -584,8 +624,10 @@ void loop()
renderBoard(); renderBoard();
for (int i = 0; i < NUM_LEDS; i++) for (int i = 0; i < NUM_LEDS; i++)
{ {
#if SHOW_BORDER == 1
if (leds[i] == CRGB::Blue) if (leds[i] == CRGB::Blue)
continue; continue;
#endif
if (gameState == FINISHED_WIN) if (gameState == FINISHED_WIN)
{ {
if (winMask[i]) if (winMask[i])
@@ -608,11 +650,5 @@ void loop()
demoResetTimer = 0; demoResetTimer = 0;
demoPly = random(3, 7); demoPly = random(3, 7);
} }
if (pressed)
{
gameState = MENU;
showMenu();
delay(300);
}
} }
} }