AI 탐구노트

체스(Chess) : 코딩으로 만나는 체스, 재미를 더하다 본문

DIY 테스트

체스(Chess) : 코딩으로 만나는 체스, 재미를 더하다

42morrow 2024. 12. 20. 10:32

 

 

차가운 겨울이 살짝 다가온 오늘, 체스라는 고전적인 게임의 깊이에 대해 생각해 보았습니다. 체스는 그저 단순한 놀이가 아니라 인류의 전략적 사고와 협력의 미학을 담아낸 보드 게임이라고 할 수 있을 것 같습니다. 이 글에서는 체스의 역사를 간략히 살펴보고, 체스 말들이 각자 맡은 역할과 협력의 중요성, 그리고 체스의 철학을 현대적으로 체험해보는 방법에 대해 이야기해 보려고 합니다.


체스의 역사 

체스는 약 1500년 전에 고대 인도에서 탄생한 차투랑가(Chaturanga)라는 게임에서 유래했습니다. 이 게임은 8x8 보드에서 보병, 기병, 전차, 코끼리(각각 현대의 폰, 나이트, 룩, 비숍)로 구성된 군대를 사용해 상대방 왕을 잡는 방식으로 진행되었죠. 전해지는 얘기에 따르면 라지푸트 왕국의 왕이 장군에게 전투 전략을 시각적으로 설명하기 위해 이 게임을 만들었다고 하죠. 흥미롭게도 체스는 단순히 오락으로 머물지 않았습니다. 앞서 얘기한 에피소드처럼 왕족이나 귀족의 전략 훈련 도구로 활용되었으며, 철학자들에게는 인간 사고의 복잡성과 결정을 탐구하는 모델이 되었습니다.

 

 

이후 페르시아를 거쳐 유럽으로 전파되며 체스라는 형태로 발전하게 되었습니다. 특히 15세기경, 여왕(퀸) 말의 강력한 이동 방식이 도입되면서 현재 우리가 즐기는 체스의 틀이 갖춰졌습니다. 우리나라에 서양식 체스가 소개된 정확한 시기는 알려져 있지 않습니다. 국제적으로 체스가 보급된 것이 20세기 들어서 활발해졌고 1924년 파리에서 처음으로 비공식 '체스 올림픽'이 개최된 것을 감안하면 아무리 빨라도 그 이후일테니 20세기 중반 이후가 되지 않을까 싶습니다. 

 

참고) 

우리나라의 장기 게임은 인도의 체스가 불교 전래와 함께 동아시아와 중국을 거쳐 들어온 것으로 알려져 있습니다. 그래서, 체스와 비슷한 움직임과 말들도 존재하죠. (기사에 馬, 코끼리에 像 등) 중국에서 넘어와서 변형된 것이라는 것은 장기의 말을 보면 알 수 있죠. 중국 초나라와 한나라의 싸움으로 되어 있으니 말입니다. 

 

게다가 한국식 장기와 중국식 장기는 아주 유사하긴 하지만 약간 차이가 있습니다. 장기판 자체도 양국 가운데 '강'이 있다는 것과 졸의 움직임이 도강(강 건너기) 이후 이전이 다르다는 것, 궁(장군)의 처음 위치 배열이 다르다는 것, 포의 움직임이 다르고 기타 등등 다양한 룰의 차이가 있으며 장기 말의 글자도 차이가 납니다. 


체스와 관련된 에피소드

역사 속에서의 체스에 대해 몇 가지 언급해 보겠습니다.

  • 냉전 시대에는 소련과 미국이 체스 대결을 통해 서로 심리전을 했다고 합니다. 1972년 미국의 바비 피셔와 소련의 보리스 스파스키 간의 대결은 '세기의 대결'로 불렸죠. 결과는 바비 피셔의 승리. 국내에서 '세기의 매치'라는 제목으로 영화로도 나왔었습니다. 스파이더맨을 연기했던 토비 맥과이어가 바비 피셔 역을 했었죠.

사진 : 영화 '세기의 매치' 포스터 (출처 : 위키백과)

 

  • 1996년 5월 IBM의 딥블루라는 인공지능 컴퓨터가 당시 세계 챔피언이었던 가리 카스파로프와의 경기에서 2승 1패 3무의 성적으로 승리한 사건이 있었습니다. 지금이야 바둑도 인공지능이 석권한 상태이기 때문에 크게 의미가 없어 보이지만 그 당시만 하더라도 이 사건은 엄청난 충격을 줬었습니다. 

체스 말들의 역할: 협력과 희생의 조화

체스판 위의 말들은 각자 독특한 역할을 가지고 있습니다. 왕(King)은 보호해야 할 최우선 대상이고, 여왕(Queen)은 가장 강력한 전투 유닛입니다. 룩(Rook)비숍(Bishop)은 장기적인 전략을 세우기에 적합하며, 기사(Knight)는 독특한 이동 방식으로 예측 불가능성을 제공합니다. 그리고 폰(Pawn)은 가장 약하지만, 협력과 희생을 통해 강력한 존재로 거듭날 가능성을 지니고 있습니다.

 

체스는 각 말이 제 역할을 다해야 승리할 수 있다는 점에서 인생과 비슷합니다. 특히 어떤 말은 전체 승리를 위해 희생될 수도 있습니다. 이러한 희생은 종종 전략적인 판단과 미래를 내다보는 통찰력에서 비롯됩니다. 체스판 위의 협력은 팀워크의 중요성을 일깨워줍니다.


전략적 사고: 수 앞을 내다보는 능력

체스의 묘미는 단순히 말들을 움직이는 데 있는 것이 아니라, 몇 수 앞을 내다보는 전략적 사고에 있습니다. 상대의 의도를 읽고, 자신의 계획을 수립하며, 변수를 고려하는 과정은 체스를 단순한 게임이 아닌 두뇌의 운동으로 만듭니다. 이러한 전략적 사고는 실제 삶에도 적용될 수 있습니다. 예를 들어, 프로젝트를 계획하거나 문제를 해결할 때, 체스처럼 다각적인 사고와 협력이 필요합니다. 체스판은 단순한 판이 아니라, 우리 삶의 축소판인 셈이죠.


Python으로 체스 게임 만들어보기

체스의 본질을 더 깊이 이해하려면, 직접 체스 게임을 만들어보는 경험도 흥미로울 것입니다. Python 같은 프로그래밍 언어를 사용하면 간단한 체스 게임을 시뮬레이션할 수 있습니다.

  • 체스판 구현 : 8x8의 이차원 배열로 체스판을 설정하고, 각 칸에 체스 말의 초기 위치를 표시합니다.
  • 말의 이동 : 각 체스 말의 이동 규칙(예: 퀸의 대각선 이동, 나이트의 L자 이동 등)을 코드로 구현합니다.
  • 승리 조건 : 상대의 왕을 체크메이트하는 규칙을 추가합니다.
  • 전략 연습 : 프로그램이 랜덤하게 움직이는 상대와 대결하거나, 간단한 AI를 적용해 플레이어의 전략적 사고를 테스트해볼 수 있습니다.

코딩 과정은 단순히 체스를 구현하는 기술적인 작업이 아닙니다. 말의 역할과 협력, 희생, 전략을 프로그램으로 표현하면서 체스의 철학을 더 깊이 느낄 수 있습니다.


ChatGPT와의 협업

아래 내용은 체스 게임 구현을 위해 ChatGPT에게 주문한 내용입니다. 

  1. 체스 기본 구현
    • Python과 Tkinter를 이용한 2인용 체스 게임으로 구현
    • 체스 표준 룰을 적용
    • 체스판, 말 초기 배치, 기본 이동 규칙 및 마우스 클릭으로 말 선택/이동
    • 유효하지 않은 이동 시 경고 메시지 표시
  2. 플레이어 턴 표시
    • 현재 플레이어(White/Black)를 표시하는 라벨 추가
    • White 턴일 때 흰색 배경/검정 글자, Black 턴일 때 검정 배경/흰색 글자
    • 화살표 기호(▲, ▼)를 사용해 공격 방향 시각화
  3. 비주얼 개선
    • 말 이미지를 로딩해 사용하되, 이미지가 없을 경우 텍스트로 표시
    • 상단에 "Black side (top)", 하단에 "White side (bottom)" 표기
    • 창 크기 변경 시 체스판 크기가 비율에 맞게 동적으로 조정
    • 체스판 좌우 여백 없이 꽉 차게 표시
  4. 폰(Pawn) 관련 기능
    • 폰 승격(Pawn Promotion) 기능 구현: 목표 줄 도달 시 사용자에게 승격 기물 선택 대화창 표시
  5. 특수 상황 처리
    • 상대말을 따 내는 기능 구현
    • 킹(White King or Black King) 잡힐 시 승자 표시
  6. 창 크기 및 비율 고정:
    • 체스판 + 상하단 레이블 영역을 합한 고정 비율(가로:세로 = 8:10) 유지
    • 창 크기를 변경해도 해당 비율을 강제로 유지하여 White 말 포함 전체 보드와 텍스트 영역이 항상 올바르게 표시토록 할 것

추가적으로 더 있었던 것 같긴 하지만 이 정도로 정리해 봅니다. 

 

생성된 코드

요구사항과 많은 시행 착오 끝에 생성된 코드는 다음과 같습니다. 실제로 체스룰 가운데 구현되어 있지 않은 것들이 많이 있으며 기본적인 배치, 움직임, 따내기, 승리 확인, 폰승격 정도만 되어 있습니다. 

 

import tkinter as tk
from tkinter import messagebox, simpledialog

BOARD_SIZE = 8

piece_image_files = {
    'P': "white_pawn.png",
    'R': "white_rook.png",
    'N': "white_knight.png",
    'B': "white_bishop.png",
    'Q': "white_queen.png",
    'K': "white_king.png",
    'p': "black_pawn.png",
    'r': "black_rook.png",
    'n': "black_knight.png",
    'b': "black_bishop.png",
    'q': "black_queen.png",
    'k': "black_king.png"
}

initial_board = [
    ['r', 'n', 'b', 'q', 'k', 'b', 'n', 'r'],  # Black side (top)
    ['p', 'p', 'p', 'p', 'p', 'p', 'p', 'p'],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    [' ', ' ', ' ', ' ', ' ', ' ', ' ', ' '],
    ['P', 'P', 'P', 'P', 'P', 'P', 'P', 'P'],
    ['R', 'N', 'B', 'Q', 'K', 'B', 'N', 'R']
]

class ChessGame:
    def __init__(self, master):
        self.master = master
        self.master.title("Chess Game")

        self.aspect_ratio = 8/10
        self.closing = False

        # 창 닫기 이벤트
        self.master.protocol("WM_DELETE_WINDOW", self.on_close)

        # 상단 프레임
        self.top_frame = tk.Frame(master)
        self.top_frame.pack(side="top", fill="x", pady=10)
        self.top_inner_frame = tk.Frame(self.top_frame)
        self.top_inner_frame.pack(anchor="center")
        self.top_side_label = tk.Label(self.top_inner_frame, text="Black side (top)")
        self.top_side_label.pack(side="left", padx=10)
        self.turn_label = tk.Label(self.top_inner_frame, text="")
        self.turn_label.pack(side="left", padx=10)

        # 체스판 캔버스
        self.canvas = tk.Canvas(master)
        self.canvas.pack(fill="both", expand=True)

        # 하단 프레임
        self.bottom_frame = tk.Frame(master)
        self.bottom_frame.pack(side="bottom", fill="x", pady=10)
        self.bottom_side_label = tk.Label(self.bottom_frame, text="White side (bottom)")
        self.bottom_side_label.pack(anchor="center")

        self.board = [row[:] for row in initial_board]
        self.selected_piece = None
        self.selected_pos = None
        self.current_player = 'W'

        self.images = {}
        self.load_images()

        self.cell_size = 60
        self.update_fonts()

        self.canvas.bind("<Button-1>", self.on_click)
        self.master.bind("<Configure>", self.on_resize)

        self.update_turn_label()
        self.draw_board()
        self.draw_pieces()

    def on_close(self):
        self.closing = True
        self.master.destroy()

    def load_images(self):
        for piece, file_name in piece_image_files.items():
            try:
                img = tk.PhotoImage(file=file_name)
                self.images[piece] = img
            except:
                pass

    def on_resize(self, event):
        if self.closing or not self.master.winfo_exists():
            return

        self.master.update_idletasks()
        current_state = str(self.master.state())

        # 최대화 상태에서는 geometry를 다시 설정하지 않고,
        # 단순히 현재 Canvas 크기에 맞춰 cell_size를 계산
        canvas_width = self.canvas.winfo_width()
        if canvas_width <= 0:
            canvas_width = self.master.winfo_width()
            if canvas_width <= 0:
                return

        # 최대화 상태이든 아니든, 여기서는 단순히 canvas_width 기준 cell_size 계산
        # 비최대화 상태에서 비율 유지 로직을 원한다면,
        # 별도로 비최대화 상태에서만 geometry 조정을 할 수 있지만,
        # 여기서는 recursion 문제를 피하기 위해 geometry 조정을 생략합니다.
        cell_size = canvas_width / BOARD_SIZE
        if cell_size < 10:
            cell_size = 10

        if abs(cell_size - self.cell_size) > 0.5:
            self.cell_size = cell_size
            self.update_fonts()
            self.draw_board()
            self.draw_pieces()

    def update_fonts(self):
        piece_font_size = max(10, int(self.cell_size/2.5))
        self.font = ("Arial", piece_font_size, "bold")

        label_font_size = max(10, int(self.cell_size/3))
        self.top_side_label.config(font=("Arial", label_font_size, "bold"))
        self.bottom_side_label.config(font=("Arial", label_font_size, "bold"))
        self.turn_label.config(font=("Arial", label_font_size, "bold"))

    def draw_board(self):
        self.canvas.delete("square")
        for row in range(BOARD_SIZE):
            for col in range(BOARD_SIZE):
                color = "#EEEED2" if (row+col) % 2 == 0 else "#769656"
                x1 = round(col * self.cell_size)
                y1 = round(row * self.cell_size)
                x2 = round((col+1) * self.cell_size)
                y2 = round((row+1) * self.cell_size)
                self.canvas.create_rectangle(x1, y1, x2, y2, fill=color, outline="", tags="square")

    def draw_pieces(self):
        self.canvas.delete("piece")
        for r in range(BOARD_SIZE):
            for c in range(BOARD_SIZE):
                piece = self.board[r][c]
                if piece.strip():
                    x = c*self.cell_size + self.cell_size/2
                    y = r*self.cell_size + self.cell_size/2
                    if piece in self.images:
                        self.canvas.create_image(round(x), round(y), image=self.images[piece], tags="piece")
                    else:
                        self.canvas.create_text(round(x), round(y), text=piece, font=self.font, tags="piece")

        self.canvas.update()  # 화면 갱신

    def on_click(self, event):
        col = int(event.x // self.cell_size)
        row = int(event.y // self.cell_size)
        if not (0 <= col < BOARD_SIZE and 0 <= row < BOARD_SIZE):
            return
        if self.selected_piece is None:
            piece = self.board[row][col]
            if piece.strip():
                if (piece.isupper() and self.current_player == 'W') or (piece.islower() and self.current_player == 'B'):
                    self.selected_piece = piece
                    self.selected_pos = (row, col)
        else:
            from_r, from_c = self.selected_pos
            to_r, to_c = row, col
            if self.is_valid_move(self.selected_piece, from_r, from_c, to_r, to_c):
                captured_piece = self.board[to_r][to_c]
                self.move_piece(from_r, from_c, to_r, to_c)
                self.check_pawn_promotion(self.selected_piece, to_r, to_c)
                if captured_piece.upper() == 'K':
                    winner = "White" if captured_piece.islower() else "Black"
                    messagebox.showinfo("Game Over", f"{winner} wins!")
                else:
                    self.switch_player()
            else:
                messagebox.showwarning("Invalid Move", "해당 위치로 이동할 수 없습니다.")

            self.selected_piece = None
            self.selected_pos = None

        self.draw_pieces()

    def check_pawn_promotion(self, piece, to_r, to_c):
        if piece == 'P' and to_r == 0:
            promoted_piece = self.promote_pawn('W')
            self.board[to_r][to_c] = promoted_piece
        elif piece == 'p' and to_r == 7:
            promoted_piece = self.promote_pawn('B')
            self.board[to_r][to_c] = promoted_piece

    def promote_pawn(self, player):
        if player == 'W':
            prompt = "Promote pawn to (Q/R/N/B):"
        else:
            prompt = "Promote pawn to (q/r/n/b):"

        choice = simpledialog.askstring("Promotion", prompt)
        if choice is None:
            choice = 'Q' if player == 'W' else 'q'
        choice = choice.strip()
        if player == 'W' and choice not in ['Q','R','N','B']:
            choice = 'Q'
        elif player == 'B' and choice not in ['q','r','n','b']:
            choice = 'q'
        return choice

    def switch_player(self):
        self.current_player = 'B' if self.current_player == 'W' else 'W'
        self.update_turn_label()

    def update_turn_label(self):
        if self.current_player == 'W':
            self.turn_label.config(text="▲ White's turn", bg="white", fg="black")
        else:
            self.turn_label.config(text="▼ Black's turn", bg="black", fg="white")

    def move_piece(self, from_r, from_c, to_r, to_c):
        self.board[to_r][to_c] = self.board[from_r][from_c]
        self.board[from_r][from_c] = ' '

    def is_valid_move(self, piece, from_r, from_c, to_r, to_c):
        if from_r == to_r and from_c == to_c:
            return False

        target = self.board[to_r][to_c]
        if target != ' ' and ((piece.isupper() and target.isupper()) or (piece.islower() and target.islower())):
            return False

        if piece.upper() == 'P':
            return self.validate_pawn_move(piece, from_r, from_c, to_r, to_c)
        elif piece.upper() == 'R':
            return self.validate_rook_move(piece, from_r, from_c, to_r, to_c)
        elif piece.upper() == 'N':
            return self.validate_knight_move(piece, from_r, from_c, to_r, to_c)
        elif piece.upper() == 'B':
            return self.validate_bishop_move(piece, from_r, from_c, to_r, to_c)
        elif piece.upper() == 'Q':
            return self.validate_queen_move(piece, from_r, from_c, to_r, to_c)
        elif piece.upper() == 'K':
            return self.validate_king_move(piece, from_r, from_c, to_r, to_c)
        else:
            return False

    def validate_pawn_move(self, piece, from_r, from_c, to_r, to_c):
        direction = -1 if piece.isupper() else 1
        start_row = 6 if piece.isupper() else 1
        target = self.board[to_r][to_c]

        if from_c == to_c:
            if to_r - from_r == direction and target == ' ':
                return True
            if from_r == start_row and to_r - from_r == 2*direction and target == ' ':
                mid_r = from_r + direction
                if self.board[mid_r][from_c] == ' ':
                    return True

        if abs(from_c - to_c) == 1 and (to_r - from_r == direction):
            if target != ' ' and ((piece.isupper() and target.islower()) or (piece.islower() and target.isupper())):
                return True
        return False

    def validate_rook_move(self, piece, from_r, from_c, to_r, to_c):
        if from_r != to_r and from_c != to_c:
            return False
        if from_r == to_r:
            step = 1 if to_c > from_c else -1
            for c in range(from_c+step, to_c, step):
                if self.board[from_r][c] != ' ':
                    return False
        else:
            step = 1 if to_r > from_r else -1
            for r in range(from_r+step, to_r, step):
                if self.board[r][from_c] != ' ':
                    return False
        return True

    def validate_knight_move(self, piece, from_r, from_c, to_r, to_c):
        dr = abs(to_r - from_r)
        dc = abs(to_c - from_c)
        return (dr, dc) in [(2,1), (1,2)]

    def validate_bishop_move(self, piece, from_r, from_c, to_r, to_c):
        if abs(to_r - from_r) != abs(to_c - from_c):
            return False
        r_step = 1 if to_r > from_r else -1
        c_step = 1 if to_c > from_c else -1
        r = from_r + r_step
        c = from_c + c_step
        while r != to_r and c != to_c:
            if self.board[r][c] != ' ':
                return False
            r += r_step
            c += c_step
        return True

    def validate_queen_move(self, piece, from_r, from_c, to_r, to_c):
        if from_r == to_r or from_c == to_c:
            return self.validate_rook_move(piece, from_r, from_c, to_r, to_c)
        else:
            return self.validate_bishop_move(piece, from_r, from_c, to_r, to_c)

    def validate_king_move(self, piece, from_r, from_c, to_r, to_c):
        return abs(to_r - from_r) <= 1 and abs(to_c - from_c) <= 1

if __name__ == "__main__":
    root = tk.Tk()
    initial_width = 400
    initial_height = int(initial_width * (10/8))
    root.geometry(f"{initial_width}x{initial_height}")
    game = ChessGame(root)
    root.mainloop()

 

 

실행 결과

생각보다 잘 작동합니다. 한가지 아쉬운 것은 화면 최대화를 하면 체스판의 세로 비율에 맞춰 창이 조정되어야 하는데 그것은 아직 구현되어 있지 않다는 것과 말들 이미지를 구하지 못해 일단은 텍스트로 표시된다는 것입니다. ^^;

그림 : 체스 실행 프로그램 화면


3.마무리하며: 체스에서 배우는 삶의 교훈

 

체스는 단순한 게임이 아닙니다. 말들이 각자의 역할을 충실히 하면서도 협력해야 승리할 수 있다는 점, 때로는 희생이 필요하다는 점, 그리고 몇 수 앞을 내다보는 통찰력이 필요하다는 점에서 우리의 삶과 닮아 있습니다. 

 

겨울 저녁, 따뜻한 차 한 잔과 함께 체스판을 마주하거나, 코딩으로 체스 게임을 만들어보는 것도 새로운 배움과 깨달음을 주지 않을까요?