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

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

코드

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;

// AI 난이도 (1-3: 쉬움, 4-7: 보통, 8-10: 어려움)
let aiDifficulty = 7;

/**
 * 게임을 시작하는 함수
 * @param {string} userChoice - 사용자가 선택한 돌 색상 ('black' 또는 'white')
 */
function startGame(userChoice) {
    // 게임 상태 초기화
    board = Array.from(Array(boardSize), () => Array(boardSize).fill(null));
    
    // 사용자와 AI의 돌 색상을 설정
    userStone = userChoice;
    aiStone = userChoice === 'black' ? 'white' : 'black';

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

    // 돌 선택 화면을 숨김
    document.getElementById('choice-container').style.display = 'none';
    
    // 난이도 설정 UI 표시
    document.getElementById('difficulty-container').style.display = 'block';
    
    // 게임 영역 숨김
    document.getElementById('game-area').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);

    // 난이도 설정 이벤트 리스너 추가
    document.getElementById('easy-button').addEventListener('click', () => {
        setAIDifficulty(3);
        startGameWithDifficulty();
    });
    document.getElementById('medium-button').addEventListener('click', () => {
        setAIDifficulty(7);
        startGameWithDifficulty();
    });
    document.getElementById('hard-button').addEventListener('click', () => {
        setAIDifficulty(10);
        startGameWithDifficulty();
    });
}

function startGameWithDifficulty() {
    try {
        // 난이도 설정 UI 숨김
        document.getElementById('difficulty-container').style.display = 'none';
        
        // 게임 영역 표시
        document.getElementById('game-area').style.display = 'block';
        
        // 바둑판 다시 그림 (게임 영역이 표시된 후에)
        setTimeout(() => {
            drawBoard();
            
            // 사용자가 백돌을 선택한 경우 AI가 먼저 한 수를 둠
            if (userStone === 'white') {
                setTimeout(aiMove, 300);
            }
        }, 100);
    } catch (error) {
        console.error("게임 시작 오류:", error);
        alert("게임 시작 중 오류가 발생했습니다.");
    }
}

// 바둑판을 그리는 함수
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 setAIDifficulty(difficulty) {
    aiDifficulty = Math.max(1, Math.min(10, difficulty));
}

// AI가 최적의 수를 찾는 함수
function findBestMove() {
    // 랜덤 요소 (난이도에 따라 최선의 수를 선택하지 않을 확률)
    const randomFactor = Math.max(0, (11 - aiDifficulty) / 10);
    
    // 확률적으로 랜덤한 수를 두도록 설정
    if (Math.random() < randomFactor) {
        // 낮은 난이도에서는 완전 랜덤하게 둠
        if (aiDifficulty <= 3) {
            return getRandomEmptyCell();
        }
        // 중간 난이도에서는 사용자 근처에 둠
        else if (aiDifficulty <= 7) {
            const nearMove = findNearUserMove();
            return nearMove || getRandomEmptyCell();
        }
    }

    // 1. 이길 수 있는 수가 있으면 그 위치에 둠
    let winningMove = findWinningMove(aiStone);
    if (winningMove) return winningMove;

    // 2. 사용자가 이길 수 있는 수를 막음
    let blockingMove = findWinningMove(userStone);
    if (blockingMove) return blockingMove;
    
    // 3. 상대방이 열린 4개 만들기 직전인지 확인 (예비 승리 수)
    let threatMove = findThreatMove(userStone);
    if (threatMove && aiDifficulty >= 6) return threatMove;

    // 4. 자신이 열린 4개를 만들 수 있는지 확인 (예비 승리 수)
    let attackingMove = findThreatMove(aiStone);
    if (attackingMove && aiDifficulty >= 7) return attackingMove;

    // 5. 미니맥스 알고리즘 (어려움 난이도에서만)
    if (aiDifficulty >= 8) {
        let miniMaxMove = findMiniMaxMove(3); // 난이도가 높을수록 더 깊게 탐색
        if (miniMaxMove) return miniMaxMove;
    }

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

    // 7. 사용자의 연속된 돌을 막는 위치에 둠
    let defensiveMove = findLongestSequenceMove(userStone);
    if (defensiveMove) return defensiveMove;

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

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

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

/**
 * 미니맥스 알고리즘을 사용하여 최적의 수를 찾는 함수
 * @param {number} depth - 탐색 깊이
 * @returns {number[] | null} - 최적의 위치 [row, col], 없으면 null
 */
function findMiniMaxMove(depth) {
    let bestScore = -Infinity;
    let bestMove = null;
    
    // 어려움 난이도에서는 탐색 깊이를 조정
    if (aiDifficulty === 10) {
        depth = 3; // 최고 난이도에서도 적절한 깊이로 제한
    } else if (aiDifficulty === 9) {
        depth = 2;
    } else {
        depth = 1; // 낮은 난이도에서는 깊이를 줄임
    }
    
    // 15x15 보드 전체 탐색 대신 유망한 셀만 탐색
    const promisingCells = findPromisingCells();
    
    // 유망한 셀들에 대해서만 시도
    for (const [row, col] of promisingCells) {
        if (board[row][col] === null) {
            // 시뮬레이션: 돌 놓기
            board[row][col] = aiStone;
            
            // 승리 시 즉시 선택
            if (checkWin(row, col, aiStone)) {
                board[row][col] = null;
                return [row, col];
            }
            
            // 미니맥스 알고리즘으로 점수 계산 (제한된 시간 내에)
            let score = miniMax(depth - 1, false, -Infinity, Infinity);
            
            // 돌 제거 (시뮬레이션 종료)
            board[row][col] = null;
            
            // 더 좋은 점수가 나오면 업데이트
            if (score > bestScore) {
                bestScore = score;
                bestMove = [row, col];
            }
        }
    }
    
    return bestMove;
}

/**
 * 유망한 셀을 찾는 함수 (주변에 돌이 있는 빈 위치)
 * @returns {Array} - 유망한 위치 목록 [row, col]
 */
function findPromisingCells() {
    const cells = new Set();
    const vicinity = 2; // 주변 범위 (2칸)
    
    // 이미 놓은 돌 주변의 빈 위치를 찾음
    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] !== null) {
                // 주변 위치를 유망한 셀에 추가
                for (let r = Math.max(0, row - vicinity); r <= Math.min(boardSize - 1, row + vicinity); r++) {
                    for (let c = Math.max(0, col - vicinity); c <= Math.min(boardSize - 1, col + vicinity); c++) {
                        if (board[r][c] === null) {
                            cells.add(`${r},${c}`);
                        }
                    }
                }
            }
        }
    }
    
    // 셀이 없으면 (첫 수 등) 중앙 근처 위치를 추가
    if (cells.size === 0) {
        const center = Math.floor(boardSize / 2);
        for (let r = center - 1; r <= center + 1; r++) {
            for (let c = center - 1; c <= center + 1; c++) {
                if (isValidPosition(r, c)) {
                    cells.add(`${r},${c}`);
                }
            }
        }
    }
    
    // Set을 배열로 변환
    return Array.from(cells).map(cell => {
        const [r, c] = cell.split(',').map(Number);
        return [r, c];
    });
}

/**
 * 미니맥스 알고리즘 구현 (최적화 버전)
 * @param {number} depth - 남은 탐색 깊이
 * @param {boolean} isMaximizing - 최대화 플레이어 차례인지 여부
 * @param {number} alpha - 알파 값 (알파-베타 가지치기)
 * @param {number} beta - 베타 값 (알파-베타 가지치기)
 * @returns {number} - 계산된 점수
 */
function miniMax(depth, isMaximizing, alpha, beta) {
    // 게임 종료 또는 최대 깊이 도달 시 평가
    if (depth === 0) {
        return evaluateBoardState();
    }
    
    // 유망한 셀만 탐색
    const promisingCells = findPromisingCells();
    
    if (isMaximizing) {
        // AI 차례 (최대화)
        let maxScore = -Infinity;
        
        // 유망한 위치만 시도
        for (const [row, col] of promisingCells) {
            if (board[row][col] === null) {
                board[row][col] = aiStone;
                
                // 승리 시 높은 점수 반환
                if (checkWin(row, col, aiStone)) {
                    board[row][col] = null;
                    return 1000 + depth; // 깊이를 더해 빠른 승리를 선호
                }
                
                // 다음 단계 탐색
                let score = miniMax(depth - 1, false, alpha, beta);
                board[row][col] = null;
                
                maxScore = Math.max(maxScore, score);
                alpha = Math.max(alpha, score);
                
                // 알파-베타 가지치기
                if (beta <= alpha) break;
            }
        }
        
        return maxScore;
    } else {
        // 사용자 차례 (최소화)
        let minScore = Infinity;
        
        // 유망한 위치만 시도
        for (const [row, col] of promisingCells) {
            if (board[row][col] === null) {
                board[row][col] = userStone;
                
                // 사용자 승리 시 낮은 점수 반환
                if (checkWin(row, col, userStone)) {
                    board[row][col] = null;
                    return -1000 - depth; // 깊이를 빼서 빠른 패배를 피함
                }
                
                // 다음 단계 탐색
                let score = miniMax(depth - 1, true, alpha, beta);
                board[row][col] = null;
                
                minScore = Math.min(minScore, score);
                beta = Math.min(beta, score);
                
                // 알파-베타 가지치기
                if (beta <= alpha) break;
            }
        }
        
        return minScore;
    }
}

/**
 * 현재 보드 상태를 평가하는 함수
 * @returns {number} - 평가 점수 (양수: AI 유리, 음수: 사용자 유리)
 */
function evaluateBoardState() {
    let score = 0;
    
    // 가로, 세로, 대각선 평가
    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            // AI 돌에 대한 평가
            if (board[row][col] === aiStone) {
                score += evaluateStonePosition(row, col, aiStone);
            }
            // 사용자 돌에 대한 평가
            else if (board[row][col] === userStone) {
                score -= evaluateStonePosition(row, col, userStone);
            }
        }
    }
    
    return score;
}

/**
 * 특정 위치의 돌에 대한 점수 평가
 * @param {number} row - 행 인덱스
 * @param {number} col - 열 인덱스
 * @param {string} stone - 돌 색상
 * @returns {number} - 평가 점수
 */
function evaluateStonePosition(row, col, stone) {
    let score = 0;
    const directions = [
        [1, 0],  // 수직 방향
        [0, 1],  // 수평 방향
        [1, 1],  // 대각선 (\) 방향
        [1, -1]  // 대각선 (/) 방향
    ];
    
    for (let [dr, dc] of directions) {
        // 양쪽 방향으로의 연속된 돌과 빈 공간 분석
        let sequence = analyzeSequence(row, col, dr, dc, stone);
        
        // 연속된 돌의 개수에 따른 점수 부여
        if (sequence.stones === 5) score += 100000;     // 승리
        else if (sequence.stones === 4) {
            // 4개 연속 + 한쪽이라도 뚫려 있으면 매우 위험/유리
            if (sequence.openEnds >= 1) score += 10000;
            else score += 500;                          // 막힌 4개는 덜 위험/유리
        }
        else if (sequence.stones === 3) {
            // 3개 연속 + 양쪽이 뚫려 있으면 매우 가치 있음
            if (sequence.openEnds === 2) score += 5000;
            // 3개 연속 + 한쪽만 뚫려 있으면 덜 가치 있음
            else if (sequence.openEnds === 1) score += 500;
        }
        else if (sequence.stones === 2) {
            // 2개 연속 + 양쪽이 뚫려 있으면 가치 있음
            if (sequence.openEnds === 2) score += 100;
            // 2개 연속 + 한쪽만 뚫려 있으면 덜 가치 있음
            else if (sequence.openEnds === 1) score += 10;
        }
        else if (sequence.stones === 1) {
            // 주변에 빈 공간이 많을수록 약간의 가치
            score += sequence.openEnds * 2;
        }
    }
    
    // 중앙 위치는 더 가치 있음
    const centerDistance = Math.sqrt(
        Math.pow(row - Math.floor(boardSize / 2), 2) + 
        Math.pow(col - Math.floor(boardSize / 2), 2)
    );
    score += Math.max(0, (boardSize / 2 - centerDistance)) * 5;
    
    return score;
}

/**
 * 특정 방향의 돌 시퀀스를 분석하는 함수
 * @param {number} row - 시작 행 인덱스
 * @param {number} col - 시작 열 인덱스
 * @param {number} dr - 행 방향
 * @param {number} dc - 열 방향
 * @param {string} stone - 돌 색상
 * @returns {{stones: number, openEnds: number}} - 연속된 돌의 수와 열린 끝의 수
 */
function analyzeSequence(row, col, dr, dc, stone) {
    let stones = 1;  // 현재 위치의 돌 포함
    let openEnds = 0;
    
    // 정방향 검사
    let r = row + dr;
    let c = col + dc;
    
    // 연속된 같은 돌 계산
    while (isValidPosition(r, c) && board[r][c] === stone) {
        stones++;
        r += dr;
        c += dc;
    }
    
    // 정방향 끝이 빈 공간인지 확인
    if (isValidPosition(r, c) && board[r][c] === null) {
        openEnds++;
    }
    
    // 역방향 검사
    r = row - dr;
    c = col - dc;
    
    // 연속된 같은 돌 계산
    while (isValidPosition(r, c) && board[r][c] === stone) {
        stones++;
        r -= dr;
        c -= dc;
    }
    
    // 역방향 끝이 빈 공간인지 확인
    if (isValidPosition(r, c) && board[r][c] === null) {
        openEnds++;
    }
    
    return { stones, openEnds };
}

/**
 * 특정 위치에서의 점수를 평가하는 함수
 * @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 sequence = analyzeDirectionalSequence(row, col, dr, dc, stone);
        
        // 연속성과 열린 공간에 따라 점수 부여
        if (sequence.consecutive === 4) {
            // 승리 가능한 위치에 매우 높은 점수
            totalScore += 10000;
        } else if (sequence.consecutive === 3) {
            // 양쪽이 열려있는 3개 연속은 가치가 매우 높음
            if (sequence.openEnds === 2) totalScore += 5000;
            // 한쪽만 열려 있는 3개 연속
            else if (sequence.openEnds === 1) totalScore += 1000;
        } else if (sequence.consecutive === 2) {
            // 양쪽이 열려있는 2개 연속
            if (sequence.openEnds === 2) totalScore += 500;
            // 한쪽만 열려 있는 2개 연속
            else if (sequence.openEnds === 1) totalScore += 100;
        } else if (sequence.consecutive === 1) {
            // 주변에 빈 공간이 많을수록 약간의 가치
            totalScore += sequence.openEnds * 10;
        }
        
        // 중앙 위치에 가까울수록 추가 점수
        totalScore += (boardSize / 2 - Math.abs(row - boardSize / 2)) * 2;
        totalScore += (boardSize / 2 - Math.abs(col - boardSize / 2)) * 2;
    }

    return totalScore;
}

/**
 * 특정 방향의 돌 배치를 분석하는 함수
 * @param {number} row - 평가할 행 인덱스
 * @param {number} col - 평가할 열 인덱스
 * @param {number} dr - 행 방향
 * @param {number} dc - 열 방향
 * @param {string} stone - 돌의 색상
 * @returns {{consecutive: number, openEnds: number}} - 연속성과 열린 끝 정보
 */
function analyzeDirectionalSequence(row, col, dr, dc, stone) {
    // 가상으로 돌을 놓는다고 가정
    let consecutive = 1;
    let openEnds = 0;
    
    // 양의 방향 탐색
    let r = row + dr;
    let c = col + dc;
    
    // 첫 번째 빈 공간, 상대 돌, 또는 보드 경계까지 확인
    let blocked = false;
    while (isValidPosition(r, c)) {
        if (board[r][c] === stone) {
            consecutive++;
        } else if (board[r][c] === null) {
            openEnds++;
            break;
        } else {
            // 상대 돌에 의해 차단됨
            blocked = true;
            break;
        }
        r += dr;
        c += dc;
    }
    
    // 보드 경계에 의해 차단됨
    if (!isValidPosition(r, c) && !blocked) {
        blocked = true;
    }
    
    // 음의 방향 탐색
    r = row - dr;
    c = col - dc;
    
    // 첫 번째 빈 공간, 상대 돌, 또는 보드 경계까지 확인
    blocked = false;
    while (isValidPosition(r, c)) {
        if (board[r][c] === stone) {
            consecutive++;
        } else if (board[r][c] === null) {
            openEnds++;
            break;
        } else {
            // 상대 돌에 의해 차단됨
            blocked = true;
            break;
        }
        r -= dr;
        c -= dc;
    }
    
    // 보드 경계에 의해 차단됨
    if (!isValidPosition(r, c) && !blocked) {
        blocked = true;
    }
    
    return { consecutive, openEnds };
}

// 사용자의 돌 근처의 빈 위치를 찾는 함수
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) {
    const directions = [
        [1, 0],   // 수직
        [0, 1],   // 수평
        [1, 1],   // 대각선 (\)
        [1, -1]   // 대각선 (/)
    ];

    for (const [dr, dc] of directions) {
        // 양쪽 방향으로 연속된 돌의 수를 합산 (현재 돌 포함)
        const count = 1 + 
            countStones(row, col, dr, dc, stone) + 
            countStones(row, col, -dr, -dc, stone);
        
        // 정확히 5개면 승리
        if (count === 5) {
            return true;
        }
    }
    
    return false;
}

/**
 * 특정 방향으로 연속된 같은 돌의 수를 세는 함수
 * @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);
}

/**
 * 특정 돌로 승리할 수 있는 위치를 찾는 함수
 * @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;
}

/**
 * 잠재적 위협(열린 3, 4)을 찾는 함수
 * @param {string} stone - 돌의 색상 ('black' 또는 'white')
 * @returns {number[] | null} - 위협을 막거나 만들 수 있는 위치
 */
function findThreatMove(stone) {
    let bestScore = 0;
    let bestMoves = [];

    // 모든 빈 위치를 평가
    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] === null) {
                // 해당 위치에 돌을 놓았을 때의 위협 정도 평가
                let threatScore = evaluateThreat(row, col, stone);
                
                if (threatScore > bestScore) {
                    bestScore = threatScore;
                    bestMoves = [[row, col]];
                } else if (threatScore === bestScore && threatScore > 0) {
                    bestMoves.push([row, col]);
                }
            }
        }
    }

    // 위협 점수가 높은 수가 있다면 선택
    if (bestMoves.length > 0 && bestScore >= 1000) {
        return bestMoves[Math.floor(Math.random() * bestMoves.length)];
    }
    
    return null;
}

/**
 * 특정 위치에 돌을 놓았을 때의 위협 정도를 평가하는 함수
 * @param {number} row - 평가할 행 인덱스
 * @param {number} col - 평가할 열 인덱스
 * @param {string} stone - 돌의 색상
 * @returns {number} - 위협 점수
 */
function evaluateThreat(row, col, stone) {
    // 임시로 돌 배치
    board[row][col] = stone;
    
    let threatScore = 0;
    const directions = [
        [1, 0],  // 수직 방향
        [0, 1],  // 수평 방향
        [1, 1],  // 대각선 (\) 방향
        [1, -1]  // 대각선 (/) 방향
    ];
    
    // 각 방향으로 분석
    for (let [dr, dc] of directions) {
        let sequence = analyzeDirectionalSequence(row, col, dr, dc, stone);
        
        // 열린 4는 매우 위험/유리한 상황
        if (sequence.consecutive === 4 && sequence.openEnds >= 1) {
            threatScore = Math.max(threatScore, 10000);
        }
        // 열린 3은 잠재적 위험/기회
        else if (sequence.consecutive === 3 && sequence.openEnds === 2) {
            threatScore = Math.max(threatScore, 5000);
        }
        // 한쪽이 막힌 3은 약간의 위험/기회
        else if (sequence.consecutive === 3 && sequence.openEnds === 1) {
            threatScore = Math.max(threatScore, 1000);
        }
    }
    
    // 돌 제거 (원래 상태로 되돌림)
    board[row][col] = null;
    
    return threatScore;
}

/**
 * 패턴 분석: 연속 돌의 구조를 분석하는 함수
 * @param {number} row - 시작 행 인덱스
 * @param {number} col - 시작 열 인덱스
 * @param {string} stone - 돌의 색상
 * @returns {Object} - 다양한 패턴의 개수 정보
 */
function analyzePatterns(row, col, stone) {
    // 임시로 돌 배치
    const originalValue = board[row][col];
    board[row][col] = stone;
    
    const patterns = {
        openFour: 0,      // 양쪽이 열린 4
        halfOpenFour: 0,  // 한쪽만 열린 4
        openThree: 0,     // 양쪽이 열린 3
        halfOpenThree: 0, // 한쪽만 열린 3
        openTwo: 0,       // 양쪽이 열린 2
    };
    
    const directions = [
        [1, 0],  // 수직 방향
        [0, 1],  // 수평 방향
        [1, 1],  // 대각선 (\) 방향
        [1, -1]  // 대각선 (/) 방향
    ];
    
    // 각 방향으로 분석
    for (let [dr, dc] of directions) {
        let sequence = analyzeDirectionalSequence(row, col, dr, dc, stone);
        
        if (sequence.consecutive === 4) {
            if (sequence.openEnds === 2) patterns.openFour++;
            else if (sequence.openEnds === 1) patterns.halfOpenFour++;
        }
        else if (sequence.consecutive === 3) {
            if (sequence.openEnds === 2) patterns.openThree++;
            else if (sequence.openEnds === 1) patterns.halfOpenThree++;
        }
        else if (sequence.consecutive === 2 && sequence.openEnds === 2) {
            patterns.openTwo++;
        }
    }
    
    // 원래 상태로 되돌림
    board[row][col] = originalValue;
    
    return patterns;
}

// AI 공격 패턴을 구현한 함수
function findAttackPattern() {
    let bestMove = null;
    let bestScore = -1;
    
    // 모든 빈 위치를 평가
    for (let row = 0; row < boardSize; row++) {
        for (let col = 0; col < boardSize; col++) {
            if (board[row][col] === null) {
                // 이 위치에 돌을 놓았을 때 생성되는 패턴 분석
                const patterns = analyzePatterns(row, col, aiStone);
                
                // 패턴에 따른 점수 계산
                let score = 
                    patterns.openFour * 10000 +
                    patterns.halfOpenFour * 1000 +
                    patterns.openThree * 500 +
                    patterns.halfOpenThree * 100 +
                    patterns.openTwo * 10;
                
                // 더 좋은 점수가 나오면 업데이트
                if (score > bestScore) {
                    bestScore = score;
                    bestMove = [row, col];
                }
            }
        }
    }
    
    return bestMove;
}

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>
    <style>
        body {
            font-family: 'Malgun Gothic', sans-serif;
            display: flex;
            flex-direction: column;
            align-items: center;
            justify-content: center;
            min-height: 100vh;
            margin: 0;
            background-color: #f0f0f0;
        }
        h1 {
            color: #333;
            margin-bottom: 20px;
        }
        #game-container {
            text-align: center;
        }
        canvas {
            background-color: #e9c170;
            border: 2px solid #333;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
            margin-bottom: 20px;
        }
        #choice-container, #difficulty-container {
            margin-bottom: 30px;
        }
        button {
            padding: 10px 20px;
            margin: 0 10px;
            font-size: 16px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            transition: all 0.2s;
            background-color: #3498db;
            color: white;
        }
        button:hover {
            background-color: #2980b9;
            transform: translateY(-2px);
        }
        #black-button {
            background-color: #333;
        }
        #black-button:hover {
            background-color: #111;
        }
        #white-button {
            background-color: #f5f5f5;
            color: #333;
            border: 1px solid #ddd;
        }
        #white-button:hover {
            background-color: #e5e5e5;
        }
        #easy-button {
            background-color: #2ecc71;
        }
        #easy-button:hover {
            background-color: #27ae60;
        }
        #medium-button {
            background-color: #f39c12;
        }
        #medium-button:hover {
            background-color: #d35400;
        }
        #hard-button {
            background-color: #e74c3c;
        }
        #hard-button:hover {
            background-color: #c0392b;
        }
        #game-area, #difficulty-container {
            display: none;
        }
        .info-text {
            margin-bottom: 15px;
            color: #555;
        }
    </style>
</head>
<body>
    <div id="game-container">
        <h1>오목 게임</h1>
        
        <div id="choice-container">
            <p class="info-text">돌 색상을 선택하세요. 흑돌이 먼저 시작합니다.</p>
            <button id="black-button" onclick="startGame('black')">흑돌</button>
            <button id="white-button" onclick="startGame('white')">백돌</button>
        </div>
        
        <div id="difficulty-container">
            <p class="info-text">AI 난이도를 선택하세요.</p>
            <button id="easy-button">쉬움</button>
            <button id="medium-button">보통</button>
            <button id="hard-button">어려움</button>
        </div>
        
        <div id="game-area">
            <canvas id="board" width="610" height="610"></canvas>
            <div>
                <button onclick="resetGame()">다시 시작</button>
            </div>
        </div>
    </div>
    
    <script src="index.js"></script>
</body>
</html>

 

데모

 

오목 게임

돌 색상을 선택하세요. 흑돌이 먼저 시작합니다. 흑돌 백돌

gomoku-chi.vercel.app

 

728x90
반응형
그리드형