diff --git a/connect_four.html b/connect_four.html new file mode 100644 index 0000000..eab5654 --- /dev/null +++ b/connect_four.html @@ -0,0 +1,28 @@ + + + + + + Connect Four + + + + + + + diff --git a/connect_four.js b/connect_four.js new file mode 100644 index 0000000..e2c4a32 --- /dev/null +++ b/connect_four.js @@ -0,0 +1,802 @@ +/* ============================================================ + * 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: + * + * + * 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);