Bami 2024. 10. 31. 22:05
728x90
반응형

오늘도 심심해서 만들어본 오목 게임입니다!
 

코드

omok.js

// 바둑판의 크기를 설정 (15 x 15)
const boardSize = 15;

// 각 셀의 크기를 설정 (픽셀 단위)
const cellSize = 40;

// 바둑판 배열을 초기화 (2차원 배열로, 모든 위치를 null로 설정)
let board = Array.from(Array(boardSize), () => Array(boardSize).fill(null));

// 사용자와 AI의 돌 색상을 저장할 변수
let userStone = null;
let aiStone = null;

// 현재 사용자의 차례인지 여부를 나타내는 변수
let isUserTurn = true;

// 캔버스 요소와 그리기 컨텍스트를 저장할 변수
let canvas, ctx;

// 마우스 커서에 따라 보여줄 미리보기 돌 정보를 저장할 변수
let previewStone = null;

/**
 * 게임을 시작하는 함수
 * @param {string} userChoice - 사용자가 선택한 돌 색상 ('black' 또는 'white')
 */
function startGame(userChoice) {
    // 사용자와 AI의 돌 색상을 설정
    userStone = userChoice;
    aiStone = userChoice === 'black' ? 'white' : 'black';

    // 사용자가 흑돌이면 먼저 시작
    isUserTurn = userStone === 'black';

    // 돌 선택 화면을 숨김
    document.getElementById('choice-container').style.display = 'none';

    // 캔버스 요소와 2D 그리기 컨텍스트를 가져옴
    canvas = document.getElementById('board');
    ctx = canvas.getContext('2d');

    // 바둑판을 그림
    drawBoard();

    // 이벤트 리스너 추가
    canvas.addEventListener('click', handleCanvasClick);
    canvas.addEventListener('mousemove', handleMouseMove);
    canvas.addEventListener('mouseleave', handleMouseLeave);

    // 사용자가 백돌을 선택한 경우 AI가 먼저 한 수를 둠
    if (userStone === 'white') {
        setTimeout(aiMove, 500);
    }
}

// 바둑판을 그리는 함수
function drawBoard() {
    // 캔버스를 깨끗하게 지움
    ctx.clearRect(0, 0, canvas.width, canvas.height);

    // 선 색상을 검정으로 설정
    ctx.strokeStyle = '#000';

    // 바둑판의 선을 그림
    for (let i = 0; i < boardSize; i++) {
        // 수직선 그리기
        ctx.beginPath();
        ctx.moveTo(cellSize / 2 + i * cellSize, cellSize / 2);
        ctx.lineTo(cellSize / 2 + i * cellSize, canvas.height - cellSize / 2);
        ctx.stroke();

        // 수평선 그리기
        ctx.beginPath();
        ctx.moveTo(cellSize / 2, cellSize / 2 + i * cellSize);
        ctx.lineTo(canvas.width - cellSize / 2, cellSize / 2 + i * cellSize);
        ctx.stroke();
    }

    // 이미 놓인 돌들을 그림
    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] !== null) {
                drawStone(row, col, board[row][col]);
            }
        }
    }

    // 미리보기 돌을 그림
    if (previewStone) {
        drawStone(previewStone.row, previewStone.col, previewStone.stone, true);
    }
}

/**
 * 돌을 그리는 함수
 * @param {number} row - 돌의 행 위치
 * @param {number} col - 돌의 열 위치
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 * @param {boolean} isPreview - 미리보기 돌인지 여부 (기본값: false)
 */
function drawStone(row, col, stone, isPreview = false) {
    // 돌의 위치 계산
    const x = cellSize / 2 + col * cellSize;
    const y = cellSize / 2 + row * cellSize;

    // 돌 그리기 시작
    ctx.beginPath();
    ctx.arc(x, y, cellSize / 2 - 2, 0, Math.PI * 2);

    // 돌의 색상 및 투명도 설정
    if (stone === 'black') {
        ctx.fillStyle = 'rgba(0, 0, 0,' + (isPreview ? '0.5' : '1') + ')';
    } else {
        ctx.fillStyle = 'rgba(255, 255, 255,' + (isPreview ? '0.5' : '1') + ')';
    }

    // 돌을 채움
    ctx.fill();

    // 돌의 테두리 그리기
    ctx.strokeStyle = '#000';
    ctx.stroke();
}

/**
 * 캔버스에서 클릭 이벤트가 발생했을 때 호출되는 함수
 * @param {MouseEvent} event - 마우스 이벤트 객체
 */
function handleCanvasClick(event) {
    // 사용자의 차례가 아니면 함수 종료
    if (!isUserTurn) return;

    // 마우스 위치로부터 행과 열을 계산
    const { row, col } = getMousePosition(event);

    // 유효한 위치이고 빈 자리인 경우에만 진행
    if (isValidPosition(row, col) && board[row][col] === null) {
        // 돌을 놓음
        placeStone(row, col, userStone);

        // 승리 여부 확인
        if (checkWin(row, col, userStone)) {
            setTimeout(() => alert("사용자가 이겼습니다!"), 100);
            resetGame();
            return;
        }

        // 차례 변경
        isUserTurn = false;
        previewStone = null;
        drawBoard();

        // 일정 시간 후에 AI의 수를 둠
        setTimeout(aiMove, 500);
    }
}

/**
 * 캔버스에서 마우스가 움직일 때 호출되는 함수
 * @param {MouseEvent} event - 마우스 이벤트 객체
 */
function handleMouseMove(event) {
    // 사용자의 차례가 아니면 함수 종료
    if (!isUserTurn) return;

    // 마우스 위치로부터 행과 열을 계산
    const { row, col } = getMousePosition(event);

    // 유효한 위치이고 빈 자리인 경우 미리보기 돌 설정
    if (isValidPosition(row, col) && board[row][col] === null) {
        previewStone = { row, col, stone: userStone };
    } else {
        previewStone = null;
    }

    // 바둑판 다시 그림
    drawBoard();
}

// 캔버스에서 마우스가 벗어날 때 호출되는 함수
function handleMouseLeave() {
    // 미리보기 돌 제거
    previewStone = null;
    drawBoard();
}

/**
 * 마우스 이벤트로부터 보드의 행과 열을 계산하는 함수
 * @param {MouseEvent} event - 마우스 이벤트 객체
 * @returns {{row: number, col: number}} - 계산된 행과 열 인덱스
 */
function getMousePosition(event) {
    // 캔버스의 위치 정보 가져오기
    const rect = canvas.getBoundingClientRect();

    // 마우스의 x, y 좌표 계산
    const x = event.clientX - rect.left - cellSize / 2;
    const y = event.clientY - rect.top - cellSize / 2;

    // 보드의 열과 행 인덱스로 변환
    const col = Math.round(x / cellSize);
    const row = Math.round(y / cellSize);

    return { row, col };
}

/**
 * 주어진 위치가 보드 내의 유효한 위치인지 확인하는 함수
 * @param {number} row - 행 인덱스
 * @param {number} col - 열 인덱스
 * @returns {boolean} - 유효한 위치이면 true, 아니면 false
 */
function isValidPosition(row, col) {
    return row >= 0 && row < boardSize && col >= 0 && col < boardSize;
}

/**
 * 보드에 돌을 놓는 함수
 * @param {number} row - 돌을 놓을 행 인덱스
 * @param {number} col - 돌을 놓을 열 인덱스
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 */
function placeStone(row, col, stone) {
    // 보드 배열에 돌을 배치
    board[row][col] = stone;
    drawBoard();
}

// AI의 수를 두는 함수
function aiMove() {
    // 최적의 수를 찾음
    let bestMove = findBestMove();

    if (bestMove) {
        const [aiRow, aiCol] = bestMove;
        placeStone(aiRow, aiCol, aiStone);

        // 승리 여부 확인
        if (checkWin(aiRow, aiCol, aiStone)) {
            setTimeout(() => alert("AI가 이겼습니다!"), 100);
            resetGame();
            return;
        }

        // 차례를 사용자에게 넘김
        isUserTurn = true;
    } else {
        // 둘 수 있는 곳이 없으면 비김
        setTimeout(() => alert("비겼습니다!"), 100);
        resetGame();
    }
}

// AI가 최적의 수를 찾는 함수
function findBestMove() {
    // 1. 이길 수 있는 수가 있으면 그 위치에 둠
    let winningMove = findWinningMove(aiStone);
    if (winningMove) return winningMove;

    // 2. 사용자가 이길 수 있는 수를 막음
    let blockingMove = findWinningMove(userStone);
    if (blockingMove) return blockingMove;

    // 3. 연속된 돌의 수를 늘릴 수 있는 위치에 둠
    let bestMove = findLongestSequenceMove(aiStone);
    if (bestMove) return bestMove;

    // 4. 사용자의 돌 근처에 둠
    let nearUserMove = findNearUserMove();
    if (nearUserMove) return nearUserMove;

    // 5. 중앙에 둠
    if (board[Math.floor(boardSize / 2)][Math.floor(boardSize / 2)] === null) {
        return [Math.floor(boardSize / 2), Math.floor(boardSize / 2)];
    }

    // 6. 랜덤한 빈 위치에 둠
    return getRandomEmptyCell();
}

/**
 * 특정 돌로 승리할 수 있는 위치를 찾는 함수
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 * @returns {number[] | null} - 승리할 수 있는 위치 [row, col], 없으면 null
 */
function findWinningMove(stone) {
    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] === null) {
                // 임시로 돌을 놓아봄
                board[row][col] = stone;

                // 승리 여부 확인
                if (checkWin(row, col, stone)) {
                    board[row][col] = null; // 원상복구
                    return [row, col];
                }

                board[row][col] = null; // 원상복구
            }
        }
    }
    return null;
}

/**
 * 가장 긴 연속된 돌의 수를 만들 수 있는 위치를 찾는 함수
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 * @returns {number[] | null} - 최적의 위치 [row, col], 없으면 null
 */
function findLongestSequenceMove(stone) {
    let maxScore = 0;
    let bestMoves = [];

    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] === null) {
                // 해당 위치에서의 점수 계산
                let score = evaluatePosition(row, col, stone);

                if (score > maxScore) {
                    maxScore = score;
                    bestMoves = [[row, col]];
                } else if (score === maxScore) {
                    bestMoves.push([row, col]);
                }
            }
        }
    }

    if (bestMoves.length > 0) {
        // 가장 좋은 위치들 중 하나를 랜덤하게 선택
        return bestMoves[Math.floor(Math.random() * bestMoves.length)];
    }
    return null;
}

/**
 * 특정 위치에서의 점수를 평가하는 함수
 * @param {number} row - 평가할 행 인덱스
 * @param {number} col - 평가할 열 인덱스
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 * @returns {number} - 해당 위치의 점수
 */
function evaluatePosition(row, col, stone) {
    let totalScore = 0;
    const directions = [
        [1, 0],  // 수직 방향
        [0, 1],  // 수평 방향
        [1, 1],  // 대각선 (\) 방향
        [1, -1]  // 대각선 (/) 방향
    ];

    for (let [dr, dc] of directions) {
        // 양쪽 방향의 연속된 돌의 수를 합산
        let count = countStones(row, col, dr, dc, stone) + countStones(row, col, -dr, -dc, stone);

        // 점수는 연속된 돌의 수의 제곱으로 계산
        totalScore += Math.pow(count, 2);
    }

    return totalScore;
}

// 사용자의 돌 근처의 빈 위치를 찾는 함수
function findNearUserMove() {
    let emptyCells = [];

    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] === null && isNearUserStone(row, col)) {
                emptyCells.push([row, col]);
            }
        }
    }

    if (emptyCells.length > 0) {
        // 근처의 빈 위치들 중 하나를 랜덤하게 선택
        return emptyCells[Math.floor(Math.random() * emptyCells.length)];
    }
    return null;
}

// 랜덤한 빈 위치를 반환하는 함수
function getRandomEmptyCell() {
    let emptyCells = [];

    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] === null) {
                emptyCells.push([row, col]);
            }
        }
    }

    if (emptyCells.length > 0) {
        // 빈 위치들 중 하나를 랜덤하게 선택
        return emptyCells[Math.floor(Math.random() * emptyCells.length)];
    }
    return null;
}

/**
 * 해당 위치가 사용자의 돌 근처인지 확인하는 함수
 * @param {number} row - 확인할 행 인덱스
 * @param {number} col - 확인할 열 인덱스
 * @returns {boolean} - 근처에 사용자의 돌이 있으면 true, 아니면 false
 */
function isNearUserStone(row, col) {
    const directions = [
        [1, 0],  [-1, 0], [0, 1],  [0, -1],
        [1, 1],  [-1, -1], [1, -1], [-1, 1]
    ];

    return directions.some(([dr, dc]) => {
        const nr = row + dr;
        const nc = col + dc;

        return isValidPosition(nr, nc) && board[nr][nc] === userStone;
    });
}

/**
 * 현재 놓은 돌로 승리했는지 확인하는 함수
 * @param {number} row - 최근에 놓은 돌의 행 인덱스
 * @param {number} col - 최근에 놓은 돌의 열 인덱스
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 * @returns {boolean} - 승리하면 true, 아니면 false
 */
function checkWin(row, col, stone) {
    return (
        countStones(row, col, 1, 0, stone) + countStones(row, col, -1, 0, stone) >= 4 ||  // 수직
        countStones(row, col, 0, 1, stone) + countStones(row, col, 0, -1, stone) >= 4 ||  // 수평
        countStones(row, col, 1, 1, stone) + countStones(row, col, -1, -1, stone) >= 4 || // 대각선 (\)
        countStones(row, col, 1, -1, stone) + countStones(row, col, -1, 1, stone) >= 4    // 대각선 (/)
    );
}

/**
 * 특정 방향으로 연속된 같은 돌의 수를 세는 함수
 * @param {number} row - 시작 행 인덱스
 * @param {number} col - 시작 열 인덱스
 * @param {number} rowDir - 행 방향 (1, 0, -1)
 * @param {number} colDir - 열 방향 (1, 0, -1)
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 * @returns {number} - 연속된 돌의 수
 */
function countStones(row, col, rowDir, colDir, stone) {
    let count = 0;
    let r = row + rowDir;
    let c = col + colDir;

    while (isValidPosition(r, c) && board[r][c] === stone) {
        count++;
        r += rowDir;
        c += colDir;
    }

    return count;
}

// 게임을 초기화하는 함수
function resetGame() {
    // 보드 배열 초기화
    board = Array.from(Array(boardSize), () => Array(boardSize).fill(null));

    // 돌 선택 화면을 다시 표시
    document.getElementById('choice-container').style.display = 'block';

    // 바둑판을 다시 그림
    drawBoard();

    // 이벤트 리스너 제거
    canvas.removeEventListener('click', handleCanvasClick);
    canvas.removeEventListener('mousemove', handleMouseMove);
    canvas.removeEventListener('mouseleave', handleMouseLeave);
}

omok.css

/* CSS 스타일 */

/* 전체 페이지의 기본 글꼴, 중앙 정렬, 배경색 설정 */
body {
    font-family: Arial, sans-serif;
    text-align: center;
    background-color: #f0f0f0;
}

/* 돌 선택 컨테이너의 여백 설정 */
#choice-container {
    margin-top: 20px;
}

/* 바둑판 컨테이너의 중앙 정렬, 크기, 위치 설정 */
#board-container {
    margin: 20px auto;
    width: 600px;
    height: 600px;
    position: relative;
}

/* 바둑판의 테두리와 배경색 설정, 커서 모양 변경 */
#board {
    border: 2px solid #000;
    background-color: #deb887; /* 바둑판 배경색 (밝은 갈색) */
    cursor: pointer; /* 바둑판 위에서 커서 모양을 포인터로 변경 */
}

/* 버튼의 크기, 여백, 글꼴 크기, 커서 모양 설정 */
button {
    padding: 10px 20px;
    margin: 0 10px;
    font-size: 16px;
    cursor: pointer; /* 버튼 위에 커서가 있을 때 포인터 표시 */
}

/* 각 바둑판 셀의 크기, 테두리, 위치 설정 */
.cell {
    width: 40px;
    height: 40px;
    box-sizing: border-box;
    border: 1px solid #000; /* 셀의 테두리 */
    position: relative;
}

/* 바둑돌 크기와 둥근 모양 설정, 위치 설정 */
.dot {
    width: 36px; /* 바둑돌의 가로 크기 */
    height: 36px; /* 바둑돌의 세로 크기 */
    border-radius: 50%; /* 원형으로 표시 */
    position: absolute;
    top: 2px; /* 바둑판 셀의 상단에서 약간 내려오도록 위치 */
    left: 2px; /* 바둑판 셀의 좌측에서 약간 오른쪽으로 위치 */
}

/* 검정 바둑돌의 배경색 설정 */
.dot.black {
    background-color: black;
}

/* 흰색 바둑돌의 배경색 설정 */
.dot.white {
    background-color: white;
}

/* 미리보기 돌의 투명도 설정 */
.preview-dot {
    opacity: 0.5; /* 미리보기 돌의 투명도를 50%로 설정 */
}

 

omok.html

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>오목 게임</title>
  <link rel="stylesheet" href="omok.css">
</head>
<body>
<div id="choice-container">
  <h2>돌 색상을 선택하세요</h2>
  <button onclick="startGame('black')">흑돌</button>
  <button onclick="startGame('white')">백돌</button>
</div>

<div id="board-container">
  <canvas id="board" width="600" height="600"></canvas>
</div>

<script src="omok.js"></script>
</body>
</html>
728x90
반응형