[update] Progressive difficulty, demo fixes, and background docs.

This commit is contained in:
2026-03-06 22:14:25 +01:00
parent 8a776dfae5
commit da63f05ac3
7 changed files with 414 additions and 575 deletions
+279 -193
View File
@@ -9,6 +9,7 @@
const int COLS = 7;
const int ROWS = 6;
// --- Configuration & Globals ---
CRGB leds[NUM_LEDS];
Encoder myEnc(ENC_A, ENC_B);
WebServer server(80);
@@ -28,52 +29,47 @@ State gameState = MENU;
int8_t menuMode = 0;
int8_t currentPlayer = 1;
int8_t winnerPlayer = 0; // Tracks who actually won for the flashing effect
int8_t activeCol = 3;
long oldEncPos = -999;
uint32_t lastActivityTime = 0;
uint32_t demoResetTimer = 0;
bool isDemoOver = false;
uint8_t demoPly = 4;
// Web-Configurable Parameters (Stored in Flash)
uint8_t current_look_ahead;
uint8_t current_brightness;
uint32_t current_idle_timeout_ms;
bool blunder_enabled = false;
bool progressive_difficulty = false;
// Thinking Animation Helpers
uint8_t aiBrightness = 0;
bool aiFadeUp = true;
// --- Helper Functions ---
int getIdx(int x, int y) { return (y * 8) + x; }
// --- Function Prototypes ---
int getIdx(int x, int y);
void drawStaticUI();
void renderBoard();
int getFirstEmptyRow(int col);
bool isBoardFull();
int8_t scanBoard(); // Changed to return the winner ID
void updateThinkingVisuals(int8_t p, int8_t col);
void animateDrop(int col, int player);
void moveDiscToCol(int startCol, int targetCol, int player, int speed);
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP, int8_t rootCol);
void performAiMove(int8_t aiP);
void showMenu();
int getDynamicPly();
void updateThinkingLED(int8_t p)
{
static uint32_t lastCycle = 0;
if (millis() - lastCycle < 20)
return;
lastCycle = millis();
if (aiFadeUp)
{
aiBrightness += 15;
if (aiBrightness >= 240)
aiFadeUp = false;
}
else
{
aiBrightness -= 15;
if (aiBrightness <= 15)
aiFadeUp = true;
}
CRGB compColor = (p == 1) ? CRGB::Yellow : CRGB::Red;
leds[getIdx(7, 0)] = compColor.nscale8(aiBrightness);
FastLED.show();
}
// --- Utility & Rendering ---
int getIdx(int x, int y) { return (y * 8) + x; }
void drawStaticUI()
{
FastLED.clear();
CRGB borderColor = CRGB::Blue;
if (gameState == DEMO || (gameState >= 2 && isDemoOver))
if (gameState == DEMO || gameState >= 2)
{
uint8_t glow = beat8(15);
borderColor = blend(CRGB::Blue, CRGB::White, glow / 4);
@@ -109,6 +105,80 @@ int getFirstEmptyRow(int col)
return -1;
}
int getDynamicPly()
{
if (!progressive_difficulty && gameState != DEMO)
return current_look_ahead;
int count = 0;
for (int c = 0; c < COLS; c++)
for (int r = 0; r < ROWS; r++)
if (board[c][r] != 0)
count++;
return constrain(current_look_ahead + (count / 7), 1, 10);
}
// --- Visuals & Animations ---
void updateThinkingVisuals(int8_t p, int8_t col)
{
static uint32_t lastCycle = 0;
if (millis() - lastCycle < 25)
return;
lastCycle = millis();
if (aiFadeUp)
{
aiBrightness += 15;
if (aiBrightness >= 240)
aiFadeUp = false;
}
else
{
aiBrightness -= 15;
if (aiBrightness <= 15)
aiFadeUp = true;
}
for (int x = 0; x < COLS; x++)
leds[getIdx(x, 0)] = CRGB::Black;
CRGB aiColor = (p == 1) ? CRGB::Yellow : CRGB::Red;
leds[getIdx(col, 0)] = aiColor.nscale8(aiBrightness);
FastLED.show();
}
void animateDrop(int col, int player)
{
int targetRow = getFirstEmptyRow(col);
if (targetRow == -1)
return;
for (int r = 5; r >= targetRow; r--)
{
renderBoard();
leds[getIdx(col, 7 - r)] = (player == 1) ? CRGB::Yellow : CRGB::Red;
FastLED.show();
delay(max(20, 80 - (5 - r) * 15));
}
board[col][targetRow] = player;
renderBoard();
FastLED.show();
}
void moveDiscToCol(int startCol, int targetCol, int player, int speed)
{
int current = startCol;
CRGB pColor = (player == 1) ? CRGB::Yellow : CRGB::Red;
while (current != targetCol)
{
leds[getIdx(current, 0)] = CRGB::Black;
current += (targetCol > current) ? 1 : -1;
renderBoard();
leds[getIdx(current, 0)] = pColor;
FastLED.show();
delay(speed);
}
activeCol = targetCol;
}
// --- AI Engine ---
bool isBoardFull()
{
for (int c = 0; c < COLS; c++)
@@ -117,74 +187,85 @@ bool isBoardFull()
return true;
}
bool scanBoard(int8_t p)
int8_t scanBoard()
{
memset(winMask, 0, sizeof(winMask));
bool found = false;
auto check = [&](int c, int r, int dc, int dr)
auto check = [&](int column, int row, int columnOffset, int rowOffset)
{
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)
int8_t postion = board[column][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)
{
for (int i = 0; i < 4; i++)
winMask[getIdx(c + i * dc, 7 - (r + i * dr))] = true;
return true;
for (int index = 0; index < 4; index++)
winMask[getIdx(column + index * columnOffset, 7 - (row + index * rowOffset))] = true;
return postion;
}
return false;
return (int8_t)0;
};
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;
for (int row = 0; row < 6; row++)
for (int column = 0; column < 4; column++)
{
int8_t res = check(column, row, 1, 0);
if (res)
return res;
}
for (int row = 0; row < 3; row++)
for (int column = 0; column < 7; column++)
{
int8_t res = check(column, row, 0, 1);
if (res)
return res;
}
for (int row = 0; row < 3; row++)
for (int column = 0; column < 4; column++)
{
int8_t res = check(column, row, 1, 1);
if (res)
return res;
}
for (int row = 3; row < 6; row++)
for (int column = 0; column < 4; column++)
{
int8_t res = check(column, row, 1, -1);
if (res)
return res;
}
return 0;
}
// --- AI Engine ---
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP)
int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiPlayer, int8_t humanPlayer, int8_t rootCol)
{
if (depth >= current_look_ahead - 1)
updateThinkingLED(aiP);
updateThinkingVisuals(aiPlayer, rootCol);
else
yield();
if (scanBoard(aiP))
// Check for wins within minimax
int8_t win = scanBoard();
if (win == aiPlayer)
return 1000 + depth;
if (scanBoard(huP))
if (win == humanPlayer)
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)
for (int column : order)
{
int r = getFirstEmptyRow(c);
if (r != -1)
int row = getFirstEmptyRow(column);
if (row != -1)
{
board[c][r] = isMax ? aiP : huP;
int val = minimax(depth - 1, alpha, beta, !isMax, aiP, huP);
board[c][r] = 0;
board[column][row] = isMax ? aiPlayer : humanPlayer;
int val = minimax(depth - 1, alpha, beta, !isMax, aiPlayer, humanPlayer, (depth == current_look_ahead ? column : rootCol));
board[column][row] = 0;
if (isMax)
{
if (val > best)
best = val;
best = max(best, val);
alpha = max(alpha, best);
}
else
{
if (val < best)
best = val;
best = min(best, val);
beta = min(beta, best);
}
if (beta <= alpha)
@@ -194,54 +275,109 @@ int minimax(int depth, int alpha, int beta, bool isMax, int8_t aiP, int8_t huP)
return best;
}
void performAiMove(int8_t aiP)
void performAiMove(int8_t aiPlayer)
{
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)
{
board[c][r] = aiP;
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;
}
}
int humanPlayer = (aiPlayer == 1) ? 2 : 1;
int bestScore = -30000;
int bestCol = 3;
for (int c : {3, 2, 4, 1, 5, 0, 6})
int originalPly = current_look_ahead;
current_look_ahead = (gameState == DEMO) ? demoPly : getDynamicPly();
for (int column = 0; column < COLS; column++)
{
int r = getFirstEmptyRow(c);
if (r != -1)
int row = getFirstEmptyRow(column);
if (row != -1)
{
board[c][r] = aiP;
int score = minimax(current_look_ahead, -30000, 30000, false, aiP, huP);
board[c][r] = 0;
board[column][row] = aiPlayer;
if (scanBoard() == aiPlayer)
{
board[column][row] = 0;
bestCol = column;
goto finalize;
}
board[column][row] = humanPlayer;
if (current_look_ahead >= 2 && scanBoard() == humanPlayer)
{
board[column][row] = 0;
bestCol = column;
goto finalize;
}
board[column][row] = 0;
}
}
for (int column : {3, 2, 4, 1, 5, 0, 6})
{
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 = c;
bestCol = column;
}
}
}
board[bestCol][getFirstEmptyRow(bestCol)] = aiP;
leds[getIdx(7, 0)] = CRGB::Black;
if ((gameState == DEMO || blunder_enabled) && random(100) < 20)
{
int rCol = random(0, 7);
if (getFirstEmptyRow(rCol) != -1)
bestCol = rCol;
}
finalize:
current_look_ahead = originalPly;
moveDiscToCol(activeCol, bestCol, aiPlayer, 100);
delay(450);
animateDrop(bestCol, aiPlayer);
}
// --- Web Portal ---
void handleRoot()
{
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>";
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);
}
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);
}
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);
}
// --- Menu UI with Restored Serifs ---
void showMenu()
{
isDemoOver = false;
@@ -279,42 +415,6 @@ void showMenu()
FastLED.show();
}
// --- 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);
@@ -322,29 +422,16 @@ void setup()
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<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
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!");
}
WiFi.softAP("Connect4-Config", WIFI_PASSWORD);
server.on("/", handleRoot);
server.on("/save", HTTP_POST, handleSave);
server.begin();
lastActivityTime = millis();
showMenu();
}
@@ -355,18 +442,17 @@ void loop()
long newPos = myEnc.read() / SENSITIVITY;
bool pressed = (digitalRead(ENC_SW) == LOW);
// Escape Demo / Interrupt
if (newPos != oldEncPos || (pressed && (millis() - lastActivityTime > 500)))
{
if (gameState == DEMO || isDemoOver)
if (gameState >= 2 || gameState == DEMO)
{
for (int i = 0; i < 10; i++)
for (int index = 0; index < 10; index++)
{
fadeToBlackBy(leds, NUM_LEDS, 32);
FastLED.show();
delay(30);
}
delay(2000);
delay(500);
gameState = MENU;
memset(board, 0, sizeof(board));
showMenu();
@@ -377,6 +463,16 @@ void loop()
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)
@@ -400,12 +496,6 @@ void loop()
}
delay(300);
}
if (millis() - lastActivityTime > current_idle_timeout_ms)
{
gameState = DEMO;
memset(board, 0, sizeof(board));
currentPlayer = 1;
}
}
else if (gameState == PLAYING)
{
@@ -422,28 +512,35 @@ void loop()
int row = getFirstEmptyRow(activeCol);
if (row != -1)
{
board[activeCol][row] = currentPlayer;
renderBoard();
FastLED.show();
if (scanBoard(currentPlayer))
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);
renderBoard();
FastLED.show();
if (scanBoard(aiP))
int8_t aiPlayer = (menuMode == 0) ? 2 : 1;
performAiMove(aiPlayer);
winnerPlayer = scanBoard();
if (winnerPlayer != 0)
{
currentPlayer = aiP;
gameState = FINISHED_WIN;
demoResetTimer = millis();
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
demoResetTimer = millis();
}
}
else
{
@@ -456,21 +553,19 @@ void loop()
}
else if (gameState == DEMO)
{
// No idle timeout check here to prevent premature restarts
renderBoard();
FastLED.show();
delay(600);
performAiMove(currentPlayer);
if (scanBoard(currentPlayer))
winnerPlayer = scanBoard();
if (winnerPlayer != 0)
{
gameState = FINISHED_WIN;
isDemoOver = true;
demoResetTimer = millis();
}
else if (isBoardFull())
{
gameState = FINISHED_DRAW;
isDemoOver = true;
demoResetTimer = millis();
}
else
@@ -479,16 +574,7 @@ void loop()
}
}
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;
}
{ // FINISHED state
static uint32_t lastFlash = 0;
static bool toggle = true;
if (millis() - lastFlash > 300)
@@ -503,9 +589,9 @@ void loop()
if (gameState == FINISHED_WIN)
{
if (winMask[i])
leds[i] = toggle ? (currentPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black;
leds[i] = toggle ? (winnerPlayer == 1 ? CRGB::Yellow : CRGB::Red) : CRGB::Black;
else
leds[i].nscale8(40);
leds[i].nscale8(60);
}
else if (gameState == FINISHED_DRAW)
{
@@ -515,12 +601,12 @@ void loop()
}
FastLED.show();
}
// Restart Demo loop if it was a demo game
if (isDemoOver && (millis() - demoResetTimer > 30000))
if (millis() - demoResetTimer > 15000)
{
memset(board, 0, sizeof(board));
gameState = DEMO;
isDemoOver = false;
demoResetTimer = 0;
demoPly = random(3, 7);
}
if (pressed)
{