[add] Javascript version for Matthis and Jef.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connect Four</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
canvas {
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<script src="connect_four.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+802
@@ -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:
|
||||
* <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);
|
||||
Reference in New Issue
Block a user