[add] Autoplay demo, web config, and WiFi AP.
This commit is contained in:
@@ -1 +0,0 @@
|
||||
3.14
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"espressif",
|
||||
"fastled",
|
||||
"lolin",
|
||||
"microcontroller",
|
||||
"paulstoffregen"
|
||||
]
|
||||
}
|
||||
@@ -1,38 +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:
|
||||
```c++
|
||||
#define ENCODER_A 2
|
||||
#define ENCODER_B 3
|
||||
#define ENCODER_SW 4
|
||||
```
|
||||
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.
|
||||
|
||||
The grid is managed using the FastLED.h library and is connected to:
|
||||
```c++
|
||||
#define LED_PIN 6
|
||||
#define LED_WIDTH 8
|
||||
#define LED_HEIGHT 8
|
||||
#define NUM_LEDS (LED_WIDTH * LED_HEIGHT)
|
||||
#define LED_TYPE WS2812B
|
||||
#define COLOR_ORDER GRB
|
||||
```
|
||||
### 1. Game States
|
||||
|
||||
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.
|
||||
- **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."
|
||||
|
||||
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 a dimmed to 15% intensity, and the winning four discs are blinking at high intensity.
|
||||
### 2. Win Detection Logic
|
||||
|
||||
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.
|
||||
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:
|
||||
|
||||
The game has four states:
|
||||
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.
|
||||
- **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]`
|
||||
|
||||
The program initializes the SerialPrint output (baud 115200) and outputs useful (debugging) information regarding the game state and selections.
|
||||
---
|
||||
|
||||
## The AI: Strategic Minimax
|
||||
|
||||
The computer opponent uses the **Minimax Algorithm**, a classic artificial intelligence method for zero-sum games.
|
||||
|
||||
### 1. Look-Ahead (Depth Search)
|
||||
|
||||
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..."_
|
||||
|
||||
### 2. Alpha-Beta Pruning
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Technical Specifications
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
def main():
|
||||
print("Hello from connect-four!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+8
-1
@@ -9,9 +9,16 @@ 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
|
||||
-D DEFAULT_LOOK_AHEAD=8
|
||||
-D DEFAULT_BRIGHTNESS=25
|
||||
-D DEFAULT_IDLE_TIMEOUT=45
|
||||
-D WIFI_PASSWORD=\"youlose4\"
|
||||
lib_deps =
|
||||
fastled/FastLED @ ^3.6.0
|
||||
paulstoffregen/Encoder @ ^1.4.4
|
||||
@@ -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",
|
||||
]
|
||||
+440
-186
@@ -1,278 +1,532 @@
|
||||
#include <Arduino.h>
|
||||
#include <FastLED.h>
|
||||
#include <Encoder.h>
|
||||
#include <WiFi.h>
|
||||
#include <WebServer.h>
|
||||
#include <Preferences.h>
|
||||
|
||||
#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
|
||||
|
||||
CRGB leds[NUM_LEDS];
|
||||
Encoder *myEnc;
|
||||
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 };
|
||||
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; // 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;
|
||||
|
||||
// Web-Configurable Parameters (Stored in Flash)
|
||||
uint8_t current_look_ahead;
|
||||
uint8_t current_brightness;
|
||||
uint32_t current_idle_timeout_ms;
|
||||
|
||||
// Thinking Animation Helpers
|
||||
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() {
|
||||
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;
|
||||
|
||||
aiBrightness = 0; aiFadeUp = true;
|
||||
int bestScore = -30000;
|
||||
int bestCol = -1;
|
||||
int getFirstEmptyRow(int col)
|
||||
{
|
||||
for (int r = 0; r < ROWS; r++)
|
||||
{
|
||||
if (board[col][r] == 0)
|
||||
return r;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 1. Immediate Win/Block Check
|
||||
for(int c=0; c<COLS; c++) {
|
||||
bool isBoardFull()
|
||||
{
|
||||
for (int c = 0; c < COLS; c++)
|
||||
if (board[c][5] == 0)
|
||||
return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// --- AI Engine ---
|
||||
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP)
|
||||
{
|
||||
if (depth >= current_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;
|
||||
}
|
||||
|
||||
// 2. Recursive Search
|
||||
for (int c : {3, 2, 4, 1, 5, 0, 6}) {
|
||||
void performAiMove(int8_t aiP)
|
||||
{
|
||||
int8_t huP = (aiP == 1) ? 2 : 1;
|
||||
aiBrightness = 0;
|
||||
aiFadeUp = true;
|
||||
for (int c = 0; c < COLS; c++)
|
||||
{
|
||||
int r = getFirstEmptyRow(c);
|
||||
if (r != -1) {
|
||||
if (r != -1)
|
||||
{
|
||||
board[c][r] = aiP;
|
||||
int score = minimax(LOOK_AHEAD, -30000, 30000, false, aiP, huP);
|
||||
if (scanBoard(aiP))
|
||||
{
|
||||
leds[getIdx(7, 0)] = CRGB::Black;
|
||||
return;
|
||||
}
|
||||
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;
|
||||
updateThinkingLED(); // Visual feedback
|
||||
if (score > bestScore) { bestScore = score; bestCol = c; }
|
||||
}
|
||||
}
|
||||
if (bestCol != -1) board[bestCol][getFirstEmptyRow(bestCol)] = aiP;
|
||||
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);
|
||||
board[c][r] = 0;
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestCol = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
board[bestCol][getFirstEmptyRow(bestCol)] = aiP;
|
||||
leds[getIdx(7, 0)] = CRGB::Black;
|
||||
}
|
||||
|
||||
void showMenu() {
|
||||
drawStaticUI();
|
||||
if (menuMode < 2) {
|
||||
// --- Menu UI with Restored Serifs ---
|
||||
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() {
|
||||
// --- Web Portal ---
|
||||
void handleRoot()
|
||||
{
|
||||
String html = "<html><head><meta name='viewport' content='width=device-width, initial-scale=1'>";
|
||||
html += "<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>";
|
||||
html += "<h1>Connect 4 Admin</h1><div class='card'><form action='/save' method='POST'>";
|
||||
html += "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 += "<input type='submit' value='Save Settings' style='background:#28a745;color:white;font-weight:bold;'></form></div></body></html>";
|
||||
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"))
|
||||
{
|
||||
uint32_t s = server.arg("idle").toInt();
|
||||
current_idle_timeout_ms = s * 1000;
|
||||
prefs.putUInt("idle", s);
|
||||
}
|
||||
server.sendHeader("Location", "/");
|
||||
server.send(303);
|
||||
}
|
||||
|
||||
void setup()
|
||||
{
|
||||
Serial.begin(115200);
|
||||
myEnc = new Encoder(ENC_A, ENC_B);
|
||||
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;
|
||||
|
||||
FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
|
||||
FastLED.setBrightness(BRIGHTNESS);
|
||||
FastLED.setBrightness(current_brightness);
|
||||
pinMode(ENC_SW, INPUT_PULLUP);
|
||||
|
||||
WiFi.disconnect(true); // Clear old settings
|
||||
WiFi.mode(WIFI_AP); // Force Access Point mode
|
||||
delay(100); // Give the radio a moment to reset
|
||||
|
||||
// SSID, Password (MUST be 8+ chars), Channel, Hidden (0=No), Max Clients
|
||||
if (WiFi.softAP("Connect4-Config", WIFI_PASSWORD, 1, 0, 4))
|
||||
{
|
||||
Serial.println("WPA2 AP Started Successfully");
|
||||
}
|
||||
else
|
||||
{
|
||||
Serial.println("AP Failed - Check if WIFI_PASSWORD is at least 8 characters!");
|
||||
}
|
||||
|
||||
server.on("/", handleRoot);
|
||||
server.on("/save", HTTP_POST, handleSave);
|
||||
server.begin();
|
||||
|
||||
lastActivityTime = millis();
|
||||
showMenu();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
long newPos = myEnc->read() / SENSITIVITY;
|
||||
void loop()
|
||||
{
|
||||
server.handleClient();
|
||||
long newPos = myEnc.read() / SENSITIVITY;
|
||||
bool pressed = (digitalRead(ENC_SW) == LOW);
|
||||
|
||||
if (gameState == MENU) {
|
||||
if (newPos != oldEncPos) {
|
||||
// Escape Demo / Interrupt
|
||||
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);
|
||||
}
|
||||
delay(2000);
|
||||
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);
|
||||
}
|
||||
}
|
||||
else if (gameState == PLAYING) {
|
||||
if (newPos != oldEncPos) {
|
||||
if (millis() - lastActivityTime > current_idle_timeout_ms)
|
||||
{
|
||||
gameState = DEMO;
|
||||
memset(board, 0, sizeof(board));
|
||||
currentPlayer = 1;
|
||||
}
|
||||
}
|
||||
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()) {
|
||||
gameState = FINISHED_DRAW;
|
||||
}
|
||||
} else { // PvP
|
||||
else if (isBoardFull())
|
||||
gameState = FINISHED_DRAW;
|
||||
}
|
||||
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);
|
||||
}
|
||||
delay(300);
|
||||
}
|
||||
}
|
||||
else {
|
||||
static unsigned long lastFlash = 0;
|
||||
}
|
||||
else if (gameState == DEMO)
|
||||
{
|
||||
// No idle timeout check here to prevent premature restarts
|
||||
renderBoard();
|
||||
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
|
||||
{
|
||||
// Monitor for Idle in Win screen to return to Demo
|
||||
if (!isDemoOver && (millis() - lastActivityTime > current_idle_timeout_ms))
|
||||
{
|
||||
memset(board, 0, sizeof(board));
|
||||
gameState = DEMO;
|
||||
currentPlayer = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
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 (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;
|
||||
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); }
|
||||
// Restart Demo loop if it was a demo game
|
||||
if (isDemoOver && (millis() - demoResetTimer > 30000))
|
||||
{
|
||||
memset(board, 0, sizeof(board));
|
||||
gameState = DEMO;
|
||||
isDemoOver = false;
|
||||
}
|
||||
if (pressed)
|
||||
{
|
||||
gameState = MENU;
|
||||
showMenu();
|
||||
delay(300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user