파이썬 강좌 – 실습 – 원카드 게임 만들기 (2)

  1. 프롤로그
  2. 개발 첫걸음
    1. 컴퓨터 구성요소 – 컴퓨터는 어떤 걸 할 수 있나?
    2. 개발과 관련된 용어
    3. 파이썬의 선택 – 왜 파이썬인가?
    4. 파이썬 설치 – Hello World 출력하기
    5. Visual Studio Code 의 편리한 기능
    6. REPL과 콘솔 창 – 파이썬 동작시키기
  3. 파이썬 기초
    1. 기초 입출력 – 소통하기
    2. 변수와 대입 – 기억하기
    3. 연산자 – 계산하기
    4. 조건문 – 분기를 만들기
    5. 반복문 – 비슷한 작업을 반복하기
    6. 반복문 코딩하기
    7. 변수와 리스트 – 비슷한 변수들을 묶기
    8. for, range – 리스트의 항목을 다루기
    9. 함수와 메소드의 호출 – 편리한 기능 이용하기
    10. 모듈 설치와 사용 – 유용한 기능 끌어다 쓰기
    11. 문자열 – 텍스트 다루기
  4. 파이썬 중급
    1. 함수를 직접 만들기 – 자주 쓰는 기능을 묶기
    2. 딕셔너리, 튜플, 세트 – 변수를 다양한 방법으로 묶기
    3. 클래스와 객체 – 변수를 사람으로 진화시키기
    1. 상속 – 클래스를 확장하기
    2. 정체성과 동질성 – 객체의 성질
    3. 특별 메소드와 연산자 – 파이썬의 내부 작동방식 이해하기
    4. 다양한 함수 인수 – 유연한 함수 만들기
    5. 슬라이싱 – 리스트 간편하게 접근하기
    6. 지능형 리스트(List Comprehension) – 리스트 갖고 놀기
    7. namedtuple - 데이터 묶음 손쉽게 만들기
    8. 조건 표현식 (Conditional Expression) - 간단한 분기 나타내기
    9. 코드 스타일 - 코드의 일관성 유지하기
    10. 명령문, 표현식 – 문법을 이루는 것들
    11. 본격적인 검색 해보기
  5. 파이썬 고급
    1. 일급 함수 다루기
    2. NotImplementedError와 NotImplemented
    3. 어노테이션 – 수월하게 프로그래밍하기
    1. 내장 함수 톺아보기
    2. 예외와 에러 – 예상치 못한 상황에 대응하기 (v0.1)
    3. 변수의 범위 – 이름 검색의 범위
  6. 파이썬 심화
    1. 시퀀스와 반복자 – 반복과 순회를 자유자재로 다루기
    2. 데코레이터 – 함수의 기능을 강화하기
    3. 프로퍼티
    4. 제너레이터
    5. async와 await
    6. 객체로서의 클래스 – 클래스를 동적으로 정의하기
  7. 파이썬 프로젝트 실습
    1. 원카드 게임 만들기 (1)
    2. 원카드 게임 만들기 (2)
    3. 원카드 게임 만들기 (3) (작성중)
    4. 턴제 자동 전투 게임 만들기 (작성중)
  8. 실전 (파이썬 외적인 것들)
    1. 정규표현식 – 문자열을 검색하고 치환하기 (작성중)
    2. 유니코드 – 컴퓨터에서 문자를 표기하는 방법
    3. html, css, 인터넷 – 자동화 첫 걸음 내딛기
    4. 네트워크 – 인터넷으로 통신하는 방법
    5. 문서 – 문맥을 읽어보기

지난 시간까지 대략적으로 핵심적인 기능만 작성해보았습니다.


비슷한 기능을 함수로 묶기

지금의 코드는 플레이어 부분과 컴퓨터 부분이 유사합니다. 완전히 똑같지는 않지만 구조는 비슷하기 때문에 공격 메커니즘을 추가한다고 했을 때 비슷한 코드를 다시 두번 작성해야 합니다. 이러한 귀찮음을 피하기 위해 비슷한 기능을 최대한 함수로 묶어보도록 합시다.

getAvailable

getAvailable은 주어진 상황에서 가능한 카드의 리스트인 available를 반환하는 함수입니다. 어떤 플레이어의 패인 hand와 마지막으로 놓여있는 카드인 last_hand 를 인수로 받습니다.

코드로는 다음과 같습니다.

# 가능한 카드 리스트를 반환
def getAvailable(hand, last_card):
    available = []
    for card in hand:
        if (card[0] == last_card[0]
            or card[1] == last_card[1]
            or card[0] == 'Joker'
            or put[-1][0] == 'Joker'):
            available.append(card)
    return available

이로써 available을 구하는 다소 긴 과정이 다음과 같은 코드로 압축될 수 있습니다.

# 플레이어 부분
available = getAvailable(player, put[-1])

# 컴퓨터 부분
available = getAvailable(computer, put[-1])

turn

비슷한 원리로 전체 과정을 줄여봅시다. turn 함수는 플레이어든, 컴퓨터든 간에 하나의 차례를 통째로 표현합니다. 함수를 정의하기 이전에 우리가 큰 while에서 어떻게 이 함수를 이용하고 싶은지 먼저 적어봅시다. 우리는 차례를 다음과 같이 간단하게 나타내고 싶습니다.

while True:
    turn(player)
    turn(computer)

하지만 turn 함수 입장에서, 들어오는 카드의 리스트가 player 것인지, computer 것인지 판단할 수 없습니다. 그러므로 인수 하나를 추가해서 컴퓨터라면 True, 그렇지 않다면 False를 넣을 수 있게 해봅시다.

while True:
    turn(player, False)
    turn(computer, True)

아직 turn 함수는 정의하지 않았습니다. 하지만 함수를 어떻게 호출할 것인지부터 적는다면 함수 정의를 더 쉽게 할 수도 있습니다.

모든 과정을 turn 안에 넣었다고 가정할 때, 문제는 이 무한루프를 멈출 방법이 없어졌다는 것입니다. 함수 안에서 break 해봤자 함수는 격리된 공간이기 때문에 외부의 while에 영향을 미칠 수 없습니다. 함수는 리턴 값으로 결과를 반환할 수 있습니다. 만약 플레이어가 이겼다면 True를, 그렇지 않다면 False를 반환하도록 합시다. 그렇다면 while을 멈출 수 있는 수단이 생깁니다.

while True:
  
    if turn(player, False):
        break

    if turn(computer, True):
        break

좋습니다. 이제 함수를 어떻게 호출하고 결과 값이 무엇이 나와야 하는지 정해졌습니다. 코드를 작성해봅시다.

def turn(hand, isComputer):

    # 전역 변수 접근
    global put, deck

    # 이름 정하기
    if isComputer:
        name = "컴퓨터"
    else:
        name = "플레이어"

    # 차례
    print(name, "의 차례입니다.")
    if not isComputer:
        print("현재 패 >>", hand)
    print("놓여진 카드 >>", put[-1])

    # 가능한 카드
    available = getAvailable(hand, put[-1])
    if not isComputer:
        print("낼 수 있는 카드:", available)

    # 낼 수 있는 카드가 있는 경우
    if len(available) > 0:
        if isComputer:
            selected = random.choice(available)
            print("컴퓨터가", selected, "를 냈습니다.")
        else:
            i = int(input("몇 번째 카드를 내시겠습니까?"))
            i -= 1
            selected = available[i]
        hand.remove(selected)
        put.append(selected)
    
    # 낼 수 있는 카드가 없는 경우
    else:
        print(name, "가 낼 수 있는 카드가 없어 먹습니다.")
        hand.append(deck.pop())

    if len(hand) == 0:
        print(name, "가 이겼습니다!")
        return True

    else:
        return False

putdeck은 함수의 외부에 있기 때문에 접근할 수 없지만, global 키워드를 이용하여 접근을 허용하고 있습니다. 또한 isComputer로 컴퓨터의 유무를 확인할 수 있도록 하였으나 TrueFalse 만으로는 어떤 뜻인지 알 수 없으므로 명시적으로 주체의 이름을 정해줍니다. 이 이름은 나중에 출력할 때 쓰입니다.

함수 내에서 필요할 때마다 if isComputer: 를 작성하여 컴퓨터로서 처리되어야 할 부분과 플레이어로서 처리되어야 할 부분을 구분합니다. 전체 코드의 줄 변화는 크게 없지만 우리는 이제 코드를 수정하기 용이해졌습니다. 전체 코드는 다음과 같습니다.


공격 메커니즘 만들기

공격 메커니즘 구상하기

대략적인 공격 메커니즘을 먼저 생각해봅시다.

graph TD st[턴이 시작된다] a{공격받는<br>상황인가?} am{낼 카드가<br>있는가?} b[더 상위의<br>공격카드를<br>낸다] c[공격을 계속한다] bn[공격받은 만큼<br>카드를 먹는다] a-->|Yes|am-->|Yes|b-->c am-->|No|bn an{낼 카드가<br>있는가?} any[카드를<br>고른다] ann[카드를 한장<br>먹는다] an-->|Yes|any an-->|No|ann a-->|No|an st-->a bn-->bn1[공격 상황을<br>종료한다] any-->any1{공격카드인가?} any1-->|Yes|sta["공격 상황을<br>시작한다"] sta-->en["턴을 끝낸다"] bn1-->en ann-->en c-->en

공격 메커니즘

위의 메커니즘은 아주 러프하게 작성한 것입니다. 본격적으로 코드를 작성하기 전에 조금 더 생각을 정리해봅시다. 상황이 더 복잡해졌고, 중복되는 코드가 많아질 예정입니다. 무작정 코드를 작성하다가는 밀려드는 코드에 정신을 차리지 못할 것입니다!!

우선 게임을 좌지우지하는 두 가지 상황이 있습니다. 첫 번째는 공격 상황인지의 여부이고 두 번째는 그에 따라 낼 수 있는 카드가 존재하는 지의 여부입니다. 이를 각각 is_attackis_available 이라는 이름으로 부르겠습니다. 이 변수를 어떻게 다룰지는 후술하겠습니다. 일단 이 변수에 따라서 영역을 분할해 봅시다.

영역 영향 받는 것 영향을 주는 것
턴 시작
낼 수 있는 카드 고르기 is_attack에 따라 낼 수 있는 카드가 다름. 낼 수 있는 카드가 없다면 is_avilable = False, 낼 수 있다면 is_available = True
카드 내기 is_availableTrue라면 실행 공격 카드일시 is_attackTrue로 설정
카드 먹기 is_availableFalse라면 실행 is_attack이 True라면, is_attackFalse로 설정
턴 끝

이렇게 분할하는 이유는, 실제로 코딩을 할 때 어떤 부분에서 무엇을 처리하는지 확실히 하기 위함입니다. if문을 그대로 따라가면서 모든 기능을 집어넣다 보면 유지보수가 굉장히 어려워집니다. 그리고 영역을 나눠놓으면 나중에 함수로 기능을 분리시키기도 편리합니다.

위 표에서는 아직 하나의 기능을 고려하지 않았습니다. 바로 카드를 먹을 때, 공격을 받는 상황이었다면 공격받은 만큼 먹었다는 것이죠. 우리는 bool 형의 두 가지 변수밖에 가지지 않았습니다. 공격을 얼마나 받았는지를 저장하는 변수가 또 하나 더 필요해졌습니다. 그 변수의 이름은 damage로 설정하고 타입은 숫자형으로 하겠습니다. 그리고 이제 여러 방면에서 damage가 다루어집니다.

영역 영향 받는 것 영향을 주는 것
턴 시작
낼 수 있는 카드 고르기 is_attack에 따라 낼 수 있는 카드가 다름. 낼 수 있는 카드가 없다면 is_avilable = False, 낼 수 있다면 is_available = True
카드 내기 is_availableTrue라면 실행 공격 카드일시 is_attackTrue로 설정. 처음 공격이라면 damage를 공격만큼만 설정하고, 이어지는 공격이라면 damage를 증감한다.
카드 먹기 is_availableFalse라면 실행, is_attack에 따라 얼마나 먹는지 달라짐 공격 상황이라면 damage만큼 카드를 먹고 damage0으로 초기화 후 공격 상황 종료. 공격 상황이 아니라면 카드를 1개 먹음.
턴 끝

우리는 이제 코드를 직접 짜지 않고도 머릿속으로(!) 코드를 최적화할 수도 있습니다. 코드를 어디서 어떻게 효율적으로 만드는가는 많이 코딩해봐야 감이 생깁니다. 카드 먹기 부분을 조금 더 손을 봅시다. 우리는 조건에 따라 분기해야 하는 비슷한 부분을, 묶어줄 수 있습니다. 저것만 따로 순서도를 만들어봅시다.

graph TD a0[먹기 시작]-->a1 a1{is_attack} a1-->|True|a2[damage만큼<br>카드를 먹음] a1-->|False|a3[1만큼 카드를 먹음] a2-->a4[damage = 0] a4-->a5[is_attack = False] a5-->a6[먹기 끝] a3-->a6

카드 먹기 부분

곰곰히 생각해봅시다. 왼쪽은 damage만큼 카드를 먹고, 오른쪽은 1만큼 카드를 먹습니다. 근데 만약에 damage1이라면 분기를 나눌 필요도 없이 damage만큼만 먹는 코드 하나만 있으면 충분하지 않을까요? 그렇다면 공격상황이 아니어도 처음부터 damage1이도록 만들면 될까요? 그런데 그게 가능할까요? 한 번 해봅시다.

graph TD a0[먹기 시작]-->a1 a1{is_attack} a1-->|True|a4[damage = 1] a4-->a5 a5[is_attack = False] a5-->a2 a6[먹기 끝] a2-->a6 a1-->|False|a2[damage만큼<br>카드를 먹음]

카드 먹기 부분 수정 1

그럴싸합니다. damage를 초기화 할 때 1로 하니까 상관없을 것 같고, 어차피 나중에 공격을 시작할 때에는 1에서 증감하는 것이 아니라 정확한 값으로 공격을 대입하는 것이니 1이라는 값이 자연스럽게 버려질 것입니다.

계속해서 수정해봅시다. 사실 공격 상황이든 상황이 아니든 일단 먹는다면, 공격이 종료된다고 보아도 됩니다. 공격 상황이 아닐 때에도 굳이 is_attack = False 라고 한다면 자원의 낭비라고 생각될 수도 있으나 코드 효율성을 생각한다면 훨씬 지구에 이득입니다. damage = 1 구문도 마찬가지입니다. 이렇게 모든 항목을 조건에서 제외하면 분기 자체가 사라지는 기적이 생깁니다!

graph TD a0[먹기 시작]-->a5 a4[damage = 1] a4-->a6 a5[is_attack = False] a5-->a2 a6[먹기 끝] a2-->a4 a2[damage만큼<br>카드를 먹음]

카드 먹기 부분 수정 2


수정한 것을 바탕으로 영역 표를 다시 한번 수정해봅시다. is_attack에 따른 분기가 사라지니까 영향 받는 것에서도 is_attack이 사라집니다.

영역 영향 받는 것 영향을 주는 것
턴 시작
낼 수 있는 카드 고르기 is_attack에 따라 낼 수 있는 카드가 다름. 낼 수 있는 카드가 없다면 is_avilable = False, 낼 수 있다면 is_available = True
카드 내기 is_availableTrue라면 실행 공격 카드일시 is_attackTrue로 설정. 처음 공격이라면 damage를 공격만큼만 설정하고, 이어지는 공격이라면 damage를 증감한다.
카드 먹기 is_availableFalse라면 실행, is_attack에 따라 얼마나 먹는지 달라짐 무조건 공격 상황을 종료하고 damage 만큼 카드를 먹은 뒤 damage를 1로 초기화함.
턴 끝

카드 내기, 카드 먹기 구현

너무 머리만 쓰니까 머리가 지끈지끈합니다. 이제 코드로 적어봅시다. 우선 영역을 주석으로 확실하게 표시해줍니다.


    # ----------- 낼 수 있는 카드 고르기 ---------------
    available = getAvailable(hand, put[-1])
    if not isComputer:
        print("낼 수 있는 카드:", available)

### 중략 ###

    # ----------- 카드 고르기 ---------------------
    if len(available) > 0:

### 중략 ###

    # ------------ 카드 먹기 -----------------------
    else:
        print(name, "가 낼 수 있는 카드가 없어 먹습니다.")
        hand.append(deck.pop())

그 다음 변수를 정의해줍니다. 특히 is_attackdamage 변수는 턴이 지나도 계속해서 기억이 되어야 하므로 turn 함수의 바깥에서 정의해주고, global로 땡겨옵니다. is_available 또한 turn 함수 내에서 조건에 맞게 정의해줍니다.

is_attack = False
damage = 1

def turn(hand, isComputer):

    # 전역 변수 접근
    global put, deck, is_attack, damage

### 중략 ###

    # ----------- 카드 고르기 ---------------------
    is_available = len(available) > 0
    if is_available:

낼 수 있는 카드를 구하는 부분부터 생각해봅시다. 근데 또 머리가 아픕니다. 공격 상황일때 덱 위에 올라가 있는 카드에 따라 낼 수 있는 카드가 한정되어 있습니다. 예를 들어 2가 올라가 있으면 Joker를 낼 수 있지만, Joker가 올라가 있다면 a를 낼 수 없습니다. 이 상황은 조금 있다가 다시 살펴보도록 합시다. 쉬운 것부터 먼저 구현해보도록 하자구요!


우선 카드를 내는 부분부터 설정해봅시다. 우리는 여기서 새로운 함수 2개를 만들 것입니다. 바로 카드가 공격 카드인지 판별하는 is_attack_card 함수와, 카드의 공격력을 계산하는 함수인 get_damage 함수입니다. 이런 기능은 다른 곳에서도 쓰일 가능성이 많으므로 처음부터 함수로 만들도록 합시다.

def is_attack_card(card):
    return card[0] == 'Joker' or card[1] in ['A', '2']

def get_damage(card):
    damage = 0
    if card[0] == 'Joker':
        if card[1] == 'colored':
            damage = 10
        else:
            damage = 5
    elif card[1] == 'A':
        damage = 3
    elif card[1] == '2':
        damage = 2
    return damage

좋습니다. 이제 카드 내는 부분을 수정해봅시다.

# ----------- 카드 고르기 ---------------------
    is_available = len(available) > 0
    if is_available:

### 중략 ###

        if is_attack_card(selected):
            if not is_attack:
                damage = get_damage(selected)
            else:
                damage += get_damage(selected)
            
            is_attack = True

처음 공격했는지를 판단하기 위해 not is_attack으로 처음에 먼저 체크를 하여 damage를 설정해주는 모습입니다. 마지막으로 is_attackTrue로 설정합니다.


이제 카드를 먹는 부분을 수정해봅시다. 더 쉽습니다!

    # ------------ 카드 먹기 -----------------------
    else:
        print(name, "가 낼 수 있는 카드가 없어", damage, "장 먹습니다.")
        is_attack = False
        for i in range(damage):
            hand.append(deck.pop())
        damage = 1

혹시 눈치 빠르신 분이 계실 지는 모르겠지만, 카드 먹는 부분에는 치명적인 오류가 있습니다. 바로 deck에서 모든 카드를 뽑았을 때, 빈 deck에서 pop가 실행되는 에러입니다. 아직 직접적으로 문제가 나오지는 않았지만 언젠가 반드시 터질 문제입니다. 일단 낼 수 있는 카드를 구하는 부분을 먼저 구현하도록 하겠습니다.


낼 수 있는 카드 고르기 구현

현재 상황이 공격 상황인지 아닌지에 따라 낼 수 있는 카드가 달라지는데, 이 부분을 더 자세히 알아보겠습니다.

공격 상황 일반 상황
put[-1]의 카드는 무조건 공격 카드임. 이 카드보다 더 높은 공격력을 가진 카드이면서 모양이 같아야함. 단, 조커는 색깔과 공격력에 상관없이 낼 수 있음. put[-1]이 조커인 경우 아무 카드나 낼 수 있음. 조커가 아니라면 모양이 같거나, 숫자가 같거나 해야 함.

저 상황을 코딩할 수 있는 상황으로 간단하게 만들어야 합니다. 변수는 다양합니다. 공격 상황과 일반 상황을 저렇게 나누어놓은 것 이외에도, hand 조커인지, 덱에 놓여져 있는 카드가 조커인지에 따라 또 달라질 수 있습니다. 쉬운 것부터 차근차근 해나가봅시다.

우선 일반 상황일 때 놓여져 있는 카드가 조커라면, 이 말인 즉슨 내가 조커를 내고 상대가 다 카드를 와장창 받아먹고 그 다음 내 턴이 왔다는 뜻이므로, 내 hand에 있는 어떤 카드든지 전부 다 낼 수 있습니다. 이 경우를 가장 먼저 처리합니다. 이제 hand의 카드 한장 한장이 가능한지 살펴보아야겠습니다. 우선 hand에 있는 카드가 조커면 공격 상황이든 아니든 무조건 낼 수 있으므로, 무조건 포함시킵니다.

그 다음으로는, 공격 상황이든 일반 상황이든 모양과 숫자가 모두 다른 카드는 절대 낼 수 없으므로 우선 제외시킵니다. 그 다음 공격 상황에 대해서만, 놓여져 있는 카드보다 공격력이 높은 카드를 낼 수 있도록 포함시킵니다. 그러면 완성입니다.

이를 순서로 표현하면 다음과 같습니다.

graph TD ed[getAvailable 끝] a[getAvailable 시작] --> b{공격 상황이 아니<br>면서 덱에 조커가<br>올려져 있는가?} b-->|yes|c[플레이어의 핸드를<br>전부 낼 수 있다.]-->ed b-->|no|d[핸드의 카드들을<br>하나하나 검사 한다.] d-->e{카드가<br>조커인가?} e-->|Yes|f[낼 수 있는 카드로<br>판단한다.] e-->|No|g{모양과 숫자가<br>모두 다른가?} g-->|Yes|h[낼 수 없는 카드로<br>판단한다.] g-->|No|i{공격 상황인가?} h-->z f-->z[완료될 때까지 반복한다]-->d z-->|완료|ed i-->|Yes|j{내어져 있는<br>카드보다<br>공격력이 같거나<br>높은가?} j-->|Yes|f j-->|No|h i-->|No|f

수정된 getAvailable의 순서 1


이제 한번 정리되었으니 코딩을 해봅시다. 공격력을 얻는 부분은 우리가 get_damage 함수를 구현했으니 손쉽게 할 수 있을 것입니다. 그리고 공격 상황인지를 알기 위해 변수를 얻어와야 하는데, global is_attack 하는 방법이 있겠지만, 간단한 변수이기 때문에 인수로 하나 추가해 주었습니다. 리스트의 extend 함수는 인수로 들어가는 또 다른 리스트를 그대로 확장한다는 의미입니다.

def getAvailable(hand, last_card, is_attack):
    available = []
    if not is_attack and last_card[0] == 'Joker':
        available.extend(hand)
        return available
    
    for card in hand:
        if card[0] == 'Joker':
            available.append(card)
            
        elif (card[0] != last_card[0] 
            and card[1] != last_card[1]):
            continue

        elif is_attack:
            if get_damage(card) >= get_damage(last_card):
                available.append(card)
        else:
            available.append(card)

    return available

함수의 정의 부분을 바꾸었으니 이제 호출 부분도 바꾸어줍시다.

    # ----------- 낼 수 있는 카드 고르기 ---------------
    available = getAvailable(hand, put[-1], is_attack)
    if not isComputer:
        print("낼 수 있는 카드:", available)

이제 비로소 제대로 작동하는지 확인할 수 있습니다! 한번 실행해봐서 문제가 없는지 테스트를 해봅시다. 아래는 지금까지 작성한 모든 코드입니다.


연습 문제: 카드 먹기 보완

아까 카드 먹기에 대한 심각한 결함이 있다고 하였죠? 실제로 게임을 진행하다 보면 다음과 같은 에러를 맞닥뜨립니다.

( ... 중략 )
플레이어 가 낼 수 있는 카드가 없어 5 장 먹습니다.
Traceback (most recent call last):
  File "c:/Users/tooth/Desktop/test.py", line 144, in <module>
    if turn(player, False):
  File "c:/Users/tooth/Desktop/test.py", line 99, in turn
    hand.append(deck.pop())
IndexError: pop from empty list

이 문제를 해결하기 위해 카드를 먹는 draw 함수를 새롭게 정의하세요. 이 함수는 deck에 카드가 없을 때 put에 가장 위에 있는 카드 한 장만 남기고 섞어 다시 deck에 넣는 기능을 포함합니다. 이 기능이 있으면 절대 에러가 나지 않겠지요. draw 함수는 인수로 어떤 hand를 가져옵니다. draw 함수에서 deckput에 대한 접근은, turn 함수가 그래왔던 것처럼 함수 내부에서 global 로 선언하여 접근할 수 있도록 합니다. 함수를 정의한 후 호출 부분까지 완전히 구현해보도록 합시다.


풀이

deck에서 모든 카드를 먹었기 때문에 deck은 텅텅 비어있게 되었는데, 이를 어떻게 처리해야 합니다. put에서 제일 위에 있는 카드 하나를 제외하고, 모든 카드를 다시 섞에 deck에 놓는 작업을 해야 합니다. draw 함수를 만들어봅시다.

def draw(hand):
    global put, deck
    
    hand.append(deck.pop())

    if len(deck) == 0:
        print("카드를 다시 섞습니다!")
        last_card = put.pop()
        random.shuffle(put)
        put, deck = deck, put
        put.append(last_card)

리스트는 가변 객체이므로 단순한 대입문은 이름을 추가시켜주는 것과 같습니다. 그러니까 ls1 = ls2 을 하게 되면, ls2을 가리키는 리스트의 이름이 ls1, ls2 두 개가 된다는 뜻입니다. 그러므로 put, deck = deck, put 구문은 실제 데이터를 바꾸는 게 아니라 그냥 이름만 떼서 서로 바꾸는 작업입니다. 커다란 창고의 출입문에 푯말만 서로 바꾼다고 상상하시면 됩니다. 함수의 정의 부분을 새로 만들었으니 호출할 부분을 수정해 봅시다.

    # ------------ 카드 먹기 -----------------------
    else:
        print(name, "가 낼 수 있는 카드가 없어", damage, "장 먹습니다.")
        is_attack = False
        for i in range(damage):
            draw(hand)
        damage = 1

좋습니다! 원하는 대로 카드가 잘 섞입니다.


연습 문제: print 개선하기

지금은 너무 보기가 힘듭니다. 카드를 보여주는 방식이 너무 파이썬에서 보여주는 방식 같습니다. ('♣', '10')말고 ♣10 이렇게 간단하게 표현하면 되지 않을까요?, 그리고 계속 아래에서 새로운 메시지가 올라오니 정신이 사납습니다. 중요한 건 고정시키는 방법은 없을까요? 위쪽에 현재 나와있는 카드, 플레이어의 패, 플레이어가 낼 수 있는 패를 고정시키고 아래쪽에는 메시지가 계속 업데이트 되는 식으로 만드는 겁니다! 하는 김에 메시지에 숫자를 매겨서 얼마나 게임이 진행됐는지도 체크해봅시다. 아래는 실행 결과를 먼저 구상해본 것입니다.

:: last put card ::  [[[[JokerBlack]
:: player's hand ::  [♣10] [♥9] [◆8]
::   available   :: 
---------------------------------------------------
[ 23] 컴퓨터 의 차례입니다.
[ 24] 컴퓨터 가 낼 수 있는 카드가 없어 3 장 먹습니다.
[ 25] 플레이어 의 차례입니다.
[ 26] 몇 번째 카드를 내시겠습니까?1
[ 27] 컴퓨터 의 차례입니다.
[ 28] 컴퓨터가 ('Joker', 'black') 를 냈습니다.
[ 29] 플레이어 의 차례입니다.
[ 30] 플레이어 가 낼 수 있는 카드가 없어 8 장 먹습니다.

이제 순차적으로 과제를 부여하도록 하겠습니다.

  1. 우선 카드를 좀 더 간편하게 출력할 수 있도록 바꾸어봅시다. card_str 함수는 카드 하나를 인수로 받고, [♣10] 형식의 문자열을 반환합니다. 이 함수를 이용해서 출력까지 해보세요.
  2. 이제 완전하게 출력하는 hand_str 함수를 만들어봅시다. 이 함수는 패가 담긴 리스트인 hand 하나를 입력받고, 안의 내용을 모두 문자열로 반환합니다. 반드시 card_str 함수를 이용하는 내용을 포함시키세요. 가능하다면 strjoin 기능을 활용하세요.
    함수 내부에서 print를 쓰지 않는 이유는, 앞으로 우리가 고정위치로 출력할 것임에 따라 실제 값이 필요하기 때문입니다. 이 또한 제대로 작동하는지까지 테스트해보시기 바랍니다.
  3. 화면의 특정 위치에 어떻게 문자열을 고정시킬지 고민해봅시다. 기나긴 사색 보다는 인터넷 검색을 적극적으로 이용해봅시다.
  4. 메시지에 번호를 매겨야 합니다. 새로운 변수가 필요할 것 같습니다. 이것도 어떻게 구현할지 고민해봅니다.
  5. 3번과 4번을 구현합니다.

풀이

card_str 함수부터 먼저 구현합니다.

def card_str(card):
    return f'[{card[0]}{card[1]}]'

그렇게 어렵지 않습니다. f-string을 이용하면 손쉽게 해결할 수 있습니다. 실제로 출력하는 것을 테스트해봅시다. 아직 엉성합니다. 뒤에서 깔끔하게 만들어봅시다.

    # 차례
    print(name, "의 차례입니다.")
    if not isComputer:
        print("현재 패 >> ", end="")
        for card in hand:
            print(card_str(card) + " ", end="")
        print("")

### 후략 ###
플레이어 의 차례입니다.
현재 패 >> [♥9][♠8][◆8][♥J][♥A][◆3][◆10]

그 다음 hand_str 함수를 구현합니다. strjoin 기능은 배열의 여러 요소에서 하나의 문자열로 변환시킬 때 아주 편리한 도구입니다. 이것을 활용할 것입니다.

def hand_str(hand):
    ls = []
    for card in hand:
        ls.append(card_str(card))
    return " ".join(ls)

파이썬에서 제공하는 map 이라는 함수를 이용하면 함수의 내용을 단 한줄로도 구현할 수 있습니다.

def hand_str(hand):
    return " ".join(map(card_str, hand))

함수를 호출할 수 있도록 turn 함수의 내부를 수정해줍시다. 만든 card_strhand_str 두 가지 함수를 적절히 사용합니다.

    # 차례
    print(name, "의 차례입니다.")
    if not isComputer:
        print("플레이어의 패 >> ", hand_str(hand))
    print("놓여진 카드 >>", card_str(put[-1]))

    # ----------- 낼 수 있는 카드 고르기 ---------------
    available = getAvailable(hand, put[-1], is_attack)
    if not isComputer:
        print("낼 수 있는 카드:", hand_str(available))

아래는 결과입니다. 보기만 해도 눈이 편해졌습니다!

플레이어의 패 >> [◆K] [◆8] [♥10] [♣A] [♥A] [♠9]
놓여진 카드 >> [◆6]
낼 수 있는 카드: [◆K] [◆8]

자, 이제 고민의 시간입니다. 어떻게 화면에 고정시킬 수 있을까요? 화면이 무슨 리스트도 아니고 대괄호로 특정한 자리를 집어낼 수 있는 것도 아니고, 어떻게 아래 쪽만 변하도록 할까요? 이럴 때에는 발상의 전환을 해야 합니다. 특정 부분만 움직이게 만드는 것은 화면을 모두 삭제하고 고정된 요소는 똑같은 위치에 출력하고 바뀌는 요소는 다른 위치에 출력하라는 것과 일맥상통합니다!

이것을 어떻게 가능하게 할 수 있을까요? 콘솔 화면 삭제 등으로 인터넷에 검색해봅시다. 검색 과정은 생략하도록 할게요. 방법은 os.system("cls") (맥은 os.system("clear"))를 이용하는 것입니다. os 모듈에서는 시스템 명령어를 사용할 수 있는 system 함수를 가지고 있습니다. cls 명령어는 콘솔에 출력되어 있는 모든 텍스트를 삭제하라는 뜻입니다.

매번 메시지를 화면에 띄울 때마다 전부 다 지우고 처음부터 작성하는 로직이 필요해졌습니다. 다음은 그 역할을 하는 print_message 함수를 구현한 모습입니다.

message_count = 0
messages = []

def print_message(message):
    global put, deck, player, is_attack, messages, message_count
    os.system("cls")

    output = []
    output.append(f":: last put card ::  [[[{card_str(put[-1])}")
    output.append(f":: player's hand ::  {hand_str(player)}")
    output.append(f"::   available   ::  {hand_str(getAvailable(player, put[-1], is_attack))}")
    output.append("-" * 30)

    message_count += 1
    messages.append(message)
    if len(messages) == 16:
        messages.pop(0)
    for i, m in enumerate(messages) :
        output.append(f'[{message_count - len(messages) + i + 1:>3}] {m}')

    output.append("-" * 30)
    
    print("\n".join(output))

이제 위 구현에 대해 설명하도록 하겠습니다.

  • 제일 먼저, 사용할 수 있는 변수를 global로 땡겨옵니다.
  • 우선 처음에 os.system("cls")로 화면을 깨끗이 정리합니다. 코드의 최상단에 import os 집어넣는 걸 잊지 마세요!
  • 코드에서는 output이라는 리스트를 만들어서 출력 내용을 저장해놓았다가 한번에 print("\n".join(output))으로 출력합니다. output.append 하지 않고 그때 그때 바로 print로 출력할 수도 있지만, print를 한 번에 많이 쓰게 되면 속도가 조금 느려지므로 한번에 처리하기 위해 위와 같이 구현했습니다.
  • 메시지를 처리하는 부분에서는, message_count를 증감시켜주고, messagesmessage를 넣어줍니다. messages의 길이가 16가 되면 하나를 삭제하여 길이가 15로 유지되도록 했습니다.
  • 메시지 번호를 계산하기 위해 message_count - len(messages) + i + 1 라는 요상한 식을 이용했습니다. i는 인덱스를 의미합니다. 표를 통해서 보면 훨씬 이해가 잘 갈 것입니다. (message_count는 250라 가정)
len(messages) i message_count - len(messages) + i + 1
15 0 236
15 1 237
15 2 238
15 3 239
15 4 240
15 5 241
15 6 242
15 7 243
15 8 244
15 9 245
15 10 246
15 11 247
15 12 248
15 13 249
15 14 250
  • enumerate라는 요상한 게 등장했습니다! 이건 무엇일까요? 이건 for 루프를 돌 때 한 껏 간편하게 돌 수 있게 해주는 도구입니다. enumerate를 사용하면 (index, value) 식으로 리스트 내 요소를 사용할 수 있게 됩니다. 코드로 설명하면 다음과 같습니다.
    i = 0
    for m in messages :
        output.append(f'[{message_count - len(messages) + i + 1:>3}] {m}')
        i += 1

## 이걸 enumerate 로 고치면?

    for i, m in enumerate(messages) :
        output.append(f'[{message_count - len(messages) + i + 1:>3}] {m}')

## 이렇게 됩니다!

이제 print_message 함수를 호출할 수 있도록 turn 함수 내부를 수정해볼까요? 일단 기존의 print 함수 호출을 대체해줍니다. print_message 함수는 print 함수와 달리 인수를 하나밖에 받지 못합니다. 그러므로 호출하는 부분도 전부 하나의 문자열을 받도록 수정해줍니다. f-string 이 간단하겠지요?

print_message(f'{name}의 차례입니다.')
print_message(f'{name}가 {selected}를 냈습니다."')
print_message(f'{name}가 낼 수 있는 카드가 없어 {damage}장 먹습니다.')
print_message(f"{name}가 이겼습니다!")

작업이 완료되면 기존의 현재 놓여져 있는 카드, 가지고 있는 패, 낼 수 있는 카드를 출력하는 부분을 없애줍니다. 이 부분은 print_message에서 자동으로 계속 출력합니다.


추가 조치

더 깔끔하게 보이기 위해, 카드를 골랐을 때에는 플레이어나 컴퓨터나 상관없이 화면에 출력하도록 합시다. 그리고 플레이어가 카드를 먹을 때에는 플레이어가 확인하고 진행할 수 있도록 input을 이용해 흐름을 멈추어줍시다. 아래 두 코드 블록은 조치한 것들입니다.

    # ----------- 카드 고르기 ---------------------
    is_available = len(available) > 0
  
### 중략 ###

        print_message(f'{name}가 {selected}를 냈습니다."')
    # ------------ 카드 먹기 -----------------------
    else:
        print_message(f'{name}가 낼 수 있는 카드가 없어 {damage}장 먹습니다.')
        if not isComputer:
            input("계속 하려면 엔터를 누르세요")

### 중략 ###

연습 문제: get_damage 개선

get_damage 함수를 완전히 뒤엎습니다. 파이썬의 딕셔너리와 딕셔너리의 get 메소드를 활용하는 방법으로 수정할 예정입니다. 딕셔너리인 damage_map 은 키로 카드의 숫자(card[1]에 해당하는 내용)를 가지고 값으로 공격력을 가집니다. 숫자에 대응되는 공격력 데이터를 가진 damage_map 으로 손쉽게 get_damage 를 구현할 수 있습니다. damage_map 은 전역으로 정의하고, get_damage 내부에서는 globaldamage_map에 접근하여 나머지 내용을 작성하도록 합니다.


풀이

damage_map = {
    'colored': 15,
    'black': 10,
    'A': 3,
    '2': 2
}

def get_damage(card):
    global damage_map
    return damage_map.get(card[1], 0)

딕셔너리는 리스트와 비슷하게 [key] 이렇게 값에 접근할 수 있습니다. 하지만 없는 키 값에 접근하게 되면 에러가 발생하는데요, 그것을 방지하기 위해 get을 씁니다. get에서 첫번째 인수를 키로 하여 값을 찾는데요, 만약 키가 없다면 인수의 두번째를 기본 값으로 하여 가져옵니다.


최종 코드

아래는 최종 코드입니다. 다음 시간에는 이 코드 기준으로 설명할 것입니다.


마무리

다음 시간에는 입력방법 개선, 낼 수 있는 카드가 있어도 먹는 행동 구현, 컴퓨터 여러 개 추가하면서 턴 시스템 개선, 7 J Q K와 같은 특수 카드를 구현해보도록 하겠습니다.

  1. 프롤로그
  2. 개발 첫걸음
    1. 컴퓨터 구성요소 – 컴퓨터는 어떤 걸 할 수 있나?
    2. 개발과 관련된 용어
    3. 파이썬의 선택 – 왜 파이썬인가?
    4. 파이썬 설치 – Hello World 출력하기
    5. Visual Studio Code 의 편리한 기능
    6. REPL과 콘솔 창 – 파이썬 동작시키기
  3. 파이썬 기초
    1. 기초 입출력 – 소통하기
    2. 변수와 대입 – 기억하기
    3. 연산자 – 계산하기
    4. 조건문 – 분기를 만들기
    5. 반복문 – 비슷한 작업을 반복하기
    6. 반복문 코딩하기
    7. 변수와 리스트 – 비슷한 변수들을 묶기
    8. for, range – 리스트의 항목을 다루기
    9. 함수와 메소드의 호출 – 편리한 기능 이용하기
    10. 모듈 설치와 사용 – 유용한 기능 끌어다 쓰기
    11. 문자열 – 텍스트 다루기
  4. 파이썬 중급
    1. 함수를 직접 만들기 – 자주 쓰는 기능을 묶기
    2. 딕셔너리, 튜플, 세트 – 변수를 다양한 방법으로 묶기
    3. 클래스와 객체 – 변수를 사람으로 진화시키기
    1. 상속 – 클래스를 확장하기
    2. 정체성과 동질성 – 객체의 성질
    3. 특별 메소드와 연산자 – 파이썬의 내부 작동방식 이해하기
    4. 다양한 함수 인수 – 유연한 함수 만들기
    5. 슬라이싱 – 리스트 간편하게 접근하기
    6. 지능형 리스트(List Comprehension) – 리스트 갖고 놀기
    7. namedtuple - 데이터 묶음 손쉽게 만들기
    8. 조건 표현식 (Conditional Expression) - 간단한 분기 나타내기
    9. 코드 스타일 - 코드의 일관성 유지하기
    10. 명령문, 표현식 – 문법을 이루는 것들
    11. 본격적인 검색 해보기
  5. 파이썬 고급
    1. 일급 함수 다루기
    2. NotImplementedError와 NotImplemented
    3. 어노테이션 – 수월하게 프로그래밍하기
    1. 내장 함수 톺아보기
    2. 예외와 에러 – 예상치 못한 상황에 대응하기 (v0.1)
    3. 변수의 범위 – 이름 검색의 범위
  6. 파이썬 심화
    1. 시퀀스와 반복자 – 반복과 순회를 자유자재로 다루기
    2. 데코레이터 – 함수의 기능을 강화하기
    3. 프로퍼티
    4. 제너레이터
    5. async와 await
    6. 객체로서의 클래스 – 클래스를 동적으로 정의하기
  7. 파이썬 프로젝트 실습
    1. 원카드 게임 만들기 (1)
    2. 원카드 게임 만들기 (2)
    3. 원카드 게임 만들기 (3) (작성중)
    4. 턴제 자동 전투 게임 만들기 (작성중)
  8. 실전 (파이썬 외적인 것들)
    1. 정규표현식 – 문자열을 검색하고 치환하기 (작성중)
    2. 유니코드 – 컴퓨터에서 문자를 표기하는 방법
    3. html, css, 인터넷 – 자동화 첫 걸음 내딛기
    4. 네트워크 – 인터넷으로 통신하는 방법
    5. 문서 – 문맥을 읽어보기

2 thoughts on “파이썬 강좌 – 실습 – 원카드 게임 만들기 (2)

    1. ㅠㅠ네 그 내용은 여기 없어요.. 네트워크 관련된 거는 다른 공부가 필요하기에~

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

Scroll to top