숫자 야구 게임

숫자 야구 게임은 컴퓨터가 0부터 9 사이의 서로 다른 세 자리 숫자를 무작위로 생성하고, 사용자가 입력한 숫자와 자리를 비교하여 스트라이크와 볼의 힌트를 얻어 정답을 맞히는 고전적인 추리 게임입니다. 파이썬의 조건문, 반복문, 리스트, 그리고 함수 구조를 학습하기에 가장 좋은 실전 프로젝트입니다.

2. 1단계: 콘솔 기반 숫자 야구 게임 (기본형)

Python의 기본 함수(random.sample, zip)를 활용하여 깔끔하고 효율적으로 구현한 콘솔 버전의 숫자 야구 게임입니다.

핵심 소스코드 (코드 1)

import random

def generate_random_number():
    # 0~9 사이의 서로 다른 3자리 숫자 생성
    digits = random.sample(range(10), 3)
    return ''.join(map(str, digits))

def check_guess(guess, answer):
    # 스트라이크와 볼 개수 계산
    strike = sum(1 for g, a in zip(guess, answer) if g == a)
    ball = sum(1 for g in guess if g in answer) - strike
    return strike, ball

# 랜덤 숫자 생성
answer = generate_random_number()
attempts = 0

print("숫자 야구 게임을 시작합니다! 세 자리 숫자를 맞춰보세요.")

while True:
    guess = input("숫자를 입력하세요 (세 자리 숫자) 또는 나가기 (q): ")
    
    if guess == 'q':
        break
    
    # 입력 유효성 검사 (길이, 숫자 여부, 중복 여부)
    if len(guess) != 3 or not guess.isdigit() or len(set(guess)) != 3:
        print("잘못된 입력입니다. 세 자리의 서로 다른 숫자로 입력하세요.")
        continue
    
    attempts += 1
    strike, ball = check_guess(guess, answer)
    
    # 결과 출력
    print(f"{guess} -> {strike} 스트라이크, {ball} 볼")
    
    # 게임 종료 조건
    if strike == 3:
        print(f"정답입니다! {attempts}번 만에 맞추셨습니다.")
        break

주요 함수 및 로직 설명

  • generate_random_number(): random.sample(range(10), 3)을 사용하여 중복 없는 3개 숫자를 추출하고 문자열로 변환합니다.

  • check_guess(): zip() 함수를 활용해 자릿수와 값이 모두 일치하는 strike를 구하고, 포함된 숫자의 총 개수에서 스트라이크를 빼서 ball을 계산합니다.

  • 입력 유효성 검사: 글자 수(3자리), 숫자 판별(isdigit), 중복 허용 여부(set)를 철저히 검사하여 예외 상황을 방지합니다.

3. 2단계: 초보자를 위한 상세 제어 구조 버전 (코드 2)

zip이나 내장 함수 축약식 대신, 인덱스와 조건문을 풀어서 직관적으로 라운드별 승부를 판정하는 콘솔 버전입니다.

핵심 소스코드 (코드 2)

import random

numbers = list(range(0, 10))
three_numbers = random.sample(numbers, 3)
print("맞춰야할 숫자:", three_numbers) # 디버깅 및 테스트용

round_num = 1

while True:
    strike = 0
    ball = 0

    print(f"\n[{round_num}라운드]\n")
    guess = input("숫자를 입력하세요 (세 자리 숫자) 또는 나가기 (q): ")
    
    if guess == 'q':
        break
    
    if len(guess) != 3 or not guess.isdigit() or len(set(guess)) != 3:
        print("잘못된 입력입니다. 세 자리의 서로 다른 숫자로 입력하세요.")
        continue
    
    num1, num2, num3 = int(guess[0]), int(guess[1]), int(guess[2])
    
    if len(set([num1, num2, num3])) != 3:
        print("중복된 숫자를 입력하지 마세요!\n")
        continue

    # 스트라이크 판정
    if three_numbers[0] == num1: strike += 1
    if three_numbers[1] == num2: strike += 1
    if three_numbers[2] == num3: strike += 1

    # 볼 판정
    if three_numbers[1] == num1 or three_numbers[2] == num1: ball += 1
    if three_numbers[0] == num2 or three_numbers[2] == num2: ball += 1
    if three_numbers[0] == num3 or three_numbers[1] == num3: ball += 1

    print(f"{guess} -> {strike} strike {ball} ball\n")

    if strike == 3:
        print(f"[게임 끝] {round_num}라운드만에 맞추셨습니다.")
        break

    round_num += 1

4. 3단계: Flet을 이용한 GUI 숫자 야구 게임

Flet 프레임워크를 활용하면 터미널 창을 벗어나 웹 브라우저나 데스크톱 앱 형태로 동작하는 세련된 UI 기반의 숫자 야구 게임을 구축할 수 있습니다. 시도 내역을 기록하는 히스토그램 박스와 알림 다이얼로그(AlertDialog)가 포함되어 있습니다.

핵심 소스코드

import flet as ft
import random

def generate_random_number():
    digits = random.sample(range(10), 3)
    return ''.join(map(str, digits))

def check_guess(guess, answer):
    strike = sum(1 for g, a in zip(guess, answer) if g == a)
    ball = sum(1 for g in guess if g in answer) - strike
    return strike, ball

def main(page: ft.Page):
    page.title = "Flet 숫자 야구 게임"
    page.vertical_alignment = ft.MainAxisAlignment.START
    page.window.width = 400
    page.window.height = 650
    page.window.resizable = False
    page.bgcolor = ft.Colors.BLUE_GREY_50

    answer = generate_random_number()
    attempts = 0
    game_over = False

    title_text = ft.Text("🥎 숫자 야구 게임", size=24, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_900)
    instruction_text = ft.Text("서로 다른 3자리 숫자를 입력하세요.", size=16, color=ft.Colors.BLUE_GREY_700)

    history_column = ft.Column(scroll=ft.ScrollMode.ADAPTIVE, spacing=8, height=300)
    history_container = ft.Container(
        content=history_column, width=350, height=300, padding=10,
        border_radius=10, bgcolor=ft.Colors.WHITE, border=ft.border.all(1, ft.Colors.BLUE_GREY_200)
    )

    guess_input = ft.TextField(
        label="숫자 입력 (3자리)", hint_text="예: 123", width=200, max_length=3,
        keyboard_type=ft.KeyboardType.NUMBER, text_align=ft.TextAlign.CENTER, border_radius=8,
        on_submit=lambda e: submit_guess(e), dense=True
    )
    
    submit_button = ft.ElevatedButton(
        text="추측", icon=ft.Icons.SEND, width=100, height=40, on_click=lambda e: submit_guess(e),
        style=ft.ButtonStyle(bgcolor={'default': ft.Colors.BLUE_ACCENT_400}, color={'default': ft.Colors.WHITE})
    )
    
    new_game_button = ft.ElevatedButton(
        text="새 게임", icon=ft.Icons.RESTART_ALT, width=100, on_click=lambda e: reset_game(e),
        style=ft.ButtonStyle(bgcolor={'default': ft.Colors.BLUE_ACCENT_400}, color={'default': ft.Colors.WHITE})
    )

    def log_result(guess, strike, ball, is_win=False, is_error=False):
        nonlocal attempts
        text_color = ft.Colors.RED_700 if is_error else (ft.Colors.GREEN_700 if is_win else ft.Colors.BLACK)
        
        if is_error:
            result_text = "⚠️ 잘못된 입력입니다."
            attempts -= 1
        elif is_win:
            result_text = f"🎉 정답! {attempts}번 만에 성공!"
        else:
            result_text = "아웃 (Out!)" if strike == 0 and ball == 0 else f"{strike}S {ball}B"
        
        history_column.controls.insert(0, 
            ft.Container(
                content=ft.Row([
                    ft.Text(f"#{attempts:02d}", size=14, width=30, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_400),
                    ft.Text(guess, size=16, width=60, text_align=ft.TextAlign.CENTER),
                    ft.VerticalDivider(width=1, color=ft.Colors.BLUE_GREY_100),
                    ft.Text(result_text, size=16, color=text_color, weight=ft.FontWeight.BOLD),
                ], alignment=ft.MainAxisAlignment.SPACE_AROUND),
                padding=5, border_radius=5, bgcolor=ft.Colors.BLUE_GREY_50 if attempts % 2 == 0 else ft.Colors.WHITE,
            )
        )
        history_column.update()

    def show_dialog(title, message):
        dlg = ft.AlertDialog(
            modal=True, title=ft.Text(title, color=ft.Colors.BLUE_GREY_900),
            content=ft.Text(message, color=ft.Colors.BLUE_GREY_700),
            actions=[
                ft.TextButton("새 게임 시작", on_click=reset_game),
                ft.TextButton("닫기", on_click=lambda e: close_dialog(e, dlg)),
            ],
        )
        page.dialog = dlg
        dlg.open = True
        page.update()
        
    def close_dialog(e, dlg):
        dlg.open = False
        page.update()

    def submit_guess(e):
        nonlocal answer, attempts, game_over
        if game_over:
            show_dialog("게임 종료", f"정답은 {answer}였습니다. 새 게임을 시작하세요.")
            return

        guess = guess_input.value
        guess_input.value = ""
        page.update()

        if len(guess) != 3 or not guess.isdigit() or len(set(guess)) != 3:
            attempts += 1
            log_result(guess, 0, 0, is_error=True)
            return

        attempts += 1
        strike, ball = check_guess(guess, answer)
        log_result(guess, strike, ball)
        
        if strike == 3:
            game_over = True
            log_result(guess, strike, ball, is_win=True)
            show_dialog("⚾ 정답!", f"축하합니다! 정답 '{answer}'을(를) {attempts}번 만에 맞추셨습니다.")
        else:
            guess_input.focus()
            page.update()    
            
    def reset_game(e=None, dlg=None):
        nonlocal answer, attempts, game_over
        if dlg:
            close_dialog(e, dlg)
            
        answer = generate_random_number()
        attempts = 0
        game_over = False
        guess_input.value = ""
        history_column.controls.clear()
        
        history_column.controls.append(
            ft.Text("--- 새로운 게임 시작! ---", size=14, color=ft.Colors.DEEP_ORANGE_400, text_align=ft.TextAlign.CENTER)
        )
        page.update()

    page.add(
        ft.Container(
            content=ft.Column([
                title_text, ft.Divider(height=10, color=ft.Colors.TRANSPARENT),
                instruction_text, ft.Divider(height=10, color=ft.Colors.TRANSPARENT),
                history_container, ft.Divider(height=10, color=ft.Colors.TRANSPARENT),
                ft.Row([guess_input, submit_button], alignment=ft.MainAxisAlignment.CENTER, spacing=10),
                ft.Divider(height=10, color=ft.Colors.TRANSPARENT),
                new_game_button
            ], horizontal_alignment=ft.CrossAxisAlignment.CENTER, spacing=5),
            padding=20, width=400, height=650,
        )
    )
    reset_game()

if __name__ == "__main__":
    ft.app(target=main, view=ft.AppView.FLET_APP)