Files
Connect-four-Esp32/connect_four.js
T

803 lines
24 KiB
JavaScript

/* ============================================================
* Connect Four — Browser Edition
* A single-file game: AI (minimax + alpha-beta), demo mode,
* game log (localStorage), blunder mode, idle timeout.
*
* Include this script in an HTML page that has:
* <canvas id="gameCanvas"></canvas>
*
* Works in Firefox, Chrome, Edge, Safari, and Brave.
* ============================================================ */
// --- Configurable Parameters --------------------------------
const COLS = 7; // board columns
const ROWS = 6; // board rows
const LOOK_AHEAD = 8; // AI search depth (plies)
const BLUNDER_ENABLED = false; // allow random AI mistakes
const BLUNDER_CHANCE = 20; // percent chance of blunder (0-100)
const DEMO_RESET_PAUSE = 5; // seconds before auto-demo after game end
const IDLE_TIMEOUT = 60; // seconds of inactivity before demo starts
const MAX_GAME_LOG = 100; // max stored game entries (localStorage)
// --- Visual Parameters --------------------------------------
const CELL_SIZE = 70; // pixel size of each board cell
const DISC_RADIUS = 28; // radius of a disc
const BOARD_PAD_TOP = 100; // space above the board (cursor + col numbers)
const BOARD_PAD_X = 40; // horizontal padding
const BOARD_PAD_BOTTOM = 40; // space below the board
const ANIM_DROP_SPEED = 1200; // pixels per second for drop animation
const FONT_FAMILY = "system-ui, -apple-system, sans-serif";
// --- Colors -------------------------------------------------
const COLOR_BG = "#1a1a2e";
const COLOR_BOARD = "#16213e";
const COLOR_GRID_LINE = "#0f3460";
const COLOR_EMPTY = "#0a1628";
const COLOR_P1 = "#ffd700"; // Yellow (player 1)
const COLOR_P2 = "#e63946"; // Red (player 2)
const COLOR_P1_DIM = "#8b7500";
const COLOR_P2_DIM = "#7a1f26";
const COLOR_HIGHLIGHT = "#ffffff";
const COLOR_TEXT = "#e0e0e0";
const COLOR_TEXT_DIM = "#666680";
const COLOR_MENU_BG = "#1a1a2e";
const COLOR_MENU_SEL = "#0f3460";
// ------------------------------------------------------------
const COL_ORDER = [3, 2, 4, 1, 5, 0, 6];
const State = Object.freeze({
MENU: 0,
PLAYING: 1,
AI_TURN: 2,
FINISHED_WIN: 3,
FINISHED_DRAW: 4,
DEMO: 5,
});
// --- Canvas setup -------------------------------------------
const canvas = document.getElementById("gameCanvas");
const ctx = canvas.getContext("2d");
const BOARD_W = COLS * CELL_SIZE;
const BOARD_H = ROWS * CELL_SIZE;
const CANVAS_W = BOARD_W + BOARD_PAD_X * 2;
const CANVAS_H = BOARD_PAD_TOP + BOARD_H + BOARD_PAD_BOTTOM;
canvas.width = CANVAS_W;
canvas.height = CANVAS_H;
canvas.style.display = "block";
canvas.style.margin = "0 auto";
canvas.tabIndex = 0;
canvas.focus();
// --- Game state ---------------------------------------------
let board = makeBoard();
let gameState = State.MENU;
let menuMode = 0;
let currentPlayer = 1;
let activeCol = 3;
let winnerPlayer = 0;
let winPositions = [];
let currentMoves = "";
let gameMenuMode = 0;
let gameLevel = LOOK_AHEAD;
let games = loadGameLog();
let demoPly = [4, 4];
let lastActivity = performance.now() / 1000;
let demoResetTimer = 0;
let flashToggle = true;
let lastFlash = 0;
let hoverCol = -1;
// Drop animation state
let dropping = false;
let dropCol = -1;
let dropPlayer = 0;
let dropTargetRow = -1;
let dropY = 0;
let dropTargetY = 0;
// --- Board helpers ------------------------------------------
function makeBoard() {
const b = [];
for (let c = 0; c < COLS; c++) {
b[c] = new Array(ROWS).fill(0);
}
return b;
}
function resetGame() {
board = makeBoard();
winnerPlayer = 0;
winPositions = [];
currentMoves = "";
}
function getFirstEmptyRow(b, col) {
for (let r = 0; r < ROWS; r++) {
if (b[col][r] === 0) return r;
}
return -1;
}
function isBoardFull(b) {
for (let c = 0; c < COLS; c++) {
if (b[c][ROWS - 1] === 0) return false;
}
return true;
}
function scanBoard(b) {
function check(c, r, dc, dr) {
const p = b[c][r];
if (p === 0) return [0, []];
const pos = [];
for (let i = 0; i < 4; i++) {
const cc = c + i * dc;
const rr = r + i * dr;
if (cc < 0 || cc >= COLS || rr < 0 || rr >= ROWS) return [0, []];
if (b[cc][rr] !== p) return [0, []];
pos.push([cc, rr]);
}
return [p, pos];
}
for (let r = 0; r < ROWS; r++)
for (let c = 0; c <= COLS - 4; c++) {
const [w, pos] = check(c, r, 1, 0);
if (w) return [w, pos];
}
for (let r = 0; r <= ROWS - 4; r++)
for (let c = 0; c < COLS; c++) {
const [w, pos] = check(c, r, 0, 1);
if (w) return [w, pos];
}
for (let r = 0; r <= ROWS - 4; r++)
for (let c = 0; c <= COLS - 4; c++) {
const [w, pos] = check(c, r, 1, 1);
if (w) return [w, pos];
}
for (let r = 3; r < ROWS; r++)
for (let c = 0; c <= COLS - 4; c++) {
const [w, pos] = check(c, r, 1, -1);
if (w) return [w, pos];
}
return [0, []];
}
// --- AI -----------------------------------------------------
function minimax(b, depth, alpha, beta, isMax, aiP, huP) {
const [winner] = scanBoard(b);
if (winner === aiP) return 1000 + depth;
if (winner === huP) return -1000 - depth;
if (depth === 0 || isBoardFull(b)) return 0;
let best = isMax ? -10000 : 10000;
for (const c of COL_ORDER) {
const r = getFirstEmptyRow(b, c);
if (r === -1) continue;
b[c][r] = isMax ? aiP : huP;
const score = minimax(b, depth - 1, alpha, beta, !isMax, aiP, huP);
b[c][r] = 0;
if (isMax) {
if (score > best) best = score;
if (best > alpha) alpha = best;
} else {
if (score < best) best = score;
if (best < beta) beta = best;
}
if (beta <= alpha) break;
}
return best;
}
function performAiMove(b, aiP, lookAhead, isDemo = false, dPly = 4) {
const huP = aiP === 1 ? 2 : 1;
const ply = isDemo ? dPly : lookAhead;
// Phase 1: instant win / block
for (let c = 0; c < COLS; c++) {
const r = getFirstEmptyRow(b, c);
if (r === -1) continue;
b[c][r] = aiP;
if (scanBoard(b)[0] === aiP) { b[c][r] = 0; return c; }
b[c][r] = huP;
if (scanBoard(b)[0] === huP) { b[c][r] = 0; return c; }
b[c][r] = 0;
}
// Phase 2: blunder
if (!isDemo && BLUNDER_ENABLED && Math.random() * 100 < BLUNDER_CHANCE) {
const valid = [];
for (let c = 0; c < COLS; c++) if (getFirstEmptyRow(b, c) !== -1) valid.push(c);
return valid[Math.floor(Math.random() * valid.length)];
}
// Phase 3: minimax
let bestScore = -30000;
let bestCol = 3;
for (const c of COL_ORDER) {
const r = getFirstEmptyRow(b, c);
if (r === -1) continue;
b[c][r] = aiP;
const score = minimax(b, ply, -30000, 30000, false, aiP, huP);
b[c][r] = 0;
if (score > bestScore) {
bestScore = score;
bestCol = c;
}
}
return bestCol;
}
function randomizeDemoPlies() {
const strong = 4 + Math.floor(Math.random() * 2);
const weak = 2 + Math.floor(Math.random() * 2);
return Math.random() < 0.5 ? [strong, weak] : [weak, strong];
}
// --- Game log (localStorage) --------------------------------
function loadGameLog() {
try {
const raw = localStorage.getItem("connectFourLog");
if (!raw) return [];
return JSON.parse(raw).slice(-MAX_GAME_LOG);
} catch { return []; }
}
function saveGameLog(g) {
try {
localStorage.setItem("connectFourLog", JSON.stringify(g.slice(-MAX_GAME_LOG)));
} catch { /* storage full or unavailable */ }
}
function logGame(g, gMenuMode, level, winner, moves) {
const type = gMenuMode === 0 ? "Y" : gMenuMode === 1 ? "R" : "2";
const winChar = winner === 1 ? "Y" : winner === 2 ? "R" : "D";
g.push({ type, level: String(level), winner: winChar, moves });
g = g.slice(-MAX_GAME_LOG);
saveGameLog(g);
return g;
}
// --- Check game end -----------------------------------------
function checkGameEnd() {
const [w, pos] = scanBoard(board);
winnerPlayer = w;
winPositions = pos;
const won = w !== 0;
const draw = !won && isBoardFull(board);
if (!won && !draw) return false;
if (gameState !== State.DEMO) {
games = logGame(games, gameMenuMode, gameLevel, won ? w : 0, currentMoves);
}
gameState = won ? State.FINISHED_WIN : State.FINISHED_DRAW;
demoResetTimer = performance.now() / 1000;
lastActivity = performance.now() / 1000;
return true;
}
// --- Drawing ------------------------------------------------
function playerColor(p) { return p === 1 ? COLOR_P1 : COLOR_P2; }
function playerColorDim(p) { return p === 1 ? COLOR_P1_DIM : COLOR_P2_DIM; }
function playerName(p) { return p === 1 ? "Yellow" : "Red"; }
function cellX(c) { return BOARD_PAD_X + c * CELL_SIZE + CELL_SIZE / 2; }
function cellY(r) { return BOARD_PAD_TOP + (ROWS - 1 - r) * CELL_SIZE + CELL_SIZE / 2; }
function isWinPos(c, r) {
for (const [wc, wr] of winPositions) {
if (wc === c && wr === r) return true;
}
return false;
}
function drawDisc(x, y, radius, color) {
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
}
function drawBoard() {
// Board background
ctx.fillStyle = COLOR_BOARD;
const bx = BOARD_PAD_X;
const by = BOARD_PAD_TOP;
ctx.beginPath();
ctx.roundRect(bx - 5, by - 5, BOARD_W + 10, BOARD_H + 10, 12);
ctx.fill();
// Grid lines
ctx.strokeStyle = COLOR_GRID_LINE;
ctx.lineWidth = 1;
for (let c = 1; c < COLS; c++) {
const x = BOARD_PAD_X + c * CELL_SIZE;
ctx.beginPath();
ctx.moveTo(x, BOARD_PAD_TOP);
ctx.lineTo(x, BOARD_PAD_TOP + BOARD_H);
ctx.stroke();
}
for (let r = 1; r < ROWS; r++) {
const y = BOARD_PAD_TOP + r * CELL_SIZE;
ctx.beginPath();
ctx.moveTo(BOARD_PAD_X, y);
ctx.lineTo(BOARD_PAD_X + BOARD_W, y);
ctx.stroke();
}
// Cells
for (let c = 0; c < COLS; c++) {
for (let r = 0; r < ROWS; r++) {
const x = cellX(c);
const y = cellY(r);
const val = board[c][r];
// Skip drawing in cell if we're animating a drop into it
if (dropping && c === dropCol && r === dropTargetRow) continue;
if (val === 0) {
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
} else {
const isWin = isWinPos(c, r);
if (gameState === State.FINISHED_WIN) {
if (isWin && flashToggle) {
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
} else if (!isWin) {
drawDisc(x, y, DISC_RADIUS, playerColorDim(val));
} else {
drawDisc(x, y, DISC_RADIUS, playerColor(val));
}
} else if (gameState === State.FINISHED_DRAW && flashToggle) {
drawDisc(x, y, DISC_RADIUS, COLOR_EMPTY);
} else {
drawDisc(x, y, DISC_RADIUS, playerColor(val));
}
}
}
}
// Drop animation disc
if (dropping) {
drawDisc(cellX(dropCol), dropY, DISC_RADIUS, playerColor(dropPlayer));
}
}
function drawCursor() {
if (gameState === State.PLAYING && !dropping) {
const x = cellX(activeCol);
const y = BOARD_PAD_TOP - 45;
drawDisc(x, y, DISC_RADIUS * 0.8, playerColor(currentPlayer));
}
if (gameState === State.PLAYING && hoverCol >= 0 && hoverCol !== activeCol && !dropping) {
const x = cellX(hoverCol);
const y = BOARD_PAD_TOP - 45;
drawDisc(x, y, DISC_RADIUS * 0.5, playerColorDim(currentPlayer));
}
}
function drawColNumbers() {
ctx.font = `14px ${FONT_FAMILY}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
for (let c = 0; c < COLS; c++) {
ctx.fillStyle = (c === activeCol && gameState === State.PLAYING) ? COLOR_TEXT : COLOR_TEXT_DIM;
ctx.fillText(String(c + 1), cellX(c), BOARD_PAD_TOP - 12);
}
}
function drawStatus() {
const y = BOARD_PAD_TOP + BOARD_H + 25;
ctx.font = `bold 18px ${FONT_FAMILY}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
if (gameState === State.PLAYING) {
ctx.fillStyle = playerColor(currentPlayer);
const label = gameMenuMode === 2 ? `${playerName(currentPlayer)}'s turn`
: currentPlayer === (gameMenuMode === 0 ? 1 : 2) ? "Your turn" : "AI thinking...";
ctx.fillText(label, CANVAS_W / 2, y);
} else if (gameState === State.AI_TURN) {
const aiP = gameMenuMode === 0 ? 2 : 1;
ctx.fillStyle = playerColor(aiP);
ctx.fillText("AI thinking...", CANVAS_W / 2, y);
} else if (gameState === State.FINISHED_WIN) {
ctx.fillStyle = playerColor(winnerPlayer);
ctx.fillText(`${playerName(winnerPlayer)} wins!`, CANVAS_W / 2, y);
ctx.font = `14px ${FONT_FAMILY}`;
ctx.fillStyle = COLOR_TEXT_DIM;
ctx.fillText("Click or press any key for menu", CANVAS_W / 2, y + 24);
} else if (gameState === State.FINISHED_DRAW) {
ctx.fillStyle = COLOR_TEXT;
ctx.fillText("Draw!", CANVAS_W / 2, y);
ctx.font = `14px ${FONT_FAMILY}`;
ctx.fillStyle = COLOR_TEXT_DIM;
ctx.fillText("Click or press any key for menu", CANVAS_W / 2, y + 24);
} else if (gameState === State.DEMO) {
ctx.fillStyle = COLOR_TEXT_DIM;
ctx.font = `14px ${FONT_FAMILY}`;
ctx.fillText("Demo mode - click or press any key for menu", CANVAS_W / 2, y);
}
}
function drawMenu() {
ctx.fillStyle = COLOR_MENU_BG;
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
ctx.font = `bold 36px ${FONT_FAMILY}`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillStyle = COLOR_P1;
ctx.fillText("Connect", CANVAS_W / 2 - 60, 80);
ctx.fillStyle = COLOR_P2;
ctx.fillText("Four", CANVAS_W / 2 + 70, 80);
const items = [
{ label: "1P Yellow (you start)", color: COLOR_P1 },
{ label: "1P Red (AI starts)", color: COLOR_P2 },
{ label: "Multiplayer", color: "#5dade2" },
];
const startY = 160;
const itemH = 60;
const itemW = 340;
for (let i = 0; i < items.length; i++) {
const y = startY + i * itemH;
const x = (CANVAS_W - itemW) / 2;
const selected = i === menuMode;
// Background
ctx.fillStyle = selected ? COLOR_MENU_SEL : "transparent";
ctx.beginPath();
ctx.roundRect(x, y, itemW, 48, 8);
ctx.fill();
// Border for selected
if (selected) {
ctx.strokeStyle = items[i].color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(x, y, itemW, 48, 8);
ctx.stroke();
}
// Arrow
ctx.font = `bold 20px ${FONT_FAMILY}`;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillStyle = selected ? items[i].color : COLOR_TEXT_DIM;
ctx.fillText(selected ? "\u25b6 " : " ", x + 16, y + 24);
// Label
ctx.font = `${selected ? "bold " : ""}18px ${FONT_FAMILY}`;
ctx.fillText(items[i].label, x + 50, y + 24);
}
// Instructions
ctx.font = `14px ${FONT_FAMILY}`;
ctx.textAlign = "center";
ctx.fillStyle = COLOR_TEXT_DIM;
ctx.fillText("Up/Down or hover to select, click or Enter to start", CANVAS_W / 2, startY + items.length * itemH + 20);
ctx.fillText("During game: Arrow keys or click columns, 1-7 for direct drop", CANVAS_W / 2, startY + items.length * itemH + 44);
}
function render() {
ctx.fillStyle = COLOR_BG;
ctx.fillRect(0, 0, CANVAS_W, CANVAS_H);
if (gameState === State.MENU) {
drawMenu();
} else {
drawBoard();
drawCursor();
drawColNumbers();
drawStatus();
}
}
// --- Drop animation -----------------------------------------
function animateDrop(col, row, player) {
return new Promise(resolve => {
dropping = true;
dropCol = col;
dropPlayer = player;
dropTargetRow = row;
dropY = BOARD_PAD_TOP - 45;
dropTargetY = cellY(row);
function step(timestamp) {
dropY += ANIM_DROP_SPEED * (1 / 60);
if (dropY >= dropTargetY) {
dropY = dropTargetY;
dropping = false;
resolve();
return;
}
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
}
// --- Input: column from mouse / touch -----------------------
function colFromEvent(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = CANVAS_W / rect.width;
const x = (e.clientX - rect.left) * scaleX;
const col = Math.floor((x - BOARD_PAD_X) / CELL_SIZE);
return (col >= 0 && col < COLS) ? col : -1;
}
function menuItemFromEvent(e) {
const rect = canvas.getBoundingClientRect();
const scaleY = CANVAS_H / rect.height;
const scaleX = CANVAS_W / rect.width;
const y = (e.clientY - rect.top) * scaleY;
const x = (e.clientX - rect.left) * scaleX;
const startY = 160;
const itemH = 60;
const itemW = 340;
const mx = (CANVAS_W - itemW) / 2;
for (let i = 0; i < 3; i++) {
const iy = startY + i * itemH;
if (x >= mx && x <= mx + itemW && y >= iy && y <= iy + 48) return i;
}
return -1;
}
// --- Place a disc (with animation) --------------------------
let busy = false; // prevents input during animation / AI
async function placeDisk(col, player) {
const r = getFirstEmptyRow(board, col);
if (r === -1) return false;
currentMoves += String(col);
await animateDrop(col, r, player);
board[col][r] = player;
return true;
}
// --- AI turn (async to not block UI) ------------------------
async function doAiTurn() {
busy = true;
const aiP = gameMenuMode === 0 ? 2 : 1;
gameState = State.AI_TURN;
// Yield a frame so "AI thinking" shows
await new Promise(r => setTimeout(r, 50));
const bestCol = performAiMove(board, aiP, LOOK_AHEAD);
await placeDisk(bestCol, aiP);
activeCol = bestCol;
if (!checkGameEnd()) {
gameState = State.PLAYING;
currentPlayer = aiP === 1 ? 2 : 1;
}
lastActivity = performance.now() / 1000;
busy = false;
}
// --- Demo turn ----------------------------------------------
let demoTimer = null;
function stopDemo() {
if (demoTimer !== null) {
clearTimeout(demoTimer);
demoTimer = null;
}
}
async function demoStep() {
if (gameState !== State.DEMO) return;
busy = true;
const ply = demoPly[currentPlayer - 1];
const bestCol = performAiMove(board, currentPlayer, LOOK_AHEAD, true, ply);
await placeDisk(bestCol, currentPlayer);
if (!checkGameEnd()) {
currentPlayer = currentPlayer === 1 ? 2 : 1;
demoTimer = setTimeout(demoStep, 400);
}
busy = false;
}
function startDemo() {
resetGame();
demoPly = randomizeDemoPlies();
gameState = State.DEMO;
currentPlayer = 1;
lastActivity = performance.now() / 1000;
demoTimer = setTimeout(demoStep, 400);
}
// --- Start game from menu -----------------------------------
function startGame(mode) {
resetGame();
gameMenuMode = mode;
gameLevel = LOOK_AHEAD;
currentPlayer = 1;
activeCol = 3;
hoverCol = -1;
if (mode === 1) {
gameState = State.PLAYING; // briefly, then AI
doAiTurn();
} else {
gameState = State.PLAYING;
}
lastActivity = performance.now() / 1000;
}
function returnToMenu() {
stopDemo();
resetGame();
gameState = State.MENU;
menuMode = 0;
lastActivity = performance.now() / 1000;
}
// --- Mouse events -------------------------------------------
canvas.addEventListener("mousemove", (e) => {
if (gameState === State.MENU) {
const mi = menuItemFromEvent(e);
if (mi >= 0) menuMode = mi;
} else if (gameState === State.PLAYING && !busy) {
hoverCol = colFromEvent(e);
}
});
canvas.addEventListener("click", async (e) => {
if (busy) return;
lastActivity = performance.now() / 1000;
if (gameState === State.MENU) {
const mi = menuItemFromEvent(e);
if (mi >= 0) {
menuMode = mi;
startGame(mi);
}
return;
}
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW || gameState === State.DEMO) {
returnToMenu();
return;
}
if (gameState === State.PLAYING) {
const col = colFromEvent(e);
if (col < 0) return;
const r = getFirstEmptyRow(board, col);
if (r === -1) return;
busy = true;
activeCol = col;
await placeDisk(col, currentPlayer);
if (!checkGameEnd()) {
if (gameMenuMode < 2) {
await doAiTurn();
} else {
currentPlayer = currentPlayer === 1 ? 2 : 1;
}
}
busy = false;
}
});
// --- Touch support (mobile) ---------------------------------
canvas.addEventListener("touchend", (e) => {
if (e.changedTouches.length > 0) {
const touch = e.changedTouches[0];
const click = new MouseEvent("click", {
clientX: touch.clientX,
clientY: touch.clientY,
});
canvas.dispatchEvent(click);
}
e.preventDefault();
}, { passive: false });
// --- Keyboard events ----------------------------------------
document.addEventListener("keydown", async (e) => {
if (busy) return;
lastActivity = performance.now() / 1000;
if (e.key === "q" || e.key === "Q") {
if (gameState !== State.MENU) {
returnToMenu();
}
return;
}
if (gameState === State.MENU) {
if (e.key === "ArrowUp") {
menuMode = (menuMode - 1 + 3) % 3;
} else if (e.key === "ArrowDown") {
menuMode = (menuMode + 1) % 3;
} else if (e.key === "Enter" || e.key === " ") {
startGame(menuMode);
}
return;
}
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW || gameState === State.DEMO) {
returnToMenu();
return;
}
if (gameState === State.PLAYING) {
if (e.key === "ArrowLeft") {
activeCol = Math.max(0, activeCol - 1);
} else if (e.key === "ArrowRight") {
activeCol = Math.min(COLS - 1, activeCol + 1);
} else if (e.key >= "1" && e.key <= "7") {
const col = parseInt(e.key) - 1;
const r = getFirstEmptyRow(board, col);
if (r === -1) return;
busy = true;
activeCol = col;
await placeDisk(col, currentPlayer);
if (!checkGameEnd()) {
if (gameMenuMode < 2) {
await doAiTurn();
} else {
currentPlayer = currentPlayer === 1 ? 2 : 1;
}
}
busy = false;
} else if (e.key === "Enter" || e.key === " ") {
const r = getFirstEmptyRow(board, activeCol);
if (r === -1) return;
busy = true;
await placeDisk(activeCol, currentPlayer);
if (!checkGameEnd()) {
if (gameMenuMode < 2) {
await doAiTurn();
} else {
currentPlayer = currentPlayer === 1 ? 2 : 1;
}
}
busy = false;
}
}
});
// --- Main loop ----------------------------------------------
let lastTime = 0;
function gameLoop(timestamp) {
const now = timestamp / 1000;
// Flash toggle for win/draw
if (gameState === State.FINISHED_WIN || gameState === State.FINISHED_DRAW) {
if (now - lastFlash > 0.4) {
lastFlash = now;
flashToggle = !flashToggle;
}
// Auto-restart to demo
if (now - demoResetTimer > DEMO_RESET_PAUSE) {
startDemo();
}
}
// Idle timeout -> demo
if (gameState !== State.DEMO && gameState !== State.FINISHED_WIN && gameState !== State.FINISHED_DRAW) {
if (now - lastActivity > IDLE_TIMEOUT) {
startDemo();
}
}
render();
requestAnimationFrame(gameLoop);
}
requestAnimationFrame(gameLoop);