[add] Autoplay and improve gameplay

This commit is contained in:
2026-03-06 13:40:46 +01:00
parent 45e06009a9
commit 917cec34e4
7 changed files with 453 additions and 253 deletions
-1
View File
@@ -1 +0,0 @@
3.14
+9
View File
@@ -0,0 +1,9 @@
{
"cSpell.words": [
"espressif",
"fastled",
"lolin",
"microcontroller",
"paulstoffregen"
]
}
+72 -50
View File
@@ -1,73 +1,95 @@
# Connect Four
# Connect Four: ESP32-C3 LED Edition
Connect Four is a two-player game played on a 7 by 6 grid. Each player has a color: one player is red, and the other player is yellow. The first player starts Connect Four by dropping one of their yellow discs into the (center) column of an empty game board. The two players then alternate turns dropping one of their discs at a time into an unfilled column, until the second player, with red discs, achieves a diagonal four in a row, and wins the game. If the board fills up before either player achieves four in a row, then the game is a draw.
A hardware-based Connect Four game featuring an 8x8 NeoPixel matrix, a strategic Minimax AI, and a dynamic "Attract Mode" for public display.
## Technical specifications
## How the Program Works
The game board is an 8 x 8 NeoPixel grid. To mark the limits of the board, the top row is off, in the row below the pixels are blue, and in the rightmost column's pixels are blue from the row below the empty row until the bottom row.
The game consists of a rotary encoder, using the Encoder.h library connected to pins:
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.
```c++
#define ENC_A 0
#define ENC_B 1
#define ENC_SW 6
```
### 1. Game States
The grid is managed using the FastLED.h library and is connected to:
- **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."
```c++
#define LED_PIN 4
#define LED_WIDTH 8
#define LED_HEIGHT 8
#define NUM_LEDS 64
#define LED_TYPE WS2812B
#define COLOR_ORDER GRB
```
### 2. Win Detection Logic
Players play by turning the rotary encoder to choose a column by rotating the encoder. When they press the button, the disc is dropped and falls to the lowest free position in the grid. If a column is full and a player tries to drop a disc, the disc at the top (column selection row) blinks, indicating that no disc can be dropped. If the player rotates the encoder to a non-full column, a disc can be played.
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:
A win is when one of the players achieves four connecting discs of the same color: horizontal, vertical, or diagonal. If a player wins, all the discs on the board are dimmed to 15% intensity, and the winning four discs are blinking at high intensity.
- **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]`
If the board is full and none of the players win, a draw, then all the discs on the board are dimmed, and a blinking animation indicates a draw.
---
The game has four states:
## The AI: Strategic Minimax
1. Menu: Here the players choose the type of game they want to play, 1 or 2 player. This is indicated on the board by a single yellow vertical bar in the center column (single player versus computer) or two vertical bars: two player game. Rotating the encoder switches between game modes, and pressing the button selects the game mode.
2. Game play: Players are playing the game until one of the players connects four or they achieve a draw.
3. Game over: One of the players connects four, or they achieve a draw. If the rotary encoder button is pressed, this state switches to the Menu state.
4. Demo mode: The computer plays against itself. This mode is automatically triggered if there is no input for 60 seconds. If the rotary encoder is turned or pushed, the demo mode exits and the game returns to the menu state.
The computer opponent uses the **Minimax Algorithm**, a classic artificial intelligence method for zero-sum games.
The program initializes the SerialPrint output (baud 115200) and outputs useful (debugging) information regarding the game state and selections.
### 1. Look-Ahead (Depth Search)
## Implementation Details
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..."_
The game is implemented using the following components:
### 2. Alpha-Beta Pruning
- **Arduino**: The main platform for the game.
- **FastLED.h**: Library for managing the NeoPixel grid.
- **Encoder.h**: Library for handling the rotary encoder.
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.
The game logic includes:
### 3. Immediate Threat Reaction
- **Board Representation**: A 7x6 grid represented as a 2D array.
- **Minimax Algorithm**: Used for the AI to determine the best move.
- **Win Detection**: Scans the board for four connected discs in any direction.
- **Visual Feedback**: Uses LED animations to indicate game state and player actions.
To prevent the AI from being "distracted" by deep strategies while missing a simple win or loss,
we implemented a high-priority **Reaction Scanner**:
## Game Modes
- **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.
1. **Single Player (Yellow)**: Player vs. AI (Red).
2. **Single Player (Red)**: Player vs. AI (Yellow).
3. **Two Player**: Player vs. Player.
### 4. Controlled Randomness (Demo Mode)
## Controls
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.
- **Rotate Encoder**: Move the cursor to select a column or game mode.
- **Press Encoder Button**: Drop a disc or select a game mode.
---
## Visual Indicators
## Technical Specifications
- **Blue LEDs**: Mark the boundaries of the game board.
- **Blinking Disc**: Indicates a full column.
- **Blinking Win Animation**: Indicates the winning discs.
- **Dimmed Board**: Indicates a game over state.
### Hardware Pins (Lolin C3 Mini)
| 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) |
### NeoPixel Grid Layout
The 8x8 matrix is mapped as follows:
- **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.
---
## Controls & Interaction
- **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.
## Build Flags (platformio.ini)
Tweak the game performance without changing the source code:
- `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.
-6
View File
@@ -1,6 +0,0 @@
def main():
print("Hello from connect-four!")
if __name__ == "__main__":
main()
+4 -1
View File
@@ -9,9 +9,12 @@ build_flags =
-D LED_PIN=4
-D ENC_A=0
-D ENC_B=1
-D ENC_SW=6
-D ENC_SW=2
-D SENSITIVITY=4
-D BRIGHTNESS=25
-D IDLE_TIMEOUT=45000
-D DEMO_RESET_PAUSE=20000
-D DEBOUNCE_DELAY=50
lib_deps =
fastled/FastLED @ ^3.6.0
paulstoffregen/Encoder @ ^1.4.4
-9
View File
@@ -1,9 +0,0 @@
[project]
name = "connect-four"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"esptool>=5.2.0",
]
+361 -179
View File
@@ -5,274 +5,456 @@
#define NUM_LEDS 64
const int COLS = 7;
const int ROWS = 6;
const int LOOK_AHEAD = 6; // Depth 6 is very stable and tough for C3
const int LOOK_AHEAD = 8;
CRGB leds[NUM_LEDS];
Encoder *myEnc;
Encoder myEnc(ENC_A, ENC_B);
int8_t board[COLS][ROWS];
bool winMask[NUM_LEDS];
enum State { MENU, PLAYING, FINISHED_WIN, FINISHED_DRAW };
enum State
{
MENU,
PLAYING,
FINISHED_WIN,
FINISHED_DRAW,
DEMO
};
State gameState = MENU;
int8_t menuMode = 0; // 0: P1-Yellow, 1: P1-Red, 2: PvP
int8_t currentPlayer = 1; // 1: Yellow, 2: Red
int8_t menuMode = 0;
int8_t currentPlayer = 1;
int8_t activeCol = 3;
long oldEncPos = -999;
uint32_t lastActivityTime = 0;
uint32_t demoResetTimer = 0;
bool isDemoOver = false;
// Thinking Animation Variables
uint8_t aiBrightness = 0;
bool aiFadeUp = true;
// --- Helper Functions ---
int getIdx(int x, int y) { return (y * 8) + x; }
void drawStaticUI() {
FastLED.clear();
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;
}
void renderBoard() {
drawStaticUI();
for(int c=0; c<COLS; c++) {
for(int r=0; r<ROWS; r++) {
if(board[c][r] == 1) leds[getIdx(c, 7-r)] = CRGB::Yellow;
if(board[c][r] == 2) leds[getIdx(c, 7-r)] = CRGB::Red;
}
}
}
int getFirstEmptyRow(int col) {
for (int r = 0; r < ROWS; r++) { if (board[col][r] == 0) return r; }
return -1;
}
bool isBoardFull() {
for (int c = 0; c < COLS; c++) if (board[c][5] == 0) return false;
return true;
}
// Scans board and fills winMask if 4+ connected
bool scanBoard(int8_t p) {
bool found = false;
memset(winMask, 0, sizeof(winMask));
auto checkLine = [&](int x, int y, int dx, int dy) {
int count = 0;
for (int i = 0; i < 7; i++) {
int nx = x + i * dx; int ny = y + i * dy;
if (nx >= 0 && nx < COLS && ny >= 0 && ny < ROWS && board[nx][ny] == p) {
count++;
} else {
if (count >= 4) {
for (int j = 1; j <= count; j++)
winMask[getIdx(nx - j * dx, 7 - (ny - j * dy))] = true;
found = true;
}
count = 0;
}
}
};
for (int i = 0; i < ROWS; i++) checkLine(0, i, 1, 0);
for (int i = 0; i < COLS; i++) checkLine(i, 0, 0, 1);
for (int i = -5; i < 7; i++) { checkLine(i, 0, 1, 1); checkLine(i, 5, 1, -1); }
return found;
}
// --- AI Thinking Visualization ---
void updateThinkingLED() {
// RESTORED: Thinking Animation Function
void updateThinkingLED(int8_t p)
{
static uint32_t lastCycle = 0;
if (millis() - lastCycle < 20) return;
if (millis() - lastCycle < 20)
return;
lastCycle = millis();
if (aiFadeUp) { aiBrightness += 15; if (aiBrightness >= 240) aiFadeUp = false; }
else { aiBrightness -= 15; if (aiBrightness <= 15) aiFadeUp = true; }
// Pulse in the computer's color
CRGB compColor = (menuMode == 0) ? CRGB::Red : CRGB::Yellow;
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();
}
// --- Minimax Logic ---
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP) {
// Check wins within the simulation
if (scanBoard(aiP)) return 1000 + depth;
if (scanBoard(huP)) return -1000 - depth;
if (depth == 0 || isBoardFull()) return 0;
void drawStaticUI()
{
FastLED.clear();
CRGB borderColor = CRGB::Blue;
if (gameState == DEMO || (gameState >= 2 && isDemoOver))
{
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;
}
int order[] = {3, 2, 4, 1, 5, 0, 6};
if (isMax) {
int maxEval = -2000;
for (int c : order) {
int r = getFirstEmptyRow(c);
if (r != -1) {
board[c][r] = aiP;
int eval = minimax(depth - 1, alpha, beta, false, aiP, huP);
board[c][r] = 0;
maxEval = max(maxEval, eval);
alpha = max(alpha, eval);
if (beta <= alpha) break;
void renderBoard()
{
drawStaticUI();
for (int c = 0; c < COLS; c++)
{
for (int r = 0; r < ROWS; r++)
{
if (board[c][r] == 1)
leds[getIdx(c, 7 - r)] = CRGB::Yellow;
if (board[c][r] == 2)
leds[getIdx(c, 7 - r)] = CRGB::Red;
}
}
return maxEval;
} else {
int minEval = 2000;
for (int c : order) {
int r = getFirstEmptyRow(c);
if (r != -1) {
board[c][r] = huP;
int eval = minimax(depth - 1, alpha, beta, true, aiP, huP);
board[c][r] = 0;
minEval = min(minEval, eval);
beta = min(beta, eval);
if (beta <= alpha) break;
}
}
return minEval;
}
}
void performAiMove() {
int8_t aiP = (menuMode == 0) ? 2 : 1; // AI is Red if player chose Yellow
int8_t huP = (menuMode == 0) ? 1 : 2;
int getFirstEmptyRow(int col)
{
for (int r = 0; r < ROWS; r++)
{
if (board[col][r] == 0)
return r;
}
return -1;
}
aiBrightness = 0; aiFadeUp = true;
int bestScore = -30000;
int bestCol = -1;
bool isBoardFull()
{
for (int c = 0; c < COLS; c++)
if (board[c][5] == 0)
return false;
return true;
}
// 1. Immediate Win/Block Check
for(int c=0; c<COLS; c++) {
bool scanBoard(int8_t p)
{
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)
{
for (int i = 0; i < 4; i++)
winMask[getIdx(c + i * dc, 7 - (r + i * dr))] = true;
return true;
}
return false;
};
for (int r = 0; r < 6; r++)
for (int c = 0; c < 4; c++)
if (check(c, r, 1, 0))
found = true;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 7; c++)
if (check(c, r, 0, 1))
found = true;
for (int r = 0; r < 3; r++)
for (int c = 0; c < 4; c++)
if (check(c, r, 1, 1))
found = true;
for (int r = 3; r < 6; r++)
for (int c = 0; c < 4; c++)
if (check(c, r, 1, -1))
found = true;
return found;
}
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP)
{
// Show thinking animation during deep recursion
if (depth == LOOK_AHEAD || depth == LOOK_AHEAD - 1)
updateThinkingLED(aiP);
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)
{
int r = getFirstEmptyRow(c);
if(r != -1) {
board[c][r] = aiP; if(scanBoard(aiP)) { leds[getIdx(7, 0)] = CRGB::Black; return; }
board[c][r] = huP; if(scanBoard(huP)) { board[c][r] = aiP; leds[getIdx(7, 0)] = CRGB::Black; return; }
if (r != -1)
{
board[c][r] = isMax ? aiP : huP;
int val = minimax(depth - 1, alpha, beta, !isMax, aiP, huP);
board[c][r] = 0;
if (isMax)
{
if (val > best)
best = val;
alpha = max(alpha, best);
}
else
{
if (val < best)
best = val;
beta = min(beta, best);
}
if (beta <= alpha)
break;
}
}
return best;
}
void performAiMove(int8_t aiP)
{
int8_t huP = (aiP == 1) ? 2 : 1;
aiBrightness = 0;
aiFadeUp = true; // Reset animation
for (int c = 0; c < COLS; c++)
{
int r = getFirstEmptyRow(c);
if (r != -1)
{
board[c][r] = aiP;
if (scanBoard(aiP))
{
leds[getIdx(7, 0)] = CRGB::Black;
return;
}
board[c][r] = huP;
if (scanBoard(huP))
{
board[c][r] = aiP;
leds[getIdx(7, 0)] = CRGB::Black;
return;
}
board[c][r] = 0;
}
}
// 2. Recursive Search
for (int c : {3, 2, 4, 1, 5, 0, 6}) {
int bestScore = -30000;
int bestCol = 3;
for (int c : {3, 2, 4, 1, 5, 0, 6})
{
int r = getFirstEmptyRow(c);
if (r != -1) {
if (r != -1)
{
board[c][r] = aiP;
int score = minimax(LOOK_AHEAD, -30000, 30000, false, aiP, huP);
board[c][r] = 0;
updateThinkingLED(); // Visual feedback
if (score > bestScore) { bestScore = score; bestCol = c; }
if (score > bestScore)
{
bestScore = score;
bestCol = c;
}
}
if (bestCol != -1) board[bestCol][getFirstEmptyRow(bestCol)] = aiP;
leds[getIdx(7, 0)] = CRGB::Black;
}
board[bestCol][getFirstEmptyRow(bestCol)] = aiP;
leds[getIdx(7, 0)] = CRGB::Black; // Clear thinking LED
}
void showMenu() {
drawStaticUI();
if (menuMode < 2) {
void showMenu()
{
isDemoOver = false;
FastLED.clear();
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;
if (menuMode < 2)
{
CRGB p1Col = (menuMode == 1) ? CRGB::Red : CRGB::Yellow;
for(int y=3; y<=6; y++) leds[getIdx(3, y)] = p1Col;
leds[getIdx(2,3)] = p1Col; leds[getIdx(4,3)] = p1Col;
leds[getIdx(2,6)] = p1Col; leds[getIdx(4,6)] = p1Col;
} 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(1, 6)] = CRGB::Yellow;
leds[getIdx(5, 3)] = CRGB::Red; leds[getIdx(5, 6)] = CRGB::Red;
leds[getIdx(3, 3)] = CRGB::Red; leds[getIdx(3, 6)] = CRGB::Yellow;
for (int y = 3; y <= 6; y++)
leds[getIdx(3, y)] = p1Col;
leds[getIdx(2, 3)] = p1Col;
leds[getIdx(4, 3)] = p1Col;
leds[getIdx(2, 6)] = p1Col;
leds[getIdx(4, 6)] = p1Col;
}
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);
myEnc = new Encoder(ENC_A, ENC_B);
void setup()
{
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
FastLED.setBrightness(BRIGHTNESS);
pinMode(ENC_SW, INPUT_PULLUP);
lastActivityTime = millis();
showMenu();
}
void loop() {
long newPos = myEnc->read() / SENSITIVITY;
void loop()
{
long newPos = myEnc.read() / SENSITIVITY;
bool pressed = (digitalRead(ENC_SW) == LOW);
if (gameState == MENU) {
if (newPos != oldEncPos) {
if (newPos != oldEncPos || (pressed && (millis() - lastActivityTime > 500)))
{
if (gameState == DEMO || isDemoOver)
{
for (int i = 0; i < 10; i++)
{
fadeToBlackBy(leds, NUM_LEDS, 32);
FastLED.show();
delay(30);
}
FastLED.clear();
FastLED.show();
delay(500);
gameState = MENU;
memset(board, 0, sizeof(board));
showMenu();
lastActivityTime = millis();
oldEncPos = newPos;
return;
}
lastActivityTime = millis();
}
if (gameState == MENU)
{
if (newPos != oldEncPos)
{
menuMode = (newPos % 3 + 3) % 3;
oldEncPos = newPos;
showMenu();
}
if (pressed) {
if (pressed)
{
memset(board, 0, sizeof(board));
gameState = PLAYING;
// If Single Player RED, computer (1/Yellow) starts
if (menuMode == 1) {
if (menuMode == 1)
{
performAiMove(1);
currentPlayer = 2;
}
else
{
currentPlayer = 1;
renderBoard(); FastLED.show();
performAiMove();
currentPlayer = 2; // Set back to player
} else {
currentPlayer = 1; // Human starts
}
delay(300);
}
if (millis() - lastActivityTime > IDLE_TIMEOUT)
{
gameState = DEMO;
memset(board, 0, sizeof(board));
currentPlayer = 1;
}
else if (gameState == PLAYING) {
if (newPos != oldEncPos) {
}
else if (gameState == PLAYING)
{
if (newPos != oldEncPos)
{
activeCol = (newPos % 7 + 7) % 7;
oldEncPos = newPos;
}
renderBoard();
leds[getIdx(activeCol, 0)] = (currentPlayer == 1) ? CRGB::Yellow : CRGB::Red;
FastLED.show();
if (pressed) {
if (pressed)
{
int row = getFirstEmptyRow(activeCol);
if (row != -1) {
if (row != -1)
{
board[activeCol][row] = currentPlayer;
renderBoard(); FastLED.show();
// 1. Check if the move just made ended the game
if (scanBoard(currentPlayer)) {
renderBoard();
FastLED.show();
if (scanBoard(currentPlayer))
{
gameState = FINISHED_WIN;
} else if (isBoardFull()) {
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
} else {
// 2. Handle Turn Switching
if (menuMode < 2) { // Single Player
}
else
{
if (menuMode < 2)
{
int8_t aiP = (menuMode == 0) ? 2 : 1;
performAiMove();
if (scanBoard(aiP)) {
currentPlayer = aiP; // For the flashing color
performAiMove(aiP);
renderBoard();
FastLED.show();
if (scanBoard(aiP))
{
currentPlayer = aiP;
gameState = FINISHED_WIN;
} else if (isBoardFull()) {
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
}
} else { // PvP
}
else
{
currentPlayer = (currentPlayer == 1) ? 2 : 1;
}
}
} else {
for(int i=0; i<3; i++) {
leds[getIdx(activeCol, 0)] = CRGB::Black; FastLED.show(); delay(80);
leds[getIdx(activeCol, 0)] = (currentPlayer == 1) ? CRGB::Yellow : CRGB::Red; FastLED.show(); delay(80);
}
}
delay(300);
}
}
else {
static unsigned long lastFlash = 0;
static bool toggle = true;
if (millis() - lastFlash > 300) {
lastFlash = millis(); toggle = !toggle;
}
else if (gameState == DEMO)
{
renderBoard();
for (int i = 0; i < NUM_LEDS; i++) {
if (gameState == FINISHED_WIN) {
if (winMask[i]) leds[i] = toggle ? (currentPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black;
else if (leds[i] && leds[i] != CRGB::Blue) leds[i].nscale8(40);
} else {
if (leds[i] && leds[i] != CRGB::Blue) leds[i] = toggle ? leds[i] : CRGB::Black;
FastLED.show();
delay(600);
performAiMove(currentPlayer);
if (scanBoard(currentPlayer))
{
gameState = FINISHED_WIN;
isDemoOver = true;
demoResetTimer = millis();
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
isDemoOver = true;
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 (leds[i] == CRGB::Blue)
continue;
if (gameState == FINISHED_WIN)
{
if (winMask[i])
leds[i] = toggle ? (currentPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black;
else
leds[i].nscale8(40);
}
else if (gameState == FINISHED_DRAW)
{
if (!toggle)
leds[i] = CRGB::Black;
}
}
FastLED.show();
}
if (pressed) { gameState = MENU; showMenu(); delay(300); }
if (isDemoOver && (millis() - demoResetTimer > DEMO_RESET_PAUSE))
{
memset(board, 0, sizeof(board));
gameState = DEMO;
isDemoOver = false;
}
if (pressed)
{
gameState = MENU;
showMenu();
delay(300);
}
}
}