파이썬으로 오목 온라인 게임 만들기

8
post-thumbnail

여러분, 가끔 친구들과 함께 보드 게임을 즐기기는 하나요? 오늘은 여러분과 함께 대표적인 보드 게임 중 하나인 오목을 파이썬으로 멀티플레이를 구현해보려고 합니다. 오목이란 무엇인지, 어떻게 동작하는지 궁금하시죠? 그럼 바로 시작해볼까요!

오목이란?

오목은 검은 돌과 하얀 돌을 번갈아가며 두어 가로, 세로, 대각선 중 한 방향으로 연속된 다섯 개의 돌을 먼저 놓으면 이기는 게임입니다. 아주 간단한 규칙이지만 깊이 있는 전략이 필요한 게임이죠. 이번에 우리는 파이썬을 활용해서 이러한 오목 게임을 구현해보겠습니다.

1.게임의 핵심 구조 이해하기

필요 패키지 설치하기

본격적으로 구현에 들어가기 전에 구현에 필요한 패키지를 설치해 봅시다. 이번 포스트에서는 pygame을 이용해서 구현했습니다.

$ pip install pygame

게임을 구현하기 위해서는 먼저 게임의 주요 구조를 이해해야 합니다. 오목 게임을 위한 클래스와 메서드를 통해 어떤 방식으로 게임이 진행되는지 살펴봅시다.

Omok 클래스 정의하기

오목 게임의 핵심 로직을 담당하는 Omok 클래스를 만들어보겠습니다. 이 클래스는 게임 보드와 현재 플레이어를 관리하고, 플레이어의 차례에 따라 돌을 놓으며, 금수(금지된 수)와 승리 조건을 체크하는 역할을 합니다.

class Omok:
    def __init__(self):
        self.board = [[0] * 19 for _ in range(19)]
        self.current_player = BLACK
        self.forbidden_moves = set()
        self.directions = [(1, 0), (0, 1), (1, 1), (1, -1)]
  • board: 19x19 크기의 게임 보드입니다. 0은 빈 곳, 1은 검은 돌, 2는 하얀 돌을 나타냅니다.
  • current_player: 현재 차례인 플레이어를 나타냅니다. 검은 돌로 시작합니다.
  • forbidden_moves: 금지된 수의 위치를 저장하는 셋입니다.
  • directions: 각 방향을 나타내는 튜플 리스트입니다.

돌을 놓기

플레이어가 돌을 놓는 동작을 구현해보겠습니다. 돌을 놓고, 금수 업데이트, 승리 체크를 진행하는 메서드입니다.

def play(self, row, col):
    self.board[row][col] = self.current_player
    self.update_forbidden_moves()
    if self.check_win(row, col):
        return {
            "board": self.board,
            "forbidden_moves": self.forbidden_moves,
            "winner": self.current_player,
            "turn": -1,
        }
    
    self.current_player = 3 - self.current_player
    return {
        "board": self.board,
        "forbidden_moves": self.forbidden_moves,
        "winner": -1,
        "turn": self.current_player,
    }

위의 메서드는 주어진 위치에 돌을 놓고, 금수와 승리 여부를 체크합니다. 승리 시에는 승리 정보를 리턴하고, 그렇지 않으면 다음 턴을 리턴합니다.

승리 조건 체크

돌을 5개 연속으로 놓았는지를 체크하는 메서드입니다. 4가지 방향을 모두 체크하여 승리 조건을 만족하는지 확인합니다.

def check_win(self, row, col):
    for d in self.directions:
        count = 1
        for i in range(1, 6):
            r, c = row + d[0] * i, col + d[1] * i
            if 0 <= r < 19 and 0 <= c < 19 and self.board[r][c] == self.current_player:
                count += 1
            else:
                break

        for i in range(1, 6):
            r, c = row - d[0] * i, col - d[1] * i
            if 0 <= r < 19 and 0 <= c < 19 and self.board[r][c] == self.current_player:
                count += 1
            else:
                break

        if count == 5:
            return True
    return False

승리 조건을 체크하는 방식은 각 방향으로 돌을 차례대로 확인하며 5개 연속인 경우를 찾는 것입니다.

2.네트워크 기능 추가하기

이제 오목 게임을 혼자가 아닌 친구와 같이 온라인으로 즐길 수 있도록 네트워크 기능을 추가해보겠습니다. 이를 위해 서버와 클라이언트를 구현할 것입니다.

서버 클래스 정의하기

서버는 두 명의 플레이어를 연결하고 돌을 주고받는 역할을 합니다.

class Server:
    def __init__(self, host="localhost", port=12345):
        self.host = host
        self.port = port
        self.socket = None
        self.clients = []
        self.game = None
        self.replay = []

    def setup_socket(self):
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.socket.bind((self.host, self.port))
        self.socket.listen(2)
        print(f"Server started on {self.host}:{self.port}")

    def setup_game(self):
        self.game = Omok()

    def handle_clients(self):
        turn = 0
        while True:
            data = self.clients[turn].recv(1024)
            if not data:
                print("no data")
                break
            data = pickle.loads(data)
            row, col = data
            result = copy.deepcopy(self.game.play(row, col))
            self.replay.append(result)
            response = pickle.dumps(result)
            for client_socket in self.clients:
                client_socket.send(response)
            if result["winner"] != -1:
                break
            turn = 1 - turn

        for client_socket in self.clients:
            replay_data = pickle.dumps(self.replay)
            client_socket.send(replay_data)
            client_socket.recv(1024)
            client_socket.close()

    def run(self):
        self.setup_socket()
        self.setup_game()
        print("Waiting for clients...")
        init_data = {
            "board": self.game.board,
            "forbidden_moves": self.game.forbidden_moves,
            "winner": -1,
            "turn": 1,
            "color": BLACK,
        }
        while len(self.clients) < 2:
            client_socket, client_address = self.socket.accept()
            client_socket.send(pickle.dumps(init_data))
            self.clients.append(client_socket)
            init_data["color"] = WHITE
        print("Game started")
        self.handle_clients()

서버는 소켓을 설정하고, 두 명의 클라이언트를 연결한 뒤 게임을 진행합니다.

  • setup_socket
    • 소켓을 생성하고, 호스트와 포트에 바인딩 합니다.
    • 소켓을 클라이언트 연결을 수신할 수 있는 상태로 설정합니다 (listen).
  • handle_clients
    • 첫 번째 플레이어부터 순차적으로 데이터를 수신하고, 게임 상태를 업데이트합니다.
    • 모든 클라이언트에게 게임의 현재 상태를 전송합니다.
    • 게임이 끝나면 리플레이 데이터를 모든 클라이언트에 전송하고 소켓을 닫습니다.
  • run
    • 두 명의 클라이언트가 연결될 때까지 기다립니다.
    • 각 클라이언트에게 초기 게임 데이터를 전송하고 게임을 시작합니다.

클라이언트 클래스 정의하기

클라이언트는 서버에 접속하여 플레이어의 입력을 받고, 돌을 놓는 역할을 합니다.

class Client:
    def __init__(self, host, port):
        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self.socket.connect((host, port))

    def receive(self):
        print("waiting for data")
        receive = self.socket.recv(16384)
        print("received data")
        return pickle.loads(receive)

    def send(self, data):
        self.socket.send(pickle.dumps(data))

클라이언트는 서버와 연결을 유지하면서 데이터를 주고받습니다.

플레이어 클래스 정의하기

게임 플레이어의 움직임은 Player 클래스에서 처리됩니다. 이 클래스는 보드의 상태를 그려주고, 플레이어의 클릭 이벤트를 처리하며 서버와의 데이터를 주고받습니다.

플래이어의 차례와 게임 상황을 제어하는 Player 클래스를 정의해봅시다.

class Player:
    board = [[0] * 19 for _ in range(19)]
    forbidden_moves = None
    winner = None
    turn = None
    color = None

    def __init__(self, client):
        self.client = client
        self.init_pygame()
        self._draw_board()

    def init_pygame(self):
        pygame.init()
        pygame.display.set_caption("Omok")
        self.screen = pygame.display.set_mode(BOARD_SIZE, 0, 32)
        self.background = pygame.image.load(BACKGROUND).convert()
        self.clickable = False

    def _draw_board(self):
        outline = pygame.Rect(45, 45, 720, 720)
        pygame.draw.rect(self.background, BLACK, outline, 3)
        for i in range(18):
            for j in range(18):
                rect = pygame.Rect(45 + (40 * i), 45 + (40 * j), 40, 40)
                pygame.draw.rect(self.background, BLACK, rect, 1)
        for i in range(3):
            for j in range(3):
                coords = (165 + (240 * i), 165 + (240 * j))
                pygame.draw.circle(self.background, BLACK, coords, 5, 0)
        self.screen.blit(self.background, (0, 0))
        pygame.display.update()

    def _draw_stone(self, row, col, color):
        coords = (45 + col * 40, 45 + row * 40)
        if color == BLACK:
            color_value = (0, 0, 0)
        else:
            color_value = (255, 255, 255)
        pygame.draw.circle(self.screen, color_value, coords, 20, 0)
        pygame.display.update()

    def run(self):
        while True:
            self._update_board()
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    raise SystemExit
                if self.clickable and event.type == pygame.MOUSEBUTTONDOWN:
                    x, y = event.pos
                    row = int(round(((y - 45) / 40.0), 0))
                    col = int(round(((x - 45) / 40.0), 0))
                    if self.board[row][col] == 0 and not (row, col) in self.forbidden_moves:
                        self.client.send((row, col))
                        self.clickable = False

    def _update_board(self):
        data = self.client.receive()
        self.board = data["board"]
        self.forbidden_moves = data["forbidden_moves"]
        self.turn = data["turn"]
        self.winner = data["winner"]
        self._draw_board()
        for row in range(19):
            for col in range(19):
                if self.board[row][col] != 0:
                    self._draw_stone(row, col, self.board[row][col])
        self.clickable = self.turn == self.color
        if self.winner != -1:
            self._show_winner()

    def _show_winner(self):
        font = pygame.font.Font(None, 36)
        if self.winner == self.color:
            text = font.render("You Win", True, (0, 0, 0))
        else:
            text = font.render("You Lose", True, (0, 0, 0))
        text_rect = text.get_rect(centerx=self.screen.get_width() // 2, top=50)
        pygame.draw.rect(self.screen, (255, 255, 255), text_rect)
        self.screen.blit(text, text_rect)
        pygame.display.update()

이제 플레이어는 서버와 연결 후 게임을 진행하는 동안 각자의 차례를 전달받고, 화면에 돌을 놓으며 게임을 진행합니다.

3.리플레이 기능 추가하기

추가적으로, 게임이 끝난 후 다시 리플레이를 볼 수 있는 기능을 구현해보겠습니다. 리플레이의 데이터는 게임이 진행되는 동안 저장해두고, 나중에 이를 불러와 재생할 수 있도록 합니다.

리플레이 클래스 정의하기

리플레이 데이터를 불러와서 게임 진행 과정을 재생하는 Replay 클래스를 만들어보겠습니다.

class Replay:
    def __init__(self, path):
        with open(path, "rb") as f:
            self.data = pickle.load(f)
        self.pointer = 0
        self.init_pygame()
        self._draw_board()

    def init_pygame(self):
        pygame.init()
        pygame.display.set_caption("Omok")
        self.screen = pygame.display.set_mode(BOARD_SIZE, 0, 32)
        self.background = pygame.image.load(BACKGROUND).convert()

    def _draw_board(self):
        outline = pygame.Rect(45, 45, 720, 720)
        pygame.draw.rect(self.background, BLACK, outline, 3)
        for i in range(18):
            for j in range(18):
                rect = pygame.Rect(45 + (40 * i), 45 + (40 * j), 40, 40)
                pygame.draw.rect(self.background, BLACK, rect, 1)
        for i in range(3):
            for j in range(3):
                coords = (165 + (240 * i), 165 + (240 * j))
                pygame.draw.circle(self.background, BLACK, coords, 5, 0)
        self.screen.blit(self.background, (0, 0))
        pygame.display.update()

    def _draw_stone(self, row, col, color):
        coords = (45 + col * 40, 45 + row * 40)
        if color == BLACK:
            color_value = (0, 0, 0)
        else:
            color_value = (255, 255, 255)
        pygame.draw.circle(self.screen, color_value, coords, 20, 0)
        pygame.display.update()

    def _update(self):
        status = self.data[self.pointer]
        self.board = status["board"]
        self.forbidden_moves = status["forbidden_moves"]
        self._draw_board()
        for row in range(19):
            for col in range(19):
                if self.board[row][col] != 0:
                    self._draw_stone(row, col, self.board[row][col])

    def run(self):
        self._update()
        while True:
            pygame.time.wait(500)
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    exit()
                if event.type == pygame.KEYDOWN:
                    if event.key == pygame.K_LEFT:
                        self.pointer = max(0, self.pointer - 1)
                    elif event.key == pygame.K_RIGHT:
                        self.pointer = min(len(self.data) - 1, self.pointer + 1)
                    self._update()

리플레이 클래스는 이전의 게임 데이터(replay.pkl 파일)에 접근하여 게임의 진행 상황을 다시 보여줍니다. 게임을 처음부터 끝까지 재생할 수 있도록 키보드 화살표(←,→)를 이용해 이전 턴과 다음 턴을 이동할 수 있습니다.

마무리

이제 여러분도 파이썬을 이용해 오목 게임을 직접 구현해볼 수 있습니다. 소켓을 이용해 네트워크 기능을 추가하고, PyGame을 이용해 그래픽을 구현하였습니다. 그리고 마지막으로 리플레이 기능을 통해 게임을 다시 재생해볼 수도 있죠. 다양한 기능을 추가하며 자신만의 오목 게임을 더욱 발전시켜보세요!

프로그래밍 공부와 프로젝트 진행에 도움이 되길 바랍니다. 함께 해주셔서 감사합니다. 다음 포스트에서 또 만나요!

🔥 해당 포스팅은 Dev.POST 도움을 받아 작성되었습니다.

profile
🔥 코드 과정 중 자연스레 쌓인 경험과 지식을 기술 블로그로 작성해줍니다.

0개의 댓글